diff --git a/packages/cli/bin/engine.js b/packages/cli/bin/engine.js index f5c7d28..da519bf 100644 --- a/packages/cli/bin/engine.js +++ b/packages/cli/bin/engine.js @@ -40,6 +40,7 @@ import { runHumanInit, } from "./human-init.js"; import { renderHumanVerify, runHumanVerify } from "./human-verify.js"; +import { runHumanToolchainMigration } from "./human-toolchain.js"; import { commandName, optionValue as argumentValue, @@ -1118,8 +1119,78 @@ async function main() { if (command === "toolchain") { const toolchainIndex = args.indexOf("toolchain"); const operation = args[toolchainIndex + 1]; + const cwd = path.resolve(optionValue("--cwd") ?? process.cwd()); + if (!json && operation === "migrate") { + if (process.stdin.isTTY !== true) { + const targetVersion = optionValue("--to"); + if (!targetVersion) { + process.stderr.write([ + "Non-TTY Project Toolchain migration requires --to with an exact SemVer.", + "No executable Agent/JSON command can be produced without the exact target version.", + ].join("\n") + "\n"); + return 2; + } + const nextAction = createNextAction(invocationContext, [ + "toolchain", + "migrate", + "--to", + targetVersion, + "--json", + "--cwd", + cwd, + ...(optionValue("--resume") ? ["--resume", optionValue("--resume")] : []), + ...(optionValue("--confirm") ? ["--confirm", optionValue("--confirm")] : []), + ...(optionValue("--permissions") + ? ["--permissions", optionValue("--permissions")] + : []), + ]); + process.stderr.write([ + "Non-TTY Human Mode cannot confirm a Project Toolchain migration safely.", + "Use this complete Agent/JSON command:", + nextAction.display, + ...(nextAction.disclosure ? [nextAction.disclosure] : []), + ].join("\n") + "\n"); + return 2; + } + if (["--resume", "--confirm", "--permissions"].some((option) => args.includes(option))) { + process.stderr.write([ + "Human Mode does not accept structured Project Toolchain migration decisions.", + "Use Agent/JSON Mode for the explicit resumable protocol.", + ].join("\n") + "\n"); + return 2; + } + const { createClackPromptAdapter, createPlainPromptAdapter } = await import( + "./prompt-adapters.js" + ); + const presentation = humanAuditPresentationOptions({ + args, + env: process.env, + output: process.stdout, + }); + const prompt = presentation.plain + ? createPlainPromptAdapter({ input: process.stdin, output: process.stderr }) + : await createClackPromptAdapter({ input: process.stdin, output: process.stderr }); + try { + const outcome = await runHumanToolchainMigration({ + cwd, + invocationContext, + prompt, + runLifecycle: runToolchainLifecycle, + styled: presentation.styled, + to: optionValue("--to"), + version: VERSION, + }); + if (outcome.result === null) { + process.stderr.write("Project Toolchain migration cancelled. No migration was applied.\n"); + } + return outcome.exitCode; + } catch { + process.stderr.write("Human Project Toolchain migration could not complete safely.\n"); + return 2; + } + } const result = await runToolchainLifecycle( - optionValue("--cwd") ?? process.cwd(), + cwd, VERSION, { operation, diff --git a/packages/cli/bin/human-toolchain.js b/packages/cli/bin/human-toolchain.js new file mode 100644 index 0000000..3a3c8c0 --- /dev/null +++ b/packages/cli/bin/human-toolchain.js @@ -0,0 +1,184 @@ +import path from "node:path"; + +import { PromptCancelledError } from "./human-audit.js"; +import { createNextAction } from "./invocation-context.js"; + +function styledText(value, style, enabled) { + return enabled ? `\u001B[${style}m${value}\u001B[0m` : value; +} + +function preservedBoundary() { + return [ + "Authoritative Project Toolchain updates: .launchrally/toolchain/package.json, package-lock.json, and authority.json only", + "Rebuildable materialization replaced: .launchrally/toolchain/node_modules (ignored and non-authoritative)", + "Report currentness: the current pointer may be marked non-current; immutable Reports and Evidence are preserved", + "Preserved: Manifest, immutable Reports and Evidence, Architecture history, and application source/dependencies", + ]; +} + +export function renderHumanToolchainMigrationPreview(value, { styled = false } = {}) { + const { preview } = value; + return [ + styledText("LaunchRally Project Toolchain Migration Preview", "1;36", styled), + `Engine: ${preview.from_version} -> ${preview.to_version}`, + "Authoritative files:", + ...preview.changes.map((change) => [ + ` - ${change.operation.toUpperCase()} ${change.path}`, + ` Before digest: ${change.before_digest ?? "none"}`, + ` After digest: ${change.after_digest ?? "none"}`, + ]).flat(), + `Materialization: ${preview.materialization.package_count} packages at ${preview.materialization.target}`, + `Materialization integrity: ${preview.materialization.integrity_digest}`, + "Materialization boundary: ignored and non-authoritative before confirmation", + ...preservedBoundary(), + "Consequence: Execution Authority changes; a fresh full Verify is required.", + "Choose View full exact diff to inspect every exact before/after content value and return here.", + ].join("\n"); +} + +function exactContent(value) { + return value === null ? "null" : JSON.stringify(value).replace( + /[\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, + (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`, + ); +} + +export function renderHumanToolchainMigrationFullPreview(value, context = {}) { + return [ + renderHumanToolchainMigrationPreview(value, context), + "", + "Full exact digest-bound diff:", + ...value.preview.changes.flatMap((change) => [ + "", + `${change.operation.toUpperCase()} ${change.path}`, + `--- before (${change.before_digest ?? "none"})`, + `- ${exactContent(change.before)}`, + `+++ after (${change.after_digest ?? "none"})`, + `+ ${exactContent(change.after)}`, + ]), + ].join("\n"); +} + +export function renderHumanToolchainPermission(value) { + const permission = value.request.permissions[0]; + const command = permission.commands[0]; + return [ + "Project Toolchain migration requires an npm registry read after the offline cache attempt failed.", + `Source: ${permission.source}`, + `Package: ${permission.package}@${permission.version}`, + `Temporary target: ${permission.temporary_target}`, + `Command: ${[command.executable, ...command.arguments].join(" ")}`, + "Lifecycle scripts remain disabled. The safe default is Deny.", + ].join("\n"); +} + +export function renderHumanToolchainOutcome(value, { + invocationContext, + preview, + root, + styled = false, +} = {}) { + if (value.outcome === "migrated") { + const nextAction = createNextAction(invocationContext, [ + "verify", + "--scope", + "full", + "--cwd", + path.resolve(root), + ]); + return [ + styledText("LaunchRally Project Toolchain Migration Complete", "1;36", styled), + `Engine: ${preview.from_version} -> ${preview.to_version}`, + `Selected Engine: @launchrally/cli@${value.authority.engine.version}`, + `Authority: ${value.authority.state} (${value.authority.source})`, + ...preservedBoundary(), + "Fresh full Verify required: execution_authority_changed", + styledText(nextAction.display, "36", styled), + ...(nextAction.disclosure ? [nextAction.disclosure] : []), + ].join("\n"); + } + if (value.outcome === "migration_declined") { + return [ + styledText("Project Toolchain Migration Declined", "1;33", styled), + `Engine remains: ${preview?.from_version ?? "unchanged"}`, + "No Project Toolchain state was changed.", + ].join("\n"); + } + if (value.outcome === "already_pinned") { + return [ + "Project Toolchain Already Pinned", + `Selected Engine: @launchrally/cli@${value.authority.engine.version}`, + `Authority: ${value.authority.state} (${value.authority.source})`, + "No Project Toolchain state was changed.", + ].join("\n"); + } + const stale = value.error === "preview_stale"; + return [ + styledText( + stale + ? "Project Toolchain Migration Preview Is Stale" + : "Project Toolchain Migration Could Not Complete", + "1;31", + styled, + ), + `Error: ${value.error ?? value.status}`, + ...(value.message ? [`Message: ${value.message}`] : []), + "Prior Project Toolchain authority was preserved; inspect the error and start a fresh migration.", + ].join("\n"); +} + +export async function runHumanToolchainMigration({ + cwd, + invocationContext, + prompt, + runLifecycle, + styled = false, + to, + version, +}) { + let preview; + let result; + try { + await prompt.start("toolchain"); + result = await runLifecycle(cwd, version, { operation: "migrate", to }); + while (["needs_permission", "needs_confirmation"].includes(result.status)) { + if (result.status === "needs_confirmation") preview = result.preview; + const response = await prompt.respondToolchain(result, { styled }); + result = await runLifecycle(cwd, version, { + operation: "migrate", + to, + resume_token: result.interaction.resume_token, + ...(result.status === "needs_permission" + ? { permission_decisions: response.permission_decisions } + : { confirmation: response.confirmation }), + }); + } + await prompt.finishToolchain(result, { + invocationContext, + preview, + root: cwd, + styled, + }); + return { + exitCode: ["unavailable", "execution_error"].includes(result.status) ? 2 : 0, + result, + }; + } catch (error) { + if (error instanceof PromptCancelledError) { + if (["needs_permission", "needs_confirmation"].includes(result?.status)) { + await runLifecycle(cwd, version, { + operation: "migrate", + to, + resume_token: result.interaction.resume_token, + ...(result.status === "needs_permission" + ? { permission_decisions: { npm_registry_read: "denied" } } + : { confirmation: "decline" }), + }); + } + return { exitCode: 130, result: null }; + } + throw error; + } finally { + await prompt.close(); + } +} diff --git a/packages/cli/bin/prompt-adapters.js b/packages/cli/bin/prompt-adapters.js index b90074f..22d4ec9 100644 --- a/packages/cli/bin/prompt-adapters.js +++ b/packages/cli/bin/prompt-adapters.js @@ -29,6 +29,12 @@ import { renderHumanHandoffReceipt, } from "./human-handoff.js"; import { renderHumanInit, renderHumanInitFullPreview } from "./human-init.js"; +import { + renderHumanToolchainMigrationFullPreview, + renderHumanToolchainMigrationPreview, + renderHumanToolchainOutcome, + renderHumanToolchainPermission, +} from "./human-toolchain.js"; function styleTextSupportsArrays() { try { @@ -243,6 +249,7 @@ function operationTitle(operation) { if (operation === "verify") return "LaunchRally Verify"; if (operation === "architect") return "LaunchRally Architect"; if (operation === "architecture-package") return "LaunchRally Architecture Package"; + if (operation === "toolchain") return "LaunchRally Project Toolchain"; if (operation === "handoff") return "LaunchRally External Executor Handoff"; return "LaunchRally Audit"; } @@ -1126,6 +1133,34 @@ export function createPlainPromptAdapter({ } return {}; }, + async respondToolchain(result, context = {}) { + if (result.status === "needs_permission") { + write(output, renderHumanToolchainPermission(result)); + const permission = result.request.permissions[0]; + return { + permission_decisions: { + [permission.id]: await confirm(`Approve ${permission.id}?`) + ? "approved" + : "denied", + }, + }; + } + write(output, renderHumanToolchainMigrationPreview(result, context)); + while (true) { + const confirmation = await choose(result.request.prompt, [ + { label: "Confirm", value: "confirm" }, + { label: "Decline", value: "decline" }, + { label: "View full exact diff", value: "view_full_preview" }, + { label: "Cancel", value: "cancel" }, + ], "decline"); + if (confirmation === "cancel") throw new PromptCancelledError(); + if (confirmation !== "view_full_preview") return { confirmation }; + write(output, renderHumanToolchainMigrationFullPreview(result, context)); + } + }, + async finishToolchain(result, context) { + write(output, renderHumanToolchainOutcome(result, context)); + }, async confirmMigration(preview) { write(output, renderHumanArchitectMigration(preview)); return choose("Adopt additive Phase 1 local records while preserving Phase 0 history?", [ @@ -1555,6 +1590,61 @@ export async function createClackPromptAdapter({ } return {}; }, + async respondToolchain(result, context = {}) { + if (result.status === "needs_permission") { + const permission = result.request.permissions[0]; + clack.note( + renderHumanToolchainPermission(result), + "Project Toolchain registry permission", + common, + ); + const approved = cancelled(await clack.confirm({ + ...common, + message: `Approve ${permission.id}?`, + initialValue: false, + }), clack, output, "Project Toolchain migration"); + return { + permission_decisions: { + [permission.id]: approved ? "approved" : "denied", + }, + }; + } + clack.note( + renderHumanToolchainMigrationPreview(result, context), + "Exact Project Toolchain migration preview", + common, + ); + while (true) { + const confirmation = cancelled(await clack.select({ + ...common, + message: result.request.prompt, + options: [ + { label: "Confirm", value: "confirm" }, + { label: "Decline", value: "decline" }, + { label: "View full exact diff", value: "view_full_preview" }, + { label: "Cancel", value: "cancel" }, + ], + initialValue: "decline", + }), clack, output, "Project Toolchain migration"); + if (confirmation === "cancel") { + clack.cancel("Project Toolchain migration cancelled.", common); + throw new PromptCancelledError(); + } + if (confirmation !== "view_full_preview") return { confirmation }; + clack.note( + renderHumanToolchainMigrationFullPreview(result, context), + "Full exact Project Toolchain diff", + common, + ); + } + }, + async finishToolchain(result, context) { + clack.note( + renderHumanToolchainOutcome(result, context), + "Project Toolchain migration outcome", + common, + ); + }, async confirmMigration(preview) { clack.note(renderHumanArchitectMigration(preview), "Phase 1 migration", common); return cancelled(await clack.select({ diff --git a/release/artifacts.json b/release/artifacts.json index cda9532..122d2cd 100644 --- a/release/artifacts.json +++ b/release/artifacts.json @@ -168,6 +168,7 @@ "bin/human-audit.js", "bin/human-handoff.js", "bin/human-init.js", + "bin/human-toolchain.js", "bin/human-verify.js", "bin/human-version.js", "bin/invocation-context.js", diff --git a/test/prompt-adapters.test.js b/test/prompt-adapters.test.js index 17e8f79..f3cf10c 100644 --- a/test/prompt-adapters.test.js +++ b/test/prompt-adapters.test.js @@ -1338,7 +1338,7 @@ test("NO_COLOR Human Mode retains textual Clack activity states", async () => { ); }); -test("the Clack adapter styles Architect and Architecture Package decisions and outcomes", async () => { +test("the Clack adapter styles Architect, Architecture Package, and Toolchain decisions", async () => { const input = ttyStream(); const output = ttyStream(); let rendered = ""; @@ -1447,6 +1447,37 @@ test("the Clack adapter styles Architect and Architecture Package decisions and persisted: true, files: persistence.preview.files, }, bundle); + const toolchainPreview = { + status: "needs_confirmation", + request: { + prompt: "Replace the complete Project Toolchain pin?", + }, + preview: { + from_version: "0.4.1", + to_version: "0.4.2", + changes: [{ + operation: "update", + path: ".launchrally/toolchain/package.json", + before: "before\n", + after: "after\n", + before_digest: `sha256:${"d".repeat(64)}`, + after_digest: `sha256:${"e".repeat(64)}`, + }], + materialization: { + package_count: 9, + target: ".launchrally/toolchain/node_modules", + integrity_digest: `sha256:${"f".repeat(64)}`, + }, + }, + }; + setTimeout(() => input.write("\r"), 20); + assert.deepEqual(await prompt.respondToolchain(toolchainPreview), { + confirmation: "decline", + }); + await prompt.finishToolchain({ + status: "completed", + outcome: "migration_declined", + }, { preview: toolchainPreview.preview }); await prompt.close(); const semanticOutput = stripVTControlCharacters(rendered); @@ -1458,6 +1489,9 @@ test("the Clack adapter styles Architect and Architecture Package decisions and assert.match(semanticOutput, /Exact persistence preview/u); assert.match(semanticOutput, /Current pointer path: \.launchrally\/architecture\/current\.json/u); assert.match(semanticOutput, /Architecture Review Complete/u); + assert.match(semanticOutput, /Exact Project Toolchain migration preview/u); + assert.match(semanticOutput, /0\.4\.1 -> 0\.4\.2/u); + assert.match(semanticOutput, /Project Toolchain Migration Declined/u); assert.match(semanticOutput, /Architecture Package Persistence Complete/u); }); diff --git a/test/toolchain-lifecycle.test.js b/test/toolchain-lifecycle.test.js index 8883406..447480d 100644 --- a/test/toolchain-lifecycle.test.js +++ b/test/toolchain-lifecycle.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { + chmod, cp, lstat, mkdir, @@ -26,6 +27,115 @@ import { const execFileAsync = promisify(execFile); const cli = path.resolve("packages/cli/bin/rally.js"); +const pythonAvailable = process.platform !== "win32" + && spawnSync("python3", ["--version"]).status === 0; +const migrationPtyRunner = [ + "import errno, os, pty, subprocess, sys", + "answers = iter(sys.argv[1].split(','))", + "master, slave = pty.openpty()", + "child = subprocess.Popen(sys.argv[2:], stdin=slave, stdout=slave, stderr=slave, close_fds=True)", + "os.close(slave)", + "observed = b''", + "answered = 0", + "while True:", + " try:", + " chunk = os.read(master, 4096)", + " except OSError as error:", + " if error.errno == errno.EIO:", + " break", + " raise", + " if not chunk:", + " break", + " sys.stdout.buffer.write(chunk)", + " sys.stdout.buffer.flush()", + " observed += chunk", + " if b'Approve npm_registry_read? [y/N]' in observed and b'__permission_answered__' not in observed:", + " os.write(master, next(answers).encode() + b'\\n')", + " observed += b'__permission_answered__'", + " prompts = observed.count(b'Choose 1-4')", + " while prompts > answered:", + " os.write(master, next(answers).encode() + b'\\n')", + " answered += 1", + "os.close(master)", + "raise SystemExit(child.wait())", +].join("\n"); +const staleMigrationPtyRunner = [ + "import errno, os, pty, subprocess, sys", + "target = sys.argv[1]", + "master, slave = pty.openpty()", + "child = subprocess.Popen(sys.argv[2:], stdin=slave, stdout=slave, stderr=slave, close_fds=True)", + "os.close(slave)", + "chunks = []", + "observed = b''", + "answered = False", + "while True:", + " try:", + " chunk = os.read(master, 4096)", + " except OSError as error:", + " if error.errno == errno.EIO:", + " break", + " raise", + " if not chunk:", + " break", + " chunks.append(chunk)", + " observed += chunk", + " if not answered and b'Choose 1-4' in observed:", + " with open(target, 'a', encoding='utf8') as changed:", + " changed.write(' ')", + " os.write(master, b'1\\n')", + " answered = True", + "os.close(master)", + "sys.stdout.buffer.write(b''.join(chunks))", + "raise SystemExit(child.wait())", +].join("\n"); +const styledMigrationPtyRunner = [ + "import errno, fcntl, os, pty, struct, subprocess, sys, termios, time", + "mode = sys.argv[1]", + "target = sys.argv[2]", + "master, slave = pty.openpty()", + "fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 40, 120, 0, 0))", + "env = os.environ.copy()", + "env['TERM'] = 'xterm-256color'", + "child = subprocess.Popen(sys.argv[3:], stdin=slave, stdout=slave, stderr=slave, close_fds=True, env=env)", + "os.close(slave)", + "observed = b''", + "phase = 'permission' if mode == 'permission_confirm' else 'decision'", + "while True:", + " try:", + " chunk = os.read(master, 4096)", + " except OSError as error:", + " if error.errno == errno.EIO:", + " break", + " raise", + " if not chunk:", + " break", + " sys.stdout.buffer.write(chunk)", + " sys.stdout.buffer.flush()", + " observed += chunk", + " if phase == 'permission' and b'Approve npm_registry_read?' in observed:", + " os.write(master, b'\\x1b[D')", + " time.sleep(0.05)", + " os.write(master, b'\\r')", + " phase = 'decision'", + " observed = b''", + " elif phase == 'decision' and b'Replace the complete Project Toolchain pin' in observed:", + " if mode == 'cancel':", + " os.write(master, b'\\x1b[B\\x1b[B')", + " time.sleep(0.05)", + " os.write(master, b'\\r')", + " phase = 'done'", + " else:", + " if mode == 'stale':", + " with open(target, 'a', encoding='utf8') as changed:", + " changed.write(' ')", + " os.write(master, b'\\x1b[A')", + " time.sleep(0.05)", + " os.write(master, b'\\r')", + " phase = 'done'", + " observed = b''", + "os.close(master)", + "raise SystemExit(child.wait())", +].join("\n"); function validManifest() { const unknown = (reason) => ({ state: "unknown", reason }); @@ -96,6 +206,43 @@ async function preparedToolchain(version) { return path.join(staging, ".launchrally", "toolchain"); } +async function npmFixture(prepared) { + const directory = await mkdtemp(path.join(os.tmpdir(), "launchrally-npm-fixture-")); + const executable = path.join(directory, process.platform === "win32" ? "npm.cmd" : "npm"); + const script = path.join(directory, "npm-fixture.cjs"); + const source = [ + "const { cpSync } = require(\"node:fs\");", + "const path = require(\"node:path\");", + "if (process.env.LAUNCHRALLY_TEST_OFFLINE_MISS === \"1\" && process.argv.includes(\"--offline\")) {", + " process.stderr.write(\"npm error code ENOTCACHED\\n\");", + " process.exit(1);", + "}", + "const source = process.env.LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN;", + "cpSync(path.join(source, \"package-lock.json\"), path.join(process.cwd(), \"package-lock.json\"));", + "cpSync(path.join(source, \"node_modules\"), path.join(process.cwd(), \"node_modules\"), { recursive: true });", + ].join("\n") + "\n"; + await writeFile(script, source); + await writeFile(executable, process.platform === "win32" + ? `@\"${process.execPath}\" \"${script}\" %*\r\n` + : `#!${process.execPath}\n${source}`); + await chmod(executable, 0o755); + return directory; +} + +async function storedLifecycleStates(root) { + const matches = []; + for (const entry of await readdir(os.tmpdir(), { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith("launchrally-toolchain-state-")) continue; + const directory = path.join(os.tmpdir(), entry.name); + for (const file of await readdir(directory).catch(() => [])) { + if (!file.endsWith(".json")) continue; + const state = JSON.parse(await readFile(path.join(directory, file), "utf8")); + if (state.root === root) matches.push(state); + } + } + return matches; +} + test("toolchain status reports the exact missing project materialization without writing", async () => { const repository = await repositoryFixture(); await writeProject(repository); @@ -136,6 +283,439 @@ test("the CLI exposes toolchain status as a public bootstrap command", async () ); }); +test("TTY Human toolchain migrate previews and confirms exact authority in one process", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + + const { stdout } = await execFileAsync("python3", [ + "-c", + migrationPtyRunner, + "1", + process.execPath, + cli, + "toolchain", + "migrate", + "--to", + "0.4.2", + "--plain", + "--cwd", + repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }); + + assert.match(stdout, /LaunchRally Project Toolchain Migration Preview/u); + assert.match(stdout, /Engine: 0\.2\.2 -> 0\.4\.2/u); + assert.match(stdout, /Authoritative files:/u); + assert.match(stdout, /Materialization: 9 packages/u); + assert.match(stdout, /1\. Confirm/u); + assert.match(stdout, /2\. Decline/u); + assert.match(stdout, /3\. View full exact diff/u); + assert.match(stdout, /LaunchRally Project Toolchain Migration Complete/u); + assert.match(stdout, /Authority: ready \(project_toolchain\)/u); + assert.match(stdout, /Rebuildable materialization replaced/u); + assert.match(stdout, /current pointer may be marked non-current/u); + assert.match(stdout, /immutable Reports and Evidence are preserved/u); + assert.match(stdout, /verify --scope full/u); + assert.doesNotMatch(stdout, /resume_token|"contract"|"preview"/u); + + const status = await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + }); + assert.equal(status.authority.engine.version, "0.4.2"); + assert.equal(status.authority.state, "ready"); +}); + +test("default styled TTY migration confirms and completes", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + const packagePath = path.join(repository, ".launchrally/toolchain/package.json"); + + const { stdout } = await execFileAsync("python3", [ + "-c", styledMigrationPtyRunner, "confirm", packagePath, process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }); + + assert.match(stdout, /\u001b\[/u); + assert.match(stdout, /LaunchRally Project Toolchain\r?\n/u); + assert.match(stdout, /LaunchRally Project Toolchain Migration Preview/u); + assert.match(stdout, /LaunchRally Project Toolchain Migration Complete/u); + assert.match(stdout, /Authority: ready \(project_toolchain\)/u); + assert.match(stdout, /verify --scope full/u); + assert.doesNotMatch(stdout, /resume_token|"contract"/u); +}); + +test("default styled TTY migration handles registry permission and cancellation safely", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + for (const scenario of [ + { mode: "permission_confirm", offlineMiss: true, code: 0, summary: /Migration Complete/u }, + { mode: "cancel", offlineMiss: false, code: 130, summary: /migration cancelled/u }, + ]) { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + const packagePath = path.join(repository, ".launchrally/toolchain/package.json"); + const run = execFileAsync("python3", [ + "-c", styledMigrationPtyRunner, scenario.mode, packagePath, process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--cwd", repository, + ], { + env: { + ...process.env, + ...(scenario.offlineMiss ? { LAUNCHRALLY_TEST_OFFLINE_MISS: "1" } : {}), + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }); + if (scenario.code === 0) { + const { stdout } = await run; + assert.match(stdout, /Project Toolchain registry permission/u); + assert.match(stdout, scenario.summary); + } else { + await assert.rejects(run, (error) => { + assert.equal(error.code, scenario.code); + assert.match(error.stdout, scenario.summary); + return true; + }); + assert.equal((await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + })).authority.engine.version, "0.2.2"); + assert.deepEqual(await storedLifecycleStates(repository), []); + } + } +}); + +test("TTY Human toolchain migrate shows the full exact diff and returns to confirmation", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + + const { stdout } = await execFileAsync("python3", [ + "-c", migrationPtyRunner, "3,1", process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--plain", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }); + + assert.match(stdout, /Full exact digest-bound diff:/u); + assert.match(stdout, /--- before \(sha256:/u); + assert.match(stdout, /\+\+\+ after \(sha256:/u); + assert.equal((stdout.match(/Choose 1-4/gu) ?? []).length, 2); + assert.match(stdout, /Migration Complete/u); +}); + +test("TTY Human toolchain migrate safely declines by default and can cancel", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + for (const scenario of [ + { answer: "", code: 0, summary: /Migration Declined/u }, + { answer: "4", code: 130, summary: /migration cancelled/u }, + ]) { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + let output; + try { + output = (await execFileAsync("python3", [ + "-c", migrationPtyRunner, scenario.answer, process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--plain", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + })).stdout; + assert.equal(scenario.code, 0); + } catch (error) { + assert.equal(error.code, scenario.code); + output = error.stdout; + } + assert.match(output, scenario.summary); + assert.doesNotMatch(output, /resume_token/u); + const status = await runToolchainLifecycle(repository, "0.4.2", { operation: "status" }); + assert.equal(status.authority.engine.version, "0.2.2"); + assert.deepEqual(await storedLifecycleStates(repository), []); + } +}); + +test("TTY Human toolchain migrate fails a stale preview closed with a concise result", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + const packagePath = path.join(repository, ".launchrally/toolchain/package.json"); + + await assert.rejects(execFileAsync("python3", [ + "-c", staleMigrationPtyRunner, packagePath, process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--plain", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }), (error) => { + assert.equal(error.code, 2); + assert.match(error.stdout, /Migration Preview Is Stale/u); + assert.match(error.stdout, /Prior Project Toolchain authority was preserved/u); + assert.doesNotMatch(error.stdout, /"error"|resume_token/u); + return true; + }); + assert.equal((await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + })).authority.engine.version, "0.2.2"); +}); + +test("TTY Human toolchain migrate requests registry permission and rolls back completion failures", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + for (const failure of [false, true]) { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + if (failure) { + await mkdir(path.join(repository, ".launchrally/cache"), { recursive: true }); + await writeFile(path.join(repository, ".launchrally/cache/current-report.json"), "not-json\n"); + } + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + try { + const { stdout } = await execFileAsync("python3", [ + "-c", migrationPtyRunner, "y,1", process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--plain", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_OFFLINE_MISS: "1", + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }); + assert.equal(failure, false); + assert.match(stdout, /requires an npm registry read/u); + assert.match(stdout, /Migration Complete/u); + } catch (error) { + assert.equal(failure, true); + assert.equal(error.code, 2); + assert.match(error.stdout, /Migration Could Not Complete/u); + assert.match(error.stdout, /prior project authority was preserved/iu); + assert.equal((await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + })).authority.engine.version, "0.2.2"); + } + } +}); + +test("TTY Human toolchain migrate denies registry permission without changing authority", { + skip: pythonAvailable ? false : "A local Python 3 PTY is required.", +}, async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + + await assert.rejects(execFileAsync("python3", [ + "-c", migrationPtyRunner, "n", process.execPath, cli, + "toolchain", "migrate", "--to", "0.4.2", "--plain", "--cwd", repository, + ], { + env: { + ...process.env, + LAUNCHRALLY_TEST_OFFLINE_MISS: "1", + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }, + timeout: 30000, + }), (error) => { + assert.equal(error.code, 2); + assert.match(error.stdout, /requires an npm registry read/u); + assert.match(error.stdout, /registry_permission_denied/u); + assert.match(error.stdout, /Prior Project Toolchain authority was preserved/u); + assert.doesNotMatch(error.stdout, /resume_token/u); + return true; + }); + assert.equal((await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + })).authority.engine.version, "0.2.2"); +}); + +test("non-TTY Human migration fails safely with a complete Agent command", async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + + await assert.rejects(execFileAsync(process.execPath, [ + cli, "toolchain", "migrate", "--to", "0.4.2", "--cwd", repository, + ]), (error) => { + assert.equal(error.code, 2); + assert.match(error.stderr, /Non-TTY Human Mode cannot confirm/u); + assert.match( + error.stderr, + /['"]?toolchain['"]?\s+['"]?migrate['"]?\s+['"]?--to['"]?\s+['"]?0\.4\.2['"]?\s+['"]?--json['"]?\s+['"]?--cwd['"]?/u, + ); + assert.doesNotMatch(error.stdout, /needs_confirmation|resume_token/u); + return true; + }); + assert.equal((await runToolchainLifecycle(repository, "0.4.2", { + operation: "status", + })).authority.engine.version, "0.2.2"); +}); + +test("non-TTY structured migration prints the complete corrected Agent command", async () => { + const repository = await repositoryFixture(); + const permissions = JSON.stringify({ npm_registry_read: "approved" }); + await assert.rejects(execFileAsync(process.execPath, [ + cli, + "toolchain", + "migrate", + "--to", + "0.4.2", + "--cwd", + repository, + "--resume", + "opaque-token", + "--confirm", + "confirm", + "--permissions", + permissions, + ]), (error) => { + assert.equal(error.code, 2); + assert.match(error.stderr, /Use this complete Agent\/JSON command/u); + assert.match( + error.stderr, + /['"]?toolchain['"]?\s+['"]?migrate['"]?\s+['"]?--to['"]?\s+['"]?0\.4\.2['"]?\s+['"]?--json['"]?\s+['"]?--cwd['"]?/u, + ); + assert.match( + error.stderr, + /['"]?--resume['"]?\s+['"]?opaque-token['"]?\s+['"]?--confirm['"]?\s+['"]?confirm['"]?/u, + ); + assert.match( + error.stderr, + /['"]?--permissions['"]?\s+['"]?\{"npm_registry_read":"approved"\}['"]?/u, + ); + assert.doesNotMatch(error.stderr, /Human Mode does not accept structured/u); + return true; + }); +}); + +test("non-TTY migration without an exact target never renders a placeholder command", async () => { + const repository = await repositoryFixture(); + await assert.rejects(execFileAsync(process.execPath, [ + cli, "toolchain", "migrate", "--cwd", repository, + ]), (error) => { + assert.equal(error.code, 2); + assert.match(error.stderr, /requires --to with an exact SemVer/u); + assert.match(error.stderr, /No executable Agent\/JSON command/u); + assert.doesNotMatch(error.stderr, /|npm exec|rally toolchain/u); + return true; + }); +}); + +test("default Human toolchain status is concise and does not expose lifecycle JSON", async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await assert.rejects(execFileAsync(process.execPath, [ + cli, "toolchain", "status", "--cwd", repository, + ]), (error) => { + assert.equal(error.code, 2); + assert.match(error.stdout, /requires the explicit action reported by Execution Authority/u); + assert.doesNotMatch(error.stdout, /"contract"|"authority"/u); + return true; + }); +}); + +test.todo("default Human toolchain restore should not render raw lifecycle JSON on completion"); +test.todo("default Human toolchain clean should not render raw lifecycle JSON on completion"); +test.todo("default TTY toolchain restore should keep registry resume tokens internal"); + +test("Agent JSON toolchain migrate preserves the exact resumable protocol", async () => { + const repository = await repositoryFixture(); + await writeProject(repository, "0.2.2"); + await materializeExactToolchain(repository, "0.2.2"); + const prepared = await preparedToolchain("0.4.2"); + const npmDirectory = await npmFixture(prepared); + const environment = { + ...process.env, + LAUNCHRALLY_TEST_PREPARED_TOOLCHAIN: prepared, + PATH: `${npmDirectory}${path.delimiter}${process.env.PATH}`, + }; + + const preview = JSON.parse((await execFileAsync(process.execPath, [ + cli, "toolchain", "migrate", "--to", "0.4.2", "--json", "--cwd", repository, + ], { env: environment })).stdout); + assert.equal(preview.contract, "launchrally.dev/toolchain-lifecycle/v1"); + assert.equal(preview.status, "needs_confirmation"); + assert.equal(preview.operation, "toolchain_migrate"); + assert.equal(preview.request.type, "confirmation"); + assert.deepEqual(preview.request.choices, ["confirm", "decline"]); + assert.match(preview.interaction.resume_token, /^lrtc_/u); + + const declined = JSON.parse((await execFileAsync(process.execPath, [ + cli, + "toolchain", + "migrate", + "--to", + "0.4.2", + "--json", + "--cwd", + repository, + "--resume", + preview.interaction.resume_token, + "--confirm", + "decline", + ], { env: environment })).stdout); + assert.deepEqual(declined, { + contract: "launchrally.dev/toolchain-lifecycle/v1", + status: "completed", + operation: "toolchain_migrate", + outcome: "migration_declined", + changes_applied: [], + }); +}); + test("toolchain restore rebuilds the established pin offline without changing authority files", async () => { const repository = await repositoryFixture(); await writeProject(repository);