Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion packages/cli/bin/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions packages/cli/bin/human-toolchain.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
90 changes: 90 additions & 0 deletions packages/cli/bin/prompt-adapters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
}
Expand Down Expand Up @@ -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?", [
Expand Down Expand Up @@ -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({
Expand Down
1 change: 1 addition & 0 deletions release/artifacts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading