faberun 0.27.0 → 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/README.md +9 -3
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +31 -4
- package/src/cli/manual.mjs +24 -8
- package/src/cli/plan.mjs +56 -5
- package/src/cli/setup.mjs +5 -0
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +72 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +164 -0
- package/src/contract/index.mjs +8 -5
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +20 -6
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +37 -11
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +117 -18
- package/src/engine/prompts.mjs +58 -4
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +15 -9
- package/src/engine/scheduler.mjs +8 -1
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +29 -0
- package/src/harnesses/claude/index.mjs +98 -1
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +14 -0
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/index.mjs +13 -0
- package/src/harnesses/index.mjs +21 -10
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +32 -9
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/freeze.mjs +2 -0
- package/src/plan/pipeline.mjs +56 -4
- package/src/plan/progress.mjs +101 -0
- package/src/plan/proof-check.mjs +27 -0
- package/src/plan/proof-scope.mjs +1 -1
- package/src/plan/repo-facts.mjs +129 -14
- package/src/plan/rounds.mjs +27 -0
- package/src/plan/spec.mjs +82 -6
- package/src/plan/template.mjs +9 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/repo/worktree-prune.mjs +13 -5
- package/src/repo/worktree.mjs +54 -0
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- 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
|
+
}
|