From 06f70a0cb15116bca59e69e5ac6143dd235c61cd Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Wed, 21 Oct 2026 14:25:00 +0200 Subject: [PATCH 1/4] refactor(canvas): establish TypeScript extension foundation Convert remote branch lookup to strict TypeScript while retaining its generated JavaScript runtime. Include migrated modules in lint, type checks, and plugin freshness checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d7306a9-9831-4c78-89e7-3c5b581c895e --- .../replica-prototype/check-assets/index.mjs | 2 +- .../check-assets/index.test.mjs | 6 +- .../read-remote-branch/index.mjs | 44 +++++++--- .../read-remote-branch/index.mts | 86 +++++++++++++++++++ .../replica-prototype/run-command/index.d.mts | 15 ++++ .../replica-prototype/tsconfig.json | 17 ++++ eslint.config.mjs | 2 +- hub/docs/prototype-canvas.md | 4 +- hub/eslint.config.ts | 3 +- hub/package.json | 7 +- 10 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 .github/extensions/replica-prototype/read-remote-branch/index.mts create mode 100644 .github/extensions/replica-prototype/run-command/index.d.mts create mode 100644 .github/extensions/replica-prototype/tsconfig.json 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..69445471 --- /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 + 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", From edfef3845277d917ec81263ad50b0e679856fbde Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Wed, 21 Oct 2026 14:33:34 +0200 Subject: [PATCH 2/4] refactor(canvas): migrate shared extension infrastructure Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1aff284a-77f1-4bcf-8245-e30889a2f392 --- .../create-canvas-context/index.mjs | 23 -- .../create-canvas-context/index.mts | 285 ++++++++++++++++++ .../replica-prototype/private-store/index.mjs | 22 +- .../replica-prototype/private-store/index.mts | 135 +++++++++ .../read-canvas-repository/index.mjs | 8 +- .../read-canvas-repository/index.mts | 24 ++ .../read-error-code/index.mjs | 4 + .../read-error-code/index.mts | 4 + .../read-repository-name/index.mjs | 8 +- .../read-repository-name/index.mts | 26 ++ .../replica-prototype/read-state/index.mjs | 19 +- .../replica-prototype/read-state/index.mts | 83 +++++ .../read-state/index.test.mjs | 44 +++ .../replica-prototype/run-command/index.d.mts | 15 - .../replica-prototype/run-command/index.mjs | 37 ++- .../replica-prototype/run-command/index.mts | 60 ++++ .../run-command/index.test.mjs | 51 ++++ .../replica-prototype/tsconfig.json | 3 +- .../replica-prototype/write-state/index.mjs | 1 - .../replica-prototype/write-state/index.mts | 14 + hub/docs/prototype-canvas.md | 4 +- hub/package.json | 2 +- hub/script/build-extension-modules.mjs | 26 ++ 23 files changed, 810 insertions(+), 88 deletions(-) create mode 100644 .github/extensions/replica-prototype/create-canvas-context/index.mts create mode 100644 .github/extensions/replica-prototype/private-store/index.mts create mode 100644 .github/extensions/replica-prototype/read-canvas-repository/index.mts create mode 100644 .github/extensions/replica-prototype/read-error-code/index.mjs create mode 100644 .github/extensions/replica-prototype/read-error-code/index.mts create mode 100644 .github/extensions/replica-prototype/read-repository-name/index.mts create mode 100644 .github/extensions/replica-prototype/read-state/index.mts create mode 100644 .github/extensions/replica-prototype/read-state/index.test.mjs delete mode 100644 .github/extensions/replica-prototype/run-command/index.d.mts create mode 100644 .github/extensions/replica-prototype/run-command/index.mts create mode 100644 .github/extensions/replica-prototype/run-command/index.test.mjs create mode 100644 .github/extensions/replica-prototype/write-state/index.mts create mode 100644 hub/script/build-extension-modules.mjs diff --git a/.github/extensions/replica-prototype/create-canvas-context/index.mjs b/.github/extensions/replica-prototype/create-canvas-context/index.mjs index 951a45dd..d4a2ce4d 100644 --- a/.github/extensions/replica-prototype/create-canvas-context/index.mjs +++ b/.github/extensions/replica-prototype/create-canvas-context/index.mjs @@ -1,9 +1,7 @@ import { createHash } from "node:crypto" import path from "node:path" - import { readRepositoryName } from "../read-repository-name/index.mjs" import { runCommand } from "../run-command/index.mjs" - export async function createCanvasContext({ prototypeUrl, name, @@ -25,7 +23,6 @@ export async function createCanvasContext({ "This Copilot session has no working directory, so repository status cannot be read.", ) } - const normalizedUrl = normalizePrototypeUrl(prototypeUrl) const stateUrl = new URL(normalizedUrl) stateUrl.hash = "" @@ -50,7 +47,6 @@ export async function createCanvasContext({ .digest("hex") .slice(0, 24) const stateDirectory = path.join(sessionWorkspacePath, "replica-prototype") - return { name: normalizeName(name, normalizedUrl), normalizedUrl, @@ -64,7 +60,6 @@ export async function createCanvasContext({ urlHost: new URL(normalizedUrl).host, } } - // Keep stored publish state tied to the branch currently shown in the canvas. export async function currentCanvasContext(context) { const [repository, branch] = await Promise.all([ @@ -81,7 +76,6 @@ export async function currentCanvasContext(context) { const currentBranch = branch.ok ? branch.stdout.replace(/\r?\n$/, "") || null : null if (currentRepository === context.target.repository && currentBranch === context.target.branch) return context - return createCanvasContext({ prototypeUrl: context.normalizedUrl, name: context.name, @@ -93,7 +87,6 @@ export async function currentCanvasContext(context) { repositoryPath: context.repositoryPath, }) } - function normalizePrototypeUrl(value) { let url try { @@ -104,7 +97,6 @@ function normalizePrototypeUrl(value) { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("prototypeUrl must use HTTP or HTTPS.") } - url.hostname = url.hostname.toLowerCase() if ( (url.protocol === "http:" && url.port === "80") || @@ -112,10 +104,8 @@ function normalizePrototypeUrl(value) { ) { url.port = "" } - return url.toString() } - function normalizeName(name, normalizedUrl) { if (name === undefined) { return new URL(normalizedUrl).hostname @@ -123,10 +113,8 @@ function normalizeName(name, normalizedUrl) { if (typeof name !== "string" || name.trim() === "") { throw new Error("name must contain visible text.") } - return name.trim() } - async function resolvePublishTarget({ repository, branch, @@ -147,7 +135,6 @@ async function resolvePublishTarget({ if (branch != null && branchResult.ok && hintedBranch !== checkoutBranch) { throw new Error("branch does not match the current checkout.") } - return { repository: resolvedRepository, branch: hintedBranch ?? checkoutBranch, @@ -155,7 +142,6 @@ async function resolvePublishTarget({ publishedUrl: normalizeOptionalUrl(publishedUrl, "publishedUrl"), } } - export async function resolveRepository(hint, repositoryPath) { const repository = normalizeRepository(hint) const checkout = await readRepositoryName(repositoryPath) @@ -174,7 +160,6 @@ export async function resolveRepository(hint, repositoryPath) { } return repository ?? checkoutRepository } - function isLocalRepositoryCheckout(repository, repositoryPath) { const localRoot = path.dirname(repositoryPath) return ( @@ -183,7 +168,6 @@ function isLocalRepositoryCheckout(repository, repositoryPath) { path.basename(path.dirname(localRoot)) === ".local" ) } - function normalizeRepository(value) { if (value == null) { return null @@ -206,10 +190,8 @@ function normalizeRepository(value) { ) { throw new Error("repository must use GitHub owner/name format.") } - return value.toLowerCase() } - export async function normalizeBranch(value, repositoryPath) { if (value === "HEAD") return null if (value == null) { @@ -235,7 +217,6 @@ export async function normalizeBranch(value, repositoryPath) { } return value } - function normalizeCommitSha(value) { if (value == null) { return null @@ -243,15 +224,12 @@ function normalizeCommitSha(value) { if (typeof value !== "string" || !/^[0-9a-f]{40}$/.test(value)) { throw new Error("commitSha must be a full lowercase Git commit SHA.") } - return value } - function normalizeOptionalUrl(value, name) { if (value == null) { return null } - let url try { url = new URL(value) @@ -261,6 +239,5 @@ function normalizeOptionalUrl(value, name) { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error(`${name} must use HTTP or HTTPS.`) } - return url.toString() } diff --git a/.github/extensions/replica-prototype/create-canvas-context/index.mts b/.github/extensions/replica-prototype/create-canvas-context/index.mts new file mode 100644 index 00000000..ff49b781 --- /dev/null +++ b/.github/extensions/replica-prototype/create-canvas-context/index.mts @@ -0,0 +1,285 @@ +import { createHash } from "node:crypto" +import path from "node:path" + +import { readRepositoryName } from "../read-repository-name/index.mjs" +import { runCommand } from "../run-command/index.mjs" + +type CanvasContextInput = { + prototypeUrl: string + name?: unknown + repository?: unknown + branch?: unknown + commitSha?: unknown + publishedUrl?: string | null + sessionId?: string + sessionWorkspacePath?: string + repositoryPath?: string +} + +type TargetInput = Pick< + CanvasContextInput, + "repository" | "branch" | "commitSha" | "publishedUrl" +> & { repositoryPath: string } + +export type CanvasContext = Awaited> + +export async function createCanvasContext({ + prototypeUrl, + name, + repository, + branch, + commitSha, + publishedUrl, + sessionId, + sessionWorkspacePath, + repositoryPath, +}: CanvasContextInput) { + if (!sessionWorkspacePath) { + throw new Error( + "This Copilot session has no workspace path, so Replica prototype state cannot be stored.", + ) + } + if (!repositoryPath) { + throw new Error( + "This Copilot session has no working directory, so repository status cannot be read.", + ) + } + + const normalizedUrl = normalizePrototypeUrl(prototypeUrl) + const stateUrl = new URL(normalizedUrl) + stateUrl.hash = "" + const resolvedRepositoryPath = path.resolve(repositoryPath) + const target = await resolvePublishTarget({ + repository, + branch, + commitSha, + publishedUrl, + repositoryPath: resolvedRepositoryPath, + }) + const stateId = createHash("sha256") + .update( + [ + path.resolve(sessionWorkspacePath), + stateUrl.toString(), + target.repository || "", + target.branch || "", + target.commitSha || "", + ].join("\n"), + ) + .digest("hex") + .slice(0, 24) + const stateDirectory = path.join(sessionWorkspacePath, "replica-prototype") + + return { + name: normalizeName(name, normalizedUrl), + normalizedUrl, + sessionId, + repositoryPath: resolvedRepositoryPath, + target, + sessionWorkspacePath: path.resolve(sessionWorkspacePath), + stateDirectory, + stateFile: path.join(stateDirectory, `${stateId}.json`), + stateId, + urlHost: new URL(normalizedUrl).host, + } +} + +// Keep stored publish state tied to the branch currently shown in the canvas. +export async function currentCanvasContext(context: CanvasContext): Promise { + const [repository, branch] = await Promise.all([ + readRepositoryName(context.repositoryPath), + runCommand("git", ["branch", "--show-current"], context.repositoryPath), + ]) + const currentRepository = + (isLocalRepositoryCheckout(context.target.repository, context.repositoryPath) + ? context.target.repository + : repository.repository) || + (repository.error === "The origin remote is not a GitHub repository." + ? null + : context.target.repository) + const currentBranch = branch.ok ? branch.stdout.replace(/\r?\n$/, "") || null : null + if (currentRepository === context.target.repository && currentBranch === context.target.branch) + return context + + return createCanvasContext({ + prototypeUrl: context.normalizedUrl, + name: context.name, + repository: currentRepository, + branch: currentBranch, + publishedUrl: context.target.publishedUrl, + sessionId: context.sessionId, + sessionWorkspacePath: context.sessionWorkspacePath, + repositoryPath: context.repositoryPath, + }) +} + +function normalizePrototypeUrl(value: string) { + let url + try { + url = new URL(value) + } catch { + throw new Error("prototypeUrl must be a valid HTTP or HTTPS URL.") + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("prototypeUrl must use HTTP or HTTPS.") + } + + url.hostname = url.hostname.toLowerCase() + if ( + (url.protocol === "http:" && url.port === "80") || + (url.protocol === "https:" && url.port === "443") + ) { + url.port = "" + } + + return url.toString() +} + +function normalizeName(name: unknown, normalizedUrl: string) { + if (name === undefined) { + return new URL(normalizedUrl).hostname + } + if (typeof name !== "string" || name.trim() === "") { + throw new Error("name must contain visible text.") + } + + return name.trim() +} + +async function resolvePublishTarget({ + repository, + branch, + commitSha, + publishedUrl, + repositoryPath, +}: TargetInput) { + const [resolvedRepository, branchResult] = await Promise.all([ + resolveRepository(repository, repositoryPath), + runCommand("git", ["branch", "--show-current"], repositoryPath), + ]) + const [hintedBranch, checkoutBranch] = await Promise.all([ + normalizeBranch(branch, repositoryPath), + branchResult.ok + ? normalizeBranch(branchResult.stdout.replace(/\r?\n$/, "") || null, repositoryPath) + : null, + ]) + if (branch != null && branchResult.ok && hintedBranch !== checkoutBranch) { + throw new Error("branch does not match the current checkout.") + } + + return { + repository: resolvedRepository, + branch: hintedBranch ?? checkoutBranch, + commitSha: normalizeCommitSha(commitSha), + publishedUrl: normalizeOptionalUrl(publishedUrl, "publishedUrl"), + } +} + +export async function resolveRepository(hint: unknown, repositoryPath: string) { + const repository = normalizeRepository(hint) + const checkout = await readRepositoryName(repositoryPath) + const checkoutRepository = normalizeRepository(checkout.repository) + if (repository && repository !== checkoutRepository) { + if (isLocalRepositoryCheckout(repository, repositoryPath)) return repository + if (repository.startsWith("local/")) { + throw new Error("A local repository hint requires its matching .local/prototypes checkout.") + } + if (checkoutRepository) { + throw new Error("repository does not match the current checkout origin.") + } + if (checkout.error === "The origin remote is not a GitHub repository.") { + throw new Error(`Cannot verify repository hint: ${checkout.error}`) + } + } + return repository ?? checkoutRepository +} + +function isLocalRepositoryCheckout(repository: string | null, repositoryPath: string) { + const localRoot = path.dirname(repositoryPath) + return ( + repository === `local/${path.basename(repositoryPath)}` && + path.basename(localRoot) === "prototypes" && + path.basename(path.dirname(localRoot)) === ".local" + ) +} + +function normalizeRepository(value: unknown) { + if (value == null) { + return null + } + if (typeof value !== "string") { + throw new Error("repository must use GitHub owner/name format.") + } + const parts = value.split("/") + const [owner, name] = parts + if ( + parts.length !== 2 || + !owner || + !name || + owner.length > 39 || + name.length > 100 || + name === "." || + name === ".." || + !/^[a-z0-9-]+$/i.test(owner) || + !/^[a-z0-9_.-]+$/i.test(name) + ) { + throw new Error("repository must use GitHub owner/name format.") + } + + return value.toLowerCase() +} + +export async function normalizeBranch(value: unknown, repositoryPath: string) { + if (value === "HEAD") return null + if (value == null) { + return null + } + if (typeof value !== "string" || value.trim() === "" || value.length > 255) { + throw new Error("branch must contain between 1 and 255 characters.") + } + if ( + value.trim() !== value || + /\s/u.test(value) || + /[\p{Cc}\p{Zl}\p{Zp}\p{Bidi_Control}\uFEFF]/u.test(value) || + value.includes("@{") + ) { + throw new Error("branch must be a valid Git branch name.") + } + const checked = await runCommand("git", ["check-ref-format", "--branch", value], repositoryPath) + if (!checked.ok) { + if (checked.error === "git is not installed.") { + throw new Error("Cannot validate branch: git is not installed.") + } + throw new Error("branch must be a valid Git branch name.") + } + return value +} + +function normalizeCommitSha(value: unknown) { + if (value == null) { + return null + } + if (typeof value !== "string" || !/^[0-9a-f]{40}$/.test(value)) { + throw new Error("commitSha must be a full lowercase Git commit SHA.") + } + + return value +} + +function normalizeOptionalUrl(value: string | null | undefined, name: string) { + if (value == null) { + return null + } + + let url + try { + url = new URL(value) + } catch { + throw new Error(`${name} must be a valid HTTP or HTTPS URL.`) + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${name} must use HTTP or HTTPS.`) + } + + return url.toString() +} diff --git a/.github/extensions/replica-prototype/private-store/index.mjs b/.github/extensions/replica-prototype/private-store/index.mjs index 040f6fe9..8e2b0a8e 100644 --- a/.github/extensions/replica-prototype/private-store/index.mjs +++ b/.github/extensions/replica-prototype/private-store/index.mjs @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto" import { mkdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises" import path from "node:path" import { setTimeout } from "node:timers/promises" - +import { readErrorCode } from "../read-error-code/index.mjs" export function createPrivateStore(directory) { async function prepare() { await mkdir(directory, { recursive: true, mode: 0o700 }) @@ -17,7 +17,7 @@ export function createPrivateStore(directory) { throw new Error("Canvas credential file is not private.") return JSON.parse(await readFile(file, "utf8")) } catch (error) { - if (error.code === "ENOENT") return null + if (readErrorCode(error) === "ENOENT") return null throw error } } @@ -25,7 +25,7 @@ export function createPrivateStore(directory) { try { await unlink(path.join(directory, name)) } catch (error) { - if (error.code !== "ENOENT") throw error + if (readErrorCode(error) !== "ENOENT") throw error } } return { @@ -53,7 +53,7 @@ export function createPrivateStore(directory) { await mkdir(lock, { mode: 0o700 }) break } catch (error) { - if (error.code !== "EEXIST") throw error + if (readErrorCode(error) !== "EEXIST") throw error } await recoverLock(lock) if (Date.now() >= deadline) @@ -71,24 +71,22 @@ export function createPrivateStore(directory) { }, } } - async function releaseLock(lock) { try { await unlink(path.join(lock, "owner")) } catch (error) { - if (error.code !== "ENOENT") throw error + if (readErrorCode(error) !== "ENOENT") throw error } for (let attempt = 0; ; attempt++) { try { await rmdir(lock) return } catch (error) { - if (error.code !== "ENOTEMPTY" || attempt >= 20) throw error + if (readErrorCode(error) !== "ENOTEMPTY" || attempt >= 20) throw error await setTimeout(50) } } } - async function recoverLock(lock) { try { const observed = await stat(lock) @@ -102,12 +100,12 @@ async function recoverLock(lock) { process.kill(owner, 0) abandoned = false } catch (error) { - if (error.code !== "ESRCH") throw error + if (readErrorCode(error) !== "ESRCH") throw error abandoned = true } } } catch (error) { - if (error.code !== "ENOENT") throw error + if (readErrorCode(error) !== "ENOENT") throw error abandoned = Date.now() - (await stat(lock)).mtimeMs > 60_000 } if (!abandoned) return @@ -116,7 +114,7 @@ async function recoverLock(lock) { try { await mkdir(reaper) } catch (error) { - if (error.code !== "EEXIST") throw error + if (readErrorCode(error) !== "EEXIST") throw error if (Date.now() - (await stat(reaper)).mtimeMs > 60_000) await rmdir(reaper) return } @@ -128,6 +126,6 @@ async function recoverLock(lock) { await rename(lock, retired) await rm(retired, { recursive: true }) } catch (error) { - if (!["ENOENT", "EEXIST"].includes(error.code)) throw error + if (readErrorCode(error) !== "ENOENT" && readErrorCode(error) !== "EEXIST") throw error } } diff --git a/.github/extensions/replica-prototype/private-store/index.mts b/.github/extensions/replica-prototype/private-store/index.mts new file mode 100644 index 00000000..5b84af0c --- /dev/null +++ b/.github/extensions/replica-prototype/private-store/index.mts @@ -0,0 +1,135 @@ +import { randomBytes } from "node:crypto" +import { mkdir, readFile, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises" +import path from "node:path" +import { setTimeout } from "node:timers/promises" + +import { readErrorCode } from "../read-error-code/index.mjs" + +export function createPrivateStore(directory: string) { + async function prepare() { + await mkdir(directory, { recursive: true, mode: 0o700 }) + if (((await stat(directory)).mode & 0o077) !== 0) + throw new Error("Canvas credential directory is not private.") + } + async function read(name: string): Promise { + await prepare() + try { + const file = path.join(directory, name) + if (((await stat(file)).mode & 0o077) !== 0) + throw new Error("Canvas credential file is not private.") + return JSON.parse(await readFile(file, "utf8")) + } catch (error) { + if (readErrorCode(error) === "ENOENT") return null + throw error + } + } + async function remove(name: string) { + try { + await unlink(path.join(directory, name)) + } catch (error) { + if (readErrorCode(error) !== "ENOENT") throw error + } + } + return { + read, + remove, + async write(name: string, value: unknown) { + await prepare() + const temporary = `.${randomBytes(16).toString("hex")}.tmp` + try { + await writeFile(path.join(directory, temporary), JSON.stringify(value), { + mode: 0o600, + flag: "wx", + }) + await rename(path.join(directory, temporary), path.join(directory, name)) + } finally { + await remove(temporary) + } + }, + async lock(name: string, action: () => T | Promise): Promise { + await prepare() + const lock = path.join(directory, `${name}.lock`) + const deadline = Date.now() + 35_000 + while (true) { + try { + await mkdir(lock, { mode: 0o700 }) + break + } catch (error) { + if (readErrorCode(error) !== "EEXIST") throw error + } + await recoverLock(lock) + if (Date.now() >= deadline) + throw new Error("Another canvas is updating this connection. Try again shortly.") + await setTimeout(50) + } + try { + await writeFile(path.join(lock, "owner"), String(process.pid), { + mode: 0o600, + }) + return await action() + } finally { + await releaseLock(lock) + } + }, + } +} + +async function releaseLock(lock: string) { + try { + await unlink(path.join(lock, "owner")) + } catch (error) { + if (readErrorCode(error) !== "ENOENT") throw error + } + for (let attempt = 0; ; attempt++) { + try { + await rmdir(lock) + return + } catch (error) { + if (readErrorCode(error) !== "ENOTEMPTY" || attempt >= 20) throw error + await setTimeout(50) + } + } +} + +async function recoverLock(lock: string) { + try { + const observed = await stat(lock) + let abandoned + try { + const owner = Number(await readFile(path.join(lock, "owner"), "utf8")) + if (!Number.isSafeInteger(owner) || owner <= 0) { + abandoned = Date.now() - (await stat(path.join(lock, "owner"))).mtimeMs > 60_000 + } else { + try { + process.kill(owner, 0) + abandoned = false + } catch (error) { + if (readErrorCode(error) !== "ESRCH") throw error + abandoned = true + } + } + } catch (error) { + if (readErrorCode(error) !== "ENOENT") throw error + abandoned = Date.now() - (await stat(lock)).mtimeMs > 60_000 + } + if (!abandoned) return + // Only one process can recover an abandoned lock; a live owner is never evicted. + const reaper = path.join(lock, "reaper") + try { + await mkdir(reaper) + } catch (error) { + if (readErrorCode(error) !== "EEXIST") throw error + if (Date.now() - (await stat(reaper)).mtimeMs > 60_000) await rmdir(reaper) + return + } + if ((await stat(lock)).ino !== observed.ino) { + await rmdir(path.join(lock, "reaper")) + return + } + const retired = `${lock}.${randomBytes(16).toString("hex")}` + await rename(lock, retired) + await rm(retired, { recursive: true }) + } catch (error) { + if (readErrorCode(error) !== "ENOENT" && readErrorCode(error) !== "EEXIST") throw error + } +} diff --git a/.github/extensions/replica-prototype/read-canvas-repository/index.mjs b/.github/extensions/replica-prototype/read-canvas-repository/index.mjs index 327b4bb9..25d6cc71 100644 --- a/.github/extensions/replica-prototype/read-canvas-repository/index.mjs +++ b/.github/extensions/replica-prototype/read-canvas-repository/index.mjs @@ -1,18 +1,16 @@ import path from "node:path" - import { readRepositoryName } from "../read-repository-name/index.mjs" - export async function readCanvasRepository(context) { const repository = await readRepositoryName(context.repositoryPath) + const targetRepository = context.target?.repository const localRoot = path.dirname(context.repositoryPath) const localName = path.basename(context.repositoryPath) const local = - context.target?.repository === `local/${localName}` && + targetRepository === `local/${localName}` && path.basename(localRoot) === "prototypes" && path.basename(path.dirname(localRoot)) === ".local" - return { - repository: local ? context.target.repository : repository.repository, + repository: local ? targetRepository : repository.repository, githubRepository: repository.repository, legacyRepository: repository.legacyRepository, local, diff --git a/.github/extensions/replica-prototype/read-canvas-repository/index.mts b/.github/extensions/replica-prototype/read-canvas-repository/index.mts new file mode 100644 index 00000000..0d0c9cdd --- /dev/null +++ b/.github/extensions/replica-prototype/read-canvas-repository/index.mts @@ -0,0 +1,24 @@ +import path from "node:path" + +import type { CanvasContext } from "../create-canvas-context/index.mjs" +import { readRepositoryName } from "../read-repository-name/index.mjs" + +export async function readCanvasRepository( + context: Pick & { target?: { repository: string | null } }, +) { + const repository = await readRepositoryName(context.repositoryPath) + const targetRepository = context.target?.repository + const localRoot = path.dirname(context.repositoryPath) + const localName = path.basename(context.repositoryPath) + const local = + targetRepository === `local/${localName}` && + path.basename(localRoot) === "prototypes" && + path.basename(path.dirname(localRoot)) === ".local" + + return { + repository: local ? targetRepository : repository.repository, + githubRepository: repository.repository, + legacyRepository: repository.legacyRepository, + local, + } +} diff --git a/.github/extensions/replica-prototype/read-error-code/index.mjs b/.github/extensions/replica-prototype/read-error-code/index.mjs new file mode 100644 index 00000000..63ee33b1 --- /dev/null +++ b/.github/extensions/replica-prototype/read-error-code/index.mjs @@ -0,0 +1,4 @@ +export function readErrorCode(error) { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined + return typeof error.code === "string" || typeof error.code === "number" ? error.code : undefined +} diff --git a/.github/extensions/replica-prototype/read-error-code/index.mts b/.github/extensions/replica-prototype/read-error-code/index.mts new file mode 100644 index 00000000..31ea1bc9 --- /dev/null +++ b/.github/extensions/replica-prototype/read-error-code/index.mts @@ -0,0 +1,4 @@ +export function readErrorCode(error: unknown): string | number | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined + return typeof error.code === "string" || typeof error.code === "number" ? error.code : undefined +} diff --git a/.github/extensions/replica-prototype/read-repository-name/index.mjs b/.github/extensions/replica-prototype/read-repository-name/index.mjs index 84927410..9b3d2200 100644 --- a/.github/extensions/replica-prototype/read-repository-name/index.mjs +++ b/.github/extensions/replica-prototype/read-repository-name/index.mjs @@ -1,11 +1,9 @@ import { runCommand } from "../run-command/index.mjs" - export async function readRepositoryName(repositoryPath) { const result = await runCommand("git", ["remote", "get-url", "origin"], repositoryPath) if (!result.ok) { return { repository: null, error: result.error } } - const repository = parseGitHubRepository(result.stdout.trim()) return repository ? { repository: repository.toLowerCase(), legacyRepository: repository, error: null } @@ -14,12 +12,12 @@ export async function readRepositoryName(repositoryPath) { error: "The origin remote is not a GitHub repository.", } } - function parseGitHubRepository(remoteUrl) { const match = remoteUrl.match( /^(?:https?:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/, ) - return match && /^[a-z0-9-]+$/i.test(match[1]) && /^[a-z0-9_.-]+$/i.test(match[2]) - ? `${match[1]}/${match[2]}` + const [, owner, name] = match ?? [] + return owner && name && /^[a-z0-9-]+$/i.test(owner) && /^[a-z0-9_.-]+$/i.test(name) + ? `${owner}/${name}` : null } diff --git a/.github/extensions/replica-prototype/read-repository-name/index.mts b/.github/extensions/replica-prototype/read-repository-name/index.mts new file mode 100644 index 00000000..dda71975 --- /dev/null +++ b/.github/extensions/replica-prototype/read-repository-name/index.mts @@ -0,0 +1,26 @@ +import { runCommand } from "../run-command/index.mjs" + +export async function readRepositoryName(repositoryPath: string) { + const result = await runCommand("git", ["remote", "get-url", "origin"], repositoryPath) + if (!result.ok) { + return { repository: null, error: result.error } + } + + const repository = parseGitHubRepository(result.stdout.trim()) + return repository + ? { repository: repository.toLowerCase(), legacyRepository: repository, error: null } + : { + repository: null, + error: "The origin remote is not a GitHub repository.", + } +} + +function parseGitHubRepository(remoteUrl: string) { + const match = remoteUrl.match( + /^(?:https?:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([^/\s]+)\/([^/\s]+?)(?:\.git)?$/, + ) + const [, owner, name] = match ?? [] + return owner && name && /^[a-z0-9-]+$/i.test(owner) && /^[a-z0-9_.-]+$/i.test(name) + ? `${owner}/${name}` + : null +} diff --git a/.github/extensions/replica-prototype/read-state/index.mjs b/.github/extensions/replica-prototype/read-state/index.mjs index 5ddd9ee1..f962cf2c 100644 --- a/.github/extensions/replica-prototype/read-state/index.mjs +++ b/.github/extensions/replica-prototype/read-state/index.mjs @@ -1,17 +1,14 @@ import { mkdir, readFile } from "node:fs/promises" - +import { readErrorCode } from "../read-error-code/index.mjs" import { writeState } from "../write-state/index.mjs" - const STATE_VERSION = 1 - export async function readState(context) { await mkdir(context.stateDirectory, { recursive: true }) - try { const content = await readFile(context.stateFile, "utf8") return validateStoredState(JSON.parse(content)) } catch (error) { - if (error?.code !== "ENOENT") { + if (readErrorCode(error) !== "ENOENT") { if (error instanceof SyntaxError) { throw new Error(`Stored Replica prototype state is invalid JSON: ${context.stateFile}`, { cause: error, @@ -20,7 +17,6 @@ export async function readState(context) { throw error } } - const state = { version: STATE_VERSION, prototypeUrl: context.normalizedUrl, @@ -35,18 +31,19 @@ export async function readState(context) { updatedAt: new Date().toISOString(), } await writeState(context, state) - return state } - function validateStoredState(value) { - if (!value || value.version !== STATE_VERSION) { + if ( + typeof value !== "object" || + value === null || + !("version" in value) || + value.version !== STATE_VERSION + ) { throw new Error("Stored Replica prototype state has an unsupported format.") } - return value } - function emptyStats(context) { return { branch: context.target.branch, diff --git a/.github/extensions/replica-prototype/read-state/index.mts b/.github/extensions/replica-prototype/read-state/index.mts new file mode 100644 index 00000000..75d47971 --- /dev/null +++ b/.github/extensions/replica-prototype/read-state/index.mts @@ -0,0 +1,83 @@ +import { mkdir, readFile } from "node:fs/promises" + +import type { CanvasContext } from "../create-canvas-context/index.mjs" +import { readErrorCode } from "../read-error-code/index.mjs" +import { writeState } from "../write-state/index.mjs" + +const STATE_VERSION = 1 + +export async function readState(context: CanvasContext): Promise { + await mkdir(context.stateDirectory, { recursive: true }) + + try { + const content = await readFile(context.stateFile, "utf8") + return validateStoredState(JSON.parse(content)) + } catch (error) { + if (readErrorCode(error) !== "ENOENT") { + if (error instanceof SyntaxError) { + throw new Error(`Stored Replica prototype state is invalid JSON: ${context.stateFile}`, { + cause: error, + }) + } + throw error + } + } + + const state = { + version: STATE_VERSION, + prototypeUrl: context.normalizedUrl, + name: context.name, + publishRequest: { + state: "idle", + requestedAt: null, + messageId: null, + error: null, + }, + stats: emptyStats(context), + updatedAt: new Date().toISOString(), + } + await writeState(context, state) + + return state +} + +function validateStoredState(value: unknown) { + if ( + typeof value !== "object" || + value === null || + !("version" in value) || + value.version !== STATE_VERSION + ) { + throw new Error("Stored Replica prototype state has an unsupported format.") + } + + return value +} + +function emptyStats(context: CanvasContext) { + return { + branch: context.target.branch, + branchError: "Status has not been refreshed.", + comparison: { + branch: null, + source: null, + behind: null, + error: "Status has not been refreshed.", + }, + repository: context.target.repository, + repositoryError: "Status has not been refreshed.", + commitSha: context.target.commitSha, + publishedUrl: context.target.publishedUrl, + workingTree: "unavailable", + workingTreeError: "Status has not been refreshed.", + replicaWorkflowExists: false, + remoteBranch: { state: "loading", repository: null, branch: null, commitSha: null }, + workflow: { + state: "unavailable", + detail: "Status has not been refreshed.", + run: null, + }, + urlHost: context.urlHost, + refreshedAt: null, + } +} diff --git a/.github/extensions/replica-prototype/read-state/index.test.mjs b/.github/extensions/replica-prototype/read-state/index.test.mjs new file mode 100644 index 00000000..25efa9f9 --- /dev/null +++ b/.github/extensions/replica-prototype/read-state/index.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { readFile, writeFile } from "node:fs/promises" +import { describe, it } from "node:test" + +import { withContext } from "../test-support/with-context/index.mjs" +import { writeState } from "../write-state/index.mjs" +import { readState } from "./index.mjs" + +describe("readState()", () => { + it("persists initial state and retains workflow-owned fields without interpreting them", async () => { + await withContext(async (context) => { + const initial = await readState(context) + assert.equal(initial.version, 1) + assert.equal(initial.publishRequest.state, "idle") + assert.deepEqual(JSON.parse(await readFile(context.stateFile, "utf8")), initial) + const state = { version: 1, workflowData: { retained: ["value", null, 2] } } + await writeState(context, state) + assert.deepEqual(await readState(context), state) + }) + }) + + it("reports corrupt JSON without replacing the stored file", async () => { + await withContext(async (context) => { + await readState(context) + await writeFile(context.stateFile, "{invalid") + await assert.rejects(readState(context), (error) => { + assert.match(error.message, /Stored Replica prototype state is invalid JSON/) + assert.ok(error.cause instanceof SyntaxError) + return true + }) + assert.equal(await readFile(context.stateFile, "utf8"), "{invalid") + }) + }) + + it("rejects unsupported stored formats without overwriting them", async () => { + await withContext(async (context) => { + for (const value of [null, [], 1, "state", {}, { version: 2 }, { version: "1" }]) { + await writeState(context, value) + await assert.rejects(readState(context), /unsupported format/) + assert.deepEqual(JSON.parse(await readFile(context.stateFile, "utf8")), value) + } + }) + }) +}) diff --git a/.github/extensions/replica-prototype/run-command/index.d.mts b/.github/extensions/replica-prototype/run-command/index.d.mts deleted file mode 100644 index 69445471..00000000 --- a/.github/extensions/replica-prototype/run-command/index.d.mts +++ /dev/null @@ -1,15 +0,0 @@ -export function runCommand( - command: string, - args: string[], - cwd?: string, - options?: { env?: Record }, -): Promise< - | { ok: true; stdout: string } - | { - ok: false - error: string - exitCode?: string | number - stdout?: string - stderr?: string - } -> diff --git a/.github/extensions/replica-prototype/run-command/index.mjs b/.github/extensions/replica-prototype/run-command/index.mjs index ed1a7cae..06e4772a 100644 --- a/.github/extensions/replica-prototype/run-command/index.mjs +++ b/.github/extensions/replica-prototype/run-command/index.mjs @@ -1,8 +1,7 @@ import { execFile } from "node:child_process" import { promisify } from "node:util" - +import { readErrorCode } from "../read-error-code/index.mjs" const execFileAsync = promisify(execFile) - export async function runCommand(command, args, cwd, options = {}) { try { const { stdout } = await execFileAsync(command, args, { @@ -15,22 +14,34 @@ export async function runCommand(command, args, cwd, options = {}) { }) return { ok: true, stdout } } catch (error) { - if (error?.code === "ENOENT") { + if (readErrorCode(error) === "ENOENT") { return { ok: false, error: `${command} is not installed.` } } - - const detail = - typeof error?.stderr === "string" && error.stderr.trim() - ? error.stderr.trim() - : error instanceof Error - ? error.message - : String(error) + const stdout = + typeof error === "object" && + error !== null && + "stdout" in error && + typeof error.stdout === "string" + ? error.stdout + : "" + const stderr = + typeof error === "object" && + error !== null && + "stderr" in error && + typeof error.stderr === "string" + ? error.stderr + : "" + const detail = stderr.trim() + ? stderr.trim() + : error instanceof Error + ? error.message + : String(error) return { ok: false, error: detail, - exitCode: error.code, - stdout: typeof error.stdout === "string" ? error.stdout : "", - stderr: typeof error.stderr === "string" ? error.stderr : "", + exitCode: readErrorCode(error), + stdout, + stderr, } } } diff --git a/.github/extensions/replica-prototype/run-command/index.mts b/.github/extensions/replica-prototype/run-command/index.mts new file mode 100644 index 00000000..4752e4cb --- /dev/null +++ b/.github/extensions/replica-prototype/run-command/index.mts @@ -0,0 +1,60 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +import { readErrorCode } from "../read-error-code/index.mjs" + +const execFileAsync = promisify(execFile) + +export type CommandResult = + | { ok: true; stdout: string } + | { ok: false; error: string; exitCode?: string | number; stdout?: string; stderr?: string } + +export async function runCommand( + command: string, + args: string[], + cwd?: string, + options: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + try { + const { stdout } = await execFileAsync(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 1024 * 1024, + timeout: 10_000, + windowsHide: true, + env: options.env, + }) + return { ok: true, stdout } + } catch (error) { + if (readErrorCode(error) === "ENOENT") { + return { ok: false, error: `${command} is not installed.` } + } + + const stdout = + typeof error === "object" && + error !== null && + "stdout" in error && + typeof error.stdout === "string" + ? error.stdout + : "" + const stderr = + typeof error === "object" && + error !== null && + "stderr" in error && + typeof error.stderr === "string" + ? error.stderr + : "" + const detail = stderr.trim() + ? stderr.trim() + : error instanceof Error + ? error.message + : String(error) + return { + ok: false, + error: detail, + exitCode: readErrorCode(error), + stdout, + stderr, + } + } +} diff --git a/.github/extensions/replica-prototype/run-command/index.test.mjs b/.github/extensions/replica-prototype/run-command/index.test.mjs new file mode 100644 index 00000000..10f12336 --- /dev/null +++ b/.github/extensions/replica-prototype/run-command/index.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { runCommand } from "./index.mjs" + +describe("runCommand()", () => { + it("preserves literal arguments, the working directory and the supplied environment", async () => { + const cwd = process.cwd() + const argument = "$(printf not-a-shell)" + const result = await runCommand( + process.execPath, + [ + "-e", + "process.stdout.write(JSON.stringify([process.cwd(), process.argv[1], process.env.REPLICA_COMMAND_TEST]))", + argument, + ], + cwd, + { env: { REPLICA_COMMAND_TEST: "present" } }, + ) + assert.equal(result.ok, true) + assert.deepEqual(JSON.parse(result.stdout), [cwd, argument, "present"]) + }) + + it("reports a missing executable without inventing process output", async () => { + const command = "/replica-command-test/missing-executable" + assert.deepEqual(await runCommand(command, [], process.cwd()), { + ok: false, + error: `${command} is not installed.`, + }) + }) + + it("retains the exit code and both output streams from a failed command", async () => { + assert.deepEqual( + await runCommand(process.execPath, [ + "-e", + 'process.stdout.write("partial result"); process.stderr.write(" command failed\\n"); process.exitCode = 7', + ]), + { + ok: false, + error: "command failed", + exitCode: 7, + stdout: "partial result", + stderr: " command failed\n", + }, + ) + const withoutStderr = await runCommand(process.execPath, ["-e", "process.exitCode = 8"]) + assert.equal(withoutStderr.ok, false) + assert.equal(withoutStderr.exitCode, 8) + assert.match(withoutStderr.error, /Command failed/) + }) +}) diff --git a/.github/extensions/replica-prototype/tsconfig.json b/.github/extensions/replica-prototype/tsconfig.json index e20c8f27..7e85685c 100644 --- a/.github/extensions/replica-prototype/tsconfig.json +++ b/.github/extensions/replica-prototype/tsconfig.json @@ -11,7 +11,8 @@ "noEmit": true, "noEmitOnError": true, "skipLibCheck": true, - "types": [] + "typeRoots": ["../../../hub/node_modules/@types"], + "types": ["node"] }, "include": ["**/*.mts"] } diff --git a/.github/extensions/replica-prototype/write-state/index.mjs b/.github/extensions/replica-prototype/write-state/index.mjs index 2136948d..af2a4ffa 100644 --- a/.github/extensions/replica-prototype/write-state/index.mjs +++ b/.github/extensions/replica-prototype/write-state/index.mjs @@ -1,6 +1,5 @@ import { randomUUID } from "node:crypto" import { mkdir, rename, writeFile } from "node:fs/promises" - export async function writeState(context, state) { await mkdir(context.stateDirectory, { recursive: true }) const temporaryFile = `${context.stateFile}.${process.pid}.${randomUUID()}.new` diff --git a/.github/extensions/replica-prototype/write-state/index.mts b/.github/extensions/replica-prototype/write-state/index.mts new file mode 100644 index 00000000..f641f02d --- /dev/null +++ b/.github/extensions/replica-prototype/write-state/index.mts @@ -0,0 +1,14 @@ +import { randomUUID } from "node:crypto" +import { mkdir, rename, writeFile } from "node:fs/promises" + +export type StateLocation = { stateDirectory: string; stateFile: string } + +export async function writeState(context: StateLocation, state: unknown) { + await mkdir(context.stateDirectory, { recursive: true }) + const temporaryFile = `${context.stateFile}.${process.pid}.${randomUUID()}.new` + await writeFile(temporaryFile, `${JSON.stringify(state, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }) + await rename(temporaryFile, context.stateFile) +} diff --git a/hub/docs/prototype-canvas.md b/hub/docs/prototype-canvas.md index bddbf14a..19a70b68 100644 --- a/hub/docs/prototype-canvas.md +++ b/hub/docs/prototype-canvas.md @@ -454,7 +454,9 @@ 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`. +The canvas frontend already uses TypeScript. Extension Node modules are migrating separately. Remote-branch checks, command execution, private storage, state-file reads and writes, and repository identity and context now have authored `index.mts` files. Hub clients, publishing and review workflows, servers, and SDK registration still use authored JavaScript. + +Edit migrated `.mts` sources, not their generated `.mjs` files. The extension's `tsconfig.json` checks them in strict mode with unchecked-index protection and uses Hub's locked Node types. Storage readers expose parsed JSON as `unknown`; consuming TypeScript workflows must check its fields before using them. Hub's `typecheck` and `lint` commands include migrated modules. Run `extension:modules:build` to compile them and format every emitted runtime module 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. diff --git a/hub/package.json b/hub/package.json index 8574be76..e2c127cf 100644 --- a/hub/package.json +++ b/hub/package.json @@ -17,7 +17,7 @@ "dev": "vite dev", "extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.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:modules:build": "node script/build-extension-modules.mjs", "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,mts}' '../.github/extensions/replica-prototype/*.json' '../packages/learning/**/*.{mjs,mts,json}' --config .prettierrc.json", diff --git a/hub/script/build-extension-modules.mjs b/hub/script/build-extension-modules.mjs new file mode 100644 index 00000000..d906aa30 --- /dev/null +++ b/hub/script/build-extension-modules.mjs @@ -0,0 +1,26 @@ +import { execFileSync } from "node:child_process" +import { readFile, writeFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import * as prettier from "prettier" + +const project = fileURLToPath( + new URL("../../.github/extensions/replica-prototype/tsconfig.json", import.meta.url), +) +const output = execFileSync( + "tsc", + ["--project", project, "--noEmit", "false", "--listEmittedFiles"], + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, +) +const options = await prettier.resolveConfig( + fileURLToPath(new URL("../.prettierrc.json", import.meta.url)), +) + +for (const line of output.split(/\r?\n/)) { + if (!line.startsWith("TSFILE: ")) continue + const file = line.slice("TSFILE: ".length) + if (!file.endsWith(".mjs")) continue + await writeFile( + file, + await prettier.format(await readFile(file, "utf8"), { ...options, filepath: file }), + ) +} From 57a6b96a4c7bb4f3f10ceea92dd1f394432f8d29 Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Thu, 22 Oct 2026 08:02:49 +0200 Subject: [PATCH 3/4] fix(canvas): type nullable command exit codes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9d7306a9-9831-4c78-89e7-3c5b581c895e --- .github/extensions/replica-prototype/run-command/index.d.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/extensions/replica-prototype/run-command/index.d.mts b/.github/extensions/replica-prototype/run-command/index.d.mts index 69445471..b80d7229 100644 --- a/.github/extensions/replica-prototype/run-command/index.d.mts +++ b/.github/extensions/replica-prototype/run-command/index.d.mts @@ -8,7 +8,7 @@ export function runCommand( | { ok: false error: string - exitCode?: string | number + exitCode?: string | number | null stdout?: string stderr?: string } From 607fccd1412b3f377bc7f4957c717949737d8584 Mon Sep 17 00:00:00 2001 From: Lukas Oppermann Date: Thu, 22 Oct 2026 08:03:27 +0200 Subject: [PATCH 4/4] fix(canvas): strengthen infrastructure migration checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1aff284a-77f1-4bcf-8245-e30889a2f392 --- .../run-command/index.test.mjs | 36 +++++++++++-------- hub/script/build-extension-modules.mjs | 31 +++++++++------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/.github/extensions/replica-prototype/run-command/index.test.mjs b/.github/extensions/replica-prototype/run-command/index.test.mjs index 10f12336..8e8d5b7d 100644 --- a/.github/extensions/replica-prototype/run-command/index.test.mjs +++ b/.github/extensions/replica-prototype/run-command/index.test.mjs @@ -1,24 +1,32 @@ import assert from "node:assert/strict" +import { mkdtemp, realpath, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" import { describe, it } from "node:test" import { runCommand } from "./index.mjs" describe("runCommand()", () => { it("preserves literal arguments, the working directory and the supplied environment", async () => { - const cwd = process.cwd() - const argument = "$(printf not-a-shell)" - const result = await runCommand( - process.execPath, - [ - "-e", - "process.stdout.write(JSON.stringify([process.cwd(), process.argv[1], process.env.REPLICA_COMMAND_TEST]))", - argument, - ], - cwd, - { env: { REPLICA_COMMAND_TEST: "present" } }, - ) - assert.equal(result.ok, true) - assert.deepEqual(JSON.parse(result.stdout), [cwd, argument, "present"]) + const directory = await mkdtemp(path.join(os.tmpdir(), "replica-run-command-")) + try { + const cwd = await realpath(directory) + const argument = "$(printf not-a-shell)" + const result = await runCommand( + process.execPath, + [ + "-e", + "process.stdout.write(JSON.stringify([process.cwd(), process.argv[1], process.env.REPLICA_COMMAND_TEST]))", + argument, + ], + cwd, + { env: { REPLICA_COMMAND_TEST: "present" } }, + ) + assert.equal(result.ok, true) + assert.deepEqual(JSON.parse(result.stdout), [cwd, argument, "present"]) + } finally { + await rm(directory, { recursive: true, force: true }) + } }) it("reports a missing executable without inventing process output", async () => { diff --git a/hub/script/build-extension-modules.mjs b/hub/script/build-extension-modules.mjs index d906aa30..fa195ae6 100644 --- a/hub/script/build-extension-modules.mjs +++ b/hub/script/build-extension-modules.mjs @@ -1,4 +1,4 @@ -import { execFileSync } from "node:child_process" +import { spawnSync } from "node:child_process" import { readFile, writeFile } from "node:fs/promises" import { fileURLToPath } from "node:url" import * as prettier from "prettier" @@ -6,21 +6,28 @@ import * as prettier from "prettier" const project = fileURLToPath( new URL("../../.github/extensions/replica-prototype/tsconfig.json", import.meta.url), ) -const output = execFileSync( +const compilation = spawnSync( "tsc", ["--project", project, "--noEmit", "false", "--listEmittedFiles"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, ) -const options = await prettier.resolveConfig( - fileURLToPath(new URL("../.prettierrc.json", import.meta.url)), -) -for (const line of output.split(/\r?\n/)) { - if (!line.startsWith("TSFILE: ")) continue - const file = line.slice("TSFILE: ".length) - if (!file.endsWith(".mjs")) continue - await writeFile( - file, - await prettier.format(await readFile(file, "utf8"), { ...options, filepath: file }), +if (compilation.error) throw compilation.error +if (compilation.status !== 0) { + process.stdout.write(compilation.stdout ?? "") + process.exitCode = compilation.status ?? 1 +} else { + const options = await prettier.resolveConfig( + fileURLToPath(new URL("../.prettierrc.json", import.meta.url)), ) + + for (const line of compilation.stdout.split(/\r?\n/)) { + if (!line.startsWith("TSFILE: ")) continue + const file = line.slice("TSFILE: ".length) + if (!file.endsWith(".mjs")) continue + await writeFile( + file, + await prettier.format(await readFile(file, "utf8"), { ...options, filepath: file }), + ) + } }