diff --git a/.github/extensions/replica-prototype/check-assets/index.mjs b/.github/extensions/replica-prototype/check-assets/index.mjs index 7ea6801e..70d33379 100644 --- a/.github/extensions/replica-prototype/check-assets/index.mjs +++ b/.github/extensions/replica-prototype/check-assets/index.mjs @@ -3,7 +3,7 @@ import { readdir, readFile } from "node:fs/promises" import { fileURLToPath } from "node:url" export async function checkAssets({ - assets = new URL("../assets/", import.meta.url), + assets = new URL("../", import.meta.url), build = () => execFileSync("npm", ["run", "extension:build"], { stdio: "inherit" }), } = {}) { const before = await snapshot(assets) diff --git a/.github/extensions/replica-prototype/check-assets/index.test.mjs b/.github/extensions/replica-prototype/check-assets/index.test.mjs index 7858c44d..cfc93613 100644 --- a/.github/extensions/replica-prototype/check-assets/index.test.mjs +++ b/.github/extensions/replica-prototype/check-assets/index.test.mjs @@ -66,11 +66,11 @@ describe("checkAssets()", () => { } }) - it("runs the CLI check from a path with spaces without invoking the real build", async () => { + it("checks generated Node modules outside browser assets from a path with spaces", async () => { const directory = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID()) const extension = path.join(directory, "extension with spaces") const scriptDirectory = path.join(extension, "check-assets") - const assetFile = path.join(extension, "assets", "renderer.js") + const assetFile = path.join(extension, "read-remote-branch", "index.mjs") const script = path.join(scriptDirectory, "index.mjs") await mkdir(scriptDirectory, { recursive: true }) await mkdir(path.dirname(assetFile), { recursive: true }) @@ -94,7 +94,7 @@ describe("checkAssets()", () => { }, stdio: "pipe", }), - /Extension assets differ after build.*renderer\.js/s, + /Extension assets differ after build.*read-remote-branch\/index\.mjs/s, ) } finally { await rm(directory, { recursive: true, force: true }) diff --git a/.github/extensions/replica-prototype/read-remote-branch/index.mjs b/.github/extensions/replica-prototype/read-remote-branch/index.mjs index 6471cd65..6b53ea7b 100644 --- a/.github/extensions/replica-prototype/read-remote-branch/index.mjs +++ b/.github/extensions/replica-prototype/read-remote-branch/index.mjs @@ -1,5 +1,5 @@ +// Edit index.mts; rebuild index.mjs with Hub's extension:modules:build command. import { runCommand } from "../run-command/index.mjs" - // Matching refs returns [] for a missing branch; authorization/HTTP errors stay unknown. export async function readRemoteBranch( { repository, branch, commitSha, repositoryPath }, @@ -7,7 +7,11 @@ export async function readRemoteBranch( ) { const identity = { repository, branch, commitSha } if (!branch) - return { ...identity, state: "detached", error: "Open a named branch before publishing." } + return { + ...identity, + state: "detached", + error: "Open a named branch before publishing.", + } if (!repository || !commitSha) return { ...identity, @@ -29,20 +33,38 @@ export async function readRemoteBranch( try { refs = JSON.parse(result.stdout) } catch { - return { ...identity, state: "unknown", error: "GitHub returned invalid branch data." } + return { + ...identity, + state: "unknown", + error: "GitHub returned invalid branch data.", + } } if ( !Array.isArray(refs) || - refs.some( + !refs.every( (ref) => - !ref || - typeof ref.ref !== "string" || - !/^refs\/heads\//.test(ref.ref) || - ref.object?.type !== "commit" || - !/^[0-9a-f]{40}$/.test(ref.object?.sha || ""), + typeof ref === "object" && + ref !== null && + "ref" in ref && + typeof ref.ref === "string" && + /^refs\/heads\//.test(ref.ref) && + "object" in ref && + typeof ref.object === "object" && + ref.object !== null && + "type" in ref.object && + ref.object.type === "commit" && + "sha" in ref.object && + typeof ref.object.sha === "string" && + /^[0-9a-f]{40}$/.test(ref.object.sha), ) ) - return { ...identity, state: "unknown", error: "GitHub returned invalid branch refs." } + return { + ...identity, + state: "unknown", + error: "GitHub returned invalid branch refs.", + } const exact = refs.find((ref) => ref.ref === `refs/heads/${branch}`) - return { ...identity, state: exact ? "present" : "absent", remoteSha: exact?.object.sha || null } + return exact + ? { ...identity, state: "present", remoteSha: exact.object.sha } + : { ...identity, state: "absent", remoteSha: null } } diff --git a/.github/extensions/replica-prototype/read-remote-branch/index.mts b/.github/extensions/replica-prototype/read-remote-branch/index.mts new file mode 100644 index 00000000..a4fd67bb --- /dev/null +++ b/.github/extensions/replica-prototype/read-remote-branch/index.mts @@ -0,0 +1,86 @@ +// Edit index.mts; rebuild index.mjs with Hub's extension:modules:build command. +import { runCommand } from "../run-command/index.mjs" + +type BranchIdentity = { + repository: string | null + branch: string | null + commitSha: string | null +} + +type RemoteBranchResult = BranchIdentity & + ( + | { state: "detached" | "unknown"; error: string } + | { state: "present"; remoteSha: string } + | { state: "absent"; remoteSha: null } + ) + +type BranchRef = { ref: string; object: { type: "commit"; sha: string } } + +// Matching refs returns [] for a missing branch; authorization/HTTP errors stay unknown. +export async function readRemoteBranch( + { repository, branch, commitSha, repositoryPath }: BranchIdentity & { repositoryPath: string }, + run: typeof runCommand = runCommand, +): Promise { + const identity = { repository, branch, commitSha } + if (!branch) + return { + ...identity, + state: "detached", + error: "Open a named branch before publishing.", + } + if (!repository || !commitSha) + return { + ...identity, + state: "unknown", + error: "The GitHub origin or checkout commit could not be verified.", + } + const result = await run( + "gh", + [ + "api", + "--hostname", + "github.com", + `repos/${repository}/git/matching-refs/heads/${encodeURIComponent(branch)}`, + ], + repositoryPath, + ) + if (!result.ok) return { ...identity, state: "unknown", error: result.error } + let refs: unknown + try { + refs = JSON.parse(result.stdout) + } catch { + return { + ...identity, + state: "unknown", + error: "GitHub returned invalid branch data.", + } + } + if ( + !Array.isArray(refs) || + !refs.every( + (ref: unknown): ref is BranchRef => + typeof ref === "object" && + ref !== null && + "ref" in ref && + typeof ref.ref === "string" && + /^refs\/heads\//.test(ref.ref) && + "object" in ref && + typeof ref.object === "object" && + ref.object !== null && + "type" in ref.object && + ref.object.type === "commit" && + "sha" in ref.object && + typeof ref.object.sha === "string" && + /^[0-9a-f]{40}$/.test(ref.object.sha), + ) + ) + return { + ...identity, + state: "unknown", + error: "GitHub returned invalid branch refs.", + } + const exact = refs.find((ref) => ref.ref === `refs/heads/${branch}`) + return exact + ? { ...identity, state: "present", remoteSha: exact.object.sha } + : { ...identity, state: "absent", remoteSha: null } +} diff --git a/.github/extensions/replica-prototype/run-command/index.d.mts b/.github/extensions/replica-prototype/run-command/index.d.mts new file mode 100644 index 00000000..b80d7229 --- /dev/null +++ b/.github/extensions/replica-prototype/run-command/index.d.mts @@ -0,0 +1,15 @@ +export function runCommand( + command: string, + args: string[], + cwd?: string, + options?: { env?: Record }, +): Promise< + | { ok: true; stdout: string } + | { + ok: false + error: string + exitCode?: string | number | null + stdout?: string + stderr?: string + } +> diff --git a/.github/extensions/replica-prototype/tsconfig.json b/.github/extensions/replica-prototype/tsconfig.json new file mode 100644 index 00000000..e20c8f27 --- /dev/null +++ b/.github/extensions/replica-prototype/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "noEmitOnError": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["**/*.mts"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 735190cc..e86c20a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,7 +1,7 @@ import hubConfig from "./hub/eslint.config.ts" export default hubConfig.map((config) => - config.files?.includes("../.github/extensions/replica-prototype/**/*.mjs") + config.files?.some((pattern) => pattern.startsWith("../.github/extensions/replica-prototype/")) ? { ...config, files: config.files.map((pattern) => diff --git a/hub/docs/prototype-canvas.md b/hub/docs/prototype-canvas.md index 40faf28c..bddbf14a 100644 --- a/hub/docs/prototype-canvas.md +++ b/hub/docs/prototype-canvas.md @@ -454,6 +454,8 @@ Plugin releases, the Controls definition format and the Canvas interface are ver Canvas frontend source follows Hub's normal layers under `hub/app/javascript/`. `application/prototype-canvas/` composes the interface; `application/start-prototype-canvas/` starts it, and `application/prototype-preview-bridge/` owns the injected preview entry point. Workflow panels belong in `features/`, reusable fields and dialogs in `components/`, and stateful browser behavior in `hooks/`. Shared contracts, locale copy, protocols and URL helpers live in `utils/`; the Controls engine and definition schema live in `utils/prototype-controls/`. Extension registration and server operations remain in `.github/extensions/replica-prototype/`. +The canvas frontend already uses TypeScript. Extension Node modules are migrating separately, starting with `read-remote-branch/index.mts`. Edit that source, not its generated `index.mjs`. The extension's `tsconfig.json` checks migrated modules in strict mode; declarations describe dependencies that remain JavaScript. Hub's `typecheck` and `lint` commands include these modules. Run `extension:modules:build` to compile them without rebuilding the browser assets. Commit generated `.mjs` files alongside their TypeScript source so the plugin still runs without installed packages or a TypeScript loader. The host entry point remains `extension.mjs`. + Hub builds renderer files, Brand fonts and browser runtime into the plugin's `assets/`; commit them so installed users need no Node packages. Build configuration lives in `hub/vite.prototype-canvas.config.mjs` and `hub/script/`. Generated assets are excluded from formatting checks; do not edit them manually. There is no Vite/npm distribution or release preparation. For example, a preview reload must cancel an unconfirmed click without losing a command already dequeued by the agent. Test each hook's public behavior beside its implementation, using fake timers for polling and timeouts. Keep cross-feature checks under `application/prototype-canvas/tests/`, including iframe preservation, focus restoration and explicit publication or sharing actions. Keep `index.test.tsx` beside the application component and test-only orchestration helpers under its `tests/`. Shared publication fixtures stay with the publication utility so lower-layer tests do not import application code. Create fresh fixtures inside each case without suite-level reset hooks. Shared jsdom setup supplies stateless browser shims and restores real timers; Vitest configuration resets mocks and stubbed globals before each case. @@ -469,4 +471,4 @@ bin/devcontainer exec npm run extension:build bin/devcontainer exec npm run extension:assets:check ``` -`extension:test` runs the root plugin's Node `.mjs` tests, including a real-browser delivery check when `/usr/bin/chromium` is available (as in the devcontainer). It covers cross-origin frames, `script-src 'self'`, HTTPS, runtime replacement, and late registration recovery. Hub's `npm test` discovers runtime and renderer Vitest tests; `npm test -- PATH` selects a test path. `npm run lint` checks authored source with ESLint and Prettier. `extension:assets:check` rebuilds and compares root plugin assets, failing for changed, missing, or unexpected generated files and files over the installer's 1,000,000-byte per-file limit. Run `extension:build` before that check, then include generated files with source changes. Both local `bin/ci` and GitHub CI run the extension checks. See [Development](development.md) and [Testing and operations](testing-and-operations.md). +`extension:test` runs the root plugin's Node `.mjs` tests, including a real-browser delivery check when `/usr/bin/chromium` is available (as in the devcontainer). It covers cross-origin frames, `script-src 'self'`, HTTPS, runtime replacement, and late registration recovery. Hub's `npm test` discovers runtime and renderer Vitest tests; `npm test -- PATH` selects a test path. `npm run lint` checks authored source with ESLint and Prettier. `extension:build` compiles migrated Node modules before building the browser assets. `extension:assets:check` rebuilds and compares the plugin's files, including generated Node modules outside `assets/`, failing for changed, missing, or unexpected generated files and files over the installer's 1,000,000-byte per-file limit. Run `extension:build` before that check, then include generated files with source changes. Both local `bin/ci` and GitHub CI run the extension checks. See [Development](development.md) and [Testing and operations](testing-and-operations.md). diff --git a/hub/eslint.config.ts b/hub/eslint.config.ts index a896d1cd..11061d31 100644 --- a/hub/eslint.config.ts +++ b/hub/eslint.config.ts @@ -27,7 +27,7 @@ export default defineConfig( js.configs.recommended, ...tseslint.configs.recommendedTypeChecked, { - files: ["**/*.{ts,tsx}"], + files: ["**/*.{ts,tsx,mts}", "../.github/extensions/replica-prototype/**/*.mts"], languageOptions: { globals: { ...globals.browser, ...globals.node }, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, @@ -103,6 +103,7 @@ export default defineConfig( "script/**/*.mjs", "test/**/*.mjs", "../.github/extensions/replica-prototype/**/*.mjs", + "../.github/extensions/replica-prototype/**/*.d.mts", "app/javascript/utils/prototype-controls/**/*.mjs", "app/javascript/utils/prototype-controls/**/*.d.mts", "../packages/learning/**/*.mjs", diff --git a/hub/package.json b/hub/package.json index 632a1066..8574be76 100644 --- a/hub/package.json +++ b/hub/package.json @@ -16,14 +16,15 @@ "build": "npm run typecheck && vite build", "dev": "vite dev", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs", - "extension:build": "vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs", + "extension:build": "npm run extension:modules:build && vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs", + "extension:modules:build": "tsc --project ../.github/extensions/replica-prototype/tsconfig.json --noEmit false && prettier --write '../.github/extensions/replica-prototype/read-remote-branch/index.mjs' --config .prettierrc.json", "extension:test": "npm run controls:runtime:test && node --test ../.github/extensions/replica-prototype/**/*.test.mjs", "format": "prettier --write .", - "lint": "eslint . ../.github/extensions/replica-prototype ../packages/learning && prettier --check . '../eslint.config.mjs' '../.github/extensions/replica-prototype/**/*.mjs' '../.github/extensions/replica-prototype/*.json' '../packages/learning/**/*.{mjs,mts,json}' --config .prettierrc.json", + "lint": "eslint . ../.github/extensions/replica-prototype ../packages/learning && prettier --check . '../eslint.config.mjs' '../.github/extensions/replica-prototype/**/*.{mjs,mts}' '../.github/extensions/replica-prototype/*.json' '../packages/learning/**/*.{mjs,mts,json}' --config .prettierrc.json", "storybook": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev --host 0.0.0.0 --port 6006 --initial-path /story/hub-design-overview--taxonomy", "storybook:build": "STORYBOOK_DISABLE_TELEMETRY=1 storybook build --output-dir storybook-static", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc --project ../.github/extensions/replica-prototype/tsconfig.json" }, "dependencies": { "@primer/octicons-react": "19.40.0",