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..3fc3c1d5 --- /dev/null +++ b/.github/extensions/replica-prototype/read-error-code/index.mjs @@ -0,0 +1,6 @@ +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 === null + ? 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..66dcd2c3 --- /dev/null +++ b/.github/extensions/replica-prototype/read-error-code/index.mts @@ -0,0 +1,6 @@ +export function readErrorCode(error: unknown): string | number | null | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined + return typeof error.code === "string" || typeof error.code === "number" || error.code === null + ? 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 b80d7229..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 | null - 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..32e01366 --- /dev/null +++ b/.github/extensions/replica-prototype/run-command/index.mts @@ -0,0 +1,66 @@ +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 | null + 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..8c093e01 --- /dev/null +++ b/.github/extensions/replica-prototype/run-command/index.test.mjs @@ -0,0 +1,66 @@ +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 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 () => { + 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/) + + const signaled = await runCommand(process.execPath, [ + "-e", + 'process.kill(process.pid, "SIGTERM")', + ]) + assert.equal(signaled.ok, false) + assert.equal(signaled.exitCode, null) + }) +}) 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..fa195ae6 --- /dev/null +++ b/hub/script/build-extension-modules.mjs @@ -0,0 +1,33 @@ +import { spawnSync } 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 compilation = spawnSync( + "tsc", + ["--project", project, "--noEmit", "false", "--listEmittedFiles"], + { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, +) + +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 }), + ) + } +}