diff --git a/CHANGELOG.md b/CHANGELOG.md index 6530d52..7017618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Publish generated OpenAPI clients through the same recoverable directory + swap as other commands. Keep complete published output when backup cleanup + fails, clean partial stages, and report retained stages after cleanup failure. + Recheck project destination ownership under the publication lock so concurrent + creates cannot replace each other's projects or unrelated files. Print project + creation success only after publication completes. + - Record directory publication before moving the original tree. Recover an interrupted create, skills or SSG swap before the next locked operation; finish failed backup cleanup on retry. Reject file and symbolic-link targets, diff --git a/README.md b/README.md index 2f13cc8..aea1fdd 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,9 @@ If a process exits between publication renames, the next build restores the old output or finishes cleanup of the completed output before copying it. A recovery conflict stops the build and names the record and original backup for inspection. See the [SSG recovery boundary](docs/ssg.md) for details. +OpenAPI client generation uses the same publication recovery. Project creation +checks that its destination is still missing or empty under the publication lock, +so a concurrent create cannot replace an already published project. ## OpenAPI artifacts diff --git a/docs/0.5.0-api.md b/docs/0.5.0-api.md index 5714cf9..b36cd45 100644 --- a/docs/0.5.0-api.md +++ b/docs/0.5.0-api.md @@ -40,9 +40,13 @@ The executed failure matrix covers replacement followed by restoration failure, ## Directory publication failure qualification -Create, skills and SSG publication use one sibling record before moving an existing directory. Before the next locked operation, a missing live target is restored from its recorded original backup. A completed publication is identified by the staged directory's device and inode before its original backup is removed. Failed backup/record cleanup is retried by the next operation. Invalid records, non-directory or symbolic-link targets, and a different directory appearing at the target stop recovery and preserve the trees for inspection. Only a missing target is treated as absent; permission errors propagate. Stage directories must be separate siblings of the target. +Create, skills, SSG and OpenAPI generator publication use one sibling record before moving an existing directory. Before the next locked operation, a missing live target is restored from its recorded original backup. A completed publication is identified by the staged directory's device and inode before its original backup is removed. Failed backup/record cleanup is retried by the next operation. Invalid records, non-directory or symbolic-link targets, and a different directory appearing at the target stop recovery and preserve the trees for inspection. Only a missing target is treated as absent; permission errors propagate. Stage directories must be separate siblings of the target. -Executed regressions kill a child process before the original rename, after the original rename and after the stage rename, then assert recovery before an incremental copy. Additional assertions cover malformed and misplaced records, junctions, conflicting live directories, missing recovery trees, failed rollback, failed backup/record cleanup, partial record writes, invalid stage paths and destination permission errors. The pre-fix source failed six minimal cases. These checkpoints qualify process termination with the tested filesystems, not power-loss durability or uncooperative concurrent changes. Generator-specific publication and remaining CLI #159 probes are tracked separately until executed. +Executed regressions kill a child process before the original rename, after the original rename and after the stage rename, then assert recovery before an incremental copy. Additional assertions cover malformed and misplaced records, junctions, conflicting live directories, missing recovery trees, failed rollback, failed backup/record cleanup, partial record writes, invalid stage paths and destination permission errors. The pre-fix shared publication source failed six minimal cases. These checkpoints qualify process termination with the tested filesystems, not power-loss durability or uncooperative concurrent changes. + +The OpenAPI generator now uses that same publication routine and preserves its directory ownership check. Separate child-process probes reproduce and check partial stage writes, partial backup deletion, failed rollback and stage cleanup, destination read/stat permission errors, symbolic-link output and the same three termination checkpoints. Check mode remains observational. Project creation rechecks the missing/empty destination while holding the publication lock, after recovery; a deterministic concurrent-create regression asserts exactly one success and preservation of the first project's unrelated file. Another case restores interrupted generated output before refusing project creation over it. Remaining CLI #159 malformed-input and subprocess probes are tracked until executed. + +Create reports success after publication completes. Child-process tests inject package-manager nonzero exit, missing-command and timeout results, then assert an error exit with no published destination or abandoned project stage. A successful install followed by failed publication also reports no success. These are subprocess result injections, not qualification of a real installer timeout or cancellation bound. ## Release boundaries diff --git a/docs/generate.md b/docs/generate.md index 1595595..91e1e87 100644 --- a/docs/generate.md +++ b/docs/generate.md @@ -7,11 +7,21 @@ askr generate ./openapi.yml --output ./src/generated/api --json ``` Generation accepts OpenAPI 3.0.x and 3.1.x JSON or YAML, bundles local and HTTPS -references, and atomically replaces only directories carrying the CLI ownership -manifest. `--check` performs no writes and fails for missing, extra, or stale +references, and publishes complete trees only to missing/empty directories or +directories carrying the CLI ownership manifest. `--check` performs no writes and fails for missing, extra, or stale generated files. `--json` emits a single machine-readable success or error object. +Normal generation recovers an interrupted publication before checking output +ownership. The shared sibling record restores the original directory if it is +missing, or finishes cleanup after a completed publication; cleanup failure +never rolls back to a partly deleted backup. A failed stage write removes its +partial stage. If stage cleanup also fails, the error names the retained stage +and preserves both failures. A check-only invocation reports the observed state +without performing recovery. Symbolic-link outputs and filesystem errors other +than a missing path are rejected. See the [publication recovery boundary](ssg.md) +for process-termination checkpoints and limits. + Remote references use a DNS-pinned HTTPS connection for every root and redirect hop. Cross-origin references require `--allow-ref-origin `. `--ref-timeout-ms`, `--ref-max-bytes`, `--ref-max-depth`, and diff --git a/src/bin/create.ts b/src/bin/create.ts index 11d6ca1..3b24b90 100644 --- a/src/bin/create.ts +++ b/src/bin/create.ts @@ -8,11 +8,41 @@ import readline from "node:readline"; import { fileURLToPath } from "node:url"; import { isDirectExecution } from "./is-direct-execution"; import { installBundledSkills } from "./skills"; -import { createSiblingStage, publishStagedDirectory } from "../directory-swap"; +import { + createSiblingStage, + swapStagedDirectoryLocked, + withDirectoryTargetLock, +} from "../directory-swap"; type CliIo = Pick; type PackageManager = "bun" | "npm" | "pnpm" | "yarn"; +async function assertCreateTarget(target: string): Promise { + const stat = await fs.lstat(target).catch((error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + }); + if (!stat) return; + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Target ${JSON.stringify(target)} must be a directory without a symbolic link; choose a missing or empty directory.`, + ); + } + if ((await fs.readdir(target)).length > 0) { + throw new Error( + `Directory ${JSON.stringify(target)} already exists and is not empty; choose a missing or empty directory.`, + ); + } +} + +async function publishProject(stage: string, target: string): Promise { + await withDirectoryTargetLock(target, async () => { + // Recovery and this ownership check must precede publication under one lock. + await assertCreateTarget(target); + await swapStagedDirectoryLocked(stage, target); + }); +} + const TEMPLATE_LABELS = { "full-stack": "Full-stack", spa: "SPA", @@ -991,18 +1021,7 @@ export async function runCreateCli( } try { - const stat = await fs.stat(target).catch(() => null); - if (stat) { - if (!stat.isDirectory()) { - io.error(`Target exists and is not a directory: ${target}`); - return 1; - } - const files = await fs.readdir(target).catch(() => [] as string[]); - if (files.length > 0) { - io.error(`Directory ${target} already exists and is not empty.`); - return 1; - } - } + await assertCreateTarget(target); } catch (error) { io.error("Failed to access target directory"); io.error(error instanceof Error ? error.message : String(error)); @@ -1082,7 +1101,7 @@ export async function runCreateCli( const pm = detectPm(); if (!parsed.install) { try { - await publishStagedDirectory(stagingTarget, target); + await publishProject(stagingTarget, target); } catch (error) { await fs.rm(stagingTarget, { recursive: true, force: true }); io.error("Failed to publish generated project"); @@ -1118,12 +1137,8 @@ export async function runCreateCli( return 1; } - io.log(""); - io.log(`Success! Created ${name}`); - io.log(""); - try { - await publishStagedDirectory(stagingTarget, target); + await publishProject(stagingTarget, target); } catch (error) { await fs.rm(stagingTarget, { recursive: true, force: true }); io.error("Failed to publish generated project"); @@ -1131,6 +1146,9 @@ export async function runCreateCli( return 1; } + io.log(""); + io.log(`Success! Created ${name}`); + io.log(""); io.log("Next steps:"); io.log(` cd ${name}`); io.log(" review .askr/builder-brief.md"); diff --git a/src/generate/generator.ts b/src/generate/generator.ts index 9a3adae..5968c53 100644 --- a/src/generate/generator.ts +++ b/src/generate/generator.ts @@ -1,19 +1,13 @@ -import { - mkdir, - mkdtemp, - readFile, - readdir, - realpath, - rename, - rm, - stat, - writeFile, -} from "node:fs/promises"; +import { mkdir, lstat, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { lookup } from "node:dns/promises"; import { request as httpsRequest } from "node:https"; import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; import { BlockList, isIP } from "node:net"; -import { withDirectoryTargetLock } from "../directory-swap"; +import { + createSiblingStage, + swapStagedDirectoryLocked, + withDirectoryTargetLock, +} from "../directory-swap"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { load } from "js-yaml"; @@ -824,8 +818,9 @@ export function generateFiles(document: Json): Record<(typeof OWNED)[number], st async function existingFiles(directory: string) { try { return (await readdir(directory)).sort(); - } catch { - return []; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; } } export async function writeGenerated( @@ -844,9 +839,14 @@ async function writeGeneratedUnlocked( check: boolean, ): Promise { const output = resolve(directory); - const outputStat = await stat(output).catch(() => null); - if (outputStat && !outputStat.isDirectory()) { - throw new GenerationError(`Generated output must be a directory: ${output}`); + const outputStat = await lstat(output).catch((error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + }); + if (outputStat && (!outputStat.isDirectory() || outputStat.isSymbolicLink())) { + throw new GenerationError( + `Generated output must be a directory without a symbolic link: ${JSON.stringify(output)}`, + ); } const entries = await existingFiles(output); if (check) { @@ -861,25 +861,22 @@ async function writeGeneratedUnlocked( } if (entries.length && !entries.includes(".askr-generated.json")) throw new GenerationError(`Refusing to overwrite non-generated directory: ${output}`); - const stage = await mkdtemp(join(dirname(output), `.${basename(output)}-stage-`)); - for (const [name, content] of Object.entries(files)) await writeFile(join(stage, name), content); - const backup = `${output}.backup-${process.pid}`; - let moved = false; + const stage = await createSiblingStage(output, "askr-generated"); try { - if (entries.length) { - await rename(output, backup); - moved = true; - } - await rename(stage, output); - if (moved) await rm(backup, { recursive: true, force: true }); + for (const [name, content] of Object.entries(files)) + await writeFile(join(stage, name), content); + await swapStagedDirectoryLocked(stage, output); } catch (error) { - if (moved) { - await rm(output, { recursive: true, force: true }); - await rename(backup, output); + try { + await rm(stage, { recursive: true, force: true }); + } catch (cleanupFailure) { + throw new AggregateError( + [error, cleanupFailure], + `Generation failed and its stage could not be removed: ${JSON.stringify(stage)}. Remove this retained stage after resolving the filesystem error, then retry.`, + { cause: error }, + ); } throw error; - } finally { - await rm(stage, { recursive: true, force: true }); } } export async function generate( diff --git a/tests/fixtures/generation-publication-worker.ts b/tests/fixtures/generation-publication-worker.ts new file mode 100644 index 0000000..a3446a7 --- /dev/null +++ b/tests/fixtures/generation-publication-worker.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { syncBuiltinESMExports } from "node:module"; +import childProcess from "node:child_process"; + +const [target, fault] = process.argv.slice(2); +const rename = fs.rename.bind(fs); +const writeFile = fs.writeFile.bind(fs); +const rm = fs.rm.bind(fs); +const readdir = fs.readdir.bind(fs); +const stat = fs.stat.bind(fs); +const lstat = fs.lstat.bind(fs); +let backup = ""; +let stage = ""; +let injected = false; +const failure = (code: string) => Object.assign(new Error(`injected ${fault}`), { code }); +async function checkpoint(phase: string) { + if (fault !== phase) return; + process.send!({ phase, backup, stage }); + await new Promise((resolve) => process.once("message", () => resolve())); +} +fs.rename = async (from, to) => { + if (String(from) === target) { + backup = String(to); + await checkpoint("prepared"); + if (fault === "original-rename-failure") { + injected = true; + throw failure("EIO"); + } + } + if (fault === "rollback-failure" && String(to) === target) { + injected = true; + throw failure("EIO"); + } + if (fault === "create-publish-failure" && String(to) === target) { + injected = true; + throw failure("EIO"); + } + await rename(from, to); + if (String(from) === target) await checkpoint("backed-up"); + else if (String(to) === target) await checkpoint("published"); +}; +fs.writeFile = async (name, ...args) => { + if (path.basename(String(name)) === "schemas.ts" && path.dirname(String(name)) !== target) { + stage = path.dirname(String(name)); + if (fault === "partial-stage-write" || fault === "stage-cleanup-failure") { + injected = true; + await writeFile(name, "partial"); + throw failure("ENOSPC"); + } + } + return writeFile(name, ...args); +}; +fs.rm = async (name, ...args) => { + if (fault === "partial-backup-cleanup" && String(name) === backup && !injected) { + injected = true; + await rm(path.join(backup, "schemas.ts")); + throw failure("EACCES"); + } + if (fault === "stage-cleanup-failure" && String(name) === stage) throw failure("EACCES"); + return rm(name, ...args); +}; +fs.readdir = (async (...args: Parameters) => { + if (fault === "target-read-denied" && String(args[0]) === target) { + injected = true; + throw failure("EACCES"); + } + return readdir(...args); +}) as typeof fs.readdir; +fs.stat = (async (...args: Parameters) => { + if (fault === "target-stat-denied" && String(args[0]) === target) { + injected = true; + throw failure("EACCES"); + } + return stat(...args); +}) as typeof fs.stat; +fs.lstat = (async (...args: Parameters) => { + if (fault === "target-stat-denied" && String(args[0]) === target) { + injected = true; + throw failure("EACCES"); + } + return lstat(...args); +}) as typeof fs.lstat; +// The generator currently imports named built-ins. Exercise their real Node +// bindings, including on the immutable pre-fix implementation. +syncBuiltinESMExports(); +const { writeGenerated } = await import("../../src/generate/generator"); +let error: { message: string; code?: string; causes?: string[] } | undefined; +let logs: string[] | undefined; +let commandCode: number | undefined; +try { + if (fault.startsWith("create-")) { + childProcess.spawnSync = (() => { + if (fault !== "create-publish-failure") injected = true; + return { + pid: 0, + output: [], + stdout: null, + stderr: null, + status: fault === "create-publish-failure" ? 0 : 1, + signal: null, + error: + fault === "create-install-timeout" + ? failure("ETIMEDOUT") + : fault === "create-install-missing" + ? failure("ENOENT") + : undefined, + }; + }) as unknown as typeof childProcess.spawnSync; + syncBuiltinESMExports(); + const { runCreateCli } = await import("../../src/bin/create"); + logs = []; + commandCode = await runCreateCli(["spa", "test-app", "--dir", target, "--no-skills"], { + log: (message) => { + logs!.push(message); + }, + error: (message) => { + logs!.push(message); + }, + }); + } else { + await writeGenerated( + target, + { ".askr-generated.json": "new manifest", "schemas.ts": "new schema" }, + false, + ); + } +} catch (caught) { + const value = caught as NodeJS.ErrnoException; + error = { + message: value.message, + code: value.code, + causes: + caught instanceof AggregateError ? caught.errors.map((item) => item.message) : undefined, + }; +} +process.send!({ done: true, error, backup, stage, injected, logs, commandCode }); +process.disconnect!(); diff --git a/tests/generate.test.ts b/tests/generate.test.ts index 5f4bfa2..6a0c4cf 100644 --- a/tests/generate.test.ts +++ b/tests/generate.test.ts @@ -190,7 +190,7 @@ describe("askr generate", () => { ); expect(await readFile(output, "utf8")).toBe("keep"); }); - it("should preserve generated output when its backup rename fails", async () => { + it("should preserve an unrelated stale backup while replacing owned generated output", async () => { const root = await mkdtemp(join(tmpdir(), "askr-stale-backup-")); const output = join(root, "generated"); const backup = `${output}.backup-${process.pid}`; @@ -200,12 +200,13 @@ describe("askr generate", () => { await mkdir(backup); await writeFile(join(backup, "stale.txt"), "stale backup\n"); - await expect(writeGenerated(output, generateFiles(document), false)).rejects.toThrow(); + const files = generateFiles(document); + await expect(writeGenerated(output, files, false)).resolves.toBeUndefined(); expect(await readFile(join(output, ".askr-generated.json"), "utf8")).toBe( - "original manifest\n", + files[".askr-generated.json"], ); - expect(await readFile(join(output, "schemas.ts"), "utf8")).toBe("original schema\n"); + expect(await readFile(join(output, "schemas.ts"), "utf8")).toBe(files["schemas.ts"]); expect(await readFile(join(backup, "stale.txt"), "utf8")).toBe("stale backup\n"); }); it("should leave one complete result under concurrent generation", async () => { diff --git a/tests/generation-publication.test.ts b/tests/generation-publication.test.ts new file mode 100644 index 0000000..a4482f3 --- /dev/null +++ b/tests/generation-publication.test.ts @@ -0,0 +1,252 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + copyTargetIntoStage, + createSiblingStage, + withDirectoryTargetLock, +} from "../src/directory-swap"; +import { runCreateCli } from "../src/bin/create"; + +const roots: string[] = []; +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-generation-publication-")); + roots.push(root); + const target = path.join(root, "generated"); + await fs.mkdir(target); + await fs.writeFile(path.join(target, ".askr-generated.json"), "old manifest"); + await fs.writeFile(path.join(target, "schemas.ts"), "old schema"); + return { root, target }; +} +async function worker( + target: string, + fault: string, + kill = false, +): Promise<{ + error?: { message: string; code?: string; causes?: string[] }; + backup: string; + stage: string; + injected: boolean; + logs?: string[]; + commandCode?: number; +}> { + const child = fork( + fileURLToPath(new URL("./fixtures/generation-publication-worker.ts", import.meta.url)), + [target, fault], + { silent: true, execArgv: ["--import", "tsx"] }, + ); + const exited = once(child, "exit"); + let stderr = ""; + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + try { + const [result] = await Promise.race([ + once(child, "message", { signal: AbortSignal.timeout(5_000) }), + exited.then(() => { + throw new Error(`Generation worker exited before its result: ${stderr}`); + }), + ]); + if (kill) expect(child.kill("SIGKILL")).toBe(true); + await exited; + return result; + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await exited; + } +} +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("generated directory ownership and recovery", () => { + it.each([ + "create-publish-failure", + "create-install-failure", + "create-install-missing", + "create-install-timeout", + ])("cleans a failed project and reports no success after %s", async (fault) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-create-failure-")); + roots.push(root); + const target = path.join(root, "project"); + const result = await worker(target, fault); + expect(result.injected).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.commandCode).toBe(1); + expect(result.logs?.join("\n")).not.toContain("Success! Created"); + expect(result.logs?.join("\n")).toContain( + fault === "create-publish-failure" + ? "Failed to publish generated project" + : "no project files were published", + ); + expect(await fs.readdir(root)).toEqual([]); + }); + + it.each(["prepared", "backed-up", "published"])( + "recovers generation killed at the %s checkpoint", + async (phase) => { + const { target } = await fixture(); + const result = await worker(target, phase, true); + const expected = phase === "published" ? "new schema" : "old schema"; + await withDirectoryTargetLock(target, async () => { + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe(expected); + }); + await expect(fs.access(result.backup)).rejects.toMatchObject({ code: "ENOENT" }); + if (phase !== "published") + expect(await fs.readFile(path.join(result.stage, "schemas.ts"), "utf8")).toBe("new schema"); + }, + 10_000, + ); + + it("cleans a partial stage without changing the original output", async () => { + const { root, target } = await fixture(); + const result = await worker(target, "partial-stage-write"); + expect(result.injected).toBe(true); + expect(result.error?.code).toBe("ENOSPC"); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readdir(root)).toEqual(["generated"]); + }); + + it("preserves complete published output if deleting its backup fails partway through", async () => { + const { root, target } = await fixture(); + const result = await worker(target, "partial-backup-cleanup"); + expect(result.injected).toBe(true); + expect(result.error).toBeUndefined(); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("new schema"); + expect(await fs.readFile(path.join(target, ".askr-generated.json"), "utf8")).toBe( + "new manifest", + ); + await withDirectoryTargetLock(target, async () => {}); + expect(await fs.readdir(root)).toEqual(["generated"]); + }); + + it("reports a retained stage when cleaning a failed stage also fails", async () => { + const { target } = await fixture(); + const result = await worker(target, "stage-cleanup-failure"); + expect(result.error?.message).toContain(JSON.stringify(result.stage)); + expect(result.error?.causes).toEqual( + expect.arrayContaining(["injected stage-cleanup-failure"]), + ); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readFile(path.join(result.stage, "schemas.ts"), "utf8")).toBe("partial"); + }); + + it("retains an original after failed rollback and recovers it before the next copy", async () => { + const { target } = await fixture(); + const result = await worker(target, "rollback-failure"); + expect(result.injected).toBe(true); + expect(result.error?.message).toContain(JSON.stringify(result.backup)); + expect(await fs.readFile(path.join(result.backup, "schemas.ts"), "utf8")).toBe("old schema"); + const stage = await createSiblingStage(target, "copy"); + expect(await copyTargetIntoStage(target, stage)).toBe(true); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readFile(path.join(stage, "schemas.ts"), "utf8")).toBe("old schema"); + }); + + it("keeps the original and removes its stage when moving the original fails", async () => { + const { root, target } = await fixture(); + const result = await worker(target, "original-rename-failure"); + expect(result.injected).toBe(true); + expect(result.error?.code).toBe("EIO"); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readdir(root)).toEqual(["generated"]); + }); + + it("recovers an interrupted generation before refusing to create a project over its owned output", async () => { + const { root, target } = await fixture(); + const result = await worker(target, "backed-up", true); + const errors: string[] = []; + expect( + await runCreateCli(["spa", "replacement", "--dir", target, "--no-install", "--no-skills"], { + log() {}, + error: (message) => { + errors.push(message); + }, + }), + ).toBe(1); + expect(errors.join("\n")).toContain("not empty"); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readFile(path.join(result.stage, "schemas.ts"), "utf8")).toBe("new schema"); + expect((await fs.readdir(root)).sort()).toEqual( + [path.basename(result.stage), "generated"].sort(), + ); + }, 10_000); + + it.each(["target-read-denied", "target-stat-denied"])( + "preserves a %s error and the original output", + async (fault) => { + const { root, target } = await fixture(); + const result = await worker(target, fault); + expect(result.injected).toBe(true); + expect(result.error?.code).toBe("EACCES"); + expect(await fs.readFile(path.join(target, "schemas.ts"), "utf8")).toBe("old schema"); + expect(await fs.readdir(root)).toEqual(["generated"]); + }, + ); + + it("rejects a junction output without replacing the link or modifying its destination", async () => { + const { root, target } = await fixture(); + const outside = path.join(root, "unrelated"); + await fs.rename(target, outside); + await fs.symlink(outside, target, "junction"); + const result = await worker(target, "no-fault"); + expect(result.error?.message).toContain(JSON.stringify(target)); + expect((await fs.lstat(target)).isSymbolicLink()).toBe(true); + expect(await fs.readFile(path.join(outside, "schemas.ts"), "utf8")).toBe("old schema"); + expect((await fs.readdir(root)).sort()).toEqual(["generated", "unrelated"]); + }); + + it("admits only one concurrent project creation and preserves the winner's unrelated files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "askr-create-ownership-")); + roots.push(root); + const target = path.join(root, "shared"); + const mkdtemp = fs.mkdtemp.bind(fs); + const rename = fs.rename.bind(fs); + let arrivals = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(fs, "mkdtemp").mockImplementation(async (...args) => { + const stage = await mkdtemp(...args); + if (String(args[0]).startsWith(path.join(root, ".shared.askr-create-"))) { + if (++arrivals === 2) release(); + await gate; + } + return stage; + }); + let publications = 0; + vi.spyOn(fs, "rename").mockImplementation(async (from, to) => { + await rename(from, to); + if ( + String(to) === target && + String(from).startsWith(path.join(root, ".shared.askr-create-")) && + ++publications === 1 + ) { + await fs.writeFile(path.join(target, "unrelated.txt"), "unrelated"); + } + }); + const errors: string[] = []; + const results = await Promise.all( + ["first-app", "second-app"].map((name) => + runCreateCli(["spa", name, "--dir", target, "--no-install", "--no-skills"], { + log() {}, + error: (message) => { + errors.push(message); + }, + }), + ), + ); + expect(arrivals).toBe(2); + expect([...results].sort()).toEqual([0, 1]); + expect(publications).toBe(1); + expect(errors.join("\n")).toContain("not empty"); + expect(await fs.readFile(path.join(target, "unrelated.txt"), "utf8")).toBe("unrelated"); + expect(await fs.readdir(root)).toEqual(["shared"]); + }); +}); diff --git a/tests/public-contract.json b/tests/public-contract.json index 0957726..3c882c6 100644 --- a/tests/public-contract.json +++ b/tests/public-contract.json @@ -25,6 +25,7 @@ "bin/cli", "create", "generate", + "generate/generator", "directory-swap", "file-changes", "ssg/sitemap",