diff --git a/README.md b/README.md index 50222a8..612daee 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,45 @@ Broad managed mode is deprecated and contained by default because it can own or tool-home surfaces without a transaction receipt. Keep using inventory and previews while the per-asset deployment replacement is built. +Build a read-only plan for one exact canonical asset and one exact destination: + +```bash +fclt deploy plan \ + --asset instruction:WORK_UNITS \ + --destination instructions/WORK_UNITS.md \ + --tool codex \ + --adapter-version v1 \ + --root /tmp/fclt-proof/.ai \ + --target-root /tmp/fclt-proof/codex-home \ + --state-root /tmp/fclt-proof/state \ + --scope global \ + --expected-current-hash absent \ + --json +``` + +`deploy plan` reads canonical, target, and ownership state directly and never writes them. The +content-addressed JSON plan fails closed on stale hashes, unsupported adapters, unresolved +variables, lossy translation, corrupt ownership state, and path escape. Ownership is keyed by the +exact tool and destination, so an asset or adapter change cannot silently claim an already-owned +path. The primary identity losslessly encodes platform path semantics and the verified canonical +path. A separate v2 portability key applies separator normalization, NFC, and the full default +Unicode case fold pinned by `unicode-case-folding@1.1.1`; it detects collisions without conflating +distinct physical paths. True filesystem aliases share ownership; ambiguous case, separator, or +normalization collisions fail closed. Older collision-key contracts require an explicit future +migration. Every persisted identity is recomputed before records are grouped, and unknown keys are +rejected at every level of the exact ownership-state schema. Canonical, target, and snapshot files +are read twice through bounded no-follow descriptors and must retain the same device/inode, +metadata, bytes, canonical identity, and physical containment. The deployment-state directory is +opened once with no-follow directory semantics; enumeration and record opens stay bound to that +descriptor. Its scan is limited to 128 entries, 256 KiB per record, and 4 MiB total. Platforms +without the descriptor-relative primitives fail closed. A shared state root may contain multiple +target roots; unrelated records are structurally validated but do not inherit the current plan's +target boundary. Persisted destination and rollback paths must be absolute, normalized, and safe. +Existing rollback targets are preserved, and snapshot targets must still match their recorded +hash. Ownership transfer is reserved for a future explicit migration command. This slice has no +apply subcommand; any future executor must repeat the safe reads and reverify every plan hash +immediately before mutation. + ```bash fclt setup codex-plugin fclt manage codex --dry-run @@ -543,6 +582,7 @@ fclt index [--force] Legacy managed-mode inspection: ```bash +fclt deploy plan --help fclt setup codex-plugin [--dry-run] [--json] [--no-codex-install] fclt manage --dry-run fclt sync [tool] --dry-run diff --git a/bun.lock b/bun.lock index 4400c9d..07bb39d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@clack/prompts": "^1.0.0", "@opentui/core": "^0.1.75", "jsonc-parser": "^3.3.1", + "unicode-case-folding": "1.1.1", "yaml": "^2.8.2", }, "devDependencies": { @@ -710,6 +711,8 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unicode-case-folding": ["unicode-case-folding@1.1.1", "", {}, "sha512-ZAYziAnMTBisO2dT5tyGvBFMNsIfonOS/BZTd3UZaKWtXMOZqK8s8HRUCrlKxGCTeCxCuCjS/6HW+4a6G+rbzw=="], + "unicode-emoji-modifier-base": ["unicode-emoji-modifier-base@1.0.0", "", {}, "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g=="], "unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], diff --git a/docs/reference.md b/docs/reference.md index 20908a7..bc06792 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -69,6 +69,38 @@ fclt index [--force] Use these to create or normalize canonical capability in `~/.ai` or `/.ai`. +## Per-asset deployment planning + +```bash +fclt deploy plan --asset instruction:|snippet: \ + --destination --tool codex --adapter-version v1 \ + --root --target-root --state-root \ + --scope global|project [--expected-source-hash sha256:] \ + [--expected-current-hash absent|sha256:] --json +``` + +This is a read-only, one-asset/one-destination planning boundary. It emits a deterministic, +content-addressed plan and has no executor. The planner scans canonical ownership records and +allows at most one claim for the exact tool and physical destination. Primary identity is a +lossless, platform-qualified encoding of the verified canonical path. The separate v2 portability +collision key applies slash normalization, NFC, and the full default Unicode case fold pinned by +`unicode-case-folding@1.1.1`: true aliases resolve to one physical identity, while distinct paths in +the same collision class fail closed. Older key contracts require explicit migration. The planner +recomputes both identities for every structurally safe state record before filtering or grouping, +and the persisted schema rejects unknown root, binding, and rollback fields. Canonical, target, and +rollback-snapshot files are limited to 16 MiB and read twice through a no-follow descriptor. +Deployment-state enumeration and record opens are anchored to one no-follow directory descriptor; +the scan allows at most 128 entries, 256 KiB per record, and 4 MiB in aggregate. Device/inode, +metadata, bytes, canonical identity, and physical-root containment must remain stable; platforms +without the required descriptor-relative primitives fail closed. Shared state roots may hold +records for multiple target roots. Persisted destination and rollback paths must be non-empty, +NUL-free, absolute, and normalized before they can be resolved or reused. Existing rollback targets +survive no-op and update replans; recorded snapshots are required and hash-verified. Asset or +adapter ownership transfer fails closed until a separate, explicitly reviewed migration command +exists. Use isolated roots while this boundary is being proven; broad managed apply remains +deprecated and contained. This slice has no executor. Any future executor must repeat the same safe +reads and reverify every recorded plan hash immediately before mutation. + ## Legacy managed mode ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index a0c7c0e..5660c42 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,10 +47,12 @@ This roadmap tracks remaining product direction for `fclt`. - Keep vendor integrations export-based and optional rather than adding credentials to core. - Make renamed-source and unavailable-source recovery easier to understand. -4. Add a structured sync plan. - - Group writes, updates, removals, skips, conflicts, and repairs. - - Expose the same plan as JSON. - - Explain source refs and policy reasons. +4. Extend immutable per-asset deployment planning beyond the first Codex instruction/snippet slice. + - Keep planning read-only and exact-bound while adapters are added. + - Add an explicit destination ownership migration/transfer contract before allowing asset or + adapter claims to move. + - Build an apply executor only after transaction, approval, receipt, and recovery contracts exist. + - Keep deprecated broad managed mutation contained. 5. Improve project onboarding. - Add a primary `fclt init project` flow. diff --git a/package.json b/package.json index 6785d4a..3d8572c 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "@clack/prompts": "^1.0.0", "@opentui/core": "^0.1.75", "jsonc-parser": "^3.3.1", + "unicode-case-folding": "1.1.1", "yaml": "^2.8.2" } } diff --git a/src/deployment-plan.test.ts b/src/deployment-plan.test.ts new file mode 100644 index 0000000..919003f --- /dev/null +++ b/src/deployment-plan.test.ts @@ -0,0 +1,1380 @@ +import { describe, expect, it } from "bun:test"; +import { createHash } from "node:crypto"; +import { + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + readlink, + realpath, + rename, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative } from "node:path"; +import { + buildDeploymentPlan, + type DeploymentPlanV1, + type DeploymentStateV1, + readStableRegularFileForTest, + scanDeploymentStateDirectoryForTest, + serializeDeploymentState, +} from "./deployment-plan"; + +const SHA256_RE = /^sha256:[a-f0-9]{64}$/; +const DESTINATION_IDENTITY_RE = + /^physical-path-v3:(?:posix|win32):[A-Za-z0-9_-]+$/; +const DESTINATION_COLLISION_KEY_RE = + /^case-folded-path-v2:unicode-case-folding@1\.1\.1:.+/; +const SOURCE_DIR_SUFFIX_RE = /\/src$/; +const DOLLAR = "$"; +const PACKAGE_VERSION = ( + JSON.parse( + await readFile(join(import.meta.dir, "..", "package.json"), "utf8") + ) as { version: string } +).version; +const MISMATCHED_PACKAGE_VERSION = + PACKAGE_VERSION === "0.0.0" ? "0.0.1" : "0.0.0"; + +interface Fixture { + canonicalRoot: string; + root: string; + sourcePath: string; + stateRoot: string; + targetRoot: string; +} + +async function createFixture( + source = "# Work units\n\nKeep work explicit.\n" +): Promise { + const root = await mkdtemp(join(tmpdir(), "fclt-deployment-plan-")); + const canonicalRoot = join(root, ".ai"); + const sourcePath = join(canonicalRoot, "instructions", "WORK_UNITS.md"); + const targetRoot = join(root, "codex-home"); + const stateRoot = join(root, "state"); + await mkdir(dirname(sourcePath), { recursive: true }); + await mkdir(targetRoot, { recursive: true }); + await mkdir(stateRoot, { recursive: true }); + await Bun.write(sourcePath, source); + return { canonicalRoot, root, sourcePath, stateRoot, targetRoot }; +} + +function planOptions(fixture: Fixture) { + return { + adapterVersion: "v1", + asset: "instruction:WORK_UNITS", + canonicalRoot: fixture.canonicalRoot, + destination: "instructions/WORK_UNITS.md", + expectedCurrentHash: null, + plannerVersion: PACKAGE_VERSION, + scope: "global" as const, + stateRoot: fixture.stateRoot, + targetRoot: fixture.targetRoot, + tool: "codex", + }; +} + +function sha256(value: Uint8Array | string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +async function snapshotTree(root: string): Promise> { + const snapshot: Record = {}; + async function visit(pathValue: string, relativePath: string): Promise { + const stat = await lstat(pathValue).catch(() => null); + if (!stat) { + return; + } + const key = relativePath || "."; + if (stat.isSymbolicLink()) { + snapshot[key] = `symlink:${await readlink(pathValue)}`; + return; + } + if (stat.isDirectory()) { + snapshot[key] = "directory"; + for (const entry of (await readdir(pathValue)).sort()) { + await visit(join(pathValue, entry), join(relativePath, entry)); + } + return; + } + snapshot[key] = `file:${(await readFile(pathValue)).toString("base64")}`; + } + await visit(root, ""); + return snapshot; +} + +function deploymentStateWrite(plan: Readonly): { + path: string; + state: DeploymentStateV1; +} { + const write = plan.operations.writes.find( + (candidate) => candidate.kind === "deployment-state" + ); + if (!write || write.contentSource.kind !== "inline-state") { + throw new Error("Expected deployment-state write"); + } + return { path: write.path, state: write.contentSource.state }; +} + +async function materializePlanFixture( + plan: Readonly +): Promise { + for (const write of plan.operations.writes) { + await mkdir(dirname(write.path), { recursive: true }); + if (write.contentSource.kind === "path") { + await Bun.write(write.path, await readFile(write.contentSource.path)); + } else { + await Bun.write( + write.path, + serializeDeploymentState(write.contentSource.state) + ); + } + } +} + +async function expectDeterministicReadOnlyFailure(args: { + action: () => Promise; + message: string; + root: string; +}): Promise { + const before = await snapshotTree(args.root); + const messages: string[] = []; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await args.action(); + throw new Error("Expected planning to fail closed"); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + messages.push(error.message); + if (!error.message.includes(args.message)) { + throw new Error( + `Expected failure containing ${args.message}, received ${error.message}` + ); + } + } + } + if (messages[1] !== messages[0]) { + throw new Error("Repeated fail-closed planning was not deterministic"); + } + if ( + JSON.stringify(await snapshotTree(args.root)) !== JSON.stringify(before) + ) { + throw new Error("Fail-closed planning mutated the fixture tree"); + } +} + +async function runCli(args: string[]): Promise<{ + code: number; + stderr: string; + stdout: string; +}> { + const child = Bun.spawn(["bun", "run", "./src/index.ts", ...args], { + cwd: import.meta.dir.replace(SOURCE_DIR_SUFFIX_RE, ""), + env: { ...globalThis.process.env }, + stderr: "pipe", + stdout: "pipe", + }); + const [code, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + return { code, stderr, stdout }; +} + +describe("immutable per-asset deployment planning", () => { + it("plans one global instruction into an isolated Codex home deterministically without mutation", async () => { + const fixture = await createFixture(); + const generatedIndex = join( + fixture.canonicalRoot, + ".facult", + "ai", + "index.json" + ); + await mkdir(dirname(generatedIndex), { recursive: true }); + await Bun.write(generatedIndex, '{"stale":true}\n'); + await rm(generatedIndex); + const before = await snapshotTree(fixture.root); + + const first = await buildDeploymentPlan(planOptions(fixture)); + const second = await buildDeploymentPlan(planOptions(fixture)); + const after = await snapshotTree(fixture.root); + + expect(second).toEqual(first); + expect(after).toEqual(before); + expect(first.schemaVersion).toBe(1); + expect(first.planner).toEqual({ name: "fclt", version: PACKAGE_VERSION }); + expect(first.planId).toMatch(SHA256_RE); + expect(first.binding.asset).toEqual({ + kind: "instruction", + selector: "instruction:WORK_UNITS", + canonicalRef: "@ai/instructions/WORK_UNITS.md", + path: fixture.sourcePath, + }); + expect(first.binding.destination.path).toBe( + join(fixture.targetRoot, "instructions", "WORK_UNITS.md") + ); + expect(first.binding.destination.identity).toMatch(DESTINATION_IDENTITY_RE); + expect(first.binding.destination.collisionKey).toMatch( + DESTINATION_COLLISION_KEY_RE + ); + expect(first.hashes.source).toBe(first.hashes.desired); + expect(first.hashes.current).toBeNull(); + expect(first.ownerMode).toBe("unowned"); + expect(first.adapter).toEqual({ id: "codex", version: "v1" }); + expect(first.lossReport).toEqual({ lossless: true, entries: [] }); + expect(first.secretReferences).toEqual([]); + expect(first.operations.reads.map((read) => read.kind)).toEqual([ + "canonical-source", + "current-target", + "ownership-directory", + "deployment-state", + ]); + expect(first.operations.writes.map((write) => write.kind)).toEqual([ + "target", + "deployment-state", + ]); + expect(first.operations.removals).toEqual([]); + expect(first.operations.nativeCommands).toEqual([]); + expect(first.verificationProbe).toEqual({ + kind: "file-sha256", + path: join(fixture.targetRoot, "instructions", "WORK_UNITS.md"), + expectedHash: first.hashes.desired, + }); + expect(first.rollbackTarget).toEqual({ + kind: "absent", + path: join(fixture.targetRoot, "instructions", "WORK_UNITS.md"), + }); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.operations.writes)).toBe(true); + }); + + it(`proves the fclt ${PACKAGE_VERSION} CLI consumer against an isolated Codex home`, async () => { + const fixture = await createFixture(); + const before = await snapshotTree(fixture.root); + const result = await runCli([ + "deploy", + "plan", + "--asset", + "instruction:WORK_UNITS", + "--destination", + "instructions/WORK_UNITS.md", + "--tool", + "codex", + "--adapter-version", + "v1", + "--root", + fixture.canonicalRoot, + "--target-root", + fixture.targetRoot, + "--state-root", + fixture.stateRoot, + "--scope", + "global", + "--expected-current-hash", + "absent", + "--json", + ]); + const after = await snapshotTree(fixture.root); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + const plan = JSON.parse(result.stdout) as DeploymentPlanV1; + expect(plan.planner.version).toBe(PACKAGE_VERSION); + expect(plan.binding.destination.root).toBe(fixture.targetRoot); + expect(plan.operations.writes.map((write) => write.kind)).toEqual([ + "target", + "deployment-state", + ]); + expect(after).toEqual(before); + }); + + it("exposes no deployment apply executor", async () => { + const result = await runCli(["deploy", "apply"]); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "deploy requires the read-only subcommand: plan\n" + ); + }); + + it("fails closed on stale source and current hashes", async () => { + const fixture = await createFixture(); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedSourceHash: `sha256:${"0".repeat(64)}`, + }) + ).rejects.toThrow("Stale source hash"); + + const targetPath = join( + fixture.targetRoot, + "instructions", + "WORK_UNITS.md" + ); + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write(targetPath, "tampered\n"); + await expect(buildDeploymentPlan(planOptions(fixture))).rejects.toThrow( + "Stale current hash" + ); + }); + + it("fails closed when owned target content was tampered with", async () => { + const fixture = await createFixture(); + const first = await buildDeploymentPlan(planOptions(fixture)); + const stateWrite = deploymentStateWrite(first); + await mkdir(dirname(stateWrite.path), { recursive: true }); + await Bun.write(stateWrite.path, `${JSON.stringify(stateWrite.state)}\n`); + const targetPath = first.binding.destination.path; + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write(targetPath, "not the planned content\n"); + + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("Target tamper detected"); + }); + + it("fails closed on corrupt deployment state", async () => { + const fixture = await createFixture(); + const first = await buildDeploymentPlan(planOptions(fixture)); + const stateWrite = deploymentStateWrite(first); + await mkdir(dirname(stateWrite.path), { recursive: true }); + await Bun.write(stateWrite.path, "{not-json\n"); + + await expect(buildDeploymentPlan(planOptions(fixture))).rejects.toThrow( + "Deployment state is corrupt JSON" + ); + }); + + it("rejects unknown persisted-state keys at root, binding, and rollback levels", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const variants: unknown[] = [ + { ...stateWrite.state, unexpectedRoot: true }, + { + ...stateWrite.state, + binding: { ...stateWrite.state.binding, unexpectedBinding: true }, + }, + { + ...stateWrite.state, + rollbackTarget: { + ...stateWrite.state.rollbackTarget, + unexpectedRollback: true, + }, + }, + ]; + for (const variant of variants) { + await Bun.write(stateWrite.path, `${JSON.stringify(variant)}\n`); + await expectDeterministicReadOnlyFailure({ + action: async () => + await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }), + message: "Deployment state", + root: fixture.root, + }); + } + }); + + it("bounds deployment-state directory entry count before ownership selection", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const directory = dirname(stateWrite.path); + for (let index = 0; index < 128; index += 1) { + await Bun.write( + join(directory, `extra-${index.toString().padStart(3, "0")}.json`), + serializeDeploymentState(stateWrite.state) + ); + } + await expectDeterministicReadOnlyFailure({ + action: async () => + await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }), + message: "exceeds the 128-record limit", + root: fixture.root, + }); + }); + + it("bounds each deployment-state record before retaining or parsing it", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const serialized = serializeDeploymentState(stateWrite.state); + await Bun.write(stateWrite.path, `${serialized}${" ".repeat(256 * 1024)}`); + await expectDeterministicReadOnlyFailure({ + action: async () => + await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }), + message: "exceeds the 262144-byte planning read limit", + root: fixture.root, + }); + }); + + it("bounds cumulative deployment-state bytes across duplicate and unowned records", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const directory = dirname(stateWrite.path); + const unownedTargetRoot = join(fixture.root, "unowned-codex-home"); + await mkdir(unownedTargetRoot); + const unownedPlan = await buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot: unownedTargetRoot, + }); + const serialized = serializeDeploymentState( + deploymentStateWrite(unownedPlan).state + ); + const recordBytes = `${serialized}${" ".repeat(245_000 - serialized.length)}`; + for (let index = 0; index < 18; index += 1) { + await Bun.write( + join(directory, `aggregate-${index.toString().padStart(2, "0")}.json`), + recordBytes + ); + } + await expectDeterministicReadOnlyFailure({ + action: async () => + await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }), + message: "exceed the 4194304-byte aggregate limit", + root: fixture.root, + }); + }); + + it("rejects relative, cwd-dependent, unnormalized, and NUL persisted paths", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const targetPath = initial.binding.destination.path; + const relativeTarget = relative(fixture.root, targetPath); + const relativeState: DeploymentStateV1 = { + ...stateWrite.state, + binding: { + ...stateWrite.state.binding, + destinationPath: relativeTarget, + }, + rollbackTarget: { kind: "absent", path: relativeTarget }, + }; + await Bun.write(stateWrite.path, serializeDeploymentState(relativeState)); + + const originalCwd = process.cwd(); + const beforeRelative = await snapshotTree(fixture.root); + try { + for (const cwd of [fixture.root, dirname(fixture.root)]) { + process.chdir(cwd); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow( + "Persisted destination path must be an absolute normalized safe path" + ); + } + } finally { + process.chdir(originalCwd); + } + expect(await snapshotTree(fixture.root)).toEqual(beforeRelative); + + const relativeRollbackState: DeploymentStateV1 = { + ...stateWrite.state, + rollbackTarget: { + kind: "snapshot", + path: "rollback/snapshot", + expectedHash: stateWrite.state.desiredHash, + }, + }; + await Bun.write( + stateWrite.path, + serializeDeploymentState(relativeRollbackState) + ); + const beforeRelativeRollback = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow( + "Persisted rollback path must be an absolute normalized safe path" + ); + expect(await snapshotTree(fixture.root)).toEqual(beforeRelativeRollback); + + const unnormalizedTarget = `${dirname(targetPath)}/nested/../${basename(targetPath)}`; + const unnormalizedState: DeploymentStateV1 = { + ...stateWrite.state, + binding: { + ...stateWrite.state.binding, + destinationPath: unnormalizedTarget, + }, + rollbackTarget: { kind: "absent", path: unnormalizedTarget }, + }; + await Bun.write( + stateWrite.path, + serializeDeploymentState(unnormalizedState) + ); + const beforeUnnormalized = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow( + "Persisted destination path must be an absolute normalized safe path" + ); + expect(await snapshotTree(fixture.root)).toEqual(beforeUnnormalized); + + const nulRollbackState: DeploymentStateV1 = { + ...stateWrite.state, + rollbackTarget: { + kind: "snapshot", + path: `${targetPath}\0snapshot`, + expectedHash: stateWrite.state.desiredHash, + }, + }; + await Bun.write( + stateWrite.path, + serializeDeploymentState(nulRollbackState) + ); + const beforeNul = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow( + "Persisted rollback path must be an absolute normalized safe path" + ); + expect(await snapshotTree(fixture.root)).toEqual(beforeNul); + }); + + it("fails closed on an escaped rollback target in otherwise valid owned state", async () => { + const fixture = await createFixture(); + const first = await buildDeploymentPlan(planOptions(fixture)); + const stateWrite = deploymentStateWrite(first); + const escapedState: DeploymentStateV1 = { + ...stateWrite.state, + rollbackTarget: { + kind: "snapshot", + path: join(fixture.root, "outside-rollback"), + expectedHash: stateWrite.state.desiredHash, + }, + }; + await mkdir(dirname(stateWrite.path), { recursive: true }); + await Bun.write(stateWrite.path, `${JSON.stringify(escapedState)}\n`); + await mkdir(dirname(first.binding.destination.path), { recursive: true }); + await Bun.write( + first.binding.destination.path, + await readFile(fixture.sourcePath) + ); + + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("escapes its root"); + }); + + it("preserves an absent rollback target across post-apply no-op and update replans", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + + const options = { + ...planOptions(fixture), + expectedCurrentHash: undefined, + }; + const beforeNoOp = await snapshotTree(fixture.root); + const firstNoOp = await buildDeploymentPlan(options); + const secondNoOp = await buildDeploymentPlan(options); + const afterNoOp = await snapshotTree(fixture.root); + + expect(afterNoOp).toEqual(beforeNoOp); + expect(secondNoOp).toEqual(firstNoOp); + expect(firstNoOp.ownerMode).toBe("fclt-owned"); + expect(firstNoOp.rollbackTarget).toEqual(initial.rollbackTarget); + expect(firstNoOp.operations.writes).toEqual([]); + + await Bun.write(fixture.sourcePath, "# Updated\n\nNew desired content.\n"); + const beforeUpdatePlan = await snapshotTree(fixture.root); + const update = await buildDeploymentPlan(options); + const afterUpdatePlan = await snapshotTree(fixture.root); + + expect(afterUpdatePlan).toEqual(beforeUpdatePlan); + expect(update.rollbackTarget).toEqual(initial.rollbackTarget); + expect(update.operations.writes.map((write) => write.kind)).toEqual([ + "target", + "deployment-state", + ]); + expect( + update.operations.writes.some( + (write) => write.kind === "rollback-snapshot" + ) + ).toBe(false); + }); + + it("preserves and verifies the original snapshot rollback across owned replans", async () => { + const fixture = await createFixture(); + const targetPath = join( + fixture.targetRoot, + "instructions", + "WORK_UNITS.md" + ); + const legacy = "legacy user content\n"; + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write(targetPath, legacy); + const initial = await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: sha256(legacy), + }); + expect(initial.rollbackTarget.kind).toBe("snapshot"); + await materializePlanFixture(initial); + + const options = { + ...planOptions(fixture), + expectedCurrentHash: undefined, + }; + const before = await snapshotTree(fixture.root); + const noOp = await buildDeploymentPlan(options); + const after = await snapshotTree(fixture.root); + expect(after).toEqual(before); + expect(noOp.rollbackTarget).toEqual(initial.rollbackTarget); + expect(noOp.operations.writes).toEqual([]); + expect( + noOp.operations.reads.find((read) => read.kind === "rollback-snapshot") + ).toMatchObject({ + required: true, + expectedHash: sha256(legacy), + }); + + if (initial.rollbackTarget.kind !== "snapshot") { + throw new Error("Expected snapshot rollback target"); + } + await rm(initial.rollbackTarget.path); + await expect(buildDeploymentPlan(options)).rejects.toThrow( + "Rollback snapshot is missing" + ); + await Bun.write(initial.rollbackTarget.path, "tampered snapshot\n"); + await expect(buildDeploymentPlan(options)).rejects.toThrow( + "Rollback snapshot tamper detected" + ); + }); + + it("enforces one destination ownership record and rejects implicit transfers", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + + const secondSource = join( + fixture.canonicalRoot, + "instructions", + "SECOND.md" + ); + await Bun.write(secondSource, await readFile(fixture.sourcePath)); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + asset: "instruction:SECOND", + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("future explicit migration/transfer command"); + + const changedAdapterState: DeploymentStateV1 = { + ...stateWrite.state, + binding: { ...stateWrite.state.binding, adapterVersion: "v0" }, + }; + await Bun.write( + stateWrite.path, + serializeDeploymentState(changedAdapterState) + ); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("future explicit migration/transfer command"); + + await Bun.write( + stateWrite.path, + serializeDeploymentState(stateWrite.state) + ); + const orphanPath = join(dirname(stateWrite.path), "orphan.json"); + await Bun.write(orphanPath, serializeDeploymentState(stateWrite.state)); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("Conflicting deployment ownership claims"); + + await rm(stateWrite.path); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("Orphaned deployment ownership claim"); + }); + + it("uses one ownership identity through a portable realpath alias", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + + const aliasRoot = join( + dirname(fixture.root), + `${basename(fixture.root)}-alias` + ); + await symlink(fixture.root, aliasRoot); + const before = await snapshotTree(fixture.root); + const aliasPlan = await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + targetRoot: join(aliasRoot, "codex-home"), + }); + const after = await snapshotTree(fixture.root); + + expect(after).toEqual(before); + expect(aliasPlan.binding.destination.path).not.toBe( + initial.binding.destination.path + ); + expect(aliasPlan.binding.destination.identity).toBe( + initial.binding.destination.identity + ); + expect(aliasPlan.binding.destination.collisionKey).toBe( + initial.binding.destination.collisionKey + ); + expect(aliasPlan.ownerMode).toBe("fclt-owned"); + expect(aliasPlan.rollbackTarget).toEqual(initial.rollbackTarget); + expect(aliasPlan.operations.writes).toEqual([]); + expect( + aliasPlan.operations.reads.find( + (read) => read.kind === "deployment-state" + )?.path + ).toBe(deploymentStateWrite(initial).path); + }); + + it("keeps POSIX backslash paths lossless while failing closed on portable collisions", async () => { + if (process.platform === "win32") { + return; + } + const fixture = await createFixture(); + const backslashRoot = join(fixture.root, "tool\\home"); + const slashRoot = join(fixture.root, "tool", "home"); + await mkdir(backslashRoot, { recursive: true }); + await mkdir(slashRoot, { recursive: true }); + const [backslashStat, slashStat] = await Promise.all([ + lstat(backslashRoot), + lstat(slashRoot), + ]); + expect(backslashRoot).not.toBe(slashRoot); + expect(await readdir(fixture.root)).toContain("tool\\home"); + expect([backslashStat.dev, backslashStat.ino]).not.toEqual([ + slashStat.dev, + slashStat.ino, + ]); + + const beforeInitialPlan = await snapshotTree(fixture.root); + let initial: Readonly; + try { + initial = await buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot: backslashRoot, + }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "identity cannot be established safely" + ); + expect(await snapshotTree(fixture.root)).toEqual(beforeInitialPlan); + return; + } + await materializePlanFixture(initial); + const encodedPath = initial.binding.destination.identity.split(":").at(-1); + expect(encodedPath).toBeTruthy(); + expect(Buffer.from(encodedPath ?? "", "base64url").toString("utf8")).toBe( + await realpath(initial.binding.destination.path) + ); + + const before = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot: slashRoot, + }) + ).rejects.toThrow("Case-folded destination collision is ambiguous"); + expect(await snapshotTree(fixture.root)).toEqual(before); + }); + + it("keeps normalization-distinct paths lossless where the filesystem preserves them", async () => { + if (process.platform === "win32") { + return; + } + const fixture = await createFixture(); + const composedRoot = join(fixture.root, "tool-\u00e9"); + const decomposedRoot = join(fixture.root, "tool-e\u0301"); + await mkdir(composedRoot, { recursive: true }); + await mkdir(decomposedRoot, { recursive: true }); + const [composedStat, decomposedStat] = await Promise.all([ + lstat(composedRoot), + lstat(decomposedRoot), + ]); + if ( + composedStat.dev === decomposedStat.dev && + composedStat.ino === decomposedStat.ino + ) { + return; + } + const [composedRealpath, decomposedRealpath] = await Promise.all([ + realpath(composedRoot), + realpath(decomposedRoot), + ]); + expect(composedRealpath).not.toBe(decomposedRealpath); + expect([composedStat.dev, composedStat.ino]).not.toEqual([ + decomposedStat.dev, + decomposedStat.ino, + ]); + + const initial = await buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot: composedRoot, + }); + await materializePlanFixture(initial); + const encodedPath = initial.binding.destination.identity.split(":").at(-1); + expect(encodedPath).toBeTruthy(); + expect(Buffer.from(encodedPath ?? "", "base64url").toString("utf8")).toBe( + await realpath(initial.binding.destination.path) + ); + const before = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot: decomposedRoot, + }) + ).rejects.toThrow("Case-folded destination collision is ambiguous"); + expect(await snapshotTree(fixture.root)).toEqual(before); + }); + + it("uses pinned full Unicode folding for sharp-s, sigma classes, and normalization interactions", async () => { + const collisionClasses = [ + ["tool-Straße", "tool-STRASSE"], + ["tool-Σ", "tool-σ", "tool-ς"], + ["tool-Straße-é", "tool-STRASSE-e\u0301"], + ]; + for (const names of collisionClasses) { + const fixture = await createFixture(); + const roots = names.map((name) => join(fixture.root, name)); + for (const root of roots) { + await mkdir(root, { recursive: true }); + } + const plans: Readonly[] = []; + for (const targetRoot of roots) { + plans.push( + await buildDeploymentPlan({ + ...planOptions(fixture), + targetRoot, + }) + ); + } + expect( + new Set(plans.map((plan) => plan.binding.destination.collisionKey)).size + ).toBe(1); + for (const plan of plans) { + expect(plan.binding.destination.collisionKey).toMatch( + DESTINATION_COLLISION_KEY_RE + ); + } + + const initialPlan = plans[0]; + if (!initialPlan) { + throw new Error("Expected a Unicode collision-class fixture plan"); + } + await materializePlanFixture(initialPlan); + for (let index = 1; index < plans.length; index += 1) { + if ( + plans[index]?.binding.destination.identity === + initialPlan.binding.destination.identity + ) { + continue; + } + await expectDeterministicReadOnlyFailure({ + action: async () => + await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + targetRoot: roots[index] ?? "", + }), + message: "Case-folded destination collision is ambiguous", + root: fixture.root, + }); + } + } + }); + + it("coalesces case-only destination aliases on case-insensitive filesystems", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + + const originalPath = initial.binding.destination.path; + const caseAliasPath = join( + fixture.targetRoot, + "instructions", + "work_units.md" + ); + const [originalStat, aliasStat] = await Promise.all([ + lstat(originalPath), + lstat(caseAliasPath).catch(() => null), + ]); + if ( + !aliasStat || + originalStat.dev !== aliasStat.dev || + originalStat.ino !== aliasStat.ino + ) { + return; + } + + const before = await snapshotTree(fixture.root); + const aliasPlan = await buildDeploymentPlan({ + ...planOptions(fixture), + destination: "instructions/work_units.md", + expectedCurrentHash: undefined, + }); + const after = await snapshotTree(fixture.root); + + expect(after).toEqual(before); + expect(aliasPlan.binding.destination.identity).toBe( + initial.binding.destination.identity + ); + expect(aliasPlan.binding.destination.collisionKey).toBe( + initial.binding.destination.collisionKey + ); + expect(aliasPlan.ownerMode).toBe("fclt-owned"); + expect(aliasPlan.operations.writes).toEqual([]); + }); + + it("rejects distinct case-only files on case-sensitive filesystems", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + + const originalPath = initial.binding.destination.path; + const caseCollisionPath = join( + fixture.targetRoot, + "instructions", + "work_units.md" + ); + const [originalStat, collisionStat] = await Promise.all([ + lstat(originalPath), + lstat(caseCollisionPath).catch(() => null), + ]); + if ( + collisionStat && + originalStat.dev === collisionStat.dev && + originalStat.ino === collisionStat.ino + ) { + return; + } + + await Bun.write(caseCollisionPath, await readFile(fixture.sourcePath)); + const before = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + destination: "instructions/work_units.md", + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("Case-folded destination collision is ambiguous"); + const after = await snapshotTree(fixture.root); + expect(after).toEqual(before); + }); + + it("rejects a corrupt persisted collision key before selecting case competitors", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const originalPath = initial.binding.destination.path; + const competitorPath = join( + fixture.targetRoot, + "instructions", + "work_units.md" + ); + const [originalStat, competitorStat] = await Promise.all([ + lstat(originalPath), + lstat(competitorPath).catch(() => null), + ]); + if ( + competitorStat && + originalStat.dev === competitorStat.dev && + originalStat.ino === competitorStat.ino + ) { + return; + } + + await Bun.write(competitorPath, await readFile(fixture.sourcePath)); + const stateWrite = deploymentStateWrite(initial); + const corruptState: DeploymentStateV1 = { + ...stateWrite.state, + binding: { + ...stateWrite.state.binding, + destinationCollisionKey: + "case-folded-path-v2:unicode-case-folding@1.1.1:corrupt", + }, + }; + await Bun.write(stateWrite.path, serializeDeploymentState(corruptState)); + const before = await snapshotTree(fixture.root); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + destination: "instructions/work_units.md", + expectedCurrentHash: undefined, + }) + ).rejects.toThrow("Deployment state has a corrupt destination identity"); + expect(await snapshotTree(fixture.root)).toEqual(before); + }); + + it("supports multiple target roots in one shared deployment state root", async () => { + const fixture = await createFixture(); + const first = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(first); + + const secondTargetRoot = join(fixture.root, "second-codex-home"); + await mkdir(secondTargetRoot); + const secondOptions = { + ...planOptions(fixture), + targetRoot: secondTargetRoot, + }; + const beforeSecondPlan = await snapshotTree(fixture.root); + const second = await buildDeploymentPlan(secondOptions); + const afterSecondPlan = await snapshotTree(fixture.root); + + expect(afterSecondPlan).toEqual(beforeSecondPlan); + expect(second.ownerMode).toBe("unowned"); + expect(second.binding.destination.identity).not.toBe( + first.binding.destination.identity + ); + expect(deploymentStateWrite(second).path).not.toBe( + deploymentStateWrite(first).path + ); + await materializePlanFixture(second); + + const firstNoOp = await buildDeploymentPlan({ + ...planOptions(fixture), + expectedCurrentHash: undefined, + }); + const secondNoOp = await buildDeploymentPlan({ + ...secondOptions, + expectedCurrentHash: undefined, + }); + expect(firstNoOp.operations.writes).toEqual([]); + expect(secondNoOp.operations.writes).toEqual([]); + }); + + it("anchors state enumeration and record opens against whole-directory substitution", async () => { + const fixture = await createFixture(); + const initial = await buildDeploymentPlan(planOptions(fixture)); + await materializePlanFixture(initial); + const stateWrite = deploymentStateWrite(initial); + const directory = dirname(stateWrite.path); + const parkedDirectory = join(fixture.stateRoot, "parked-deployments"); + const substituteDirectory = join( + fixture.stateRoot, + "substitute-deployments" + ); + const activeSubstitute = join(fixture.stateRoot, "active-substitute"); + const substitutedState: DeploymentStateV1 = { + ...stateWrite.state, + desiredHash: `sha256:${"0".repeat(64)}`, + }; + await mkdir(substituteDirectory); + await Bun.write( + join(substituteDirectory, basename(stateWrite.path)), + serializeDeploymentState(substitutedState) + ); + const before = await snapshotTree(fixture.root); + let swapped = false; + try { + await expect( + scanDeploymentStateDirectoryForTest({ + afterEnumeration: async () => { + await rename(directory, parkedDirectory); + await rename(substituteDirectory, directory); + swapped = true; + }, + directory, + expectedPath: stateWrite.path, + requestedBinding: stateWrite.state.binding, + stateRoot: fixture.stateRoot, + }) + ).rejects.toThrow("Deployment state path changed during planning"); + } finally { + if (swapped) { + await rename(directory, activeSubstitute); + await rename(parkedDirectory, directory); + await rename(activeSubstitute, substituteDirectory); + } + } + expect(await snapshotTree(fixture.root)).toEqual(before); + }); + + it("rejects descriptor replacement races for every planner file role", async () => { + const fixture = await createFixture(); + const labels = [ + "Canonical source", + "Current target", + "Deployment state", + "Rollback snapshot", + ]; + for (const [index, label] of labels.entries()) { + const root = join(fixture.root, `replacement-race-${index}`); + const path = join(root, "observed"); + const replacement = join(root, "replacement"); + await mkdir(root); + await Bun.write(path, "before\n"); + await Bun.write(replacement, "after!\n"); + const beforeStat = await lstat(path); + + await expect( + readStableRegularFileForTest({ + afterOpen: async () => { + await rm(path); + await rename(replacement, path); + }, + label, + path, + root, + }) + ).rejects.toThrow(`${label} changed`); + const afterStat = await lstat(path); + expect([afterStat.dev, afterStat.ino]).not.toEqual([ + beforeStat.dev, + beforeStat.ino, + ]); + } + }); + + it("rejects final-component symlink swaps for every planner file role", async () => { + if (process.platform === "win32") { + return; + } + const fixture = await createFixture(); + const labels = [ + "Canonical source", + "Current target", + "Deployment state", + "Rollback snapshot", + ]; + for (const [index, label] of labels.entries()) { + const root = join(fixture.root, `symlink-race-${index}`); + const path = join(root, "observed"); + const parked = join(root, "parked"); + const replacement = join(root, "replacement"); + await mkdir(root); + await Bun.write(path, "before\n"); + await Bun.write(replacement, "after!\n"); + + await expect( + readStableRegularFileForTest({ + afterOpen: async () => { + await rename(path, parked); + await symlink(basename(replacement), path); + }, + label, + path, + root, + }) + ).rejects.toThrow(new RegExp(`${label}(?: path)? changed`)); + expect((await lstat(path)).isSymbolicLink()).toBe(true); + await expect( + readStableRegularFileForTest({ + afterOpen: async () => undefined, + label, + path, + root, + }) + ).rejects.toThrow("regular non-symlink file"); + } + }); + + it("fails closed on path traversal and symlink escape", async () => { + const fixture = await createFixture(); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + destination: "../escape.md", + }) + ).rejects.toThrow("safe relative path"); + + const outside = join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, join(fixture.targetRoot, "instructions")); + await expect(buildDeploymentPlan(planOptions(fixture))).rejects.toThrow( + "traverses a symlink" + ); + + const blocked = await createFixture(); + await Bun.write(join(blocked.targetRoot, "blocked"), "not a directory\n"); + await expect( + buildDeploymentPlan({ + ...planOptions(blocked), + destination: "blocked/WORK_UNITS.md", + }) + ).rejects.toThrow("identity cannot be established safely"); + }); + + it("fails closed on unsupported adapter versions, unresolved variables, and lossy translation", async () => { + const fixture = await createFixture(`Use ${DOLLAR}{HOME}.\n`); + await expect( + buildDeploymentPlan({ ...planOptions(fixture), adapterVersion: "v999" }) + ).rejects.toThrow("Unsupported codex adapter version"); + await expect(buildDeploymentPlan(planOptions(fixture))).rejects.toThrow( + "Invalid interpolation at line 1, column 5 (expression redacted)." + ); + + const clean = await createFixture(); + await expect( + buildDeploymentPlan({ + ...planOptions(clean), + translation: { + desiredContent: new TextEncoder().encode("translated\n"), + lossReport: { + lossless: false, + entries: [ + { + code: "dropped-frontmatter", + message: "Frontmatter was dropped.", + }, + ], + }, + }, + }) + ).rejects.toThrow("Lossy translation"); + }); + + it("reports secret references without reading or emitting their values", async () => { + const fixture = await createFixture( + `Token: ${DOLLAR}{secret:API_TOKEN}\nVault: op://Engineering/Codex/token\n` + ); + const previous = process.env.API_TOKEN; + process.env.API_TOKEN = "must-never-appear"; + try { + const plan = await buildDeploymentPlan(planOptions(fixture)); + expect(plan.secretReferences).toEqual([ + { kind: "environment", name: "API_TOKEN" }, + { kind: "one-password", reference: "op://Engineering/Codex/token" }, + ]); + expect(JSON.stringify(plan)).not.toContain("must-never-appear"); + } finally { + if (previous === undefined) { + Reflect.deleteProperty(process.env, "API_TOKEN"); + } else { + process.env.API_TOKEN = previous; + } + } + }); + + it("keeps planner provenance deterministic despite ambient package-manager variables", async () => { + const fixture = await createFixture(); + const priorFacultVersion = process.env.FACULT_NPM_PACKAGE_VERSION; + const priorNpmVersion = process.env.npm_package_version; + try { + process.env.FACULT_NPM_PACKAGE_VERSION = "99.0.0"; + process.env.npm_package_version = "98.0.0"; + const first = await buildDeploymentPlan({ + ...planOptions(fixture), + plannerVersion: undefined, + }); + process.env.FACULT_NPM_PACKAGE_VERSION = "1.0.0"; + process.env.npm_package_version = "2.0.0"; + const second = await buildDeploymentPlan({ + ...planOptions(fixture), + plannerVersion: undefined, + }); + expect(second).toEqual(first); + expect(second.planner.version).toBe(PACKAGE_VERSION); + await expect( + buildDeploymentPlan({ + ...planOptions(fixture), + plannerVersion: MISMATCHED_PACKAGE_VERSION, + }) + ).rejects.toThrow("does not match the authoritative fclt version"); + } finally { + if (priorFacultVersion === undefined) { + Reflect.deleteProperty(process.env, "FACULT_NPM_PACKAGE_VERSION"); + } else { + process.env.FACULT_NPM_PACKAGE_VERSION = priorFacultVersion; + } + if (priorNpmVersion === undefined) { + Reflect.deleteProperty(process.env, "npm_package_version"); + } else { + process.env.npm_package_version = priorNpmVersion; + } + } + }); + + it("redacts malformed interpolation expressions from CLI stderr", async () => { + const fallbackLiteral = "credential-literal-must-not-leak"; + const fixture = await createFixture( + `Token: ${DOLLAR}{secret:API_TOKEN:-${fallbackLiteral}}\n` + ); + const before = await snapshotTree(fixture.root); + const result = await runCli([ + "deploy", + "plan", + "--asset", + "instruction:WORK_UNITS", + "--destination", + "instructions/WORK_UNITS.md", + "--tool", + "codex", + "--adapter-version", + "v1", + "--root", + fixture.canonicalRoot, + "--target-root", + fixture.targetRoot, + "--state-root", + fixture.stateRoot, + "--expected-current-hash", + "absent", + "--json", + ]); + const after = await snapshotTree(fixture.root); + + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + "Invalid interpolation at line 1, column 8 (expression redacted).\n" + ); + expect(result.stderr).not.toContain("API_TOKEN"); + expect(result.stderr).not.toContain(fallbackLiteral); + expect(result.stderr).not.toContain("secret:"); + expect(after).toEqual(before); + + await Bun.write(fixture.sourcePath, `Token: ${DOLLAR}{secret:API_TOKEN`); + await expect(buildDeploymentPlan(planOptions(fixture))).rejects.toThrow( + "Invalid interpolation at line 1, column 8 (expression redacted)." + ); + }); +}); diff --git a/src/deployment-plan.ts b/src/deployment-plan.ts new file mode 100644 index 0000000..a06de8f --- /dev/null +++ b/src/deployment-plan.ts @@ -0,0 +1,1704 @@ +import { createHash } from "node:crypto"; +import { + type BigIntStats, + closeSync, + constants, + fstatSync, + readSync, +} from "node:fs"; +import { + type FileHandle, + lstat, + open, + readFile, + realpath, +} from "node:fs/promises"; +import { + basename, + dirname, + isAbsolute, + join, + normalize as normalizePath, + relative, + resolve, +} from "node:path"; +import { caseFold } from "unicode-case-folding"; +import { getAdapter } from "./adapters"; +import { openReadOnlyAt, readDirectoryEntriesAt } from "./audit/safe-openat"; + +declare const FCLT_COMPILED_VERSION: string | undefined; + +const SHA256_RE = /^sha256:[a-f0-9]{64}$/; +const SECRET_VARIABLE_RE = /^secret:([A-Z_][A-Z0-9_]*)$/; +const ONE_PASSWORD_REFERENCE_RE = /op:\/\/[A-Za-z0-9._~%/-]+/g; +const SAFE_RELATIVE_SEGMENT_RE = /^[A-Za-z0-9._/-]+$/; +const MARKDOWN_SUFFIX_RE = /\.md$/; +const PATH_SEPARATOR_RE = /[\\/]/; +const DEPLOYMENT_PLAN_SCHEMA_VERSION = 1 as const; +const DEPLOYMENT_STATE_SCHEMA_VERSION = 1 as const; +const PACKAGE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const DESTINATION_IDENTITY_PREFIX = "physical-path-v3:"; +const DESTINATION_COLLISION_KEY_PREFIX = + "case-folded-path-v2:unicode-case-folding@1.1.1:"; +const MAX_PLANNING_FILE_BYTES = 16 * 1024 * 1024; +const MAX_DEPLOYMENT_STATE_RECORD_BYTES = 256 * 1024; +const MAX_DEPLOYMENT_STATE_TOTAL_BYTES = 4 * 1024 * 1024; +const MAX_DEPLOYMENT_STATE_RECORDS = 128; + +export type DeploymentAssetKind = "instruction" | "snippet"; +export type DeploymentOwnerMode = "fclt-owned" | "unowned"; + +export interface DeploymentLossReport { + lossless: boolean; + entries: Array<{ + code: string; + message: string; + sourcePath?: string; + }>; +} + +export interface DeploymentTranslation { + desiredContent: Uint8Array; + lossReport: DeploymentLossReport; +} + +export interface DeploymentStateV1 { + schemaVersion: 1; + planSchemaVersion: 1; + binding: { + assetCanonicalRef: string; + destinationCollisionKey: string; + destinationIdentity: string; + destinationPath: string; + tool: string; + adapterVersion: string; + }; + desiredHash: string; + ownerMode: "fclt-owned"; + rollbackTarget: + | { kind: "absent"; path: string } + | { kind: "snapshot"; path: string; expectedHash: string }; +} + +export interface DeploymentPlanV1 { + schemaVersion: 1; + planId: string; + planner: { name: "fclt"; version: string }; + binding: { + asset: { + kind: DeploymentAssetKind; + selector: string; + canonicalRef: string; + path: string; + }; + destination: { + tool: string; + root: string; + relativePath: string; + path: string; + collisionKey: string; + identity: string; + }; + }; + hashes: { + source: string; + current: string | null; + desired: string; + state: string | null; + }; + ownerMode: DeploymentOwnerMode; + adapter: { id: string; version: string }; + lossReport: DeploymentLossReport; + secretReferences: Array< + | { kind: "environment"; name: string } + | { kind: "one-password"; reference: string } + >; + operations: { + reads: Array<{ + kind: + | "canonical-source" + | "current-target" + | "ownership-directory" + | "deployment-state" + | "rollback-snapshot"; + path: string; + required: boolean; + expectedHash: string | null; + }>; + writes: Array<{ + kind: "rollback-snapshot" | "target" | "deployment-state"; + path: string; + contentSource: + | { kind: "path"; path: string } + | { + kind: "inline-state"; + serialization: "stable-json-v1"; + state: DeploymentStateV1; + }; + expectedCurrentHash: string | null; + desiredHash: string; + }>; + removals: Array<{ path: string; expectedHash: string }>; + nativeCommands: Array<{ argv: string[]; cwd: string }>; + }; + verificationProbe: { + kind: "file-sha256"; + path: string; + expectedHash: string; + }; + rollbackTarget: DeploymentStateV1["rollbackTarget"]; +} + +export interface BuildDeploymentPlanOptions { + adapterVersion: string; + asset: string; + canonicalRoot: string; + destination: string; + expectedCurrentHash?: string | null; + expectedSourceHash?: string; + plannerVersion?: string; + scope: "global" | "project"; + stateRoot: string; + targetRoot: string; + tool: string; + translation?: DeploymentTranslation; +} + +interface ParsedAssetSelector { + canonicalRef: string; + kind: DeploymentAssetKind; + relativePath: string; + selector: string; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys( + value: Record, + expectedKeys: readonly string[] +): boolean { + const actualKeys = Object.keys(value).sort(); + const sortedExpectedKeys = [...expectedKeys].sort(); + return ( + actualKeys.length === sortedExpectedKeys.length && + actualKeys.every((key, index) => key === sortedExpectedKeys[index]) + ); +} + +function sha256(value: Uint8Array | string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortValue); + } + if (!isPlainObject(value)) { + return value; + } + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortValue(value[key]); + } + return sorted; +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +function deepFreeze(value: T): Readonly { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) { + deepFreeze(child); + } + } + return value; +} + +function validateHash(value: string, label: string): string { + const normalized = value.toLowerCase(); + if (!SHA256_RE.test(normalized)) { + throw new Error(`${label} must be a sha256:<64 lowercase hex> hash.`); + } + return normalized; +} + +function validateRelativePath(pathValue: string, label: string): string { + const normalized = pathValue.replace(/\\/g, "/"); + if ( + !normalized || + isAbsolute(pathValue) || + normalized.startsWith("/") || + normalized.includes("\0") || + !SAFE_RELATIVE_SEGMENT_RE.test(normalized) || + normalized + .split("/") + .some((segment) => !segment || segment === "." || segment === "..") + ) { + throw new Error(`${label} must be a safe relative path.`); + } + return normalized; +} + +function parseAssetSelector(args: { + scope: "global" | "project"; + selector: string; +}): ParsedAssetSelector { + const separator = args.selector.indexOf(":"); + const kind = args.selector.slice(0, separator); + const rawName = args.selector.slice(separator + 1); + if (separator <= 0 || (kind !== "instruction" && kind !== "snippet")) { + throw new Error( + "--asset must use instruction: or snippet:." + ); + } + const safeName = validateRelativePath(rawName, "Asset name"); + const relativeName = safeName.endsWith(".md") ? safeName : `${safeName}.md`; + const relativePath = `${kind === "instruction" ? "instructions" : "snippets"}/${relativeName}`; + const prefix = args.scope === "global" ? "@ai" : "@project"; + return { + canonicalRef: `${prefix}/${relativePath}`, + kind, + relativePath, + selector: `${kind}:${safeName.replace(MARKDOWN_SUFFIX_RE, "")}`, + }; +} + +async function assertRootDirectory( + pathValue: string, + label: string +): Promise { + const absolute = resolve(pathValue); + const stat = await lstat(absolute).catch(() => null); + if (!stat?.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `${label} must be an existing non-symlink directory: ${absolute}` + ); + } + return absolute; +} + +async function assertContainedPath(args: { + label: string; + path: string; + root: string; +}): Promise { + const candidate = resolve(args.path); + const rel = relative(args.root, candidate); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`${args.label} escapes its root: ${candidate}`); + } + + let cursor = args.root; + for (const segment of rel.split(PATH_SEPARATOR_RE)) { + cursor = join(cursor, segment); + const stat = await lstat(cursor).catch(() => null); + if (!stat) { + break; + } + if (stat.isSymbolicLink()) { + throw new Error(`${args.label} traverses a symlink: ${cursor}`); + } + } + return candidate; +} + +function validatePersistedAbsolutePath( + pathValue: string, + label: string +): string { + if ( + !pathValue || + pathValue.includes("\0") || + !isAbsolute(pathValue) || + normalizePath(pathValue) !== pathValue + ) { + throw new Error(`${label} must be an absolute normalized safe path.`); + } + return pathValue; +} + +async function canonicalPhysicalPath( + pathValue: string, + label: string +): Promise { + const absolute = validatePersistedAbsolutePath(pathValue, label); + const missingSegments: string[] = []; + let cursor = absolute; + let stat = await lstat(cursor).catch(() => null); + while (!stat) { + const parent = dirname(cursor); + if (parent === cursor) { + throw new Error(`${label} identity cannot be established safely.`); + } + missingSegments.unshift(basename(cursor)); + cursor = parent; + stat = await lstat(cursor).catch(() => null); + } + if ( + stat.isSymbolicLink() || + (missingSegments.length > 0 && !stat.isDirectory()) + ) { + throw new Error(`${label} identity cannot be established safely.`); + } + let canonicalAncestor: string; + try { + canonicalAncestor = await realpath(cursor); + } catch { + throw new Error(`${label} identity cannot be established safely.`); + } + const canonicalStat = await lstat(canonicalAncestor).catch(() => null); + if ( + !canonicalStat || + canonicalStat.dev !== stat.dev || + canonicalStat.ino !== stat.ino + ) { + throw new Error(`${label} identity cannot be established safely.`); + } + return join(canonicalAncestor, ...missingSegments); +} + +interface PhysicalDestinationIdentity { + collisionKey: string; + identity: string; +} + +function physicalPathIdentities( + canonicalPath: string +): PhysicalDestinationIdentity { + const portablePath = canonicalPath.replace(/\\/g, "/").normalize("NFC"); + const foldedPortablePath = caseFold(portablePath).normalize("NFC"); + const pathSemantics = process.platform === "win32" ? "win32" : "posix"; + const losslessPath = Buffer.from(canonicalPath, "utf8").toString("base64url"); + return { + collisionKey: `${DESTINATION_COLLISION_KEY_PREFIX}${foldedPortablePath}`, + identity: `${DESTINATION_IDENTITY_PREFIX}${pathSemantics}:${losslessPath}`, + }; +} + +async function resolveDestinationIdentity(args: { + destinationPath: string; + label: string; + targetRoot?: string; +}): Promise { + const canonicalDestination = await canonicalPhysicalPath( + args.destinationPath, + args.label + ); + if (args.targetRoot) { + const canonicalRoot = await canonicalPhysicalPath( + args.targetRoot, + "Target root" + ); + const rel = relative(canonicalRoot, canonicalDestination); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`${args.label} escapes its physical root.`); + } + } + return physicalPathIdentities(canonicalDestination); +} + +interface StableReadInterlock { + afterOpen?: () => Promise; +} + +interface StableRegularFileReadArgs { + expectedIdentity?: string; + interlock?: StableReadInterlock; + label: string; + path: string; + root: string; +} + +function isErrnoException(error: unknown, code: string): boolean { + return ( + error instanceof Error && + "code" in error && + (error as Error & { code?: string }).code === code + ); +} + +function stableStatMatches(left: BigIntStats, right: BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function stableFileIdentityMatches( + left: BigIntStats, + right: BigIntStats +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function stableReadFlags(): number { + const noFollow = constants.O_NOFOLLOW; + if (typeof noFollow !== "number" || noFollow === 0) { + throw new Error( + "Safe descriptor reads are unsupported on this platform: O_NOFOLLOW is unavailable." + ); + } + const nonBlocking = + typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0; + const extendedConstants = constants as typeof constants & { + O_CLOEXEC?: number; + }; + const closeOnExec = + typeof extendedConstants.O_CLOEXEC === "number" + ? extendedConstants.O_CLOEXEC + : 0; + return constants.O_RDONLY + noFollow + nonBlocking + closeOnExec; +} + +function stableDirectoryFlags(): number { + const directory = constants.O_DIRECTORY; + const noFollow = constants.O_NOFOLLOW; + if ( + typeof directory !== "number" || + directory === 0 || + typeof noFollow !== "number" || + noFollow === 0 + ) { + throw new Error( + "Safe deployment-state directory scans are unsupported on this platform." + ); + } + const extendedConstants = constants as typeof constants & { + O_CLOEXEC?: number; + }; + const closeOnExec = extendedConstants.O_CLOEXEC ?? 0; + return constants.O_RDONLY + directory + noFollow + closeOnExec; +} + +function readDirectoryEntriesFromDescriptor(args: { + directory: string; + handle: FileHandle; +}): string[] { + const entries = readDirectoryEntriesAt({ + directoryFd: args.handle.fd, + maxEntries: MAX_DEPLOYMENT_STATE_RECORDS + 1, + }).map((entry) => entry.name); + for (const entry of entries) { + if ( + !entry || + entry.includes("\0") || + entry.includes("/") || + entry.includes("\\") || + entry.includes("\uFFFD") + ) { + throw new Error( + `Unsafe entry in deployment state directory: ${args.directory}` + ); + } + } + if (entries.length > MAX_DEPLOYMENT_STATE_RECORDS) { + throw new Error( + `Deployment state directory exceeds the ${MAX_DEPLOYMENT_STATE_RECORDS}-record limit.` + ); + } + return entries; +} + +function assertStableRegularFileStat( + stat: BigIntStats, + label: string, + maxBytes = MAX_PLANNING_FILE_BYTES +): number { + if (!stat.isFile() || stat.ino <= 0n || stat.dev < 0n) { + throw new Error( + `${label} must expose a stable regular-file identity for planning.` + ); + } + if (stat.size < 0n || stat.size > BigInt(maxBytes)) { + throw new Error( + `${label} exceeds the ${maxBytes}-byte planning read limit.` + ); + } + return Number(stat.size); +} + +async function readDescriptorBytes(args: { + handle: FileHandle; + label: string; + size: number; +}): Promise { + const bytes = Buffer.alloc(args.size); + let offset = 0; + while (offset < bytes.length) { + const result = await args.handle.read( + bytes, + offset, + bytes.length - offset, + offset + ); + if (result.bytesRead === 0) { + throw new Error(`${args.label} changed while it was being read.`); + } + offset += result.bytesRead; + } + const extra = Buffer.alloc(1); + const trailing = await args.handle.read(extra, 0, 1, args.size); + if (trailing.bytesRead !== 0) { + throw new Error(`${args.label} changed while it was being read.`); + } + return bytes; +} + +function readRawDescriptorBytes(args: { + descriptor: number; + label: string; + size: number; +}): Uint8Array { + const bytes = Buffer.alloc(args.size); + let offset = 0; + while (offset < bytes.length) { + const bytesRead = readSync( + args.descriptor, + bytes, + offset, + bytes.length - offset, + offset + ); + if (bytesRead === 0) { + throw new Error(`${args.label} changed while it was being read.`); + } + offset += bytesRead; + } + const extra = Buffer.alloc(1); + if (readSync(args.descriptor, extra, 0, 1, args.size) !== 0) { + throw new Error(`${args.label} changed while it was being read.`); + } + return bytes; +} + +async function verifyStableReadPath(args: { + descriptorStat: BigIntStats; + expectedIdentity?: string; + label: string; + path: string; + root: string; +}): Promise { + const pathStat = await lstat(args.path, { bigint: true }).catch(() => null); + if ( + !pathStat?.isFile() || + pathStat.isSymbolicLink() || + !stableFileIdentityMatches(args.descriptorStat, pathStat) + ) { + throw new Error(`${args.label} path changed during planning.`); + } + + let canonicalPath: string; + try { + canonicalPath = await realpath(args.path); + } catch { + throw new Error(`${args.label} path changed during planning.`); + } + const canonicalRoot = await canonicalPhysicalPath( + args.root, + `${args.label} root` + ); + const rel = relative(canonicalRoot, canonicalPath); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`${args.label} escapes its physical root.`); + } + const canonicalStat = await lstat(canonicalPath, { bigint: true }).catch( + () => null + ); + if ( + !( + canonicalStat?.isFile() && + stableFileIdentityMatches(args.descriptorStat, canonicalStat) + ) + ) { + throw new Error(`${args.label} path changed during planning.`); + } + if ( + args.expectedIdentity && + physicalPathIdentities(canonicalPath).identity !== args.expectedIdentity + ) { + throw new Error(`${args.label} physical identity changed during planning.`); + } +} + +async function verifyStableDirectoryPath(args: { + descriptorStat: BigIntStats; + directory: string; + root: string; +}): Promise { + const pathStat = await lstat(args.directory, { bigint: true }).catch( + () => null + ); + if ( + !pathStat?.isDirectory() || + pathStat.isSymbolicLink() || + !stableFileIdentityMatches(args.descriptorStat, pathStat) + ) { + throw new Error("Deployment state directory changed during planning."); + } + let canonicalDirectory: string; + try { + canonicalDirectory = await realpath(args.directory); + } catch { + throw new Error("Deployment state directory changed during planning."); + } + const canonicalRoot = await canonicalPhysicalPath( + args.root, + "Deployment state root" + ); + const rel = relative(canonicalRoot, canonicalDirectory); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error("Deployment state directory escapes its physical root."); + } + const canonicalStat = await lstat(canonicalDirectory, { + bigint: true, + }).catch(() => null); + if ( + !( + canonicalStat?.isDirectory() && + stableFileIdentityMatches(args.descriptorStat, canonicalStat) + ) + ) { + throw new Error("Deployment state directory changed during planning."); + } +} + +async function readAnchoredRegularFile(args: { + directory: string; + directoryHandle: FileHandle; + entry: string; + root: string; +}): Promise { + const label = "Deployment state"; + let descriptor: number; + try { + descriptor = openReadOnlyAt({ + directoryFd: args.directoryHandle.fd, + fileName: args.entry, + }); + } catch { + throw new Error( + `${label} must be openable through its anchored directory as a regular non-symlink file.` + ); + } + const path = join(args.directory, args.entry); + try { + const before = fstatSync(descriptor, { bigint: true }); + const size = assertStableRegularFileStat( + before, + label, + MAX_DEPLOYMENT_STATE_RECORD_BYTES + ); + const firstRead = readRawDescriptorBytes({ descriptor, label, size }); + const middle = fstatSync(descriptor, { bigint: true }); + if (!stableStatMatches(before, middle)) { + throw new Error(`${label} changed while it was being read.`); + } + const secondRead = readRawDescriptorBytes({ descriptor, label, size }); + const after = fstatSync(descriptor, { bigint: true }); + if ( + !( + stableStatMatches(middle, after) && + Buffer.from(firstRead).equals(secondRead) + ) + ) { + throw new Error(`${label} changed while it was being read.`); + } + await verifyStableReadPath({ + descriptorStat: after, + label, + path, + root: args.root, + }); + return firstRead; + } finally { + closeSync(descriptor); + } +} + +async function readRegularFileOrNull( + args: StableRegularFileReadArgs +): Promise { + let handle: FileHandle; + try { + handle = await open(args.path, stableReadFlags()); + } catch (error) { + if (isErrnoException(error, "ENOENT")) { + return null; + } + throw new Error( + `${args.label} must be openable as a regular non-symlink file.` + ); + } + + try { + const before = await handle.stat({ bigint: true }); + const size = assertStableRegularFileStat(before, args.label); + await args.interlock?.afterOpen?.(); + const firstRead = await readDescriptorBytes({ + handle, + label: args.label, + size, + }); + const middle = await handle.stat({ bigint: true }); + if (!stableStatMatches(before, middle)) { + throw new Error(`${args.label} changed while it was being read.`); + } + const secondRead = await readDescriptorBytes({ + handle, + label: args.label, + size, + }); + const after = await handle.stat({ bigint: true }); + if ( + !( + stableStatMatches(middle, after) && + Buffer.from(firstRead).equals(secondRead) + ) + ) { + throw new Error(`${args.label} changed while it was being read.`); + } + await verifyStableReadPath({ + descriptorStat: after, + expectedIdentity: args.expectedIdentity, + label: args.label, + path: args.path, + root: args.root, + }); + return firstRead; + } finally { + await handle.close(); + } +} + +/** Deterministic seam for descriptor replacement and symlink-race tests. */ +export async function readStableRegularFileForTest(args: { + afterOpen: () => Promise; + label: string; + path: string; + root: string; +}): Promise { + return await readRegularFileOrNull({ + interlock: { afterOpen: args.afterOpen }, + label: args.label, + path: args.path, + root: args.root, + }); +} + +function decodeUtf8(value: Uint8Array, label: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(value); + } catch { + throw new Error(`${label} must contain valid UTF-8 text.`); + } +} + +function collectSecretReferences( + text: string +): DeploymentPlanV1["secretReferences"] { + const references: DeploymentPlanV1["secretReferences"] = []; + const environmentNames = new Set(); + const onePasswordReferences = new Set(); + + let cursor = 0; + while (cursor < text.length) { + const start = text.indexOf("${", cursor); + if (start < 0) { + break; + } + const end = text.indexOf("}", start + 2); + if (end < 0) { + throwInterpolationError(text, start); + } + const expression = text.slice(start + 2, end); + const secret = SECRET_VARIABLE_RE.exec(expression); + if (!secret?.[1]) { + throwInterpolationError(text, start); + } + environmentNames.add(secret[1]); + cursor = end + 1; + } + for (const match of text.matchAll(ONE_PASSWORD_REFERENCE_RE)) { + if (match[0]) { + onePasswordReferences.add(match[0]); + } + } + for (const name of [...environmentNames].sort()) { + references.push({ kind: "environment", name }); + } + for (const reference of [...onePasswordReferences].sort()) { + references.push({ kind: "one-password", reference }); + } + return references; +} + +function throwInterpolationError(text: string, offset: number): never { + const before = text.slice(0, offset); + const line = before.split("\n").length; + const lastNewline = before.lastIndexOf("\n"); + const column = offset - lastNewline; + throw new Error( + `Invalid interpolation at line ${line}, column ${column} (expression redacted).` + ); +} + +function parseDeploymentState(args: { + bytes: Uint8Array; + path: string; +}): DeploymentStateV1 { + validatePersistedAbsolutePath(args.path, "Deployment state record path"); + let parsed: unknown; + try { + parsed = JSON.parse(decodeUtf8(args.bytes, "Deployment state")) as unknown; + } catch (error) { + if (error instanceof Error && error.message.includes("valid UTF-8")) { + throw error; + } + throw new Error(`Deployment state is corrupt JSON: ${args.path}`); + } + if (!isPlainObject(parsed)) { + throw new Error(`Deployment state is corrupt: ${args.path}`); + } + if (parsed.schemaVersion !== DEPLOYMENT_STATE_SCHEMA_VERSION) { + throw new Error( + `Unsupported deployment state version: ${String(parsed.schemaVersion)}` + ); + } + if ( + !hasExactKeys(parsed, [ + "schemaVersion", + "planSchemaVersion", + "binding", + "desiredHash", + "ownerMode", + "rollbackTarget", + ]) || + parsed.planSchemaVersion !== DEPLOYMENT_PLAN_SCHEMA_VERSION || + parsed.ownerMode !== "fclt-owned" || + typeof parsed.desiredHash !== "string" || + !SHA256_RE.test(parsed.desiredHash) || + !isPlainObject(parsed.binding) || + !hasExactKeys(parsed.binding, [ + "assetCanonicalRef", + "destinationCollisionKey", + "destinationIdentity", + "destinationPath", + "tool", + "adapterVersion", + ]) || + typeof parsed.binding.assetCanonicalRef !== "string" || + !parsed.binding.assetCanonicalRef || + typeof parsed.binding.destinationCollisionKey !== "string" || + !parsed.binding.destinationCollisionKey.startsWith( + DESTINATION_COLLISION_KEY_PREFIX + ) || + parsed.binding.destinationCollisionKey.length === + DESTINATION_COLLISION_KEY_PREFIX.length || + typeof parsed.binding.destinationIdentity !== "string" || + !parsed.binding.destinationIdentity.startsWith( + DESTINATION_IDENTITY_PREFIX + ) || + parsed.binding.destinationIdentity.length === + DESTINATION_IDENTITY_PREFIX.length || + typeof parsed.binding.destinationPath !== "string" || + typeof parsed.binding.tool !== "string" || + !parsed.binding.tool || + typeof parsed.binding.adapterVersion !== "string" || + !parsed.binding.adapterVersion || + !isPlainObject(parsed.rollbackTarget) + ) { + throw new Error(`Deployment state is corrupt: ${args.path}`); + } + const destinationPath = validatePersistedAbsolutePath( + parsed.binding.destinationPath, + "Persisted destination path" + ); + const rollback = parsed.rollbackTarget; + let rollbackTarget: DeploymentStateV1["rollbackTarget"] | null = null; + if (typeof rollback.path === "string") { + const rollbackPath = validatePersistedAbsolutePath( + rollback.path, + "Persisted rollback path" + ); + if ( + rollback.kind === "absent" && + hasExactKeys(rollback, ["kind", "path"]) && + rollbackPath === destinationPath + ) { + rollbackTarget = { kind: "absent", path: rollbackPath }; + } else if ( + rollback.kind === "snapshot" && + hasExactKeys(rollback, ["kind", "path", "expectedHash"]) && + typeof rollback.expectedHash === "string" && + SHA256_RE.test(rollback.expectedHash) + ) { + rollbackTarget = { + expectedHash: rollback.expectedHash, + kind: "snapshot", + path: rollbackPath, + }; + } + } + if (!rollbackTarget) { + throw new Error( + `Deployment state has an invalid or escaped rollback target: ${args.path}` + ); + } + return { + binding: { + adapterVersion: parsed.binding.adapterVersion, + assetCanonicalRef: parsed.binding.assetCanonicalRef, + destinationCollisionKey: parsed.binding.destinationCollisionKey, + destinationIdentity: parsed.binding.destinationIdentity, + destinationPath, + tool: parsed.binding.tool, + }, + desiredHash: parsed.desiredHash, + ownerMode: "fclt-owned", + planSchemaVersion: DEPLOYMENT_PLAN_SCHEMA_VERSION, + rollbackTarget, + schemaVersion: DEPLOYMENT_STATE_SCHEMA_VERSION, + }; +} + +function destinationOwnershipId( + binding: Pick +): string { + return createHash("sha256") + .update( + stableJson({ + destinationIdentity: binding.destinationIdentity, + tool: binding.tool, + }) + ) + .digest("hex") + .slice(0, 24); +} + +export function serializeDeploymentState(state: DeploymentStateV1): string { + return `${stableJson(state)}\n`; +} + +interface ScannedDeploymentState { + hash: string; + path: string; + physicalIdentity: PhysicalDestinationIdentity; + state: DeploymentStateV1; +} + +async function scanDeploymentStates(args: { + afterEnumerationForTest?: () => Promise; + directory: string; + expectedPath: string; + requestedBinding: DeploymentStateV1["binding"]; + stateRoot: string; +}): Promise<{ + directoryHash: string | null; + existing: ScannedDeploymentState | null; + records: ScannedDeploymentState[]; +}> { + const directoryFlags = stableDirectoryFlags(); + let initialPathStat: BigIntStats | null; + try { + initialPathStat = await lstat(args.directory, { bigint: true }); + } catch (error) { + if (!isErrnoException(error, "ENOENT")) { + throw new Error( + `Deployment state directory cannot be inspected safely: ${args.directory}` + ); + } + initialPathStat = null; + } + if (!initialPathStat) { + return { directoryHash: null, existing: null, records: [] }; + } + if (!initialPathStat.isDirectory() || initialPathStat.isSymbolicLink()) { + throw new Error( + `Deployment state directory must be a non-symlink directory: ${args.directory}` + ); + } + + const records: ScannedDeploymentState[] = []; + const rawRecords: Array<{ bytes: Uint8Array; path: string }> = []; + let directoryHandle: FileHandle; + try { + directoryHandle = await open(args.directory, directoryFlags); + } catch { + throw new Error( + `Deployment state directory must be openable as a non-symlink directory: ${args.directory}` + ); + } + try { + const descriptorStat = await directoryHandle.stat({ bigint: true }); + if ( + !( + descriptorStat.isDirectory() && + stableStatMatches(initialPathStat, descriptorStat) + ) + ) { + throw new Error("Deployment state directory changed during planning."); + } + await verifyStableDirectoryPath({ + descriptorStat, + directory: args.directory, + root: args.stateRoot, + }); + const entries = readDirectoryEntriesFromDescriptor({ + directory: args.directory, + handle: directoryHandle, + }); + await args.afterEnumerationForTest?.(); + let totalBytes = 0; + for (const entry of entries) { + if (!entry.endsWith(".json")) { + throw new Error( + `Unexpected entry in deployment state directory: ${join(args.directory, entry)}` + ); + } + const path = join(args.directory, entry); + const bytes = await readAnchoredRegularFile({ + directory: args.directory, + directoryHandle, + entry, + root: args.directory, + }); + totalBytes += bytes.byteLength; + if (totalBytes > MAX_DEPLOYMENT_STATE_TOTAL_BYTES) { + throw new Error( + `Deployment state records exceed the ${MAX_DEPLOYMENT_STATE_TOTAL_BYTES}-byte aggregate limit.` + ); + } + rawRecords.push({ bytes, path }); + } + + const afterEntries = readDirectoryEntriesFromDescriptor({ + directory: args.directory, + handle: directoryHandle, + }); + const afterStat = await directoryHandle.stat({ bigint: true }); + if ( + !stableStatMatches(descriptorStat, afterStat) || + stableJson(entries) !== stableJson(afterEntries) + ) { + throw new Error("Deployment state directory changed during planning."); + } + await verifyStableDirectoryPath({ + descriptorStat: afterStat, + directory: args.directory, + root: args.stateRoot, + }); + } finally { + await directoryHandle.close(); + } + + for (const { bytes, path } of rawRecords) { + const state = parseDeploymentState({ bytes, path }); + const physicalIdentity = await resolveDestinationIdentity({ + destinationPath: state.binding.destinationPath, + label: "Recorded deployment destination", + }); + if ( + physicalIdentity.identity !== state.binding.destinationIdentity || + physicalIdentity.collisionKey !== state.binding.destinationCollisionKey + ) { + throw new Error( + `Deployment state has a corrupt destination identity: ${path}` + ); + } + if (state.rollbackTarget.kind === "snapshot") { + await assertContainedPath({ + label: "Recorded rollback snapshot", + path: state.rollbackTarget.path, + root: args.stateRoot, + }); + } + records.push({ + hash: sha256(bytes), + path, + physicalIdentity, + state, + }); + } + + const collisionSet = records.filter( + (record) => + record.state.binding.tool === args.requestedBinding.tool && + record.physicalIdentity.collisionKey === + args.requestedBinding.destinationCollisionKey + ); + const claims = collisionSet.filter( + (record) => + record.physicalIdentity.identity === + args.requestedBinding.destinationIdentity + ); + if (collisionSet.length !== claims.length) { + throw new Error( + "Case-folded destination collision is ambiguous across distinct physical paths." + ); + } + if (claims.length > 1) { + throw new Error( + "Conflicting deployment ownership claims exist for the destination." + ); + } + const existing = claims[0] ?? null; + const recordAtAuthoritativePath = records.find( + (record) => record.path === args.expectedPath + ); + if (recordAtAuthoritativePath && !existing) { + throw new Error( + "Authoritative destination state path is occupied by an orphaned ownership claim." + ); + } + if (existing && existing.path !== args.expectedPath) { + throw new Error( + "Orphaned deployment ownership claim exists outside the authoritative destination state path." + ); + } + if ( + existing && + (existing.state.binding.assetCanonicalRef !== + args.requestedBinding.assetCanonicalRef || + existing.state.binding.adapterVersion !== + args.requestedBinding.adapterVersion) + ) { + throw new Error( + "Destination ownership transfer requires a future explicit migration/transfer command; implicit transfer is not supported." + ); + } + + const manifest = records.map((record) => ({ + hash: record.hash, + path: record.path, + })); + return { + directoryHash: sha256(stableJson(manifest)), + existing, + records, + }; +} + +/** Deterministic seam for deployment-state directory replacement tests. */ +export async function scanDeploymentStateDirectoryForTest(args: { + afterEnumeration: () => Promise; + directory: string; + expectedPath: string; + requestedBinding: DeploymentStateV1["binding"]; + stateRoot: string; +}): Promise { + await scanDeploymentStates({ + afterEnumerationForTest: args.afterEnumeration, + directory: args.directory, + expectedPath: args.expectedPath, + requestedBinding: args.requestedBinding, + stateRoot: args.stateRoot, + }); +} + +async function authoritativePlannerVersion( + explicitVersion?: string +): Promise { + let authoritative: unknown = + typeof FCLT_COMPILED_VERSION === "string" + ? FCLT_COMPILED_VERSION + : undefined; + if (authoritative === undefined) { + const packagePath = resolve(import.meta.dir, "..", "package.json"); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(packagePath, "utf8")) as unknown; + } catch { + throw new Error("Authoritative fclt planner version is unavailable."); + } + authoritative = isPlainObject(parsed) ? parsed.version : undefined; + } + if ( + typeof authoritative !== "string" || + !PACKAGE_VERSION_RE.test(authoritative) + ) { + throw new Error("Authoritative fclt planner version is invalid."); + } + if ( + explicitVersion !== undefined && + (!PACKAGE_VERSION_RE.test(explicitVersion) || + explicitVersion !== authoritative) + ) { + throw new Error( + "Explicit planner version does not match the authoritative fclt version." + ); + } + return authoritative; +} + +export async function buildDeploymentPlan( + options: BuildDeploymentPlanOptions +): Promise> { + if (options.tool !== "codex") { + throw new Error(`Unsupported deployment tool: ${options.tool}`); + } + const adapter = getAdapter(options.tool); + if (!(adapter && adapter.versions.includes(options.adapterVersion))) { + throw new Error( + `Unsupported ${options.tool} adapter version: ${options.adapterVersion}` + ); + } + + const canonicalRoot = await assertRootDirectory( + options.canonicalRoot, + "Canonical root" + ); + const targetRoot = await assertRootDirectory( + options.targetRoot, + "Target root" + ); + const stateRoot = await assertRootDirectory(options.stateRoot, "State root"); + const asset = parseAssetSelector({ + scope: options.scope, + selector: options.asset, + }); + const sourcePath = await assertContainedPath({ + label: "Canonical source path", + path: join(canonicalRoot, asset.relativePath), + root: canonicalRoot, + }); + const sourceBytes = await readRegularFileOrNull({ + label: "Canonical source", + path: sourcePath, + root: canonicalRoot, + }); + if (!sourceBytes) { + throw new Error(`Canonical source does not exist: ${sourcePath}`); + } + const sourceHash = sha256(sourceBytes); + if ( + options.expectedSourceHash && + validateHash(options.expectedSourceHash, "Expected source hash") !== + sourceHash + ) { + throw new Error( + "Stale source hash: canonical source changed before planning." + ); + } + + const destinationRelativePath = validateRelativePath( + options.destination, + "Destination" + ); + const destinationPath = await assertContainedPath({ + label: "Destination path", + path: join(targetRoot, destinationRelativePath), + root: targetRoot, + }); + const { + collisionKey: destinationCollisionKey, + identity: destinationIdentity, + } = await resolveDestinationIdentity({ + destinationPath, + label: "Destination path", + targetRoot, + }); + const currentBytes = await readRegularFileOrNull({ + expectedIdentity: destinationIdentity, + label: "Current target", + path: destinationPath, + root: targetRoot, + }); + const currentHash = currentBytes ? sha256(currentBytes) : null; + if (options.expectedCurrentHash !== undefined) { + const expected = + options.expectedCurrentHash === null + ? null + : validateHash(options.expectedCurrentHash, "Expected current hash"); + if (expected !== currentHash) { + throw new Error( + "Stale current hash: destination changed before planning." + ); + } + } + + const translation = options.translation ?? { + desiredContent: sourceBytes, + lossReport: { lossless: true, entries: [] }, + }; + if ( + !translation.lossReport.lossless || + translation.lossReport.entries.length > 0 + ) { + throw new Error("Lossy translation is not allowed for deployment plans."); + } + const desiredHash = sha256(translation.desiredContent); + if (desiredHash !== sourceHash) { + throw new Error( + "Non-identity translation is unsupported by the first deployment planner slice." + ); + } + const desiredText = decodeUtf8(translation.desiredContent, "Desired content"); + const secretReferences = collectSecretReferences(desiredText); + + const stateBinding: DeploymentStateV1["binding"] = { + adapterVersion: options.adapterVersion, + assetCanonicalRef: asset.canonicalRef, + destinationCollisionKey, + destinationIdentity, + destinationPath, + tool: options.tool, + }; + const ownershipId = destinationOwnershipId(stateBinding); + const deploymentsDirectory = await assertContainedPath({ + label: "Deployment state directory", + path: join(stateRoot, "deployments"), + root: stateRoot, + }); + const statePath = await assertContainedPath({ + label: "Deployment state path", + path: join(deploymentsDirectory, `${ownershipId}.json`), + root: stateRoot, + }); + const stateScan = await scanDeploymentStates({ + directory: deploymentsDirectory, + expectedPath: statePath, + requestedBinding: stateBinding, + stateRoot, + }); + const existingStateRecord = stateScan.existing; + const existingState = existingStateRecord?.state ?? null; + const stateHash = existingStateRecord?.hash ?? null; + const ownerMode: DeploymentOwnerMode = existingState + ? "fclt-owned" + : "unowned"; + if (existingState && existingState.desiredHash !== currentHash) { + throw new Error( + "Target tamper detected: owned destination no longer matches deployment state." + ); + } + if ( + !existingState && + currentHash !== null && + options.expectedCurrentHash === undefined + ) { + throw new Error( + "Unowned destination exists; provide its exact --expected-current-hash to plan replacement." + ); + } + + let rollbackTarget: DeploymentStateV1["rollbackTarget"]; + let snapshotHash: string | null = null; + let snapshotRequired = false; + if (existingState) { + rollbackTarget = existingState.rollbackTarget; + if (rollbackTarget.kind === "snapshot") { + const snapshotPath = await assertContainedPath({ + label: "Existing rollback snapshot path", + path: rollbackTarget.path, + root: stateRoot, + }); + const snapshotBytes = await readRegularFileOrNull({ + label: "Rollback snapshot", + path: snapshotPath, + root: stateRoot, + }); + if (!snapshotBytes) { + throw new Error( + "Rollback snapshot is missing for the owned destination." + ); + } + snapshotHash = sha256(snapshotBytes); + if (snapshotHash !== rollbackTarget.expectedHash) { + throw new Error( + "Rollback snapshot tamper detected: content hash does not match deployment state." + ); + } + snapshotRequired = true; + } + } else if (currentHash) { + const snapshotPath = await assertContainedPath({ + label: "Rollback snapshot path", + path: join( + stateRoot, + "rollback", + ownershipId, + currentHash.slice("sha256:".length) + ), + root: stateRoot, + }); + const snapshotBytes = await readRegularFileOrNull({ + label: "Rollback snapshot", + path: snapshotPath, + root: stateRoot, + }); + snapshotHash = snapshotBytes ? sha256(snapshotBytes) : null; + if (snapshotHash && snapshotHash !== currentHash) { + throw new Error( + "Rollback snapshot tamper detected: content hash does not match its destination binding." + ); + } + rollbackTarget = { + kind: "snapshot", + path: snapshotPath, + expectedHash: currentHash, + }; + } else { + rollbackTarget = { kind: "absent", path: destinationPath }; + } + + const desiredState: DeploymentStateV1 = { + schemaVersion: DEPLOYMENT_STATE_SCHEMA_VERSION, + planSchemaVersion: DEPLOYMENT_PLAN_SCHEMA_VERSION, + binding: existingState?.binding ?? stateBinding, + desiredHash, + ownerMode: "fclt-owned", + rollbackTarget, + }; + const desiredStateHash = sha256(serializeDeploymentState(desiredState)); + + const reads: DeploymentPlanV1["operations"]["reads"] = [ + { + kind: "canonical-source", + path: sourcePath, + required: true, + expectedHash: sourceHash, + }, + { + kind: "current-target", + path: destinationPath, + required: false, + expectedHash: currentHash, + }, + { + kind: "ownership-directory", + path: deploymentsDirectory, + required: false, + expectedHash: stateScan.directoryHash, + }, + ...stateScan.records.map((record) => ({ + kind: "deployment-state" as const, + path: record.path, + required: true, + expectedHash: record.hash, + })), + ...(existingStateRecord + ? [] + : [ + { + kind: "deployment-state" as const, + path: statePath, + required: false, + expectedHash: null, + }, + ]), + ]; + if (rollbackTarget.kind === "snapshot") { + reads.push({ + kind: "rollback-snapshot", + path: rollbackTarget.path, + required: snapshotRequired, + expectedHash: snapshotHash, + }); + } + const writes: DeploymentPlanV1["operations"]["writes"] = []; + if ( + !existingState && + rollbackTarget.kind === "snapshot" && + currentHash && + snapshotHash === null + ) { + writes.push({ + kind: "rollback-snapshot", + path: rollbackTarget.path, + contentSource: { kind: "path", path: destinationPath }, + expectedCurrentHash: null, + desiredHash: currentHash, + }); + } + if (currentHash !== desiredHash) { + writes.push({ + kind: "target", + path: destinationPath, + contentSource: { kind: "path", path: sourcePath }, + expectedCurrentHash: currentHash, + desiredHash, + }); + } + if (stateHash !== desiredStateHash) { + writes.push({ + kind: "deployment-state", + path: statePath, + contentSource: { + kind: "inline-state", + serialization: "stable-json-v1", + state: desiredState, + }, + expectedCurrentHash: stateHash, + desiredHash: desiredStateHash, + }); + } + + const plannerVersion = await authoritativePlannerVersion( + options.plannerVersion + ); + + const planBody = { + schemaVersion: DEPLOYMENT_PLAN_SCHEMA_VERSION, + planner: { + name: "fclt" as const, + version: plannerVersion, + }, + binding: { + asset: { + kind: asset.kind, + selector: asset.selector, + canonicalRef: asset.canonicalRef, + path: sourcePath, + }, + destination: { + tool: options.tool, + root: targetRoot, + relativePath: destinationRelativePath, + path: destinationPath, + collisionKey: destinationCollisionKey, + identity: destinationIdentity, + }, + }, + hashes: { + source: sourceHash, + current: currentHash, + desired: desiredHash, + state: stateHash, + }, + ownerMode, + adapter: { id: adapter.id, version: options.adapterVersion }, + lossReport: translation.lossReport, + secretReferences, + operations: { + reads, + writes, + removals: [], + nativeCommands: [], + }, + verificationProbe: { + kind: "file-sha256" as const, + path: destinationPath, + expectedHash: desiredHash, + }, + rollbackTarget, + }; + const plan: DeploymentPlanV1 = { + ...planBody, + planId: sha256(stableJson(planBody)), + }; + return deepFreeze(plan); +} + +interface ParsedDeployArgs { + adapterVersion?: string; + asset?: string; + destination?: string; + expectedCurrentHash?: string | null; + expectedSourceHash?: string; + root?: string; + scope: "global" | "project"; + stateRoot?: string; + targetRoot?: string; + tool?: string; +} + +function parseDeployArgs(argv: string[]): ParsedDeployArgs { + const parsed: ParsedDeployArgs = { scope: "global" }; + const valueFlags = new Map([ + ["--adapter-version", "adapterVersion"], + ["--asset", "asset"], + ["--destination", "destination"], + ["--expected-current-hash", "expectedCurrentHash"], + ["--expected-source-hash", "expectedSourceHash"], + ["--root", "root"], + ["--scope", "scope"], + ["--state-root", "stateRoot"], + ["--target-root", "targetRoot"], + ["--tool", "tool"], + ]); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg || arg === "--json") { + continue; + } + const key = valueFlags.get(arg); + if (!key) { + throw new Error(`Unknown deploy plan option: ${arg}`); + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value.`); + } + index += 1; + if (key === "scope") { + if (value !== "global" && value !== "project") { + throw new Error("--scope must be global or project."); + } + parsed.scope = value; + } else if (key === "expectedCurrentHash") { + parsed.expectedCurrentHash = value === "absent" ? null : value; + } else { + parsed[key] = value; + } + } + return parsed; +} + +function requireDeployOption(value: string | undefined, flag: string): string { + if (!value) { + throw new Error(`${flag} is required.`); + } + return value; +} + +export async function deployCommand(argv: string[]): Promise { + if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") { + console.log(`fclt deploy plan — build one immutable read-only deployment plan + +Usage: + fclt deploy plan --asset instruction:|snippet: --destination \\ + --tool codex --adapter-version v1 --root \\ + --target-root --state-root \\ + [--scope global|project] [--expected-source-hash sha256:] \\ + [--expected-current-hash absent|sha256:] [--json] + +This command only reads files and prints a plan. It cannot apply a plan. +`); + return; + } + try { + if (argv[0] !== "plan") { + throw new Error("deploy requires the read-only subcommand: plan"); + } + const parsed = parseDeployArgs(argv.slice(1)); + const plan = await buildDeploymentPlan({ + adapterVersion: requireDeployOption( + parsed.adapterVersion, + "--adapter-version" + ), + asset: requireDeployOption(parsed.asset, "--asset"), + canonicalRoot: requireDeployOption(parsed.root, "--root"), + destination: requireDeployOption(parsed.destination, "--destination"), + expectedCurrentHash: parsed.expectedCurrentHash, + expectedSourceHash: parsed.expectedSourceHash, + scope: parsed.scope, + stateRoot: requireDeployOption(parsed.stateRoot, "--state-root"), + targetRoot: requireDeployOption(parsed.targetRoot, "--target-root"), + tool: requireDeployOption(parsed.tool, "--tool"), + }); + console.log(JSON.stringify(plan, null, 2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/src/index.ts b/src/index.ts index 84a71f3..b3afda7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -143,6 +143,7 @@ function printHelp() { ], ["search/install/update", "Work with remote capability indices"], ["manage/sync", "Preview deprecated broad managed-mode output"], + ["deploy plan", "Build one immutable per-asset deployment plan"], [ "setup", "Install narrow agent integrations without full managed mode", @@ -1359,6 +1360,11 @@ async function main(argv: string[]) { case "manage": await import("./manage").then(({ manageCommand }) => manageCommand(rest)); return; + case "deploy": + await import("./deployment-plan").then(({ deployCommand }) => + deployCommand(rest) + ); + return; case "unmanage": await import("./manage").then(({ unmanageCommand }) => unmanageCommand(rest)