faberun 0.27.0 → 0.28.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.
Files changed (73) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +5 -0
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +67 -7
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +5 -1
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +15 -9
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +29 -0
  45. package/src/harnesses/claude/index.mjs +98 -1
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +14 -0
  48. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  49. package/src/harnesses/fx/index.mjs +13 -0
  50. package/src/harnesses/index.mjs +21 -10
  51. package/src/harnesses/replay/index.mjs +12 -0
  52. package/src/harnesses/zcode/index.mjs +19 -0
  53. package/src/host/config.mjs +8 -2
  54. package/src/host/environment-report.mjs +85 -0
  55. package/src/host/home.mjs +27 -0
  56. package/src/host/install-registry.mjs +511 -0
  57. package/src/host/preflight.mjs +32 -9
  58. package/src/host/uninstall-roots.mjs +196 -0
  59. package/src/plan/proof-check.mjs +15 -0
  60. package/src/plan/repo-facts.mjs +95 -11
  61. package/src/plan/rounds.mjs +27 -0
  62. package/src/repo/declared-paths.mjs +74 -6
  63. package/src/repo/integrate.mjs +1 -1
  64. package/src/repo/scope-closure.mjs +59 -0
  65. package/src/repo/signal-block.mjs +26 -0
  66. package/src/repo/signal.mjs +9 -24
  67. package/src/repo/workspace.mjs +189 -14
  68. package/src/report/campaign-brief.mjs +4 -0
  69. package/src/report/message.mjs +37 -6
  70. package/src/report/progress.mjs +13 -2
  71. package/src/run/paths.mjs +14 -1
  72. package/src/run/usage.mjs +66 -1
  73. package/src/util.mjs +18 -2
