projectstore-codex 0.28.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/bin/projectstore-codex.mjs +14 -9
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +4 -4
- package/node_modules/projectstore/docs/harnesses.md +23 -10
- package/node_modules/projectstore/harnesses/claude-code.json +3 -0
- package/node_modules/projectstore/harnesses/codex.json +7 -3
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/build-adapters.mjs +8 -5
- package/node_modules/projectstore/scripts/cli.mjs +173 -26
- package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
- package/node_modules/projectstore/scripts/term.mjs +147 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +8 -2
- package/package.json +2 -3
- package/skills/projectstore-agents/SKILL.md +5 -4
- package/skills/projectstore-doctor/SKILL.md +3 -1
- package/plugin.json +0 -53
|
@@ -23,12 +23,14 @@
|
|
|
23
23
|
// pure string. confirm() takes its streams as parameters. apply() is the only
|
|
24
24
|
// function that writes, and it writes only through lib.mjs writeFileAtomic.
|
|
25
25
|
//
|
|
26
|
-
// The gate (contract 9, distribution ADR decision 6):
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// The gate (contract 9 as amended 2026-10-04, distribution ADR decision 6):
|
|
27
|
+
// the plan is always printed first. A person at a terminal is then asked,
|
|
28
|
+
// even when the harness is named; without one (a pipe, an agent's tool, CI,
|
|
29
|
+
// --json, a host session) a call that NAMES its harness is the confirmation
|
|
30
|
+
// and a bare one refuses. There is no --yes flag. --surface narrows the plan
|
|
31
|
+
// (by prefix, so `statusline` covers the launcher too); it confirms nothing —
|
|
32
|
+
// except that naming the statusline surface is how a user opts into it
|
|
33
|
+
// without the config flag.
|
|
32
34
|
//
|
|
33
35
|
// Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
|
|
34
36
|
// by a harness id: adding a harness is adding harnesses/<id>.json, and this
|
|
@@ -56,13 +58,13 @@
|
|
|
56
58
|
// (MultiProjectStore); the host-managed report shape is Maxim
|
|
57
59
|
// Podreshetnikov's (PR #13, installElsewhere). Pure node, no external deps.
|
|
58
60
|
|
|
59
|
-
import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync } from "node:fs";
|
|
60
|
-
import { join, resolve, dirname, relative, isAbsolute } from "node:path";
|
|
61
|
+
import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync, realpathSync } from "node:fs";
|
|
62
|
+
import { join, resolve, dirname, relative, isAbsolute, basename } from "node:path";
|
|
61
63
|
import { homedir } from "node:os";
|
|
62
64
|
import { randomUUID } from "node:crypto";
|
|
63
65
|
import { fileURLToPath } from "node:url";
|
|
64
|
-
import { createInterface } from "node:readline/promises";
|
|
65
66
|
import { spawnSync } from "node:child_process";
|
|
67
|
+
import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, wrap, askLine } from "./term.mjs";
|
|
66
68
|
import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
|
|
67
69
|
import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
|
|
68
70
|
import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
|
|
@@ -141,8 +143,9 @@ function planAgentsBlock(ctx, key, s) {
|
|
|
141
143
|
.filter((m) => m.id !== ctx.harness?.id)
|
|
142
144
|
.some((m) => (m.surfaces?.agents_block?.files || []).includes(file));
|
|
143
145
|
for (const e of withBlock) {
|
|
144
|
-
// Naming the surface
|
|
145
|
-
//
|
|
146
|
+
// Naming the surface removes it regardless: `--surface agents_block`
|
|
147
|
+
// is the confirmation without a terminal, and a terminal is asked
|
|
148
|
+
// (contract 9 as amended 2026-10-04).
|
|
146
149
|
if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
|
|
147
150
|
items.push({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "skip",
|
|
148
151
|
reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}` });
|
|
@@ -658,7 +661,14 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
658
661
|
// Items planned before the render root was known (none today: the registration sorts first) are not re-planned.
|
|
659
662
|
}
|
|
660
663
|
if (ctx.incomplete) out.incomplete = true;
|
|
661
|
-
if (hostRows.length && !surfaces)
|
|
664
|
+
if (hostRows.length && !surfaces) {
|
|
665
|
+
out.reports.push(hostManagedReport(harness, hostRows, registration));
|
|
666
|
+
// The same fact in one line, for the compact preview (contract 18). Not
|
|
667
|
+
// enumerable: the preview reads it, and plan --json keeps the shape it
|
|
668
|
+
// had (the planner's review, 2026-10-05).
|
|
669
|
+
if (!Object.hasOwn(out, "hostManaged")) Object.defineProperty(out, "hostManaged", { value: [], enumerable: false });
|
|
670
|
+
out.hostManaged.push({ harness: harness.id, display: harness.display_name, rows: hostRows, entry: registration?.entry || null, action: registration?.action || null });
|
|
671
|
+
}
|
|
662
672
|
// An unsupported host surface gets the same row shape a shared one does
|
|
663
673
|
// (planJsonEntry's unsupported branch): state "unsupported", action "skip",
|
|
664
674
|
// and the manifest's own reason. One treatment for one fact, so a reader —
|
|
@@ -729,12 +739,14 @@ function planLayout(ctx) {
|
|
|
729
739
|
return { first, last };
|
|
730
740
|
}
|
|
731
741
|
|
|
732
|
-
function applyLayout(p, i, { failed, home = homedir() }) {
|
|
742
|
+
function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
|
|
733
743
|
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
734
744
|
const within = p.projectDir;
|
|
735
745
|
if (i.action === "cleanup" && failed) { out.action = "skipped"; out.reason = "an earlier item failed; the legacy files stay until the next run"; return out; }
|
|
736
746
|
const fail = (step, message) => { out.failed = { step, status: null, stderr: message }; return out; };
|
|
737
747
|
for (const st of i.steps || []) {
|
|
748
|
+
const seen = out.steps.length;
|
|
749
|
+
if (onStep) onStep(st, "start");
|
|
738
750
|
try {
|
|
739
751
|
if (st.kind === "ensure") { ensureRuntimeDir(within); out.steps.push({ kind: st.kind, ok: true }); }
|
|
740
752
|
else if (st.kind === "move-state") { const r = moveStateDir(st.from, st.to, within); ensureStateDir(within); out.steps.push({ kind: st.kind, ok: true, ...r }); }
|
|
@@ -788,10 +800,18 @@ function applyLayout(p, i, { failed, home = homedir() }) {
|
|
|
788
800
|
}
|
|
789
801
|
else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
|
|
790
802
|
} catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
|
|
803
|
+
finally { if (onStep) onStep(st, "end", stepResult(out, seen)); }
|
|
791
804
|
}
|
|
792
805
|
return out;
|
|
793
806
|
}
|
|
794
807
|
|
|
808
|
+
// What one step left behind, for the APPLY line: its record when it pushed
|
|
809
|
+
// one, and a failure when the item failed under it.
|
|
810
|
+
function stepResult(out, seen) {
|
|
811
|
+
const rec = out.steps.length > seen ? out.steps[out.steps.length - 1] : null;
|
|
812
|
+
return { ok: !out.failed && (!rec || rec.ok !== false), kept: Boolean(rec && rec.removed === false && rec.reason) };
|
|
813
|
+
}
|
|
814
|
+
|
|
795
815
|
// Does any settings file the host reads run this file as its status line?
|
|
796
816
|
// Compared by identity (device and inode), so a path spelled through a symlink
|
|
797
817
|
// or in another case still counts; the project-directory variable a command
|
|
@@ -856,96 +876,183 @@ function hostManagedReport(m, rows, registration = null) {
|
|
|
856
876
|
const isWrite = (i) => !["skip", "refuse"].includes(i.action);
|
|
857
877
|
|
|
858
878
|
// ─── preview ───────────────────────────────────────────────────────────
|
|
879
|
+
//
|
|
880
|
+
// Contract 18: a header, then PLAN — one line per item (what, where, the state
|
|
881
|
+
// transition) with its steps beneath. Every path written and every host argv
|
|
882
|
+
// with the files it touches stays in the default view: that is contract 9's
|
|
883
|
+
// consent content, and a reader that is an agent needs it as much as a person.
|
|
884
|
+
// The explanations — the host-managed report, per-row reasons, each step's why,
|
|
885
|
+
// the planned-against note — are folded behind `--verbose`. Colour comes from
|
|
886
|
+
// the caller (term.mjs decides); the default is plain text.
|
|
887
|
+
|
|
888
|
+
// Every action a plan item can carry. A write never wears the skip glyph:
|
|
889
|
+
// `add` and `replace-entry` are changes as much as `create` and `update`.
|
|
890
|
+
const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "update", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
|
|
891
|
+
const ACTION_COLOR = { create: "green", add: "green", update: "cyan", "replace-entry": "cyan", migrate: "cyan", disable: "cyan", cleanup: "yellow", remove: "yellow", prune: "yellow", skip: "gray", refuse: "red" };
|
|
892
|
+
|
|
893
|
+
// One step of an item as the lines it shows: always the action and its target,
|
|
894
|
+
// then — under --verbose — why it runs.
|
|
895
|
+
function stepLines(p, st, { verbose, paint, width = 0 }) {
|
|
896
|
+
const r = (x) => rel(p.projectDir, x);
|
|
897
|
+
const lead = " ";
|
|
898
|
+
const sub = " ";
|
|
899
|
+
const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("gray", l)) : [];
|
|
900
|
+
switch (st.kind) {
|
|
901
|
+
case "host": {
|
|
902
|
+
const out = [`${lead}${paint("cyan", "$")} ${[st.bin, ...st.argv].join(" ")}`];
|
|
903
|
+
if (st.touches.length) out.push(`${sub}${paint("gray", `touches ${st.touches.map(r).join(", ")}`)}`);
|
|
904
|
+
return [...out, ...why];
|
|
905
|
+
}
|
|
906
|
+
case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("yellow", "note:") + l.slice(5)));
|
|
907
|
+
case "write": return [`${lead}write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ...why];
|
|
908
|
+
case "portable-write": return [`${lead}stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ...why];
|
|
909
|
+
case "portable-remove":
|
|
910
|
+
case "remove": return [`${lead}remove ${st.path}`, ...why];
|
|
911
|
+
case "unregister": return [`${lead}edit ${r(st.path)} [${st.pointer}.${st.name}] → removed`, ...why];
|
|
912
|
+
case "ensure": return [`${lead}ensure ${r(st.path)}/`, ...why];
|
|
913
|
+
case "move-state": return [`${lead}move ${r(st.from)}/ → ${r(st.to)}/ (${st.files} entries)`, ...why];
|
|
914
|
+
case "merge-log": return [`${lead}merge ${r(st.from)} → ${r(st.to)}`, ...why];
|
|
915
|
+
case "delete": return [`${lead}delete ${r(st.path)}`, ...why];
|
|
916
|
+
case "move-marker": return [`${lead}move ${r(st.from)} → ${r(st.to)}`, ...why];
|
|
917
|
+
case "move-binding": return [`${lead}move ${r(st.from)} → ${r(st.to)} (agents → ${r(st.overlay)})`, ...why];
|
|
918
|
+
case "remove-legacy-launcher":
|
|
919
|
+
case "rmdir-legacy":
|
|
920
|
+
case "remove-legacy-runtime": return [`${lead}remove ${r(st.path)}`, ...why];
|
|
921
|
+
default: return [];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// The harnesses a plan names, by their display names — for the header.
|
|
926
|
+
function displayNames(p) {
|
|
927
|
+
return p.harnesses.map((id) => loadHarness(id)?.display_name || id).join(", ") || "(no harness)";
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const tilde = (path) => {
|
|
931
|
+
const h = homedir();
|
|
932
|
+
return path === h || path.startsWith(h + "/") ? "~" + path.slice(h.length) : path;
|
|
933
|
+
};
|
|
859
934
|
|
|
860
|
-
export function renderPreview(p) {
|
|
861
|
-
const
|
|
862
|
-
for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(` ${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`, "");
|
|
863
|
-
for (const r of p.reports) lines.push(...r.split("\n").map((l) => " " + l), "");
|
|
935
|
+
export function renderPreview(p, { verbose = false, verb = null, paint = (_style, text) => String(text), icon = null, width = 0 } = {}) {
|
|
936
|
+
const glyph = icon || ((name) => ({ create: "+", update: "↻", migrate: "↻", cleanup: "✕", remove: "✕", skip: "·", refuse: "!" }[name] || "·"));
|
|
864
937
|
const writes = p.items.filter(isWrite);
|
|
938
|
+
const lines = [
|
|
939
|
+
`${paint("bold", "projectstore")} · ${verb || p.mode} · ${displayNames(p)}`,
|
|
940
|
+
` ${paint("gray", tilde(p.projectDir))}`,
|
|
941
|
+
"",
|
|
942
|
+
];
|
|
943
|
+
// Prose, wrapped to the terminal with every line indented; a path or a
|
|
944
|
+
// command inside it is one word and never broken (term.mjs wrap).
|
|
945
|
+
const prose = (indent, text, style = "gray") => wrap(text, width, indent).split("\n").map((l, k) => (k ? "" : indent) + paint(style, l));
|
|
946
|
+
if (verbose) {
|
|
947
|
+
for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(...prose(" ", `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`), "");
|
|
948
|
+
for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
|
|
949
|
+
}
|
|
950
|
+
const count = writes.length === 1 ? "1 change" : `${writes.length} changes`;
|
|
951
|
+
lines.push(`${paint("bold", "PLAN")} — ${p.ok ? count : "refused"}`);
|
|
952
|
+
// Unsupported host rows (no path, nothing this harness has) fold into one
|
|
953
|
+
// line per harness by default; --verbose lists each with the manifest's reason.
|
|
954
|
+
const folded = new Map();
|
|
865
955
|
for (const i of p.items) {
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
956
|
+
if (!verbose && i.path === null && i.state === "unsupported" && i.action === "skip") {
|
|
957
|
+
const k = loadHarness(i.harness)?.display_name || i.harness;
|
|
958
|
+
if (!folded.has(k)) folded.set(k, []);
|
|
959
|
+
folded.get(k).push(i.surface);
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
const target = i.path === null ? `${i.surface} [${i.harness}, no filesystem path]` : rel(p.projectDir, i.path);
|
|
869
963
|
const where = target + (i.entry ? ` [${i.entry}]` : "");
|
|
870
964
|
let state = i.state;
|
|
871
965
|
if (i.state === "current" && i.writtenBy && !i.sameProject) state = `current, last written by ${i.writtenBy}`;
|
|
966
|
+
// A row's reason is shown whenever it has one: a row of something
|
|
967
|
+
// already right carries none, and every other reason — a change, a skip
|
|
968
|
+
// that needs a terminal or a PATH, a block kept for another harness —
|
|
969
|
+
// is something the reader acts on (the reviewer's pass, 2026-10-05).
|
|
872
970
|
if (i.reason && i.action !== "refuse") state += ` (${i.reason})`;
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
else if (st.kind === "delete") lines.push(` delete ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
|
|
887
|
-
else if (st.kind === "move-marker") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
|
|
888
|
-
else if (st.kind === "move-binding") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)} (agents → ${rel(p.projectDir, st.overlay)})`, ` ${st.why}`);
|
|
889
|
-
else if (st.kind === "remove-legacy-launcher" || st.kind === "rmdir-legacy" || st.kind === "remove-legacy-runtime") lines.push(` remove ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
|
|
890
|
-
}
|
|
891
|
-
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` (harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`);
|
|
892
|
-
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` (the file would hold nothing else and is removed)`);
|
|
971
|
+
const color = ACTION_COLOR[i.action] || (isWrite(i) ? "cyan" : "gray");
|
|
972
|
+
const mark = paint(color, glyph(ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip")));
|
|
973
|
+
const transition = `${state} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
|
|
974
|
+
lines.push(` ${mark} ${paint("bold", i.kind.padEnd(12))} ${where}`);
|
|
975
|
+
lines.push(...prose(" ", transition));
|
|
976
|
+
for (const st of i.steps || []) lines.push(...stepLines(p, st, { verbose, paint, width }));
|
|
977
|
+
if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` ${paint("gray", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
|
|
978
|
+
if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` ${paint("gray", "(the file would hold nothing else and is removed)")}`);
|
|
979
|
+
}
|
|
980
|
+
const hostLine = (text) => { const [first, ...rest] = wrap(text, width, " ".repeat(17)).split("\n"); return [` ${paint("gray", glyph("skip"))} ${paint("bold", "host".padEnd(12))} ${first}`, ...rest]; };
|
|
981
|
+
for (const h of p.hostManaged || []) {
|
|
982
|
+
const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
|
|
983
|
+
lines.push(...hostLine(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`));
|
|
893
984
|
}
|
|
985
|
+
for (const [display, rows] of folded) lines.push(...hostLine(`not on ${display}: ${rows.join(", ")} — unsupported → skip ${paint("gray", "(--verbose says why)")}`));
|
|
894
986
|
const exclusiveRemoval = p.items.find((i) => i.action === "remove" && i.kind === "exclusive");
|
|
895
|
-
if (exclusiveRemoval) lines.push(`
|
|
896
|
-
for (const r of p.refusals) lines.push(` refused
|
|
897
|
-
lines.push("", "
|
|
898
|
-
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push("
|
|
899
|
-
if (!p.ok) lines.push("", "
|
|
987
|
+
if (exclusiveRemoval) lines.push(` ${paint("gray", `(an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`)}`);
|
|
988
|
+
for (const r of p.refusals) { const [first, ...rest] = wrap(r, width, " ".repeat(17)).split("\n"); lines.push(` ${paint("red", glyph("refuse"))} ${paint("bold", "refused".padEnd(12))} ${first}`, ...rest); }
|
|
989
|
+
lines.push("", ` ${paint("gray", "Nothing outside a marked entry is read, rewritten or removed.")}`);
|
|
990
|
+
if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(` ${paint("gray", "Each $ line runs the host's own CLI, which writes the host-owned files named after it.")}`);
|
|
991
|
+
if (!p.ok) lines.push("", ` ${paint("red", "Nothing will be written: resolve the refusals above first.")}`);
|
|
900
992
|
else if (!writes.length) lines.push("", " Nothing to change." + (p.incomplete ? " One surface could not be planned (see above)." : ""));
|
|
901
|
-
else lines.push("",
|
|
993
|
+
else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
|
|
902
994
|
return lines.join("\n") + "\n";
|
|
903
995
|
}
|
|
904
996
|
|
|
905
997
|
// ─── gate ──────────────────────────────────────────────────────────────
|
|
906
998
|
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
|
|
999
|
+
// Contract 9, amended 2026-10-04: a person at a terminal is asked, even when
|
|
1000
|
+
// the harness is named — the shells always name it, so naming alone had
|
|
1001
|
+
// stopped meaning a person agreed. Without one (a pipe, an agent's tool call,
|
|
1002
|
+
// CI, --json, a command run inside a host session) a named harness is the
|
|
1003
|
+
// confirmation and a bare one refuses, exactly as before.
|
|
1004
|
+
export function isInteractive({ stdin = null, stdout = null, env = process.env, json = false } = {}) {
|
|
1005
|
+
if (json) return false;
|
|
1006
|
+
if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return false;
|
|
1007
|
+
if (env.CI && !["0", "false"].includes(String(env.CI).toLowerCase())) return false;
|
|
1008
|
+
// A host session's own tool may run us in a pseudo-terminal; the manifests'
|
|
1009
|
+
// session markers say when that is happening — every manifest's, not only
|
|
1010
|
+
// the planned harness's: a Claude Code agent running the Codex shell is
|
|
1011
|
+
// still an agent. (Codex's own markers are unmeasured; for it the TTY test
|
|
1012
|
+
// above carries the rule.)
|
|
1013
|
+
if ([...loadHarnesses().values()].some((m) => insideHostSession(env, m))) return false;
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Streams and `ask` are parameters so the terminal branch is testable without
|
|
1018
|
+
// a pseudo-terminal; passing `ask` means "this is a terminal". Without streams
|
|
1019
|
+
// the library never asks — the bin and main() pass theirs.
|
|
1020
|
+
export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, paint = (_style, text) => String(text) } = {}) {
|
|
911
1021
|
if (!p.ok) return { confirmed: false, why: "refused" };
|
|
912
1022
|
const writes = p.items.filter(isWrite);
|
|
913
1023
|
if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const answer = ask ? await ask(
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
})();
|
|
921
|
-
return /^y(es)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
1024
|
+
const interactive = !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
|
|
1025
|
+
if (!interactive) return p.named ? { confirmed: true, why: "named" } : { confirmed: false, why: "non-tty" };
|
|
1026
|
+
const question = `${paint("bold", `Apply ${writes.length === 1 ? "1 change" : `${writes.length} changes`}?`)} ${paint("gray", "[Y/n]")} `;
|
|
1027
|
+
const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
|
|
1028
|
+
if (answer === null || answer === undefined) return { confirmed: false, why: "declined" };
|
|
1029
|
+
return /^(y(es)?)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
|
|
922
1030
|
}
|
|
923
1031
|
|
|
924
1032
|
// ─── apply ─────────────────────────────────────────────────────────────
|
|
925
1033
|
|
|
926
|
-
export function apply(p, { env = process.env, spawn = spawnSync, home = homedir() } = {}) {
|
|
1034
|
+
export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(), onItem = null, onStep = null } = {}) {
|
|
927
1035
|
if (!p.ok) throw new Error("apply: the plan carries refusals; nothing is written");
|
|
928
1036
|
const done = [];
|
|
929
1037
|
let registrationFailed = false;
|
|
930
1038
|
let layoutFailed = false;
|
|
931
|
-
|
|
932
|
-
|
|
1039
|
+
// One item's writes, returning the record apply reports for it. The order
|
|
1040
|
+
// and the failure rules are the loop's; onItem only watches (contract 18).
|
|
1041
|
+
const one = (i) => {
|
|
933
1042
|
if (i.kind === "layout") {
|
|
934
|
-
const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home });
|
|
935
|
-
done.push(r);
|
|
1043
|
+
const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home, onStep });
|
|
936
1044
|
if (r.failed) { done.failed = r.failed; layoutFailed = true; }
|
|
937
|
-
|
|
1045
|
+
return r;
|
|
938
1046
|
}
|
|
939
1047
|
if (i.kind === "registration") {
|
|
940
|
-
const r = applyRegistration(p, i, { env, spawn, home });
|
|
941
|
-
done.push(r);
|
|
1048
|
+
const r = applyRegistration(p, i, { env, spawn, home, onStep });
|
|
942
1049
|
// A registration that did not complete leaves the surfaces planned against
|
|
943
1050
|
// its install path unwritten: a launcher pointing at nothing is worse than
|
|
944
1051
|
// none. Surfaces rendered from the package root (the block) still apply.
|
|
945
1052
|
if (r.failed) { done.failed = r.failed; registrationFailed = true; }
|
|
946
|
-
|
|
1053
|
+
return r;
|
|
947
1054
|
}
|
|
948
|
-
if (registrationFailed && i.plannedAgainst)
|
|
1055
|
+
if (registrationFailed && i.plannedAgainst) return { path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" };
|
|
949
1056
|
if (i.kind === "shared" && typeof i.after === "object" && i.after !== null && !Array.isArray(i.after)) {
|
|
950
1057
|
mkdirSync(dirname(i.path), { recursive: true });
|
|
951
1058
|
// Re-read at write time: a host command run earlier in this apply (the
|
|
@@ -970,7 +1077,16 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
970
1077
|
mkdirSync(dirname(i.path), { recursive: true });
|
|
971
1078
|
writeFileAtomic(i.path, i.after, { sweep: false });
|
|
972
1079
|
}
|
|
973
|
-
|
|
1080
|
+
return { path: i.path, action: i.action, surface: i.surface };
|
|
1081
|
+
};
|
|
1082
|
+
for (const i of p.items) {
|
|
1083
|
+
if (!isWrite(i)) continue;
|
|
1084
|
+
if (onItem) onItem(i, "start");
|
|
1085
|
+
let r;
|
|
1086
|
+
try { r = one(i); }
|
|
1087
|
+
catch (e) { if (onItem) onItem(i, "abort"); throw e; }
|
|
1088
|
+
done.push(r);
|
|
1089
|
+
if (onItem) onItem(i, "end", r);
|
|
974
1090
|
}
|
|
975
1091
|
return done;
|
|
976
1092
|
}
|
|
@@ -980,7 +1096,7 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
|
|
|
980
1096
|
// same home the plan was read from — and with the project as its cwd, which is
|
|
981
1097
|
// how the host resolves `--scope local`. A non-zero exit stops the item and
|
|
982
1098
|
// is recorded, never retried, never masked.
|
|
983
|
-
function applyRegistration(p, i, { env, spawn, home }) {
|
|
1099
|
+
function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
|
|
984
1100
|
const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
|
|
985
1101
|
const harness = loadHarness(i.harness);
|
|
986
1102
|
const childEnv = { ...env, [homeEnvName(i.harness)]: i.home || claudeHome(home) };
|
|
@@ -1187,6 +1303,11 @@ function applyRegistration(p, i, { env, spawn, home }) {
|
|
|
1187
1303
|
return out;
|
|
1188
1304
|
};
|
|
1189
1305
|
for (const st of i.steps || []) {
|
|
1306
|
+
// Host commands report through the spawn the caller passed (one line per
|
|
1307
|
+
// argv, rollbacks included); the filesystem steps report here.
|
|
1308
|
+
const seen = out.steps.length;
|
|
1309
|
+
const watched = onStep && st.kind !== "host";
|
|
1310
|
+
if (watched) onStep(st, "start");
|
|
1190
1311
|
try {
|
|
1191
1312
|
if (st.kind === "portable-write") {
|
|
1192
1313
|
const token = `${process.pid}-${randomUUID()}`;
|
|
@@ -1242,6 +1363,8 @@ function applyRegistration(p, i, { env, spawn, home }) {
|
|
|
1242
1363
|
}
|
|
1243
1364
|
} catch (e) {
|
|
1244
1365
|
return fail(st.kind, null, e && e.message ? e.message : String(e));
|
|
1366
|
+
} finally {
|
|
1367
|
+
if (watched) onStep(st, "end", stepResult(out, seen));
|
|
1245
1368
|
}
|
|
1246
1369
|
}
|
|
1247
1370
|
// The host's registry is read back: the install path the rest of the plan
|
|
@@ -1298,24 +1421,151 @@ function pruneEmptyDir(dir, projectDir) {
|
|
|
1298
1421
|
export async function runVerb(verb, projectDir, opts = {}) {
|
|
1299
1422
|
const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
|
|
1300
1423
|
const p = plan(projectDir, { ...opts, mode });
|
|
1301
|
-
const
|
|
1302
|
-
|
|
1303
|
-
|
|
1424
|
+
const env = opts.env || process.env;
|
|
1425
|
+
// `out` is the text-mode caller's stdout. With it, this prints: the plan
|
|
1426
|
+
// first, then the question, then each step as it runs, then DONE.
|
|
1427
|
+
const out = opts.out || null;
|
|
1428
|
+
const c = out ? termCaps(out, env) : null;
|
|
1429
|
+
const paint = c ? painter(c) : (_style, text) => String(text);
|
|
1430
|
+
const glyph = c ? (name) => termIcon(c, name) : null;
|
|
1431
|
+
const preview = renderPreview(p, { verbose: Boolean(opts.verbose), verb, paint, icon: glyph, width: c && c.live ? c.width : 0 });
|
|
1432
|
+
if (out) out.write(preview + "\n");
|
|
1433
|
+
const gate = await confirm(p, { ...opts, env, paint });
|
|
1434
|
+
const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0 };
|
|
1304
1435
|
if (gate.confirmed) {
|
|
1305
|
-
|
|
1436
|
+
const t0 = Date.now();
|
|
1437
|
+
const reporter = out ? applyReporter(out, c, p) : null;
|
|
1438
|
+
const spawn = opts.spawn || spawnSync;
|
|
1439
|
+
result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home: opts.home || homedir(), onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
|
|
1306
1440
|
result.failed = result.applied.failed || null;
|
|
1441
|
+
result.elapsed = Date.now() - t0;
|
|
1442
|
+
if (out) out.write(renderDone(result, { paint, glyph: glyph || undefined, verbose: Boolean(opts.verbose) }));
|
|
1307
1443
|
}
|
|
1308
1444
|
return result;
|
|
1309
1445
|
}
|
|
1310
1446
|
|
|
1447
|
+
// APPLY, one line per step (contract 18). An item with steps — a
|
|
1448
|
+
// registration, the layout move — prints its name, then one line per step
|
|
1449
|
+
// beneath it: each host command, staging write, write and move. Any other
|
|
1450
|
+
// item is one line.
|
|
1451
|
+
function applyReporter(out, c, p) {
|
|
1452
|
+
const paint = painter(c);
|
|
1453
|
+
const item = stepReporter(out, c, { indent: 2 });
|
|
1454
|
+
const step = stepReporter(out, c, { indent: 6 });
|
|
1455
|
+
let opened = false;
|
|
1456
|
+
const open = () => { if (!opened) { out.write(`${paint("bold", "APPLY")}\n`); opened = true; } };
|
|
1457
|
+
const label = (i) => {
|
|
1458
|
+
const target = i.path === null ? i.surface : rel(p.projectDir, i.path);
|
|
1459
|
+
return `${i.kind} ${target}${i.entry ? ` [${i.entry}]` : ""}`;
|
|
1460
|
+
};
|
|
1461
|
+
const stepped = (i) => i.kind === "registration" || i.kind === "layout";
|
|
1462
|
+
return {
|
|
1463
|
+
onItem(i, phase, r) {
|
|
1464
|
+
open();
|
|
1465
|
+
if (stepped(i)) {
|
|
1466
|
+
if (phase === "start") out.write(` ${paint(ACTION_COLOR[i.action] || "cyan", termIcon(c, ACTION_ICON[i.action] || "update"))} ${label(i)}\n`);
|
|
1467
|
+
else if (phase === "abort") step.abort();
|
|
1468
|
+
else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("gray", termIcon(c, "skip"))} ${paint("gray", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
|
|
1469
|
+
else if (r && r.failed) out.write(` ${paint("red", termIcon(c, "fail"))} ${paint("red", "stopped")}\n`);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (phase === "start") item.start(label(i));
|
|
1473
|
+
else if (phase === "abort") item.abort();
|
|
1474
|
+
else item.end(!(r && (r.failed || r.action === "skipped")), r && r.action === "skipped" ? "skipped" : "");
|
|
1475
|
+
},
|
|
1476
|
+
onStep(st, phase, r) {
|
|
1477
|
+
const text = stepLabel(p, st);
|
|
1478
|
+
if (!text) return;
|
|
1479
|
+
if (phase === "start") step.start(text);
|
|
1480
|
+
else step.end(r ? r.ok : true, r && r.kept ? "kept" : "", { kept: Boolean(r && r.kept) });
|
|
1481
|
+
},
|
|
1482
|
+
spawn(inner) {
|
|
1483
|
+
return (bin, argv, o) => {
|
|
1484
|
+
open();
|
|
1485
|
+
step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
|
|
1486
|
+
let r;
|
|
1487
|
+
try { r = inner(bin, argv, o); }
|
|
1488
|
+
catch (e) { step.abort(); throw e; }
|
|
1489
|
+
step.end(!r.error && r.status === 0);
|
|
1490
|
+
return r;
|
|
1491
|
+
};
|
|
1492
|
+
},
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// A step as its APPLY line names it: the verb and the target, short. The
|
|
1497
|
+
// full form — file counts, entry pointers — was in PLAN.
|
|
1498
|
+
function stepLabel(p, st) {
|
|
1499
|
+
const r = (x) => tilde(rel(p.projectDir, x));
|
|
1500
|
+
switch (st.kind) {
|
|
1501
|
+
case "portable-write": return `stage ${r(st.path)}`;
|
|
1502
|
+
case "write": return `write ${r(st.path)}`;
|
|
1503
|
+
case "unregister": return `edit ${r(st.path)} [${st.pointer}.${st.name}]`;
|
|
1504
|
+
case "ensure": return `ensure ${r(st.path)}/`;
|
|
1505
|
+
case "move-state": return `move ${r(st.from)}/ → ${r(st.to)}/`;
|
|
1506
|
+
case "merge-log": return `merge ${r(st.from)} → ${r(st.to)}`;
|
|
1507
|
+
case "move-marker":
|
|
1508
|
+
case "move-binding": return `move ${r(st.from)} → ${r(st.to)}`;
|
|
1509
|
+
case "delete":
|
|
1510
|
+
case "portable-remove":
|
|
1511
|
+
case "remove":
|
|
1512
|
+
case "remove-legacy-launcher":
|
|
1513
|
+
case "rmdir-legacy":
|
|
1514
|
+
case "remove-legacy-runtime": return `remove ${r(st.path)}`;
|
|
1515
|
+
default: return null;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// DONE: what happened, how long it took, what to do next — from the manifests,
|
|
1520
|
+
// never from a harness-id branch — and at most two tips.
|
|
1521
|
+
export function renderDone(r, { paint = (_style, text) => String(text), glyph = (n) => termIcon({ ascii: false }, n), verbose = false } = {}) {
|
|
1522
|
+
const p = r.plan;
|
|
1523
|
+
// What applied: a record that neither failed nor was skipped — a recheck
|
|
1524
|
+
// that found the work already done is not a change.
|
|
1525
|
+
const n = r.applied.filter((a) => !a.failed && !["skipped", "skip"].includes(a.action)).length;
|
|
1526
|
+
const changes = n === 1 ? "1 change" : `${n} changes`;
|
|
1527
|
+
const lines = [""];
|
|
1528
|
+
if (r.failed) {
|
|
1529
|
+
const f = r.failed;
|
|
1530
|
+
const record = r.applied.find((a) => a.failed);
|
|
1531
|
+
const item = record ? p.items.find((i) => i.surface === record.surface && i.path === record.path) : null;
|
|
1532
|
+
const what = f.argv ? "a host command failed" : `${f.step} failed`;
|
|
1533
|
+
lines.push(`${paint("bold", "STOPPED")} — ${changes} applied, then ${what}`);
|
|
1534
|
+
if (f.argv) lines.push(` ${paint("red", glyph("fail"))} $ ${f.argv.join(" ")}${f.status === null || f.status === undefined ? "" : ` exited ${f.status}`}`);
|
|
1535
|
+
else lines.push(` ${paint("red", glyph("fail"))} ${f.step}${item ? ` (${item.kind} ${item.path === null ? item.surface : rel(p.projectDir, item.path)})` : ""}`);
|
|
1536
|
+
if (f.stderr) for (const l of String(f.stderr).split("\n")) lines.push(` ${l}`);
|
|
1537
|
+
if (item && item.kind === "registration") lines.push(" The surfaces planned against its install path were not written; run the verb again once it succeeds.");
|
|
1538
|
+
else lines.push(" Run the verb again once the cause is fixed: its plan starts from what is on disk now.");
|
|
1539
|
+
return lines.join("\n") + "\n";
|
|
1540
|
+
}
|
|
1541
|
+
lines.push(`${paint("bold", "DONE")} — ${changes} in ${duration(r.elapsed || 0)}`);
|
|
1542
|
+
const next = [], tips = [];
|
|
1543
|
+
const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(process.cwd()); } catch { return p.projectDir === process.cwd(); } })();
|
|
1544
|
+
for (const id of p.harnesses) {
|
|
1545
|
+
const m = loadHarness(id);
|
|
1546
|
+
if (!m) continue;
|
|
1547
|
+
const steps = r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || []);
|
|
1548
|
+
for (const s of steps) if (!next.includes(s)) next.push(s);
|
|
1549
|
+
// `plan` previews an install: after an uninstall it would preview the
|
|
1550
|
+
// opposite of what just ran.
|
|
1551
|
+
if (r.verb === "uninstall") continue;
|
|
1552
|
+
const preview = packageCommand(m, "plan", { args: `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}` });
|
|
1553
|
+
if (!tips.some((t) => t.includes(preview))) tips.push(`preview without writing: ${preview}`);
|
|
1554
|
+
}
|
|
1555
|
+
if (!verbose) tips.push("every row's reasoning: add --verbose");
|
|
1556
|
+
for (const s of next) lines.push(` ${paint("cyan", "next")} ${s}`);
|
|
1557
|
+
for (const t of tips.slice(0, 2)) lines.push(` ${paint("gray", "tip")} ${paint("gray", t)}`);
|
|
1558
|
+
return lines.join("\n") + "\n";
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1311
1561
|
// ─── main ──────────────────────────────────────────────────────────────
|
|
1312
1562
|
|
|
1313
1563
|
function usage() {
|
|
1314
1564
|
return [
|
|
1315
|
-
"usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--json]",
|
|
1565
|
+
"usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--verbose] [--json]",
|
|
1316
1566
|
` harnesses: ${harnessIds().join(", ")}`,
|
|
1317
1567
|
" --surface narrows the plan to a surface and the surfaces beneath it (statusline covers statusline_launcher)",
|
|
1318
|
-
" --harness names the harness — and,
|
|
1568
|
+
" --harness names the harness — and, without a terminal, is the confirmation (a terminal is asked); there is no --yes",
|
|
1319
1569
|
].join("\n");
|
|
1320
1570
|
}
|
|
1321
1571
|
|
|
@@ -1344,7 +1594,7 @@ async function main() {
|
|
|
1344
1594
|
const verb = argv[0];
|
|
1345
1595
|
if (!["install", "uninstall", "upgrade", "plan"].includes(verb)) { process.stderr.write(usage() + "\n"); process.exit(2); }
|
|
1346
1596
|
const harnesses = [], surfaces = [];
|
|
1347
|
-
let projectDir = null, json = false, globalRemoval = false, register = true;
|
|
1597
|
+
let projectDir = null, json = false, globalRemoval = false, register = true, verbose = false;
|
|
1348
1598
|
for (let i = 1; i < argv.length; i++) {
|
|
1349
1599
|
const a = argv[i];
|
|
1350
1600
|
const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) { process.stderr.write(`${a} needs a value\n${usage()}\n`); process.exit(2); } return v; };
|
|
@@ -1354,34 +1604,27 @@ async function main() {
|
|
|
1354
1604
|
else if (a === "--global") globalRemoval = true;
|
|
1355
1605
|
else if (a === "--no-register" && verb !== "uninstall") register = false;
|
|
1356
1606
|
else if (a === "--json") json = true;
|
|
1607
|
+
else if (a === "--verbose") verbose = true;
|
|
1357
1608
|
else { process.stderr.write(`unknown argument ${a}\n${usage()}\n`); process.exit(2); }
|
|
1358
1609
|
}
|
|
1359
1610
|
const src = sourceHarness();
|
|
1360
1611
|
projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
|
|
1361
|
-
const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register };
|
|
1612
|
+
const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register, json, verbose, stdin: process.stdin, stdout: process.stdout };
|
|
1362
1613
|
if (verb === "plan") {
|
|
1363
1614
|
const p = plan(projectDir, opts);
|
|
1364
|
-
process.stdout
|
|
1615
|
+
const c = termCaps(process.stdout, process.env);
|
|
1616
|
+
process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, paint: painter(c), icon: (n) => termIcon(c, n), width: c.live ? c.width : 0 }));
|
|
1365
1617
|
process.exit(p.ok && !p.incomplete ? 0 : 1);
|
|
1366
1618
|
}
|
|
1367
|
-
const r = await runVerb(verb, projectDir, opts);
|
|
1619
|
+
const r = await runVerb(verb, projectDir, json ? opts : { ...opts, out: process.stdout });
|
|
1368
1620
|
if (json) {
|
|
1369
1621
|
process.stdout.write(JSON.stringify({ verb, ok: r.plan.ok && !r.plan.incomplete && !r.failed, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports }, null, 2) + "\n");
|
|
1370
|
-
} else {
|
|
1371
|
-
process.stdout.write(r.
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
else if (r.gate.why === "declined") process.stdout.write("nothing written.\n");
|
|
1622
|
+
} else if (r.gate.why === "non-tty") {
|
|
1623
|
+
process.stdout.write(`Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
|
|
1624
|
+
} else if (r.gate.why === "declined") {
|
|
1625
|
+
process.stdout.write("Nothing written.\n");
|
|
1375
1626
|
}
|
|
1376
1627
|
process.exit(r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do") ? 0 : 1);
|
|
1377
1628
|
}
|
|
1378
1629
|
|
|
1379
|
-
// What apply did, for a terminal: the count, and a failed host command with
|
|
1380
|
-
// its stderr — the user sees what the host said, verbatim.
|
|
1381
|
-
export function appliedLine(r) {
|
|
1382
|
-
let s = `applied ${r.applied.length} change(s).\n`;
|
|
1383
|
-
if (r.failed) s += `stopped: ${r.failed.argv ? "$ " + r.failed.argv.join(" ") : r.failed.step} exited ${r.failed.status ?? "without running"}${r.failed.stderr ? "\n " + r.failed.stderr.split("\n").join("\n ") : ""}\n the surfaces planned against its install path were not written; run the verb again once it succeeds.\n`;
|
|
1384
|
-
return s;
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
1630
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|