From c16810d037f2b0478e7952358e08815dd69604ee Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 01:32:27 +0200 Subject: [PATCH 01/15] fix: run reconciliation CLI under the pinned Node runtime Preload source alias resolution and transform TypeScript syntax. Validate CLI arguments before constructing database dependencies and smoke-test the documented pnpm commands. Co-Authored-By: GPT-6 Astra --- README.md | 2 +- package.json | 2 +- scripts/reconcile.ts | 25 ++++++++----- scripts/register-path-aliases.ts | 18 ++++++++++ tests/deploy/reconcile-cli.test.ts | 56 ++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 scripts/register-path-aliases.ts create mode 100644 tests/deploy/reconcile-cli.test.ts diff --git a/README.md b/README.md index 34e40b4e..d88e7895 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ Run reconciliation explicitly when GitHub history must be re-read: ```bash # Reconcile one explicit registered repository by owner/name. -pnpm reconcile -- --repository / +pnpm reconcile --repository / # Reconcile every active registered repository. pnpm reconcile diff --git a/package.json b/package.json index e64eb4a5..21669603 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "db:migrate": "node scripts/migrate.ts", "release:switch": "node scripts/release.ts switch", "release:prune": "node scripts/release.ts prune", - "reconcile": "node scripts/reconcile.ts" + "reconcile": "node --experimental-transform-types --import ./scripts/register-path-aliases.ts scripts/reconcile.ts" }, "dependencies": { "next": "16.3.4", diff --git a/scripts/reconcile.ts b/scripts/reconcile.ts index 73b84323..13a060b3 100644 --- a/scripts/reconcile.ts +++ b/scripts/reconcile.ts @@ -16,21 +16,20 @@ export async function runReconciliationCli( ): Promise; export async function runReconciliationCli( argumentsList: readonly string[] = process.argv.slice(2), - dependencies: ReconcileCliDependencies = productionDependencies(), + dependencies?: ReconcileCliDependencies, ): Promise { - const repositoryIds = await repositoryIdsForArguments(argumentsList, dependencies.store); + const ownerName = parseArguments(argumentsList); + dependencies ??= productionDependencies(); + const repositoryIds = await repositoryIdsForOwnerName(ownerName, dependencies.store); for (const repositoryId of repositoryIds) { const summary = await dependencies.reconcile(repositoryId); dependencies.write(JSON.stringify(summary)); } } -async function repositoryIdsForArguments( - argumentsList: readonly string[], - store: ReconcileCliDependencies["store"], -): Promise { +function parseArguments(argumentsList: readonly string[]): string | null { if (argumentsList.length === 0) { - return store.listActiveRepositoryIds(); + return null; } if ( argumentsList.length !== 2 || @@ -40,7 +39,17 @@ async function repositoryIdsForArguments( throw new Error("Usage: pnpm reconcile [--repository owner/name]"); } - const repository = await store.findRepositoryByOwnerName(argumentsList[1]!); + return argumentsList[1]!; +} + +async function repositoryIdsForOwnerName( + ownerName: string | null, + store: ReconcileCliDependencies["store"], +): Promise { + if (ownerName === null) { + return store.listActiveRepositoryIds(); + } + const repository = await store.findRepositoryByOwnerName(ownerName); if (repository === null) { throw new Error("Registered active repository was not found."); } diff --git a/scripts/register-path-aliases.ts b/scripts/register-path-aliases.ts new file mode 100644 index 00000000..b37ab4f0 --- /dev/null +++ b/scripts/register-path-aliases.ts @@ -0,0 +1,18 @@ +import { existsSync } from "node:fs"; +import { registerHooks } from "node:module"; + +const sourceRoot = new URL("../src/", import.meta.url); + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith("@/")) { + for (const suffix of [".ts", ".tsx", "/index.ts"]) { + const candidate = new URL(`${specifier.slice(2)}${suffix}`, sourceRoot); + if (existsSync(candidate)) { + return nextResolve(candidate.href, context); + } + } + } + return nextResolve(specifier, context); + }, +}); diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts new file mode 100644 index 00000000..18eabff2 --- /dev/null +++ b/tests/deploy/reconcile-cli.test.ts @@ -0,0 +1,56 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); +const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8"); +const reconciliationSection = readme.split(/^## Reconciliation\r?$/m)[1]?.split(/^## /m)[0] ?? ""; +const documentedCommands = [...reconciliationSection.matchAll(/^```bash\r?\n([\s\S]*?)^```\s*$/gm)] + .flatMap((block) => block[1]!.split(/\r?\n/)) + .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); +const databaseError = "DATABASE_URL must be configured before using the database."; + +function runCommand(command: string) { + // Replace the README's owner/name placeholder with a syntactically valid repository. + const [executable, ...argumentsList] = command.replaceAll("/", "octocat/hello-world").split(/\s+/); + const environment = { ...process.env }; + delete environment.DATABASE_URL; + const result = spawnSync(executable!, argumentsList, { + cwd: repositoryRoot, + env: environment, + encoding: "utf8", + timeout: 60_000, + }); + // A missing pnpm executable or a timed-out child is a failure, never a skip. + if (result.error) throw result.error; + expect(result.signal).toBeNull(); + expect(result.status).not.toBeNull(); + expect(result.status).not.toBe(0); + return result; +} + +describe("documented reconciliation CLI commands", () => { + it("extracts at least two commands from the Reconciliation bash block", () => { + expect(documentedCommands.length).toBeGreaterThanOrEqual(2); + }); + + it.each(documentedCommands)("loads and parses %s before requiring a database", (command) => { + const { stderr } = runCommand(command); + for (const loadingError of [ + "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX", + "ERR_MODULE_NOT_FOUND", + "Cannot find package", + "SyntaxError", + ]) { + expect(stderr).not.toContain(loadingError); + } + expect(stderr).toContain(databaseError); + }, 120_000); + + it("reports invalid arguments before requiring a database", () => { + const { stderr } = runCommand("pnpm reconcile --not-a-flag"); + expect(stderr).toContain("Usage: pnpm reconcile [--repository owner/name]"); + expect(stderr).not.toContain(databaseError); + }, 120_000); +}); From cb500ebe2025bf4d71c93bd508b34c8089da917a Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 02:08:16 +0200 Subject: [PATCH 02/15] test: run documented reconciliation commands with PostgreSQL Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 68 +++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 18eabff2..a5077c15 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -1,7 +1,10 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import postgres, { type Sql } from "postgres"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { validDifficultyScheme } from "../support/difficulty-scheme"; +import { startPostgresContainer, type StartedPostgres } from "../support/postgres-container"; const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8"); @@ -11,11 +14,12 @@ const documentedCommands = [...reconciliationSection.matchAll(/^```bash\r?\n([\s .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); const databaseError = "DATABASE_URL must be configured before using the database."; -function runCommand(command: string) { +function runCommand(command: string, databaseUrl?: string) { // Replace the README's owner/name placeholder with a syntactically valid repository. const [executable, ...argumentsList] = command.replaceAll("/", "octocat/hello-world").split(/\s+/); const environment = { ...process.env }; delete environment.DATABASE_URL; + if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; const result = spawnSync(executable!, argumentsList, { cwd: repositoryRoot, env: environment, @@ -26,7 +30,6 @@ function runCommand(command: string) { if (result.error) throw result.error; expect(result.signal).toBeNull(); expect(result.status).not.toBeNull(); - expect(result.status).not.toBe(0); return result; } @@ -36,7 +39,8 @@ describe("documented reconciliation CLI commands", () => { }); it.each(documentedCommands)("loads and parses %s before requiring a database", (command) => { - const { stderr } = runCommand(command); + const { status, stderr } = runCommand(command); + expect(status).not.toBe(0); for (const loadingError of [ "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX", "ERR_MODULE_NOT_FOUND", @@ -49,8 +53,62 @@ describe("documented reconciliation CLI commands", () => { }, 120_000); it("reports invalid arguments before requiring a database", () => { - const { stderr } = runCommand("pnpm reconcile --not-a-flag"); + const { status, stderr } = runCommand("pnpm reconcile --not-a-flag"); + expect(status).not.toBe(0); expect(stderr).toContain("Usage: pnpm reconcile [--repository owner/name]"); expect(stderr).not.toContain(databaseError); }, 120_000); }); + +describe("documented reconciliation CLI commands with PostgreSQL", () => { + let started: StartedPostgres | undefined; + let sql: Sql; + const repositoryIds: string[] = []; + + beforeAll(async () => { + started = await startPostgresContainer({ database: "cli", user: "cli", password: "cli" }); + sql = postgres(started.databaseUrl, { max: 1 }); + const migration = runCommand("pnpm db:migrate", started.databaseUrl); + expect(migration.status, migration.stderr).toBe(0); + + // No OAuth token is seeded: cooldown must return before any GitHub access. + const [sponsor] = await sql<{ id: string }[]>` + insert into users (github_user_id, github_login) + values (10001, 'cli-sponsor') returning id + `; + for (const [index, ownerName] of ["octocat/hello-world", "cli/second"].entries()) { + const [repository] = await sql<{ id: string }[]>` + insert into registered_repositories + (github_repository_id, owner_name, sponsor_id, visibility, github_webhook_id, + difficulty_scheme, reconciliation_not_before) + values (${10002 + index}, ${ownerName}, ${sponsor!.id}, 'PUBLIC', ${10002 + index}, + ${sql.json(validDifficultyScheme())}, now() + interval '1 day') + returning id + `; + repositoryIds.push(repository!.id); + } + }, 120_000); + + afterAll(async () => { + try { + await sql?.end(); + } finally { + await started?.container.stop(); + } + }, 120_000); + + it.each(documentedCommands)("successfully runs %s without GitHub access", async (command) => { + const { status, stdout, stderr } = runCommand(command, started!.databaseUrl); + expect(status, stderr).toBe(0); + const summaries = stdout.split(/\r?\n/) + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)); + const expectedIds = command.includes("--repository") ? [repositoryIds[0]!] : repositoryIds; + expect(summaries).toHaveLength(expectedIds.length); + expect(summaries).toEqual(expect.arrayContaining(expectedIds.map((repositoryId) => ({ + repositoryId, runId: null, skipped: true, + adds: 0, changes: 0, removals: 0, added: 0, changed: 0, removed: 0, + })))); + expect(await sql`select id from reconciliation_runs`).toHaveLength(0); + }, 120_000); +}); From 2a65134604a1c461bee04c9ad71105781b05a3b0 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 02:17:34 +0200 Subject: [PATCH 03/15] test: pin reconciliation modes and isolate child runtime options Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index a5077c15..d3e49b31 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -14,11 +14,22 @@ const documentedCommands = [...reconciliationSection.matchAll(/^```bash\r?\n([\s .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); const databaseError = "DATABASE_URL must be configured before using the database."; +function tokenizeCommand(command: string): string[] { + return command.replaceAll("/", "octocat/hello-world").split(/\s+/); +} + function runCommand(command: string, databaseUrl?: string) { // Replace the README's owner/name placeholder with a syntactically valid repository. - const [executable, ...argumentsList] = command.replaceAll("/", "octocat/hello-world").split(/\s+/); + const [executable, ...argumentsList] = tokenizeCommand(command); const environment = { ...process.env }; delete environment.DATABASE_URL; + // Node preloads/flags and pnpm's lifecycle options must not repair the command + // under test. Custom shell startup files can inject the same flags as well. + for (const name of Object.keys(environment)) { + if (/^(node_options|node_path|npm_config_(node_options|script_shell|shell_emulator)|bash_env|env)$/.test( + name.toLowerCase().replaceAll("-", "_"), + )) delete environment[name]; + } if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; const result = spawnSync(executable!, argumentsList, { cwd: repositoryRoot, @@ -34,8 +45,14 @@ function runCommand(command: string, databaseUrl?: string) { } describe("documented reconciliation CLI commands", () => { - it("extracts at least two commands from the Reconciliation bash block", () => { - expect(documentedCommands.length).toBeGreaterThanOrEqual(2); + it("extracts exactly one all-repositories and one selected-repository invocation", () => { + expect(documentedCommands, "No reconciliation commands found in the README bash block").not.toHaveLength(0); + const argumentShapes = documentedCommands.map((command) => tokenizeCommand(command).slice(2)); + expect(argumentShapes).toHaveLength(2); + expect(argumentShapes).toEqual(expect.arrayContaining([ + [], + ["--repository", expect.stringMatching(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/)], + ])); }); it.each(documentedCommands)("loads and parses %s before requiring a database", (command) => { From a6617d785ce006eb05ac7928c72444aa044e4792 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 02:21:55 +0200 Subject: [PATCH 04/15] test: tokenize quoted reconciliation command arguments Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 67 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index d3e49b31..d75ac79a 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -15,11 +15,44 @@ const documentedCommands = [...reconciliationSection.matchAll(/^```bash\r?\n([\s const databaseError = "DATABASE_URL must be configured before using the database."; function tokenizeCommand(command: string): string[] { - return command.replaceAll("/", "octocat/hello-world").split(/\s+/); + const words: string[] = []; + let word = ""; + let wordStarted = false; + let quote: "'" | '"' | null = null; + + // Only literal shell words are supported. Never silently reinterpret expansion, + // redirection, escapes or operators as argv passed to spawnSync. + for (const character of command.replaceAll("/", "octocat/hello-world")) { + const unsupported = () => { + throw new Error(`Unsupported shell syntax ${JSON.stringify(character)} in documented command: ${command}`); + }; + if (character === "\n" || character === "\r") unsupported(); + if (quote !== null) { + if (character === quote) { + quote = null; + } else { + if (quote === '"' && "$`\\".includes(character)) unsupported(); + word += character; + } + } else if (character === "'" || character === '"') { + quote = character; + wordStarted = true; + } else if (character === " " || character === "\t") { + if (wordStarted) words.push(word); + word = ""; + wordStarted = false; + } else { + if ("$`\\;&|<>(){}*?[]~#!".includes(character) || /\s/.test(character)) unsupported(); + word += character; + wordStarted = true; + } + } + if (quote !== null) throw new Error(`Unterminated quote in documented command: ${command}`); + if (wordStarted) words.push(word); + return words; } function runCommand(command: string, databaseUrl?: string) { - // Replace the README's owner/name placeholder with a syntactically valid repository. const [executable, ...argumentsList] = tokenizeCommand(command); const environment = { ...process.env }; delete environment.DATABASE_URL; @@ -75,6 +108,34 @@ describe("documented reconciliation CLI commands", () => { expect(stderr).toContain("Usage: pnpm reconcile [--repository owner/name]"); expect(stderr).not.toContain(databaseError); }, 120_000); + + it.each(["'", '"'])("passes a %s-quoted repository argument to the real CLI", (quote) => { + const { status, stderr } = runCommand(`pnpm reconcile --repository ${quote}/${quote}`); + expect(status).not.toBe(0); + expect(stderr).toContain(databaseError); + expect(stderr).not.toContain("Usage:"); + }, 120_000); +}); + +describe("documented command tokenization", () => { + it.each([ + { command: `pnpm reconcile --repository 'octocat/hello-world'`, words: ["pnpm", "reconcile", "--repository", "octocat/hello-world"] }, + { command: `pnpm reconcile --repository "octocat/hello-world"`, words: ["pnpm", "reconcile", "--repository", "octocat/hello-world"] }, + { command: `pnpm reconcile --repo"sitory" octocat/'hello-world'`, words: ["pnpm", "reconcile", "--repository", "octocat/hello-world"] }, + { command: `pnpm reconcile "two words" ''`, words: ["pnpm", "reconcile", "two words", ""] }, + { command: `pnpm reconcile '$HOME;*'`, words: ["pnpm", "reconcile", "$HOME;*"] }, + ])("preserves literal shell words in $command", ({ command, words }) => { + expect(tokenizeCommand(command)).toEqual(words); + }); + + it.each([ + '"unterminated', "'unterminated", "$OWNER/name", '"$OWNER/name"', "$(pwd)", "`pwd`", + "owner/*", "owner/{one,two}", "owner/name; true", "owner/name | cat", "> output", + "owner/\\name", '"owner/\\name"', "owner/name # comment", + ])("rejects unsupported shell syntax: %s", (argument) => { + expect(() => tokenizeCommand(`pnpm reconcile --repository ${argument}`)) + .toThrow(/Unsupported shell syntax|Unterminated quote/); + }); }); describe("documented reconciliation CLI commands with PostgreSQL", () => { @@ -120,7 +181,7 @@ describe("documented reconciliation CLI commands with PostgreSQL", () => { const summaries = stdout.split(/\r?\n/) .filter((line) => line.startsWith("{")) .map((line) => JSON.parse(line)); - const expectedIds = command.includes("--repository") ? [repositoryIds[0]!] : repositoryIds; + const expectedIds = tokenizeCommand(command).length === 4 ? [repositoryIds[0]!] : repositoryIds; expect(summaries).toHaveLength(expectedIds.length); expect(summaries).toEqual(expect.arrayContaining(expectedIds.map((repositoryId) => ({ repositoryId, runId: null, skipped: true, From 8a6ac1259902e1fca68c862ef94b59fd638324f4 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 02:22:49 +0200 Subject: [PATCH 05/15] test: exercise path alias preload outside the package root Co-Authored-By: GPT-6 Astra --- tests/deploy/register-path-aliases.test.ts | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 tests/deploy/register-path-aliases.test.ts diff --git a/tests/deploy/register-path-aliases.test.ts b/tests/deploy/register-path-aliases.test.ts new file mode 100644 index 00000000..90317127 --- /dev/null +++ b/tests/deploy/register-path-aliases.test.ts @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const hookPath = fileURLToPath(new URL("../../scripts/register-path-aliases.ts", import.meta.url)); + +function importOutsideRepositoryRoot(source: string) { + const environment = { ...process.env }; + delete environment.NODE_OPTIONS; + delete environment.NODE_PATH; + const result = spawnSync(process.execPath, [ + "--experimental-transform-types", "--import", hookPath, "--input-type=module", "--eval", source, + ], { + // A real directory inside the package still allows ordinary package resolution, + // but makes a hook incorrectly anchored at process.cwd() look in tests/src/. + cwd: new URL("../", import.meta.url), + env: environment, + encoding: "utf8", + timeout: 60_000, + }); + if (result.error) throw result.error; + expect(result.signal).toBeNull(); + return result; +} + +describe("Node path alias preload", () => { + it("resolves aliases, packages and builtins outside the repository root", () => { + const result = importOutsideRepositoryRoot(` + import { validateDifficultyScheme } from "@/lib/domain/difficulty-scheme"; + import { z } from "zod"; + import { basename } from "node:path"; + console.log(JSON.stringify([ + typeof validateDifficultyScheme, + z.string().parse("package resolved"), + basename("/a/builtin resolved"), + ])); + `); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(["function", "package resolved", "builtin resolved"]); + }); + + it("preserves ERR_MODULE_NOT_FOUND for a missing alias", () => { + const result = importOutsideRepositoryRoot('import "@/missing-reconcile-cli-test-module";'); + expect(result.status).toBe(1); + expect(result.stderr).toContain("ERR_MODULE_NOT_FOUND"); + }); +}); From 6f3caace5651519829a2c8a2d8f5e4114b407ae2 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 02:29:25 +0200 Subject: [PATCH 06/15] test: exclude indirect pnpm runtime overrides from CLI children Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index d75ac79a..5ba38b96 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -57,9 +57,10 @@ function runCommand(command: string, databaseUrl?: string) { const environment = { ...process.env }; delete environment.DATABASE_URL; // Node preloads/flags and pnpm's lifecycle options must not repair the command - // under test. Custom shell startup files can inject the same flags as well. + // under test. Alternate runtimes, config files and shell startup files can + // inject the same flags as well. for (const name of Object.keys(environment)) { - if (/^(node_options|node_path|npm_config_(node_options|script_shell|shell_emulator)|bash_env|env)$/.test( + if (/^(node_options|node_path|npm_config_(node_options|script_shell|shell_emulator|use_node_version|userconfig|globalconfig)|bash_env|env)$/.test( name.toLowerCase().replaceAll("-", "_"), )) delete environment[name]; } From 8c2ac4293d9878d30dfab15d7e0e48cc2872a967 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 03:22:55 +0200 Subject: [PATCH 07/15] test: isolate pnpm configuration in CLI smoke tests Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 55 +++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 5ba38b96..03f7cab9 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -1,5 +1,7 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import postgres, { type Sql } from "postgres"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -65,20 +67,49 @@ function runCommand(command: string, databaseUrl?: string) { )) delete environment[name]; } if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; - const result = spawnSync(executable!, argumentsList, { - cwd: repositoryRoot, - env: environment, - encoding: "utf8", - timeout: 60_000, - }); - // A missing pnpm executable or a timed-out child is a failure, never a skip. - if (result.error) throw result.error; - expect(result.signal).toBeNull(); - expect(result.status).not.toBeNull(); - return result; + const configDirectory = mkdtempSync(join(tmpdir(), "reconcile-cli-config-")); + try { + // Empty environment options do not override pnpm/rc. Explicit XDG and npm + // paths prevent fallback to home config while retaining Corepack's cache. + const npmConfig = join(configDirectory, "npmrc"); + writeFileSync(npmConfig, ""); + environment.XDG_CONFIG_HOME = configDirectory; + environment.npm_config_userconfig = npmConfig; + environment.npm_config_globalconfig = npmConfig; + const result = spawnSync(executable!, argumentsList, { + cwd: repositoryRoot, + env: environment, + encoding: "utf8", + timeout: 60_000, + }); + // A missing pnpm executable or a timed-out child is a failure, never a skip. + if (result.error) throw result.error; + expect(result.signal).toBeNull(); + expect(result.status).not.toBeNull(); + return result; + } finally { + rmSync(configDirectory, { recursive: true, force: true }); + } } describe("documented reconciliation CLI commands", () => { + it("does not inherit Node options from the parent's pnpm config file", () => { + const parentConfig = mkdtempSync(join(tmpdir(), "reconcile-cli-parent-config-")); + const previousConfigHome = process.env.XDG_CONFIG_HOME; + try { + mkdirSync(join(parentConfig, "pnpm")); + writeFileSync(join(parentConfig, "pnpm/rc"), "node-options=--experimental-transform-types\n"); + process.env.XDG_CONFIG_HOME = parentConfig; + const { status, stdout, stderr } = runCommand("pnpm exec node -p 'JSON.stringify(process.env.NODE_OPTIONS ?? null)'"); + expect(status, stderr).toBe(0); + expect(JSON.parse(stdout)).toBeNull(); + } finally { + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + rmSync(parentConfig, { recursive: true, force: true }); + } + }); + it("extracts exactly one all-repositories and one selected-repository invocation", () => { expect(documentedCommands, "No reconciliation commands found in the README bash block").not.toHaveLength(0); const argumentShapes = documentedCommands.map((command) => tokenizeCommand(command).slice(2)); From 6d4e31af15b31868b79822fb6afcc369f18388bb Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 03:28:59 +0200 Subject: [PATCH 08/15] test: support continued reconciliation documentation commands Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 71 ++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 03f7cab9..cdb80187 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -10,12 +10,40 @@ import { startPostgresContainer, type StartedPostgres } from "../support/postgre const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); const readme = readFileSync(new URL("../../README.md", import.meta.url), "utf8"); -const reconciliationSection = readme.split(/^## Reconciliation\r?$/m)[1]?.split(/^## /m)[0] ?? ""; -const documentedCommands = [...reconciliationSection.matchAll(/^```bash\r?\n([\s\S]*?)^```\s*$/gm)] - .flatMap((block) => block[1]!.split(/\r?\n/)) - .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); +const documentedCommands = extractReconciliationCommands(readme); const databaseError = "DATABASE_URL must be configured before using the database."; +function extractReconciliationCommands(markdown: string): string[] { + const section = markdown.split(/^## Reconciliation\r?$/m)[1]?.split(/^## /m)[0] ?? ""; + return [...section.matchAll(/^```bash\r?\n([\s\S]*?)^```\s*$/gm)] + .flatMap((block) => joinContinuations(block[1]!.replace(/^[ \t]*#.*$/gm, "")).split(/\r?\n/)) + .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); +} + +function joinContinuations(source: string): string { + let joined = ""; + let quote: "'" | '"' | null = null; + for (let index = 0; index < source.length; index++) { + const character = source[index]!; + if (character === "\\" && quote !== "'") { + const newline = /^\r?\n/.exec(source.slice(index + 1)); + if (newline !== null) { + index += newline[0].length; + continue; + } + // Preserve other escapes for the tokenizer to accept or reject; an + // escaped quote or backslash cannot begin a quote or a continuation. + joined += character; + if (index + 1 < source.length) joined += source[++index]; + } else { + if (character === quote) quote = null; + else if (quote === null && (character === "'" || character === '"')) quote = character; + joined += character; + } + } + return joined; +} + function tokenizeCommand(command: string): string[] { const words: string[] = []; let word = ""; @@ -149,6 +177,41 @@ describe("documented reconciliation CLI commands", () => { }, 120_000); }); +describe("documented command extraction", () => { + it("does not reinterpret a single-quoted backslash and newline as a continuation", () => { + const commands = extractReconciliationCommands([ + "## Reconciliation", "```bash", "pnpm reconcile --repository 'octocat/hello-\\", + "world'", "```", + ].join("\n")); + expect(() => tokenizeCommand(commands[0]!)).toThrow(/Unterminated quote/); + }); + + it.each(["\n", "\r\n"])("joins backslash continuations with %j line endings before running the command", (newline) => { + const commands = extractReconciliationCommands([ + "## Reconciliation", "```bash", "pnpm reconcile \\", + " --repository /", "pnpm reconcile", "```", + ].join(newline)); + expect(commands.map(tokenizeCommand)).toEqual([ + ["pnpm", "reconcile", "--repository", "octocat/hello-world"], + ["pnpm", "reconcile"], + ]); + const { status, stderr } = runCommand(commands[0]!); + expect(status).not.toBe(0); + expect(stderr).toContain(databaseError); + expect(stderr).not.toContain("Usage:"); + }); + + it("keeps a bare newline as a command boundary", () => { + const commands = extractReconciliationCommands([ + "## Reconciliation", "```bash", "pnpm reconcile", + " --repository /", "pnpm reconcile", "```", + ].join("\n")); + expect(commands.map(tokenizeCommand)).toEqual([ + ["pnpm", "reconcile"], ["pnpm", "reconcile"], + ]); + }); +}); + describe("documented command tokenization", () => { it.each([ { command: `pnpm reconcile --repository 'octocat/hello-world'`, words: ["pnpm", "reconcile", "--repository", "octocat/hello-world"] }, From 84b459337e4df82d4e151b13d19fe98ced6c4869 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 03:32:37 +0200 Subject: [PATCH 09/15] test: cover accepted reconciliation repository names Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index cdb80187..0803d32b 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -169,6 +169,22 @@ describe("documented reconciliation CLI commands", () => { expect(stderr).not.toContain(databaseError); }, 120_000); + it.each(["vercel/next.js", "cli/hello_world", "dot.owner/name", "under_score/name"])( + "accepts repository name %s before requiring a database", (ownerName) => { + const { status, stderr } = runCommand(`pnpm reconcile --repository ${ownerName}`); + expect(status).not.toBe(0); + expect(stderr).toContain(databaseError); + expect(stderr).not.toContain("Usage:"); + }, + ); + + it("rejects a malformed repository name before requiring a database", () => { + const { status, stderr } = runCommand("pnpm reconcile --repository cli//second"); + expect(status).not.toBe(0); + expect(stderr).toContain("Usage: pnpm reconcile [--repository owner/name]"); + expect(stderr).not.toContain(databaseError); + }); + it.each(["'", '"'])("passes a %s-quoted repository argument to the real CLI", (quote) => { const { status, stderr } = runCommand(`pnpm reconcile --repository ${quote}/${quote}`); expect(status).not.toBe(0); From dbd579212b191c6ff4320fa3d212f53a20e75052 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 03:36:47 +0200 Subject: [PATCH 10/15] test: resolve CLI summaries from repository names Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 0803d32b..b592c435 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -249,10 +249,10 @@ describe("documented command tokenization", () => { }); }); -describe("documented reconciliation CLI commands with PostgreSQL", () => { +describe("reconciliation CLI commands with PostgreSQL", () => { let started: StartedPostgres | undefined; let sql: Sql; - const repositoryIds: string[] = []; + const repositoryIdsByOwnerName = new Map(); beforeAll(async () => { started = await startPostgresContainer({ database: "cli", user: "cli", password: "cli" }); @@ -274,7 +274,7 @@ describe("documented reconciliation CLI commands with PostgreSQL", () => { ${sql.json(validDifficultyScheme())}, now() + interval '1 day') returning id `; - repositoryIds.push(repository!.id); + repositoryIdsByOwnerName.set(ownerName, repository!.id); } }, 120_000); @@ -286,13 +286,18 @@ describe("documented reconciliation CLI commands with PostgreSQL", () => { } }, 120_000); - it.each(documentedCommands)("successfully runs %s without GitHub access", async (command) => { + it.each([...documentedCommands, "pnpm reconcile --repository cli/second"])("successfully runs %s without GitHub access", async (command) => { const { status, stdout, stderr } = runCommand(command, started!.databaseUrl); expect(status, stderr).toBe(0); const summaries = stdout.split(/\r?\n/) .filter((line) => line.startsWith("{")) .map((line) => JSON.parse(line)); - const expectedIds = tokenizeCommand(command).length === 4 ? [repositoryIds[0]!] : repositoryIds; + const words = tokenizeCommand(command); + const repositoryOption = words.indexOf("--repository"); + const expectedIds = repositoryOption === -1 + ? [...repositoryIdsByOwnerName.values()] + : [repositoryIdsByOwnerName.get(words[repositoryOption + 1]!)]; + expect(expectedIds, "Every selected repository must be seeded").not.toContain(undefined); expect(summaries).toHaveLength(expectedIds.length); expect(summaries).toEqual(expect.arrayContaining(expectedIds.map((repositoryId) => ({ repositoryId, runId: null, skipped: true, From 2528ae67ae6dcf7668868287633bbfea0c672b44 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 03:38:57 +0200 Subject: [PATCH 11/15] test: state cooldown smoke guarantees precisely Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index b592c435..0f3c6b58 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -260,7 +260,7 @@ describe("reconciliation CLI commands with PostgreSQL", () => { const migration = runCommand("pnpm db:migrate", started.databaseUrl); expect(migration.status, migration.stderr).toBe(0); - // No OAuth token is seeded: cooldown must return before any GitHub access. + // The sponsor has no OAuth token, and both repositories are cooled down. const [sponsor] = await sql<{ id: string }[]>` insert into users (github_user_id, github_login) values (10001, 'cli-sponsor') returning id @@ -286,7 +286,7 @@ describe("reconciliation CLI commands with PostgreSQL", () => { } }, 120_000); - it.each([...documentedCommands, "pnpm reconcile --repository cli/second"])("successfully runs %s without GitHub access", async (command) => { + it.each([...documentedCommands, "pnpm reconcile --repository cli/second"])("completes %s with cooldown skips and no reconciliation runs", async (command) => { const { status, stdout, stderr } = runCommand(command, started!.databaseUrl); expect(status, stderr).toBe(0); const summaries = stdout.split(/\r?\n/) @@ -303,6 +303,6 @@ describe("reconciliation CLI commands with PostgreSQL", () => { repositoryId, runId: null, skipped: true, adds: 0, changes: 0, removals: 0, added: 0, changed: 0, removed: 0, })))); - expect(await sql`select id from reconciliation_runs`).toHaveLength(0); + expect(await sql`select id from reconciliation_runs`, "Cooldown skips must not create reconciliation runs").toHaveLength(0); }, 120_000); }); From b4f0030eff085e9029c19aee5cb44cac7abc36b0 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 04:44:35 +0200 Subject: [PATCH 12/15] test: preserve shell comment boundaries in reconciliation smoke commands Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 58 +++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 0f3c6b58..b73cee63 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -16,16 +16,25 @@ const databaseError = "DATABASE_URL must be configured before using the database function extractReconciliationCommands(markdown: string): string[] { const section = markdown.split(/^## Reconciliation\r?$/m)[1]?.split(/^## /m)[0] ?? ""; return [...section.matchAll(/^```bash\r?\n([\s\S]*?)^```\s*$/gm)] - .flatMap((block) => joinContinuations(block[1]!.replace(/^[ \t]*#.*$/gm, "")).split(/\r?\n/)) + .flatMap((block) => joinContinuations(block[1]!).split(/\r?\n/)) .filter((line) => /^pnpm reconcile(?:\s|$)/.test(line)); } function joinContinuations(source: string): string { let joined = ""; let quote: "'" | '"' | null = null; + let wordStarted = false; + let comment = false; for (let index = 0; index < source.length; index++) { const character = source[index]!; - if (character === "\\" && quote !== "'") { + if (comment) { + // Quotes and backslashes in a comment cannot affect the next command. + joined += character; + if (character === "\n") { + comment = false; + wordStarted = false; + } + } else if (character === "\\" && quote !== "'") { const newline = /^\r?\n/.exec(source.slice(index + 1)); if (newline !== null) { index += newline[0].length; @@ -35,9 +44,16 @@ function joinContinuations(source: string): string { // escaped quote or backslash cannot begin a quote or a continuation. joined += character; if (index + 1 < source.length) joined += source[++index]; + wordStarted = true; } else { if (character === quote) quote = null; - else if (quote === null && (character === "'" || character === '"')) quote = character; + else if (quote === null) { + // Joining a continuation does not start a new word; only unquoted + // whitespace makes a following hash start a comment. + if (character === "#" && !wordStarted) comment = true; + if (character === "'" || character === '"') quote = character; + wordStarted = !" \t\r\n".includes(character); + } joined += character; } } @@ -71,8 +87,10 @@ function tokenizeCommand(command: string): string[] { if (wordStarted) words.push(word); word = ""; wordStarted = false; + } else if (character === "#" && !wordStarted) { + break; } else { - if ("$`\\;&|<>(){}*?[]~#!".includes(character) || /\s/.test(character)) unsupported(); + if ("$`\\;&|<>(){}*?[]~!".includes(character) || /\s/.test(character)) unsupported(); word += character; wordStarted = true; } @@ -194,6 +212,34 @@ describe("documented reconciliation CLI commands", () => { }); describe("documented command extraction", () => { + it.each(["\n", "\r\n"])("strips trailing comments after continuations with %j line endings before running the command", (newline) => { + const commands = extractReconciliationCommands([ + "## Reconciliation", "```bash", "pnpm reconcile \\", + " --repository cli/second # selected ' \" \\", "pnpm reconcile", "```", + ].join(newline)); + expect(commands.map(tokenizeCommand)).toEqual([ + ["pnpm", "reconcile", "--repository", "cli/second"], ["pnpm", "reconcile"], + ]); + const { status, stderr } = runCommand(commands[0]!); + expect(status).not.toBe(0); + expect(stderr).toContain(databaseError); + expect(stderr).not.toContain("Usage:"); + }); + + it.each(["\n", "\r\n"])("keeps a continuation-joined hash inside a repository word with %j line endings", (newline) => { + const commands = extractReconciliationCommands([ + "## Reconciliation", "```bash", "pnpm reconcile --repository cli/second\\", + "#oops", "```", + ].join(newline)); + expect(commands.map(tokenizeCommand)).toEqual([ + ["pnpm", "reconcile", "--repository", "cli/second#oops"], + ]); + const { status, stderr } = runCommand(commands[0]!); + expect(status).not.toBe(0); + expect(stderr).toContain("Usage: pnpm reconcile [--repository owner/name]"); + expect(stderr).not.toContain(databaseError); + }); + it("does not reinterpret a single-quoted backslash and newline as a continuation", () => { const commands = extractReconciliationCommands([ "## Reconciliation", "```bash", "pnpm reconcile --repository 'octocat/hello-\\", @@ -235,6 +281,8 @@ describe("documented command tokenization", () => { { command: `pnpm reconcile --repo"sitory" octocat/'hello-world'`, words: ["pnpm", "reconcile", "--repository", "octocat/hello-world"] }, { command: `pnpm reconcile "two words" ''`, words: ["pnpm", "reconcile", "two words", ""] }, { command: `pnpm reconcile '$HOME;*'`, words: ["pnpm", "reconcile", "$HOME;*"] }, + { command: `pnpm reconcile '# literal' "# literal"`, words: ["pnpm", "reconcile", "# literal", "# literal"] }, + { command: `pnpm reconcile ''#literal cli/second#literal`, words: ["pnpm", "reconcile", "#literal", "cli/second#literal"] }, ])("preserves literal shell words in $command", ({ command, words }) => { expect(tokenizeCommand(command)).toEqual(words); }); @@ -242,7 +290,7 @@ describe("documented command tokenization", () => { it.each([ '"unterminated', "'unterminated", "$OWNER/name", '"$OWNER/name"', "$(pwd)", "`pwd`", "owner/*", "owner/{one,two}", "owner/name; true", "owner/name | cat", "> output", - "owner/\\name", '"owner/\\name"', "owner/name # comment", + "owner/\\name", '"owner/\\name"', ])("rejects unsupported shell syntax: %s", (argument) => { expect(() => tokenizeCommand(`pnpm reconcile --repository ${argument}`)) .toThrow(/Unsupported shell syntax|Unterminated quote/); From 87ad70b12cb5ad7e8be52eb22ae2287ce1cf6c5b Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 04:50:54 +0200 Subject: [PATCH 13/15] test: allowlist reconciliation CLI child environments Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 86 +++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index b73cee63..772aec96 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import postgres, { type Sql } from "postgres"; @@ -100,30 +100,39 @@ function tokenizeCommand(command: string): string[] { return words; } -function runCommand(command: string, databaseUrl?: string) { +function runCommand(command: string, databaseUrl?: string, cwd = repositoryRoot) { const [executable, ...argumentsList] = tokenizeCommand(command); - const environment = { ...process.env }; - delete environment.DATABASE_URL; - // Node preloads/flags and pnpm's lifecycle options must not repair the command - // under test. Alternate runtimes, config files and shell startup files can - // inject the same flags as well. - for (const name of Object.keys(environment)) { - if (/^(node_options|node_path|npm_config_(node_options|script_shell|shell_emulator|use_node_version|userconfig|globalconfig)|bash_env|env)$/.test( - name.toLowerCase().replaceAll("-", "_"), - )) delete environment[name]; - } - if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; const configDirectory = mkdtempSync(join(tmpdir(), "reconcile-cli-config-")); try { - // Empty environment options do not override pnpm/rc. Explicit XDG and npm - // paths prevent fallback to home config while retaining Corepack's cache. const npmConfig = join(configDirectory, "npmrc"); writeFileSync(npmConfig, ""); - environment.XDG_CONFIG_HOME = configDirectory; - environment.npm_config_userconfig = npmConfig; - environment.npm_config_globalconfig = npmConfig; + // Allowlist only: host Node flags, pnpm hooks and shell startup settings + // must not supply behavior missing from the documented package script. + const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, // Find the installed pnpm and Node executables. + HOME: configDirectory, + XDG_CONFIG_HOME: configDirectory, + npm_config_userconfig: npmConfig, + npm_config_globalconfig: npmConfig, + // Keep the installed Corepack distribution cache available with an empty + // HOME. This is Corepack's cache-location precedence, not pnpm config. + COREPACK_HOME: process.env.COREPACK_HOME ?? join( + process.env.XDG_CACHE_HOME ?? process.env.LOCALAPPDATA ?? + join(homedir(), process.platform === "win32" ? "AppData/Local" : ".cache"), + "node/corepack", + ), + COREPACK_ENABLE_NETWORK: "0", // A missing cached package manager must fail offline. + }; + if (process.platform === "win32") { + environment.SystemRoot = process.env.SystemRoot; // Windows runtime and executable lookup. + // Windows tools use these home/config paths instead of HOME or XDG. + environment.USERPROFILE = configDirectory; + environment.APPDATA = configDirectory; + environment.LOCALAPPDATA = configDirectory; + } + if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; const result = spawnSync(executable!, argumentsList, { - cwd: repositoryRoot, + cwd, env: environment, encoding: "utf8", timeout: 60_000, @@ -139,6 +148,45 @@ function runCommand(command: string, databaseUrl?: string) { } describe("documented reconciliation CLI commands", () => { + it("does not inherit a global pnpmfile that injects Node options through the lifecycle shell", () => { + const fixture = mkdtempSync(join(tmpdir(), "reconcile-cli-pnpmfile-")); + const previousPnpmfile = process.env.npm_config_global_pnpmfile; + try { + const { packageManager } = JSON.parse(readFileSync(join(repositoryRoot, "package.json"), "utf8")); + writeFileSync(join(fixture, "package.json"), JSON.stringify({ + name: "reconcile-cli-lifecycle-witness", private: true, packageManager, + scripts: { "inspect-options": `node -p 'JSON.stringify(process.env.NODE_OPTIONS ?? null)'` }, + })); + const shell = join(fixture, "inject-node-options.sh"); + writeFileSync(shell, [ + "#!/bin/sh", "export NODE_OPTIONS=--experimental-transform-types", 'exec /bin/sh "$@"', "", + ].join("\n"), { mode: 0o755 }); + const pnpmfile = join(fixture, "global-pnpmfile.cjs"); + writeFileSync(pnpmfile, `module.exports = { + hooks: { updateConfig: (config) => ({ ...config, scriptShell: ${JSON.stringify(shell)} }) }, + };`); + + // First prove an unsanitized child sees this lifecycle shell; pnpm exec + // would miss the injection and make the isolation assertion meaningless. + process.env.npm_config_global_pnpmfile = pnpmfile; + const control = spawnSync("pnpm", ["--silent", "run", "inspect-options"], { + cwd: fixture, env: process.env, encoding: "utf8", timeout: 60_000, + }); + if (control.error) throw control.error; + expect(control.signal).toBeNull(); + expect(control.status, control.stderr).toBe(0); + expect(JSON.parse(control.stdout)).toBe("--experimental-transform-types"); + + const isolated = runCommand("pnpm --silent run inspect-options", undefined, fixture); + expect(isolated.status, isolated.stderr).toBe(0); + expect(JSON.parse(isolated.stdout)).toBeNull(); + } finally { + if (previousPnpmfile === undefined) delete process.env.npm_config_global_pnpmfile; + else process.env.npm_config_global_pnpmfile = previousPnpmfile; + rmSync(fixture, { recursive: true, force: true }); + } + }); + it("does not inherit Node options from the parent's pnpm config file", () => { const parentConfig = mkdtempSync(join(tmpdir(), "reconcile-cli-parent-config-")); const previousConfigHome = process.env.XDG_CONFIG_HOME; From b667578378c9036529a36458ec5242da068c55ae Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 04:56:56 +0200 Subject: [PATCH 14/15] test: require a persisted reconciliation attempt from the CLI Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 772aec96..4b0877ce 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -348,6 +348,7 @@ describe("documented command tokenization", () => { describe("reconciliation CLI commands with PostgreSQL", () => { let started: StartedPostgres | undefined; let sql: Sql; + let sponsorId: string; const repositoryIdsByOwnerName = new Map(); beforeAll(async () => { @@ -361,6 +362,7 @@ describe("reconciliation CLI commands with PostgreSQL", () => { insert into users (github_user_id, github_login) values (10001, 'cli-sponsor') returning id `; + sponsorId = sponsor!.id; for (const [index, ownerName] of ["octocat/hello-world", "cli/second"].entries()) { const [repository] = await sql<{ id: string }[]>` insert into registered_repositories @@ -401,4 +403,36 @@ describe("reconciliation CLI commands with PostgreSQL", () => { })))); expect(await sql`select id from reconciliation_runs`, "Cooldown skips must not create reconciliation runs").toHaveLength(0); }, 120_000); + + it("records a failed run for a due repository whose sponsor has no OAuth token", async () => { + // Seed inside this test so all-repository cooldown checks keep their own + // fixture. Missing credentials stop the real callback before any GitHub read. + const [repository] = await sql<{ id: string }[]>` + insert into registered_repositories + (github_repository_id, owner_name, sponsor_id, visibility, github_webhook_id, + difficulty_scheme, reconciliation_not_before) + values (10004, 'cli/tokenless', ${sponsorId}, 'PUBLIC', 10004, + ${sql.json(validDifficultyScheme())}, now() - interval '1 day') + returning id + `; + try { + const { status, stdout, stderr } = runCommand("pnpm reconcile --repository cli/tokenless", started!.databaseUrl); + const runs = await sql` + select repository_id, status, error_message, completed_at + from reconciliation_runs where repository_id = ${repository!.id} + `; + expect(runs, "The real callback must persist its attempt instead of fabricating a cooldown skip").toEqual([{ + repository_id: repository!.id, + status: "FAILED", + error_message: "Reconciliation failed.", + completed_at: expect.any(Date), + }]); + expect(status, stderr).toBe(1); + expect(stderr).toContain("GitHub access token was not available."); + expect(stdout.split(/\r?\n/).filter((line) => line.startsWith("{"))).toEqual([]); + } finally { + await sql`delete from reconciliation_runs where repository_id = ${repository!.id}`; + await sql`delete from registered_repositories where id = ${repository!.id}`; + } + }, 120_000); }); From 0bab91c20cee24a015b9fb05b0d3d6f76d2a7f2a Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Mon, 7 Sep 2026 05:17:13 +0200 Subject: [PATCH 15/15] test: type isolated child environments independently of Next globals Co-Authored-By: GPT-6 Astra --- tests/deploy/reconcile-cli.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/deploy/reconcile-cli.test.ts b/tests/deploy/reconcile-cli.test.ts index 4b0877ce..3ee0dd45 100644 --- a/tests/deploy/reconcile-cli.test.ts +++ b/tests/deploy/reconcile-cli.test.ts @@ -108,7 +108,7 @@ function runCommand(command: string, databaseUrl?: string, cwd = repositoryRoot) writeFileSync(npmConfig, ""); // Allowlist only: host Node flags, pnpm hooks and shell startup settings // must not supply behavior missing from the documented package script. - const environment: NodeJS.ProcessEnv = { + const environment: Record = { PATH: process.env.PATH, // Find the installed pnpm and Node executables. HOME: configDirectory, XDG_CONFIG_HOME: configDirectory, @@ -133,7 +133,9 @@ function runCommand(command: string, databaseUrl?: string, cwd = repositoryRoot) if (databaseUrl !== undefined) environment.DATABASE_URL = databaseUrl; const result = spawnSync(executable!, argumentsList, { cwd, - env: environment, + // Next requires NODE_ENV on ProcessEnv, but Node accepts an environment + // without it. Keep the child's allowlist independent of that augmentation. + env: environment as NodeJS.ProcessEnv, encoding: "utf8", timeout: 60_000, });