@ours.network/install 0.17.0 → 0.18.0-nightly.2

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/lib/extras.mjs ADDED
@@ -0,0 +1,351 @@
1
+ // ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
2
+ //
3
+ // The v3 installer keeps harness plugins, ours-fleet, voice setup and the
4
+ // copy-paste hand-off prompt; spec v3's silence about them was an oversight.
5
+ //
6
+ // The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
7
+ // stdio adapter with no unit, and these extra phases preserve that boundary.
8
+ //
9
+ // Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
10
+ // terminal. Every function takes what was observed and returns a plan; the
11
+ // orchestrator (a later PR) is what performs it.
12
+
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { pkgSpec } from './logic.mjs';
15
+
16
+ const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
17
+
18
+ // -----------------------------------------------------------------------------
19
+ // §5 — harness plugins
20
+ // -----------------------------------------------------------------------------
21
+
22
+ export const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
23
+ export const CODEX_MARKET = 'adapt-toolkit/ours-codex-marketplace';
24
+
25
+ export const HARNESSES = [
26
+ { name: 'claude-code', label: 'Claude Code' },
27
+ { name: 'codex', label: 'Codex' },
28
+ { name: 'hermes', label: 'Hermes' },
29
+ ];
30
+
31
+ /**
32
+ * SPEC §5 PROMISES SOMETHING TWO OF THE THREE REGISTRATIONS CANNOT DO.
33
+ *
34
+ * §5: "For any other state directory the installer registers the harness MCP
35
+ * entry with OURS_CONFIG=<state-dir>/config.json in its environment, so the pair
36
+ * travels together." planMcpAttachment already returns exactly that harnessEnv —
37
+ * and the orchestrator only PRINTS it. That is not an oversight to be fixed by
38
+ * wiring it up harder; none of the three registrations can carry a value:
39
+ *
40
+ * Claude Code the marketplace plugin's mcpServers.ours is command+args, with
41
+ * no env key, and `claude plugin install` injects nothing per
42
+ * install.
43
+ * Codex .mcp.json's env_vars is an allowlist of NAMES, not a value map
44
+ * (pinned by packages/codex/test/plugin-package.test.mjs). The
45
+ * value must already be in the ambient environment.
46
+ * Hermes renderConfigBlock is OUR writer, and now emits an `env:` block
47
+ * carrying OURS_CONFIG — so for Hermes the pair is real.
48
+ *
49
+ * So §5's guarantee is ALREADY unmet today for every non-default state
50
+ * directory, silently: the harness attaches to ~/.ours while the operator was
51
+ * told the run targeted somewhere else. The shape is:
52
+ *
53
+ * default state directory today's behaviour, byte for byte.
54
+ * Hermes, non-default real: the pair is handed to ours-hermes-install's
55
+ * invocation and written into ~/.hermes/config.yaml
56
+ * as the ours server's own env block.
57
+ * Claude / Codex, non-def install the plugin (it is still the right plugin)
58
+ * and PRINT the exact line the operator must add.
59
+ * Never claim §5's guarantee in the screen text.
60
+ *
61
+ * Deliberately NOT done: registering a second, user-scoped `ours` MCP server via
62
+ * `claude mcp add --env`. Two `ours` servers in front of one harness, and which
63
+ * wins is not something anyone here has verified.
64
+ */
65
+ export const HARNESS_ENV_SUPPORT = {
66
+ // 'applied' — the pair is genuinely carried into the registration.
67
+ // 'printed' — the operator is told the exact line and nothing is claimed.
68
+ 'claude-code': 'printed',
69
+ codex: 'printed',
70
+ hermes: 'applied',
71
+ };
72
+
73
+ const manualSteps = {
74
+ 'claude-code': (channel) => [
75
+ `/plugin marketplace add ${CLAUDE_MARKET}`,
76
+ '/plugin install ours',
77
+ ],
78
+ codex: (channel) => [
79
+ `codex plugin marketplace add ${CODEX_MARKET}`,
80
+ 'codex plugin add ours@ours-codex-marketplace',
81
+ `npm i -g ${pkgSpec('codex', channel)}`,
82
+ ],
83
+ hermes: (channel) => [
84
+ `npm i -g ${pkgSpec('hermes', channel)}`,
85
+ 'ours-hermes-install',
86
+ ],
87
+ };
88
+
89
+ const driveSteps = {
90
+ 'claude-code': (channel) => [
91
+ ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKET],
92
+ ['claude', 'plugin', 'install', 'ours@ours.network'],
93
+ ],
94
+ codex: (channel) => [
95
+ ['codex', 'plugin', 'marketplace', 'add', CODEX_MARKET],
96
+ ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'],
97
+ // Owner-mandated in v2 and kept: choosing the Codex plugin also installs the
98
+ // ours-codex live launcher, in the same step.
99
+ ['npm', 'i', '-g', pkgSpec('codex', channel)],
100
+ ],
101
+ // Hermes has no driven CLI: nothing here ever calls a `hermes` binary. Its
102
+ // plugin install is npm + ours-hermes-install, which writes ~/.hermes.
103
+ // --skip-daemon because in v3 the daemon is emphatically not ours-mcp's.
104
+ hermes: (channel) => [
105
+ ['npm', 'i', '-g', pkgSpec('hermes', channel)],
106
+ ['ours-hermes-install', '--skip-daemon'],
107
+ ],
108
+ };
109
+
110
+ /**
111
+ * One plan per harness the caller observed.
112
+ *
113
+ * `harnesses` is [{ name, status }] where status is classifyHarnessProbe's
114
+ * verdict ('ok' | 'alias' | 'unsafe' | 'absent'). Hermes is detected by its
115
+ * config directory rather than a CLI, which is the caller's business; this only
116
+ * consumes the verdict.
117
+ *
118
+ * The v2 golden rule is kept intact: 'alias' / 'unsafe' NEVER dead-end. A
119
+ * harness we cannot safely drive still gets its manual steps printed, so the
120
+ * plugin is still installable.
121
+ *
122
+ * `env` is what an invocation must carry, and it is EMPTY unless the harness can
123
+ * genuinely apply it. `envLine` is what the operator is told. `claimsPair` is
124
+ * false whenever the pair is only printed — the screen text renderer reads it so
125
+ * §5's guarantee cannot be claimed where it does not hold.
126
+ */
127
+ export function planHarnessPlugins({
128
+ harnesses = [],
129
+ stateDir,
130
+ isDefaultStateDir,
131
+ channel = 'latest',
132
+ assumeYes = false,
133
+ answers = {},
134
+ } = {}) {
135
+ const config = stateDir ? cfgPath(stateDir) : null;
136
+ return harnesses.map((h) => {
137
+ const name = String(h?.name ?? '');
138
+ const known = HARNESSES.find((k) => k.name === name);
139
+ const label = known?.label ?? name;
140
+ const status = String(h?.status ?? 'absent');
141
+ const support = HARNESS_ENV_SUPPORT[name] ?? 'printed';
142
+ const applies = !isDefaultStateDir && support === 'applied';
143
+
144
+ const base = {
145
+ name,
146
+ label,
147
+ status,
148
+ // Default state directory → today's behaviour, byte for byte: no env
149
+ // anywhere, nothing extra printed, nothing claimed.
150
+ envSupport: isDefaultStateDir ? 'none' : support,
151
+ env: applies ? { OURS_CONFIG: config } : {},
152
+ envLine: isDefaultStateDir || applies ? null : `export OURS_CONFIG=${config}`,
153
+ claimsPair: isDefaultStateDir ? true : applies,
154
+ manual: manualSteps[name] ? manualSteps[name](channel) : [],
155
+ };
156
+
157
+ if (!known) return { ...base, action: 'skip', reason: 'unknown harness' };
158
+ if (status === 'absent') return { ...base, action: 'skip', reason: 'not installed' };
159
+
160
+ const wanted = assumeYes ? true : answers[name] !== false;
161
+ if (!wanted) return { ...base, action: 'skip', reason: 'declined', offerOnRerun: true };
162
+
163
+ if (status === 'ok') return { ...base, action: 'drive', steps: driveSteps[name](channel) };
164
+ return {
165
+ ...base,
166
+ action: 'manual',
167
+ reason: status === 'alias' ? 'installed as an alias, not the real command' : 'found, but did not answer --version',
168
+ };
169
+ });
170
+ }
171
+
172
+ // -----------------------------------------------------------------------------
173
+ // §5 — what the operator has to do BEFORE any of this works
174
+ // -----------------------------------------------------------------------------
175
+
176
+ /**
177
+ * The restart each harness needs before its new plugin is live.
178
+ *
179
+ * A CORRECTNESS PROBLEM WEARING A COSMETIC COSTUME. The ours MCP server is spawned
180
+ * BY the harness, once per session (`ours-mcp proxy` over stdio), so a harness that
181
+ * was already running when its plugin was installed has no ours tools and will not
182
+ * get them until it restarts. v3 said nothing at all about this: the screen read
183
+ * "Everything installed cleanly", the user went back to a running Claude Code,
184
+ * found no ours tools, and concluded the install had failed. The nightly installer
185
+ * prints these hints and v3 dropped them.
186
+ *
187
+ * Derived from what THIS RUN installed rather than from a registry — v3 already
188
+ * knows, and its own summary is a better source than a persisted file that can go
189
+ * stale against reality.
190
+ *
191
+ * The connectors are deliberately absent. The installer runs their
192
+ * `install-service` itself, so their new configuration is already applied; telling
193
+ * someone to restart something that was just restarted for them is noise, and noise
194
+ * in this list is what stops the real lines being read.
195
+ */
196
+ export const HARNESS_RESTART = {
197
+ 'claude-code': 'restart Claude Code',
198
+ codex: 'start a new Codex session (or `ours-codex`)',
199
+ hermes: 'run /reload-mcp in Hermes',
200
+ };
201
+
202
+ export function restartHints(summary = []) {
203
+ const live = (row) => row && (row.state === 'installed' || row.state === 'current');
204
+ const hints = [];
205
+ for (const [name, action] of Object.entries(HARNESS_RESTART)) {
206
+ const row = summary.find((r) => r.key === name);
207
+ if (live(row)) hints.push({ key: name, action });
208
+ }
209
+ // Nothing to restart if no harness got a plugin this run. The MCP server on its
210
+ // own changes nothing a running harness can see, so an "install the MCP server
211
+ // and restart everything" line would be advice with no reason behind it.
212
+ return hints;
213
+ }
214
+
215
+ // -----------------------------------------------------------------------------
216
+ // ours-fleet — installed and staged, never started implicitly
217
+ // -----------------------------------------------------------------------------
218
+
219
+ export const fleetConfigPath = (home) => join(resolve(home), 'fleet.yaml');
220
+
221
+ const yamlString = (value) => JSON.stringify(String(value));
222
+
223
+ /** A conservative, useful first fleet: one coordinator, one watchdog, one loop. */
224
+ export function defaultFleetConfig({ home, stateDir, isDefaultStateDir } = {}) {
225
+ const cwd = resolve(home);
226
+ const config = stateDir ? cfgPath(stateDir) : null;
227
+ const roleEnv = isDefaultStateDir || !config
228
+ ? ''
229
+ : `\n env:\n OURS_CONFIG: ${yamlString(config)}`;
230
+ return `# Generated by ours-install. Review this file before starting Fleet.\n`
231
+ + `defaults:\n`
232
+ + ` harness: codex\n`
233
+ + ` session: acp\n`
234
+ + ` permissions:\n`
235
+ + ` approval: allow\n`
236
+ + ` filesystem: workspace\n`
237
+ + ` unattended: wait\n`
238
+ + ` monitor:\n`
239
+ + ` mode: fleet\n\n`
240
+ + `roles:\n`
241
+ + ` FleetCoordinator:\n`
242
+ + ` identity: FleetCoordinator\n`
243
+ + ` cwd: ${yamlString(cwd)}\n`
244
+ + ` mission: Coordinate durable agent work, delegate bounded tasks, and report material outcomes.\n`
245
+ + ` bio: Fleet coordinator for this host; engage it to assign work or check agent status.\n`
246
+ + ` persona: |\n`
247
+ + ` Keep a concise durable worklog. Preserve user state, verify delegated results,\n`
248
+ + ` and escalate decisions that require new authority. Report material progress only.${roleEnv}\n\n`
249
+ + `watchdogs:\n`
250
+ + ` fleet-health:\n`
251
+ + ` coordinator: FleetCoordinator\n`
252
+ + ` watch: [FleetCoordinator]\n`
253
+ + ` harness: codex\n`
254
+ + ` session: acp\n`
255
+ + ` interval: 10m\n`
256
+ + ` timeout: 8m\n\n`
257
+ + `loops:\n`
258
+ + ` coordinator_health:\n`
259
+ + ` roles: [FleetCoordinator]\n`
260
+ + ` interval: 10m\n`
261
+ + ` initial_delay: 10m\n`
262
+ + ` enabled: true\n`
263
+ + ` prompt: |\n`
264
+ + ` Perform one bounded fleet health pass. Reconcile active work, specialist state,\n`
265
+ + ` declared blockers, and CI. Unstick only safe in-scope work. If nothing material\n`
266
+ + ` changed, complete silently.\n`;
267
+ }
268
+
269
+ export function planFleet({ home, stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
270
+ const config = stateDir ? cfgPath(stateDir) : null;
271
+ const resolvedHome = home ?? (stateDir ? dirname(resolve(stateDir)) : null);
272
+ const path = resolvedHome ? fleetConfigPath(resolvedHome) : null;
273
+ const plan = {
274
+ key: 'fleet',
275
+ label: 'ours-fleet',
276
+ // FOLLOWS THE CHANNEL, and the correction matters more than it looks.
277
+ //
278
+ // This comment used to say the opposite — that ours-fleet lives in its own
279
+ // repo and publishes no nightly tag, so pkgSpec pinned it to @latest. That
280
+ // was true when v3 was written against `main` and it is FALSE here: fleet
281
+ // does publish a nightly dist-tag, and the nightly stack needs the fleet
282
+ // build carrying the SDK integration. A nightly installer that quietly
283
+ // installs stable fleet is precisely the split-brain deployment the channel
284
+ // exists to prevent — the same architecture boundary that made a mixed
285
+ // tg-connector fatal. lib/logic.mjs is the single source of that mapping and
286
+ // this defers to it rather than restating it.
287
+ install: ['npm', 'i', '-g', pkgSpec('fleet', channel)],
288
+ init: ['ours-fleet', 'init'],
289
+ configPath: path,
290
+ config: resolvedHome ? defaultFleetConfig({ home: resolvedHome, stateDir, isDefaultStateDir }) : null,
291
+ writes: path ? [path] : [],
292
+ roleEnv: isDefaultStateDir ? {} : { OURS_CONFIG: config },
293
+ instruction: isDefaultStateDir
294
+ ? `review ${path}, then run ours-fleet doctor and ours-fleet up when you are ready`
295
+ : `review ${path}; its coordinator is pinned to this daemon with OURS_CONFIG=${config}`,
296
+ };
297
+ return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
298
+ }
299
+
300
+ // -----------------------------------------------------------------------------
301
+ // the copy-paste hand-off prompt
302
+ // -----------------------------------------------------------------------------
303
+
304
+ /**
305
+ * buildHandoffPrompt is pure, renumbers automatically, and drops steps for
306
+ * components that were not installed. The default path needs no daemon preamble;
307
+ * a non-default path names the exact config so the assistant cannot guess.
308
+ */
309
+ export function buildHandoffPromptV3({
310
+ identity = false,
311
+ fleet = false,
312
+ telegram = false,
313
+ stateDir = null,
314
+ isDefaultStateDir = true,
315
+ } = {}) {
316
+ const steps = [];
317
+ if (identity) {
318
+ steps.push(
319
+ 'Create my Ours human identity — this is me, the human; my agents act on\n'
320
+ + ' my behalf. Ask me what name others should see, then create it.',
321
+ );
322
+ }
323
+ if (fleet) {
324
+ steps.push(
325
+ 'Review ~/fleet.yaml with me. It already contains a stopped FleetCoordinator,\n'
326
+ + ' a fleet-health watchdog, and a 10-minute coordinator health loop. Ask\n'
327
+ + ' before changing permissions or starting the fleet.',
328
+ );
329
+ }
330
+ if (telegram) {
331
+ steps.push(
332
+ 'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
333
+ + ' and guide me through entering the @BotFather token locally. Register the\n'
334
+ + ' route, then start the connector only after I approve.',
335
+ );
336
+ }
337
+ if (steps.length === 0) return { text: '', empty: true };
338
+
339
+ const preamble = isDefaultStateDir || !stateDir
340
+ ? ''
341
+ : `My ours daemon uses the state directory ${resolve(stateDir)} (config\n${cfgPath(stateDir)}). When you configure anything for me — fleet roles,\nharness environments — set OURS_CONFIG to that path.\n\n`;
342
+
343
+ const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
344
+ const text = preamble
345
+ + 'I just installed the ours.network stack. Please help me finish setup, one\n'
346
+ + 'step at a time, explaining as you go:\n\n'
347
+ + numbered + '\n\n'
348
+ + 'Do these in order, wait for my answers, and tell me if you need anything\n'
349
+ + "from me. Don't assume — ask.";
350
+ return { text, empty: false };
351
+ }
@@ -0,0 +1,158 @@
1
+ // ours-install v3 — the config journal.
2
+ //
3
+ // WHAT THIS IS FOR, IN ONE SENTENCE: v3 could write a config file describing a
4
+ // daemon it then failed to bring up, print one warning line, and go on to say
5
+ // "install complete".
6
+ //
7
+ // The nightly installer does not have that failure. It snapshots every config
8
+ // file before touching it and restores the bytes when the plan does not complete
9
+ // (lib/nightly-install.mjs `snap`/`rollbackSnapshots`), and it says precisely what
10
+ // it could NOT undo. This is that behaviour, carried into v3's shape rather than
11
+ // copied into it.
12
+ //
13
+ // THE LINE THIS DOES NOT CROSS. Package and plugin installs are NOT rolled back.
14
+ // Not because it is hard, but because it is wrong: npm cannot be un-run
15
+ // meaningfully, a newer package is not damage, and nightly draws the line in the
16
+ // same place and says so. What gets restored is exactly the bytes of files this
17
+ // installer rewrote. Everything else is REPORTED.
18
+ //
19
+ // SCOPE IS PER UNIT OF WORK, NOT PER RUN, and that is the one design decision
20
+ // here worth reading twice. v3's rule is that a failed extra never undoes a
21
+ // daemon that came up correctly (lib/orchestrate.mjs `attempt`), so a single
22
+ // run-wide journal restored at the end would fight the architecture: a cowork
23
+ // failure would roll back the daemon's own config. Instead each journal covers
24
+ // ONE write and the step that makes its bytes true — write the connector config,
25
+ // then install its service; if the service does not come up, the config goes
26
+ // back. The pairing is the whole idea, and it is why `snapshot` and `restoreAll`
27
+ // are on an object you hold for the length of one unit of work rather than
28
+ // functions you call anywhere.
29
+
30
+ import { ok, info, warn } from './ui.mjs';
31
+
32
+ /**
33
+ * A journal for one unit of work.
34
+ *
35
+ * `effects.snapshot(path)` and `effects.restore(path, snapshot)` are the seam, so
36
+ * this is testable without a filesystem for the same reason everything else here
37
+ * is. On a dry run nothing was written, so nothing is snapshotted and nothing can
38
+ * be restored — the journal is inert rather than special-cased at each call site.
39
+ */
40
+ export function configJournal(effects, { dryRun = false } = {}) {
41
+ const entries = [];
42
+ return {
43
+ get entries() { return entries.slice(); },
44
+
45
+ /**
46
+ * Record the current bytes of `path` BEFORE it is written. Idempotent per
47
+ * path: the FIRST snapshot is the one that survives, because that is the
48
+ * state the run started from. Snapshotting twice and keeping the second would
49
+ * "restore" to a value this run itself wrote.
50
+ */
51
+ snapshot(path) {
52
+ if (dryRun) return null;
53
+ if (entries.some((e) => e.path === path)) return entries.find((e) => e.path === path).snapshot;
54
+ const snapshot = effects.snapshot(path);
55
+ entries.push({ path, snapshot });
56
+ return snapshot;
57
+ },
58
+
59
+ /**
60
+ * Put every journalled file back, most recent first.
61
+ *
62
+ * A restore that itself fails is reported, never thrown: the caller is already
63
+ * on a failure path, and losing the original error to a second one would hide
64
+ * the thing that actually went wrong. The report says which files went back
65
+ * and which did not, so a partially recovered state is visible rather than
66
+ * implied.
67
+ */
68
+ restoreAll() {
69
+ const restored = [];
70
+ const failed = [];
71
+ for (const entry of entries.slice().reverse()) {
72
+ try {
73
+ effects.restore(entry.path, entry.snapshot);
74
+ // A write that RETURNED is not a file that HOLDS. Read the bytes back.
75
+ const mismatch = verifyRestored(effects, entry);
76
+ if (mismatch) failed.push({ path: entry.path, reason: mismatch });
77
+ else restored.push({ path: entry.path, existed: entry.snapshot?.exists !== false });
78
+ } catch (error) {
79
+ failed.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
80
+ }
81
+ }
82
+ entries.length = 0;
83
+ return { restored, failed };
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Did the restore actually take? Returns null when it did, or the reason it did not.
90
+ *
91
+ * WHY THIS EXISTS. `restore` returning is evidence that a call completed, not that
92
+ * a file holds the bytes it was given. A full disk, a read-only mount, a rename
93
+ * that lands somewhere else, an editor holding the inode — each of those can let
94
+ * the write return and leave the old contents in place. Reporting on the call
95
+ * rather than on the state is how a rollback comes to LIE, and a rollback that
96
+ * lies is worse than one that admits it failed: the operator is told the machine
97
+ * is back where it was and stops looking.
98
+ *
99
+ * The read-back seam is `effects.snapshot`, the same one used to record the bytes
100
+ * in the first place — it already returns exactly the shape being compared, so
101
+ * this needs no second seam and no second notion of what a file's state is.
102
+ *
103
+ * Its own failure is a mismatch, not an exception: if the file cannot be read
104
+ * after being written, that is precisely the case this check exists to catch.
105
+ */
106
+ function verifyRestored(effects, entry) {
107
+ const expected = entry.snapshot;
108
+ let actual;
109
+ try {
110
+ actual = effects.snapshot(entry.path);
111
+ } catch (error) {
112
+ return `restored, but the file could not be read back to confirm it (${error instanceof Error ? error.message : String(error)})`;
113
+ }
114
+ const shouldExist = expected?.exists !== false;
115
+ if (!shouldExist) {
116
+ return actual?.exists ? 'this run created it and the removal did not take — the file is still there' : null;
117
+ }
118
+ if (!actual?.exists) return 'the restore reported success but the file is not there';
119
+ if (actual.text !== expected.text) return 'the restore reported success but the bytes on disk are not the previous ones';
120
+ // Bytes first, permissions second: the contents are back either way, so this
121
+ // must not be reported as "could not restore the config".
122
+ if (expected.mode !== undefined && actual.mode !== undefined && actual.mode !== expected.mode) {
123
+ return `contents restored, but the permissions are ${fmtMode(actual.mode)} and were ${fmtMode(expected.mode)} — check them before re-running`;
124
+ }
125
+ return null;
126
+ }
127
+
128
+ const fmtMode = (mode) => `0${(mode & 0o777).toString(8).padStart(3, '0')}`;
129
+
130
+ /**
131
+ * The report, and it is half the feature.
132
+ *
133
+ * A rollback nobody is told about is indistinguishable from a run that did
134
+ * nothing. Three separate facts, each stated plainly:
135
+ *
136
+ * · which files were put back, and whether "back" meant deleting one this run
137
+ * created — because "restored" and "removed the file we made" are different
138
+ * things to read on a screen;
139
+ * · which could not be put back, if any;
140
+ * · that completed package installs were NOT rolled back. This last line is the
141
+ * honest boundary, and it is nightly's wording rather than a new one.
142
+ */
143
+ export function reportRollback(effects, outcome, { packagesInstalled = false } = {}) {
144
+ const { restored = [], failed = [] } = outcome ?? {};
145
+ if (restored.length === 0 && failed.length === 0) return false;
146
+ for (const item of restored) {
147
+ effects.out(item.existed
148
+ ? ok(`rolled back ${item.path} to its previous contents`)
149
+ : ok(`removed ${item.path} — this run created it and did not finish`));
150
+ }
151
+ for (const item of failed) {
152
+ effects.out(warn(`could NOT roll back ${item.path}: ${item.reason} — inspect it before re-running`));
153
+ }
154
+ if (packagesInstalled) {
155
+ effects.out(info('completed package installs were not rolled back — a newer package is not damage, and npm cannot be un-run'));
156
+ }
157
+ return true;
158
+ }