@@ -0,0 +1,542 @@
1
+ /**
2
+ * `faberun uninstall [--dry-run] [--force] [--yes]`: the inverse of every write
3
+ * faberun makes outside a target repository.
4
+ *
5
+ * The command has three jobs and this module keeps them separate. Planning
6
+ * turns the two independent sources of truth into one list:
7
+ *
8
+ * - the install registry, which records the exact path, install-site root and
9
+ * settings pointer/value of every artifact a registry-aware faberun wrote;
10
+ * - `KNOWN_INSTALL_SITES`, the published manifest of where faberun writes, so
11
+ * an artifact an older faberun installed before the registry existed is still
12
+ * found by walking the well-known sites and testing ownership.
13
+ *
14
+ * Applying removes that list through `uninstall-roots.mjs`, which re-resolves
15
+ * every path against the *injected* `HOME`/`FABERUN_HOME` immediately before
16
+ * deletion. Nothing is ever derived from the current working directory, so a
17
+ * target repository is never even a candidate.
18
+ *
19
+ * The one piece of state removal must protect is campaign evidence. A campaign
20
+ * whose ledger was not preserved at its durable, versioned repository location
21
+ * refuses the command unless `--force`, because deleting `$FABERUN_HOME` would
22
+ * delete the only copy.
23
+ *
24
+ * Removal of `$FABERUN_HOME` is confirmed interactively (or by `--yes`; and
25
+ * `--force`, being the operator's explicit consent, skips the prompt too). A
26
+ * removal never touches the effective `HOME` itself: when `$FABERUN_HOME` is
27
+ * the effective home, that would delete the operator's whole home and whatever
28
+ * repositories live under it, so only the individually owned artifacts are
29
+ * removed and the wholesale delete is skipped.
30
+ *
31
+ * The last line names the command that removes the *published package*, which
32
+ * no local removal can undo.
33
+ */
34
+ import { existsSync, readdirSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { createInterface } from "node:readline/promises";
37
+ import { dirname, join } from "node:path";
38
+ import { readInstallEntries, knownInstallSites, removeRecordedSettingsEntries } from "../host/install-registry.mjs";
39
+ import { assertWithinRemovalRoots, canonicalPath, removalRoots, removeWithinRemovalRoots } from "../host/uninstall-roots.mjs";
40
+ import { effectiveHome, faberunHome, projectsDir } from "../host/home.mjs";
41
+ import { unpreservedLedgerCampaigns } from "../campaign/ledger.mjs";
42
+ import { readJson, writeJsonAtomic } from "../run/store.mjs";
43
+ import { packageName } from "../host/package.mjs";
44
+ import { colorLevel, statusToken } from "./brand.mjs";
45
+ import { errorMessage } from "../util.mjs";
46
+
47
+ /** @typedef {import("../host/install-registry.mjs").SettingsEntry} SettingsEntry */
48
+ /** @typedef {import("../host/install-registry.mjs").InstallEntry} InstallEntry */
49
+ /** @typedef {(text: string) => void} Writer */
50
+ /** @typedef {(question: string) => Promise<string>} Asker */
51
+
52
+ /**
53
+ * @typedef {object} PlannedArtifact
54
+ * @property {"skill"|"statusline"|"hook"} kind
55
+ * @property {string} path the absolute path faberun wrote
56
+ * @property {string} root the install-site root that owns `path`
57
+ * @property {string|null} harness the owning harness, when one applies
58
+ * @property {"recorded"|"known"} source where the plan learned about it
59
+ * @property {"tree"|"settings"} mode a whole tree/file versus one settings file
60
+ * @property {"recordedEntries"|"ownedStatusline"|"ownedHooks"} [mutation] how a settings file is edited
61
+ * @property {SettingsEntry[]} entries recorded settings entries, empty otherwise
62
+ */
63
+
64
+ /**
65
+ * @typedef {object} UninstallPlan
66
+ * @property {string} home the `$FABERUN_HOME` this command removes
67
+ * @property {string[]} roots the resolved removal roots the plan is confined to
68
+ * @property {boolean} homeExists whether `$FABERUN_HOME` exists on disk
69
+ * @property {boolean} homeRemovable whether the wholesale home delete may run
70
+ * @property {boolean} homeIsEffective whether `$FABERUN_HOME` is the effective home
71
+ * @property {PlannedArtifact[]} artifacts the owned artifacts outside the home
72
+ * @property {string[]} unpreservedLedgers `<project>/<campaign>` ids without a durable ledger
73
+ */
74
+
75
+ /**
76
+ * @typedef {object} UninstallOptions
77
+ * @property {boolean} [dryRun]
78
+ * @property {boolean} [force]
79
+ * @property {boolean} [yes]
80
+ * @property {NodeJS.ProcessEnv} [env]
81
+ * @property {boolean} [isTTY]
82
+ * @property {Asker} [ask]
83
+ * @property {Writer} [stdout]
84
+ * @property {Writer} [stderr]
85
+ */
86
+
87
+ /**
88
+ * The `$FABERUN_HOME` uninstall targets, derived from the injected environment
89
+ * the same way `removalRoots` derives it. `faberunHome` may fall back to
90
+ * `os.homedir()`, which is exactly the fallback a removal scoped to a throwaway
91
+ * home must not take, so the effective home is preferred and the fallback is
92
+ * reached only when the environment names no home at all (and then removal
93
+ * roots are empty, so nothing can be deleted).
94
+ *
95
+ * @param {NodeJS.ProcessEnv} [env]
96
+ * @returns {string}
97
+ */
98
+ export function removalHome(env = process.env) {
99
+ const configured = env.FABERUN_HOME;
100
+ if (typeof configured === "string" && configured) return configured;
101
+ const effective = effectiveHome(env);
102
+ if (effective !== null) return join(effective, ".faberun");
103
+ return faberunHome(env);
104
+ }
105
+
106
+ /**
107
+ * Build the union of the recorded registry and the known install sites, plus
108
+ * the ledger refusal and the home safety facts. Pure: it reads the registry,
109
+ * the manifest-derived settings files and the campaign records, but changes
110
+ * nothing.
111
+ *
112
+ * @param {NodeJS.ProcessEnv} [env]
113
+ * @returns {UninstallPlan}
114
+ */
115
+ export function planUninstall(env = process.env) {
116
+ const home = removalHome(env);
117
+ const roots = removalRoots(env);
118
+ // Force every registry/manifest read at the same home the removal roots name,
119
+ // so a thrown-away home never reads the operator's real registry.
120
+ const scopedEnv = { ...env, FABERUN_HOME: home };
121
+ const registry = readInstallEntries(scopedEnv);
122
+ // Skill and settings sites hang off the *user* home (`skills.mjs`'s
123
+ // `skillHome`), never off `$FABERUN_HOME`; the two are the same tree only
124
+ // when an installation chose to point them at one directory.
125
+ const userHome = effectiveHome(env) ?? homedir();
126
+ const sites = knownInstallSites(userHome);
127
+ /** @type {PlannedArtifact[]} */
128
+ const artifacts = [];
129
+ const seen = new Set();
130
+
131
+ /** @param {PlannedArtifact} artifact */
132
+ const add = (artifact) => {
133
+ const key = `${artifact.kind}:${artifact.mode}:${canonicalPath(artifact.path)}`;
134
+ if (seen.has(key)) return;
135
+ seen.add(key);
136
+ artifacts.push(artifact);
137
+ };
138
+
139
+ const recordedSkills = new Set(registry.filter((entry) => entry.kind === "skill").map((entry) => canonicalPath(entry.path)));
140
+ const recordedStatusFiles = new Set(registry.filter((entry) => entry.kind === "statusline").map((entry) => canonicalPath(entry.path)));
141
+ const recordedHookFiles = new Set(
142
+ registry.filter((entry) => entry.kind === "hook" && entry.entries.length > 0).map((entry) => canonicalPath(entry.path)),
143
+ );
144
+ const recordedHookTrees = new Set(
145
+ registry.filter((entry) => entry.kind === "hook" && entry.entries.length === 0).map((entry) => canonicalPath(entry.path)),
146
+ );
147
+
148
+ for (const entry of registry) {
149
+ if (entry.kind === "skill") {
150
+ add({ kind: "skill", path: entry.path, root: entry.root, harness: entry.harness, source: "recorded", mode: "tree", entries: [] });
151
+ } else if (entry.kind === "statusline") {
152
+ add({
153
+ kind: "statusline", path: entry.path, root: entry.root, harness: entry.harness,
154
+ source: "recorded", mode: "settings", mutation: "recordedEntries", entries: entry.entries,
155
+ });
156
+ } else if (entry.kind === "hook") {
157
+ if (entry.entries.length > 0) {
158
+ add({
159
+ kind: "hook", path: entry.path, root: entry.root, harness: entry.harness,
160
+ source: "recorded", mode: "settings", mutation: "recordedEntries", entries: entry.entries,
161
+ });
162
+ } else {
163
+ add({ kind: "hook", path: entry.path, root: entry.root, harness: entry.harness, source: "recorded", mode: "tree", entries: [] });
164
+ }
165
+ }
166
+ // A recorded `config` lives inside `$FABERUN_HOME`, which is removed whole.
167
+ }
168
+
169
+ // A skill is the faberun-named directory inside a harness's skills site. The
170
+ // manifest names the parent, so the owned leaf is derived, never guessed.
171
+ for (const site of sites.skills) {
172
+ const path = join(site.path, "faberun");
173
+ if (recordedSkills.has(canonicalPath(path))) continue;
174
+ if (!existsSync(path)) continue;
175
+ add({ kind: "skill", path, root: site.path, harness: site.harness, source: "known", mode: "tree", entries: [] });
176
+ }
177
+
178
+ // The status line is one settings key. A pre-registry install is recognized
179
+ // by the value still pointing at faberun, so an operator's own replacement is
180
+ // left alone.
181
+ for (const site of sites.settings) {
182
+ if (recordedStatusFiles.has(canonicalPath(site.path))) continue;
183
+ if (!existsSync(site.path)) continue;
184
+ if (!settingsHasOwnedStatusline(site.path, site.key)) continue;
185
+ add({
186
+ kind: "statusline", path: site.path, root: dirname(site.path), harness: site.harness,
187
+ source: "known", mode: "settings", mutation: "ownedStatusline", entries: [],
188
+ });
189
+ }
190
+
191
+ // A hook may be registered as entries inside the settings file...
192
+ for (const site of sites.hooks) {
193
+ if (site.key === null) continue;
194
+ if (recordedHookFiles.has(canonicalPath(site.path))) continue;
195
+ if (!existsSync(site.path)) continue;
196
+ if (!settingsHasOwnedHooks(site.path)) continue;
197
+ add({
198
+ kind: "hook", path: site.path, root: dirname(site.path), harness: site.harness,
199
+ source: "known", mode: "settings", mutation: "ownedHooks", entries: [],
200
+ });
201
+ }
202
+
203
+ // ...or as a script faberun dropped in a hooks directory. Only faberun-named
204
+ // entries are candidates, so a neighbour's hook script survives.
205
+ for (const site of sites.hooks) {
206
+ if (site.key !== null) continue;
207
+ if (!existsSync(site.path)) continue;
208
+ for (const path of faberunNamedEntries(site.path)) {
209
+ if (recordedHookTrees.has(canonicalPath(path))) continue;
210
+ add({ kind: "hook", path, root: site.path, harness: site.harness, source: "known", mode: "tree", entries: [] });
211
+ }
212
+ }
213
+
214
+ const effective = effectiveHome(env);
215
+ const homeExists = existsSync(home);
216
+ const homeIsEffective = effective !== null && canonicalPath(effective) === canonicalPath(home);
217
+ return {
218
+ home,
219
+ roots,
220
+ homeExists,
221
+ homeRemovable: homeExists && !homeIsEffective,
222
+ homeIsEffective,
223
+ artifacts,
224
+ unpreservedLedgers: unpreservedLedgers(home),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * The campaigns under every project in `home` whose ledger is not preserved at
230
+ * its durable, versioned repository location, as `<project>/<campaign>`. A
231
+ * runs directory that does not exist is simply skipped.
232
+ *
233
+ * @param {string} home
234
+ * @returns {string[]}
235
+ */
236
+ function unpreservedLedgers(home) {
237
+ /** @type {string[]} */
238
+ const found = [];
239
+ let projects;
240
+ try {
241
+ projects = readdirSync(projectsDir(home), { withFileTypes: true });
242
+ } catch {
243
+ return found;
244
+ }
245
+ for (const project of projects) {
246
+ if (!project.isDirectory()) continue;
247
+ const runsDir = join(projectsDir(home), project.name, "runs");
248
+ if (!existsSync(runsDir)) continue;
249
+ for (const campaign of unpreservedLedgerCampaigns(runsDir)) found.push(`${project.name}/${campaign}`);
250
+ }
251
+ return [...new Set(found)].sort();
252
+ }
253
+
254
+ /**
255
+ * The children of a hooks directory whose name marks them as faberun's, so the
256
+ * operators's own hook scripts are never candidates.
257
+ *
258
+ * @param {string} directory
259
+ * @returns {string[]}
260
+ */
261
+ function faberunNamedEntries(directory) {
262
+ /** @type {string[]} */
263
+ const paths = [];
264
+ /** @type {string[]} */
265
+ let names;
266
+ try {
267
+ names = readdirSync(directory);
268
+ } catch {
269
+ return paths;
270
+ }
271
+ for (const name of names) {
272
+ if (/^faberun(?:[-._]|$)/iu.test(name)) paths.push(join(directory, name));
273
+ }
274
+ return paths;
275
+ }
276
+
277
+ /**
278
+ * Whether the settings file carries a status-line entry still pointing at
279
+ * faberun. A faberun-owned value is one whose serialization names the tool, so
280
+ * `statusLine` alone is not proof: an operator who replaced it owns it now.
281
+ *
282
+ * @param {string} path
283
+ * @param {string|null} key
284
+ * @returns {boolean}
285
+ */
286
+ function settingsHasOwnedStatusline(path, key) {
287
+ if (key !== "statusLine") return false;
288
+ const settings = readSettingsFile(path);
289
+ return referencesFaberun(settings[key]);
290
+ }
291
+
292
+ /**
293
+ * Whether the settings file carries at least one hook entry that references
294
+ * faberun.
295
+ *
296
+ * @param {string} path
297
+ * @returns {boolean}
298
+ */
299
+ function settingsHasOwnedHooks(path) {
300
+ const settings = readSettingsFile(path);
301
+ const hooks = settings.hooks;
302
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
303
+ const record = /** @type {Record<string, unknown>} */ (hooks);
304
+ return Object.values(record).some((entries) => Array.isArray(entries) && entries.some((entry) => referencesFaberun(entry)));
305
+ }
306
+
307
+ /**
308
+ * A settings document's JSON value, or an empty object when it is absent,
309
+ * unreadable or not an object. The settings file is the operator's; only the
310
+ * entries faberun recorded or still owns are ever taken back.
311
+ *
312
+ * @param {string} path
313
+ * @returns {Record<string, unknown>}
314
+ */
315
+ function readSettingsFile(path) {
316
+ try {
317
+ const parsed = readJson(path);
318
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
319
+ } catch {
320
+ return {};
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Whether a JSON value mentions faberun anywhere, the ownership test a
326
+ * pre-registry statusline or hook must pass.
327
+ *
328
+ * @param {unknown} value
329
+ * @returns {boolean}
330
+ */
331
+ function referencesFaberun(value) {
332
+ try {
333
+ const text = JSON.stringify(value);
334
+ return typeof text === "string" && text.toLowerCase().includes("faberun");
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Remove the recorded settings entries and the known-site entries a settings
342
+ * artifact names. The file is only rewritten when the edit changed it, so an
343
+ * operator's later edit that no longer matches is left byte-for-byte alone.
344
+ *
345
+ * @param {PlannedArtifact} artifact
346
+ * @returns {void}
347
+ */
348
+ function applySettings(artifact) {
349
+ const settings = readSettingsFile(artifact.path);
350
+ const before = JSON.stringify(settings);
351
+ /** @type {unknown} */
352
+ let next = settings;
353
+ if (artifact.mutation === "recordedEntries") {
354
+ next = removeRecordedSettingsEntries(settings, artifact.entries);
355
+ } else if (artifact.mutation === "ownedStatusline") {
356
+ const copy = structuredClone(settings);
357
+ if (referencesFaberun(copy.statusLine)) delete copy.statusLine;
358
+ next = copy;
359
+ } else {
360
+ const copy = structuredClone(settings);
361
+ stripOwnedHooks(copy);
362
+ next = copy;
363
+ }
364
+ if (JSON.stringify(next) !== before) writeJsonAtomic(artifact.path, next);
365
+ }
366
+
367
+ /**
368
+ * Remove every hook entry whose serialization references faberun, then any
369
+ * event list and the `hooks` key left empty. The operator's own hook entries
370
+ * keep their place. Returns whether anything changed.
371
+ *
372
+ * @param {Record<string, unknown>} settings
373
+ * @returns {boolean}
374
+ */
375
+ function stripOwnedHooks(settings) {
376
+ const hooks = settings.hooks;
377
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
378
+ const record = /** @type {Record<string, unknown>} */ (hooks);
379
+ let changed = false;
380
+ for (const event of Object.keys(record)) {
381
+ const entries = record[event];
382
+ if (!Array.isArray(entries)) continue;
383
+ const kept = entries.filter((entry) => !referencesFaberun(entry));
384
+ if (kept.length !== entries.length) changed = true;
385
+ if (kept.length === 0) {
386
+ delete record[event];
387
+ changed = true;
388
+ } else {
389
+ record[event] = kept;
390
+ }
391
+ }
392
+ if (Object.keys(record).length === 0) {
393
+ delete settings.hooks;
394
+ changed = true;
395
+ }
396
+ return changed;
397
+ }
398
+
399
+ /**
400
+ * Apply a plan: every artifact through the root-confined removal, then
401
+ * `$FABERUN_HOME` itself. A failure is collected, not thrown, so one refused
402
+ * link cannot strand the rest of the cleanup. Returns the failure messages.
403
+ *
404
+ * @param {UninstallPlan} plan
405
+ * @param {NodeJS.ProcessEnv} [env]
406
+ * @returns {string[]}
407
+ */
408
+ export function applyUninstall(plan, env = process.env) {
409
+ /** @type {string[]} */
410
+ const failures = [];
411
+ for (const artifact of plan.artifacts) {
412
+ try {
413
+ // The registry is data, not a promise: a recorded path outside the
414
+ // effective roots is refused exactly like a known-site one.
415
+ assertWithinRemovalRoots(artifact.path, plan.roots);
416
+ if (artifact.mode === "settings") applySettings(artifact);
417
+ else removeWithinRemovalRoots(artifact.path, env);
418
+ } catch (error) {
419
+ failures.push(`${artifact.path} · ${errorMessage(error)}`);
420
+ }
421
+ }
422
+ if (plan.homeRemovable) {
423
+ try {
424
+ removeWithinRemovalRoots(plan.home, env);
425
+ } catch (error) {
426
+ failures.push(`${plan.home} · ${errorMessage(error)}`);
427
+ }
428
+ }
429
+ return failures;
430
+ }
431
+
432
+ /**
433
+ * The whole command: plan, refuse an unsafe ledger, list, confirm, apply, and
434
+ * close with the published-package removal.
435
+ *
436
+ * @param {UninstallOptions} [options]
437
+ * @returns {Promise<number>} the process exit code
438
+ */
439
+ export async function uninstallCommand(options = {}) {
440
+ const env = options.env ?? process.env;
441
+ const stdout = options.stdout ?? ((text) => process.stdout.write(text));
442
+ const stderr = options.stderr ?? ((text) => process.stderr.write(text));
443
+ const isTTY = options.isTTY ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
444
+ const level = colorLevel(env, isTTY);
445
+ const plan = planUninstall(env);
446
+
447
+ if (plan.unpreservedLedgers.length > 0 && options.force !== true) {
448
+ stderr(`${statusToken("fail", level)} uninstall · unpreserved campaign ledger · ${plan.unpreservedLedgers.join(", ")}\n`);
449
+ stderr("preserve each ledger under its repository (`faberun campaign close --ledger-in-repo`) or re-run with --force\n");
450
+ return 1;
451
+ }
452
+ if (plan.unpreservedLedgers.length > 0) {
453
+ stderr(`${statusToken("warn", level)} uninstall · removing an unpreserved campaign ledger under --force · ${plan.unpreservedLedgers.join(", ")}\n`);
454
+ }
455
+ if (plan.homeExists && plan.homeIsEffective) {
456
+ stderr(`${statusToken("warn", level)} uninstall · $FABERUN_HOME is the effective home; removing the owned artifacts only\n`);
457
+ }
458
+
459
+ for (const artifact of plan.artifacts) {
460
+ stdout(`uninstall · ${artifact.kind} · ${artifact.path}\n`);
461
+ }
462
+ if (plan.homeExists) {
463
+ stdout(`uninstall · FABERUN_HOME${plan.homeRemovable ? "" : " (kept)"} · ${plan.home}\n`);
464
+ }
465
+ const total = plan.artifacts.length + (plan.homeRemovable ? 1 : 0);
466
+
467
+ if (options.dryRun === true) {
468
+ stdout(`${statusToken("ok", level)} uninstall · dry run · would remove ${total} artifact${total === 1 ? "" : "s"}\n`);
469
+ printPackageHint(stdout, level);
470
+ return 0;
471
+ }
472
+ if (total === 0) {
473
+ stdout(`${statusToken("ok", level)} uninstall · nothing to remove\n`);
474
+ return 0;
475
+ }
476
+
477
+ // `--force` and `--yes` are both explicit consent: the operator who asked to
478
+ // remove an unpreserved ledger without being prompted has already decided not
479
+ // to be prompted about the rest either.
480
+ if (options.yes !== true && options.force !== true) {
481
+ const confirmed = await confirmRemoval(options, plan, isTTY);
482
+ if (confirmed === null) {
483
+ stderr(`${statusToken("fail", level)} uninstall · confirmation required; re-run with --yes or --force\n`);
484
+ return 1;
485
+ }
486
+ if (!confirmed) {
487
+ stderr(`${statusToken("fail", level)} uninstall · aborted; nothing removed\n`);
488
+ return 1;
489
+ }
490
+ }
491
+
492
+ const failures = applyUninstall(plan, env);
493
+ if (failures.length > 0) {
494
+ for (const failure of failures) stderr(`${statusToken("fail", level)} uninstall · ${failure}\n`);
495
+ return 1;
496
+ }
497
+ stdout(`${statusToken("ok", level)} uninstall · removed ${total} artifact${total === 1 ? "" : "s"}\n`);
498
+ printPackageHint(stdout, level);
499
+ return 0;
500
+ }
501
+
502
+ /**
503
+ * Ask before the destructive part. `null` means no one could be asked (not a
504
+ * terminal and no injected asker), which is a refusal, never a default yes.
505
+ *
506
+ * @param {UninstallOptions} options
507
+ * @param {UninstallPlan} plan
508
+ * @param {boolean} isTTY
509
+ * @returns {Promise<boolean|null>}
510
+ */
511
+ async function confirmRemoval(options, plan, isTTY) {
512
+ if (options.ask === undefined && !isTTY) return null;
513
+ const asker = options.ask === undefined
514
+ ? makeAsker()
515
+ : { ask: options.ask, close: () => {} };
516
+ try {
517
+ const target = plan.homeRemovable ? ` and FABERUN_HOME ${plan.home}` : "";
518
+ const answer = (await asker.ask(`Remove ${plan.artifacts.length} faberun artifact(s)${target}? [y/N] `)).trim().toLowerCase();
519
+ return answer === "y" || answer === "yes";
520
+ } finally {
521
+ asker.close();
522
+ }
523
+ }
524
+
525
+ /**
526
+ * @returns {{ask: Asker, close: () => void}}
527
+ */
528
+ function makeAsker() {
529
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
530
+ return { ask: (question) => rl.question(question), close: () => rl.close() };
531
+ }
532
+
533
+ /**
534
+ * The one thing a local removal cannot undo: the published package itself.
535
+ *
536
+ * @param {Writer} stdout
537
+ * @param {number} level
538
+ * @returns {void}
539
+ */
540
+ function printPackageHint(stdout, level) {
541
+ stdout(`${statusToken("ok", level)} uninstall · remove the published package · npm uninstall -g ${packageName()}\n`);
542
+ }