@ours.network/install 0.17.0-nightly.9 → 0.18.0-nightly.1
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 +37 -315
- package/install.mjs +22 -1181
- package/lib/components.mjs +18 -9
- package/lib/detect.mjs +8 -21
- package/lib/effects.mjs +32 -14
- package/lib/extras.mjs +5 -85
- package/lib/journal.mjs +46 -1
- package/lib/logic.mjs +13 -85
- package/lib/orchestrate-uninstall.mjs +105 -19
- package/lib/orchestrate.mjs +134 -118
- package/lib/plan.mjs +40 -8
- package/lib/target.mjs +38 -5
- package/lib/uninstall.mjs +285 -16
- package/package.json +1 -1
- package/uninstall.mjs +22 -225
- package/uninstall.sh +13 -4
- package/lib/nightly-install.mjs +0 -739
- package/lib/nightly-uninstall.mjs +0 -396
- package/lib/profiles.mjs +0 -524
package/lib/plan.mjs
CHANGED
|
@@ -94,10 +94,9 @@ export function classifyUnit(text) {
|
|
|
94
94
|
* informational line naming it
|
|
95
95
|
* { action: 'refuse', exitCode: 2, … } — unknown unit, or unusable state dir
|
|
96
96
|
*
|
|
97
|
-
* Adoption of a legacy unit is SILENT
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* is not a warning.
|
|
97
|
+
* Adoption of a legacy unit is SILENT: no prompt, no question, so an upgrading
|
|
98
|
+
* user has no manual step. The one line of output exists so the replacement is not
|
|
99
|
+
* literally invisible; it does not block and it is not a warning.
|
|
101
100
|
*
|
|
102
101
|
* BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
|
|
103
102
|
* safety boundary between a stranger's file and a silent rewrite. It must stay
|
|
@@ -110,7 +109,40 @@ export function classifyUnit(text) {
|
|
|
110
109
|
* without a question in front of it — it is what keeps --force from becoming a
|
|
111
110
|
* default that spreads to the other cases.
|
|
112
111
|
*/
|
|
113
|
-
export function planServiceInstall({ stateDir, home, readText }) {
|
|
112
|
+
export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
|
|
113
|
+
// THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
|
|
114
|
+
//
|
|
115
|
+
// `ours daemon install-service` in @ours.network/cli builds its adapter with
|
|
116
|
+
// `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
|
|
117
|
+
// factory's FIRST line is
|
|
118
|
+
// if (deps.platform !== 'linux') throw new Error('service management is not
|
|
119
|
+
// supported on <platform>; use an external launcher for `ours daemon serve`')
|
|
120
|
+
// — verified by reading the published 0.4.1 tarball, which contains zero
|
|
121
|
+
// occurrences of launchd, LaunchAgents or plist.
|
|
122
|
+
//
|
|
123
|
+
// So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
|
|
124
|
+
// was told their platform was supported, watched the CLI install, the config
|
|
125
|
+
// write and the daemon start, and then got an exception and a rolled-back
|
|
126
|
+
// config. Skipping the step leaves them a working daemon and one true sentence
|
|
127
|
+
// instead — which is the whole of this change.
|
|
128
|
+
//
|
|
129
|
+
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
130
|
+
// package can install a launchd agent, and pretending otherwise by writing a
|
|
131
|
+
// plist ourselves would put a second service implementation in a second repo.
|
|
132
|
+
// `ours daemon install-service` supports Linux/systemd only: its adapter throws
|
|
133
|
+
// for any other platform, so calling it would fail the run rather than degrade.
|
|
134
|
+
// Skip it and say so; the daemon itself is unaffected.
|
|
135
|
+
if (platform && platform !== 'linux') {
|
|
136
|
+
return {
|
|
137
|
+
action: 'unsupported',
|
|
138
|
+
platform,
|
|
139
|
+
reason: 'no-service-manager',
|
|
140
|
+
message: platform === 'darwin'
|
|
141
|
+
? 'installing a boot service is not available on macOS — the ours CLI can only manage a Linux user systemd service'
|
|
142
|
+
: `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
|
|
143
|
+
manual: ['ours', 'daemon', 'serve', '--config'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
114
146
|
const derived = unitPathForStateDir(stateDir, home);
|
|
115
147
|
if (!derived.ok) {
|
|
116
148
|
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
|
@@ -180,10 +212,10 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
180
212
|
// --json so the caller can read back whether the unit actually CHANGED. The
|
|
181
213
|
// CLI owns that byte-comparison, and an installer that guessed at it would
|
|
182
214
|
// report "nothing changed" on a run that rewrote a unit.
|
|
215
|
+
// --json so the caller can read back whether the unit actually CHANGED rather
|
|
216
|
+
// than assuming it did. --force is reachable only through the explicit argument
|
|
217
|
+
// above, and the CLI refuses to overwrite a unit it did not write.
|
|
183
218
|
const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
|
|
184
|
-
// --force is reachable ONLY through this explicit argument, which the
|
|
185
|
-
// orchestrator passes only after the user answered yes to legacyReplacePrompt.
|
|
186
|
-
// It is never a default and never appears in a plan.
|
|
187
219
|
if (adoptLegacyUnit) cmd.push('--force');
|
|
188
220
|
return cmd;
|
|
189
221
|
}
|
package/lib/target.mjs
CHANGED
|
@@ -31,6 +31,33 @@ export const INSTALL_RESERVED_PORTS = [3051, 3052];
|
|
|
31
31
|
// The CLI-owned PID record that proves a daemon belongs to a state directory
|
|
32
32
|
// even when nothing is recorded in its config (spec §1).
|
|
33
33
|
export const CLI_PID_RECORD = 'ours-cli-daemon.json';
|
|
34
|
+
// The SAME record, written by a different daemon. `ours daemon start` writes
|
|
35
|
+
// ours-cli-daemon.json; prerelease ours-mcp wrote daemon.pid (packages/core
|
|
36
|
+
// cli.ts PID_PATH). BOTH are read, and neither replaces the other: a machine
|
|
37
|
+
// installed yesterday has the first and a machine installed today has the
|
|
38
|
+
// second, and detection that goes blind on either one concludes "no daemon
|
|
39
|
+
// here" and creates a SECOND daemon on a state directory that already has one.
|
|
40
|
+
// Two writers on one state_data.bin is the corruption case this lookup exists
|
|
41
|
+
// to prevent, so the lookup has to know both spellings.
|
|
42
|
+
export const MCP_PID_RECORD = 'daemon.pid';
|
|
43
|
+
export const PID_RECORDS = [CLI_PID_RECORD, MCP_PID_RECORD];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The port a PID record names, whichever daemon wrote it.
|
|
47
|
+
*
|
|
48
|
+
* ours-cli-daemon.json is JSON with a `port`. daemon.pid is a bare pid — no
|
|
49
|
+
* port — so it proves a daemon EXISTS for this state directory without saying
|
|
50
|
+
* where. That distinction is the whole reason this returns both fields.
|
|
51
|
+
*/
|
|
52
|
+
export function readPidRecords(target, readJson, readText) {
|
|
53
|
+
const record = readJson(join(target, CLI_PID_RECORD));
|
|
54
|
+
const port = record && typeof record.port === 'number' && Number.isFinite(record.port) ? record.port : null;
|
|
55
|
+
const rawPid = typeof readText === 'function' ? readText(join(target, MCP_PID_RECORD)) : null;
|
|
56
|
+
const pid = rawPid !== null && rawPid !== undefined && /^\s*\d+\s*$/.test(String(rawPid))
|
|
57
|
+
? Number.parseInt(String(rawPid).trim(), 10)
|
|
58
|
+
: null;
|
|
59
|
+
return { port, pid };
|
|
60
|
+
}
|
|
34
61
|
export const DAEMON_CONFIG = 'config.json';
|
|
35
62
|
|
|
36
63
|
export class InstallUsageError extends Error {
|
|
@@ -192,7 +219,7 @@ export function classifyProbe(probe, targetStateDir) {
|
|
|
192
219
|
* is still no I/O here, and `await` on a non-promise is the same value back, so
|
|
193
220
|
* every existing fake keeps working unchanged.
|
|
194
221
|
*/
|
|
195
|
-
export async function findDaemon({ stateDir, probe, readJson }) {
|
|
222
|
+
export async function findDaemon({ stateDir, probe, readJson, readText }) {
|
|
196
223
|
const target = resolve(stateDir);
|
|
197
224
|
const config = readJson(join(target, DAEMON_CONFIG));
|
|
198
225
|
const recordedPort = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
|
|
@@ -204,8 +231,8 @@ export async function findDaemon({ stateDir, probe, readJson }) {
|
|
|
204
231
|
if (byConfig.kind === 'present') return { ...byConfig, port: first, via: 'config', config };
|
|
205
232
|
if (byConfig.kind === 'foreign' && !guessed) return { ...byConfig, port: first, via: 'config', config };
|
|
206
233
|
|
|
207
|
-
const
|
|
208
|
-
const recorded =
|
|
234
|
+
const records = readPidRecords(target, readJson, readText);
|
|
235
|
+
const recorded = records.port;
|
|
209
236
|
if (recorded !== null && recorded !== first) {
|
|
210
237
|
const byRecord = classifyProbe(await probe(recorded), target);
|
|
211
238
|
if (byRecord.kind === 'present') return { ...byRecord, port: recorded, via: 'pid-record', config };
|
|
@@ -229,6 +256,12 @@ export async function findDaemon({ stateDir, probe, readJson }) {
|
|
|
229
256
|
// daemon owns THIS directory. A foreign answer on a port the directory
|
|
230
257
|
// actually RECORDED still refuses, because there the operator's own file said
|
|
231
258
|
// the daemon was there and something else is.
|
|
259
|
+
// A bare pid record with no port still says a daemon OWNS this directory. It
|
|
260
|
+
// cannot say where, so this is not "present" — but it is a reason to report a
|
|
261
|
+
// stale record rather than silently create a second daemon beside it.
|
|
262
|
+
if (records.pid !== null && byConfig.kind !== 'present') {
|
|
263
|
+
return { kind: 'absent', reason: 'a daemon pid record exists but nothing answers', stalePidRecord: first, port: first, via: 'config', config };
|
|
264
|
+
}
|
|
232
265
|
if (byConfig.kind === 'foreign') {
|
|
233
266
|
return {
|
|
234
267
|
kind: 'absent',
|
|
@@ -265,9 +298,9 @@ export async function findDaemon({ stateDir, probe, readJson }) {
|
|
|
265
298
|
* if it is occupied that is a refusal, not a reason to shift. Only a derived port
|
|
266
299
|
* is searched, from 3050 upward, skipping the reserved defaults.
|
|
267
300
|
*/
|
|
268
|
-
export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, isTaken }) {
|
|
301
|
+
export async function resolveTarget({ stateDir, port = null, portExplicit = false, probe, readJson, readText, isTaken }) {
|
|
269
302
|
const target = resolve(stateDir);
|
|
270
|
-
const found = await findDaemon({ stateDir: target, probe, readJson });
|
|
303
|
+
const found = await findDaemon({ stateDir: target, probe, readJson, readText });
|
|
271
304
|
|
|
272
305
|
if (found.kind === 'foreign') {
|
|
273
306
|
return {
|
package/lib/uninstall.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { join, resolve } from 'node:path';
|
|
18
18
|
import { unitNameForStateDir } from './plan.mjs';
|
|
19
19
|
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
20
|
+
import { canonHarnesses } from './logic.mjs';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Does this directory even look like an ours state directory?
|
|
@@ -170,15 +171,16 @@ export function planComponentDetach(key, existing) {
|
|
|
170
171
|
export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
171
172
|
const dir = resolve(stateDir);
|
|
172
173
|
const unit = unitNameForStateDir(dir);
|
|
174
|
+
const service = {
|
|
175
|
+
id: 'service',
|
|
176
|
+
unit: unit.ok ? unit.unit : null,
|
|
177
|
+
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', join(dir, 'config.json')],
|
|
178
|
+
note: 'removes the unit managed by the ours CLI',
|
|
179
|
+
};
|
|
173
180
|
return [
|
|
174
|
-
|
|
175
|
-
id: 'service',
|
|
176
|
-
unit: unit.ok ? unit.unit : null,
|
|
177
|
-
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
|
|
178
|
-
note: 'refuses a unit not marked as CLI-managed',
|
|
179
|
-
},
|
|
181
|
+
service,
|
|
180
182
|
cliStartedIt
|
|
181
|
-
? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
|
|
183
|
+
? { id: 'stop', command: ['ours', 'daemon', 'stop', '--state-dir', dir, '--config', join(dir, 'config.json')] }
|
|
182
184
|
: { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
|
|
183
185
|
];
|
|
184
186
|
}
|
|
@@ -306,6 +308,13 @@ export function stripManagedBlock(text, markers) {
|
|
|
306
308
|
return { action: 'strip', text: out.join('\n') };
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The three harnesses this uninstaller knows, in the order they are offered.
|
|
313
|
+
* Same names and same order as the nightly picker and as `canonHarnesses`, so a
|
|
314
|
+
* selection written for one is a selection for the other.
|
|
315
|
+
*/
|
|
316
|
+
export const HARNESS_ORDER = ['claude-code', 'codex', 'hermes'];
|
|
317
|
+
|
|
309
318
|
/**
|
|
310
319
|
* What the harness-plugin half of an uninstall removes.
|
|
311
320
|
*
|
|
@@ -313,6 +322,14 @@ export function stripManagedBlock(text, markers) {
|
|
|
313
322
|
* rather than recomputed: while another daemon is still on this machine its
|
|
314
323
|
* harnesses still need their plugins, so nothing here is touched at all.
|
|
315
324
|
*
|
|
325
|
+
* `explicitSelection` OVERRIDES that keep, and only that. The keep is a guess
|
|
326
|
+
* made on the operator's behalf — "another daemon is here, so you probably still
|
|
327
|
+
* want these". When the operator has named the harnesses themselves (the picker,
|
|
328
|
+
* or OURS_UNINSTALL), the guess has been answered and must not outrank the
|
|
329
|
+
* answer. Nothing else about the plan changes: WHICH of the discovered harnesses
|
|
330
|
+
* are then acted on is decided by the caller, not here, so this stays the single
|
|
331
|
+
* description of what exists on disk.
|
|
332
|
+
*
|
|
316
333
|
* Claude Code is deliberately manual-only. Its plugin lives in the in-app
|
|
317
334
|
* marketplace, and there is no file on disk we own — so this prints the two
|
|
318
335
|
* commands and claims nothing, which is the same never-dead-end contract the
|
|
@@ -321,7 +338,7 @@ export function stripManagedBlock(text, markers) {
|
|
|
321
338
|
* Every path is EXACT — the precise file or directory the plugin installer
|
|
322
339
|
* writes. Nothing here is a glob, and nothing walks a tree looking for matches.
|
|
323
340
|
*/
|
|
324
|
-
export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true } = {}) {
|
|
341
|
+
export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true, explicitSelection = false } = {}) {
|
|
325
342
|
const hermesDir = env.HERMES_DIR || join(home, '.hermes');
|
|
326
343
|
const codexDir = env.CODEX_DIR || join(home, '.codex');
|
|
327
344
|
const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
|
|
@@ -333,7 +350,7 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
|
|
|
333
350
|
reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
|
|
334
351
|
steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
|
|
335
352
|
};
|
|
336
|
-
if (!lastDaemon) {
|
|
353
|
+
if (!lastDaemon && !explicitSelection) {
|
|
337
354
|
return {
|
|
338
355
|
action: 'keep',
|
|
339
356
|
reason: 'another daemon on this machine still uses these plugins',
|
|
@@ -379,14 +396,89 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
|
|
|
379
396
|
};
|
|
380
397
|
}
|
|
381
398
|
|
|
399
|
+
/**
|
|
400
|
+
* §8 — WHICH harnesses to detach (inventory item 9.5).
|
|
401
|
+
*
|
|
402
|
+
* The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
|
|
403
|
+
* terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
|
|
404
|
+
* neither. v3 had no choice at all — it removed every plugin artefact it found
|
|
405
|
+
* whenever this was the last daemon. A user who wanted to detach one harness had
|
|
406
|
+
* no way to say so, and an unattended run removed plugins that leaving
|
|
407
|
+
* `OURS_UNINSTALL` unset used to protect.
|
|
408
|
+
*
|
|
409
|
+
* This decides the three cases; the orchestrator only does the asking, so the
|
|
410
|
+
* rule and the terminal I/O stay apart.
|
|
411
|
+
*
|
|
412
|
+
* explicit — a selection was given (OURS_UNINSTALL, or the answers to the
|
|
413
|
+
* per-harness questions). Exactly those, intersected with what is
|
|
414
|
+
* actually there. A name for a harness that is not installed is
|
|
415
|
+
* reported as `ignored`, not silently dropped.
|
|
416
|
+
* keep — unattended with no selection. Nothing is removed, and the caller
|
|
417
|
+
* says how to select. This is the conservative side: an uninstall
|
|
418
|
+
* nobody is watching does not decide on its own that a harness
|
|
419
|
+
* should lose its plugin.
|
|
420
|
+
* ask — a terminal and no selection. One question per harness, DEFAULTING
|
|
421
|
+
* TO YES, because the daemon these plugins talk to is going away
|
|
422
|
+
* and a plugin left behind advertises tools that no longer resolve.
|
|
423
|
+
* An operator who just presses Enter gets exactly what v3 does
|
|
424
|
+
* today; the only new thing is that they can now say no.
|
|
425
|
+
*
|
|
426
|
+
* A checkbox picker would match nightly's chrome more closely, but the effects
|
|
427
|
+
* contract this uninstaller runs on has `ask`, not `checkboxSelect` — and the
|
|
428
|
+
* question here is which harnesses, not which widget.
|
|
429
|
+
*/
|
|
430
|
+
export function planHarnessSelection(plugins, { selection = null, assumeYes = false } = {}) {
|
|
431
|
+
const offered = [...(plugins.manual ?? []), ...(plugins.harnesses ?? [])]
|
|
432
|
+
.map((h) => ({ key: h.key, label: h.label }))
|
|
433
|
+
.sort((a, b) => HARNESS_ORDER.indexOf(a.key) - HARNESS_ORDER.indexOf(b.key));
|
|
434
|
+
if (selection !== null) {
|
|
435
|
+
const keys = offered.map((o) => o.key);
|
|
436
|
+
return {
|
|
437
|
+
mode: 'explicit',
|
|
438
|
+
offered,
|
|
439
|
+
chosen: keys.filter((k) => selection.includes(k)),
|
|
440
|
+
ignored: selection.filter((k) => !keys.includes(k)),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (assumeYes) {
|
|
444
|
+
return {
|
|
445
|
+
mode: 'keep',
|
|
446
|
+
offered,
|
|
447
|
+
chosen: [],
|
|
448
|
+
ignored: [],
|
|
449
|
+
reason: 'no OURS_UNINSTALL was set and there is nobody to ask',
|
|
450
|
+
hint: `set OURS_UNINSTALL="${HARNESS_ORDER.join(' ')}" (or a subset, or "all") to remove harness plugins unattended`,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
return { mode: 'ask', offered, chosen: null, ignored: [] };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Narrow a plugin plan to the chosen harnesses. Pure, so the orchestrator never
|
|
458
|
+
* decides what "chosen" means to a plan — it only supplies the answers.
|
|
459
|
+
*
|
|
460
|
+
* `packages` follows the harnesses it narrows to: a plugin package belongs to
|
|
461
|
+
* the harness that was removed, so a harness that was kept keeps its package.
|
|
462
|
+
*/
|
|
463
|
+
export function selectHarnesses(plugins, chosen) {
|
|
464
|
+
const keep = (h) => chosen.includes(h.key);
|
|
465
|
+
const harnesses = (plugins.harnesses ?? []).filter(keep);
|
|
466
|
+
return {
|
|
467
|
+
...plugins,
|
|
468
|
+
harnesses,
|
|
469
|
+
manual: (plugins.manual ?? []).filter(keep),
|
|
470
|
+
packages: harnesses.map((h) => h.pkg),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
382
474
|
/**
|
|
383
475
|
* The whole §8 order, refusing at step 1 rather than starting and stopping
|
|
384
476
|
* half-way.
|
|
385
477
|
*/
|
|
386
|
-
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null }) {
|
|
478
|
+
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
|
|
387
479
|
const dir = resolve(stateDir);
|
|
388
480
|
const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
|
|
389
|
-
const plugins = planPluginRemoval({ home, env, exists, lastDaemon });
|
|
481
|
+
const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
|
|
390
482
|
|
|
391
483
|
// BEFORE the pointing question, and not resolvable by confirming anything: a
|
|
392
484
|
// component config that will not parse cannot be proven not to name this
|
|
@@ -442,11 +534,16 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
|
|
|
442
534
|
/**
|
|
443
535
|
* What each question answers to under OURS_ASSUME_YES.
|
|
444
536
|
*
|
|
445
|
-
* The
|
|
446
|
-
* component on that was off, never MOVES one that already exists,
|
|
447
|
-
* deletes state. It suppresses questions; it does
|
|
448
|
-
* behalf to anything irreversible or to anything
|
|
449
|
-
* component is pointing.
|
|
537
|
+
* The `false` entries are the point of the table: assume-yes never turns a
|
|
538
|
+
* component on that was off, never MOVES one that already exists, never removes
|
|
539
|
+
* a harness's plugin, and never deletes state. It suppresses questions; it does
|
|
540
|
+
* not consent on the operator's behalf to anything irreversible or to anything
|
|
541
|
+
* that changes where an existing component is pointing.
|
|
542
|
+
*
|
|
543
|
+
* `plugins: false` is the one that answers a question this uninstaller used to
|
|
544
|
+
* answer the other way. A harness plugin is not this daemon's to give away
|
|
545
|
+
* unasked, and the operator has a way to ask for it by name — OURS_UNINSTALL —
|
|
546
|
+
* which is precisely the interface an unattended run should have to go through.
|
|
450
547
|
*/
|
|
451
548
|
export const NON_INTERACTIVE_ANSWERS = {
|
|
452
549
|
daemon: true,
|
|
@@ -454,6 +551,7 @@ export const NON_INTERACTIVE_ANSWERS = {
|
|
|
454
551
|
tg: false,
|
|
455
552
|
cowork: false,
|
|
456
553
|
repointExistingConnector: false,
|
|
554
|
+
plugins: false,
|
|
457
555
|
purge: false,
|
|
458
556
|
};
|
|
459
557
|
|
|
@@ -465,3 +563,174 @@ export const NON_INTERACTIVE_ANSWERS = {
|
|
|
465
563
|
export function refusalSurvivesAssumeYes(refusal) {
|
|
466
564
|
return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
|
|
467
565
|
}
|
|
566
|
+
|
|
567
|
+
// -----------------------------------------------------------------------------
|
|
568
|
+
// §9 — the OURS_UNINSTALL_* contract (inventory item 10.9)
|
|
569
|
+
// -----------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The seven variables. THIS IS A DOCUMENTED PUBLIC INTERFACE, not an internal
|
|
573
|
+
* detail: all seven are in packages/installer/README.md, two of them are in
|
|
574
|
+
* uninstall.sh's header as well, and somebody's unattended uninstall script is
|
|
575
|
+
* written against them. v3 honoured none of them and said nothing, which is the
|
|
576
|
+
* worst of the three available behaviours — a documented variable that quietly
|
|
577
|
+
* does nothing is worse than one that errors, because the operator believes it
|
|
578
|
+
* worked.
|
|
579
|
+
*
|
|
580
|
+
* Each is in exactly one of two categories, and which one is not a matter of
|
|
581
|
+
* taste. A variable is HONOURED when v3 can deliver what it documents. A
|
|
582
|
+
* variable is REFUSED — exit 2, nothing removed, naming the variable and naming
|
|
583
|
+
* the replacement — when what it documents depends on something v3 does not
|
|
584
|
+
* have. Nothing is silently ignored and nothing is silently obeyed.
|
|
585
|
+
*
|
|
586
|
+
* HONOURED
|
|
587
|
+
* OURS_UNINSTALL which harness plugins to remove. Parsed by the same
|
|
588
|
+
* canonHarnesses the nightly picker used, so names,
|
|
589
|
+
* numbers, "all" and "none" all still mean what they
|
|
590
|
+
* meant. Feeds item 9.5's selection.
|
|
591
|
+
* OURS_UNINSTALL_DAEMON whether the daemon goes. See below — this is the one
|
|
592
|
+
* that matters most.
|
|
593
|
+
* OURS_UNINSTALL_TELEGRAM
|
|
594
|
+
* OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
|
|
595
|
+
* confirmation §8 step 1 asks a human for and which an
|
|
596
|
+
* unattended run otherwise cannot give, so today the
|
|
597
|
+
* run refuses instead of detaching.
|
|
598
|
+
*
|
|
599
|
+
* REFUSED
|
|
600
|
+
* OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
|
|
601
|
+
* present (§9), and that rule protects private
|
|
602
|
+
* keys that exist nowhere else.
|
|
603
|
+
* OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
|
|
604
|
+
* has no registry; the selector is --state-dir.
|
|
605
|
+
* OURS_UNINSTALL_FORGET_PROFILE asks to forget a daemon as metadata while it
|
|
606
|
+
* keeps running. v3 has no metadata to forget.
|
|
607
|
+
* OURS_UNINSTALL_TELEGRAM/_ROOMS at `uninstall` (v3 detaches, it does not
|
|
608
|
+
* remove connector packages) or
|
|
609
|
+
* `reassign:<profile-id>` (again the registry).
|
|
610
|
+
*
|
|
611
|
+
* WHY THE WHOLE CONTRACT ENGAGES OR NONE OF IT DOES. v2 gated on
|
|
612
|
+
* `OURS_UNINSTALL != null || OURS_UNINSTALL_DATA || OURS_UNINSTALL_DAEMON` and
|
|
613
|
+
* then asked nothing at all. That gate is kept exactly: with none of the seven
|
|
614
|
+
* set, `ours-uninstall` behaves precisely as it does today, so the blast radius
|
|
615
|
+
* of this whole change is "an operator who set one of the documented variables".
|
|
616
|
+
*
|
|
617
|
+
* AND THE ESCALATION THIS CLOSES. Before this, `OURS_UNINSTALL="hermes"` — a
|
|
618
|
+
* script that asked for ONE HARNESS PLUGIN to be removed — reached v3, which
|
|
619
|
+
* read none of it, and removed THE DAEMON: its service, its process, and the
|
|
620
|
+
* global packages. Honouring OURS_UNINSTALL_DAEMON is what makes a request to
|
|
621
|
+
* detach a plugin stay a request to detach a plugin.
|
|
622
|
+
*/
|
|
623
|
+
export const UNINSTALL_ENV_VARS = [
|
|
624
|
+
'OURS_UNINSTALL',
|
|
625
|
+
'OURS_UNINSTALL_PROFILE',
|
|
626
|
+
'OURS_UNINSTALL_FORGET_PROFILE',
|
|
627
|
+
'OURS_UNINSTALL_DAEMON',
|
|
628
|
+
'OURS_UNINSTALL_DATA',
|
|
629
|
+
'OURS_UNINSTALL_TELEGRAM',
|
|
630
|
+
'OURS_UNINSTALL_ROOMS',
|
|
631
|
+
];
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Is the contract engaged at all? PRESENCE, not truth — `OURS_UNINSTALL=""` is a
|
|
635
|
+
* deliberate "no harnesses", exactly as v2's `!= null` read it, and is not the
|
|
636
|
+
* same as never having set it.
|
|
637
|
+
*/
|
|
638
|
+
export function uninstallEnvEngaged(env = {}) {
|
|
639
|
+
return UNINSTALL_ENV_VARS.some((name) => env[name] !== undefined && env[name] !== null);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const CONNECTOR_VARS = [
|
|
643
|
+
{ name: 'OURS_UNINSTALL_TELEGRAM', key: 'tg', pkg: '@ours.network/tg-connector' },
|
|
644
|
+
{ name: 'OURS_UNINSTALL_ROOMS', key: 'cowork', pkg: '@ours.network/cowork' },
|
|
645
|
+
];
|
|
646
|
+
|
|
647
|
+
function connectorRefusal(name, value, pkg) {
|
|
648
|
+
if (value === 'uninstall') {
|
|
649
|
+
return `${name}=uninstall asks for the connector's global package to be removed as well; this uninstaller detaches a connector, it does not uninstall one. Use ${name}=detach, then 'npm rm -g ${pkg}' yourself.`;
|
|
650
|
+
}
|
|
651
|
+
if (value.startsWith('reassign:')) {
|
|
652
|
+
return `${name}=${value} asks for the connector to be pointed at another daemon by profile id. This uninstaller has no profile registry, so there is no id it could resolve. Point the connector at the daemon you want first — 'ours-install --state-dir <path>' — and then re-run this uninstall.`;
|
|
653
|
+
}
|
|
654
|
+
return `${name}=${value} is not a value this uninstaller understands. The only supported value is 'detach'.`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Read the contract, or refuse it. Pure: the caller passes the environment in.
|
|
659
|
+
*
|
|
660
|
+
* A refusal is returned rather than thrown, in the same shape planUninstall uses
|
|
661
|
+
* for its own refusals, so the orchestrator has one thing to print and one exit
|
|
662
|
+
* code to return — and so a test can assert the whole set of refusals at once
|
|
663
|
+
* instead of catching them one at a time.
|
|
664
|
+
*/
|
|
665
|
+
export function parseUninstallEnv(env = {}) {
|
|
666
|
+
const engaged = uninstallEnvEngaged(env);
|
|
667
|
+
const value = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
|
|
668
|
+
const refusals = [];
|
|
669
|
+
|
|
670
|
+
if (value('OURS_UNINSTALL_DATA') === 'yes') {
|
|
671
|
+
refusals.push({
|
|
672
|
+
variable: 'OURS_UNINSTALL_DATA',
|
|
673
|
+
value: 'yes',
|
|
674
|
+
message: "OURS_UNINSTALL_DATA=yes asks this uninstaller to delete a state directory with nobody present. It will not: that directory holds identity private keys that exist nowhere else and that no peer can give back, and state is never deleted in an unattended run. To delete it, run 'ours-uninstall --state-dir <dir> --purge' from a terminal and type the full path when it asks.",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const profile = value('OURS_UNINSTALL_PROFILE');
|
|
678
|
+
if (profile) {
|
|
679
|
+
refusals.push({
|
|
680
|
+
variable: 'OURS_UNINSTALL_PROFILE',
|
|
681
|
+
value: profile,
|
|
682
|
+
message: `OURS_UNINSTALL_PROFILE=${profile} names an entry in the daemon profile registry, which this uninstaller does not have — there is no profile to select. Choose the daemon by its state directory instead: 'ours-uninstall --state-dir <path>'.`,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (value('OURS_UNINSTALL_FORGET_PROFILE') === 'yes') {
|
|
686
|
+
refusals.push({
|
|
687
|
+
variable: 'OURS_UNINSTALL_FORGET_PROFILE',
|
|
688
|
+
value: 'yes',
|
|
689
|
+
message: 'OURS_UNINSTALL_FORGET_PROFILE=yes asks for a daemon to be forgotten as metadata while it keeps running. This uninstaller keeps no registry of daemons, so there is nothing to forget and nothing it could do that would match that request — a daemon it is not pointed at is already left entirely alone.',
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const components = {};
|
|
694
|
+
for (const connector of CONNECTOR_VARS) {
|
|
695
|
+
const raw = value(connector.name);
|
|
696
|
+
if (!raw) continue;
|
|
697
|
+
if (raw === 'detach') { components[connector.key] = 'detach'; continue; }
|
|
698
|
+
refusals.push({
|
|
699
|
+
variable: connector.name,
|
|
700
|
+
value: raw,
|
|
701
|
+
message: connectorRefusal(connector.name, raw, connector.pkg),
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const canon = engaged ? canonHarnesses(env.OURS_UNINSTALL ?? '') : null;
|
|
706
|
+
// A token we cannot map is a request we cannot deliver. The nightly picker
|
|
707
|
+
// reported these and carried on, which under an unattended run means
|
|
708
|
+
// OURS_UNINSTALL="hermez" removes nothing and says so into a log nobody reads.
|
|
709
|
+
// Same rule as the rest of this contract: name it, refuse, remove nothing.
|
|
710
|
+
for (const token of canon?.unknown ?? []) {
|
|
711
|
+
refusals.push({
|
|
712
|
+
variable: 'OURS_UNINSTALL',
|
|
713
|
+
value: token,
|
|
714
|
+
message: `OURS_UNINSTALL names "${token}", which is not a harness this uninstaller knows. Use ${HARNESS_ORDER.join(', ')}, or "all", or "none".`,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const contract = {
|
|
719
|
+
engaged,
|
|
720
|
+
// Not engaged means "decide this the way you always did": null selection, and
|
|
721
|
+
// the daemon removal that IS this command.
|
|
722
|
+
harnesses: canon ? canon.names : null,
|
|
723
|
+
unknownHarnessTokens: canon ? canon.unknown : [],
|
|
724
|
+
daemon: engaged ? value('OURS_UNINSTALL_DAEMON') === 'yes' : true,
|
|
725
|
+
confirmedComponents: Object.keys(components),
|
|
726
|
+
};
|
|
727
|
+
if (refusals.length === 0) return contract;
|
|
728
|
+
return {
|
|
729
|
+
...contract,
|
|
730
|
+
action: 'refuse',
|
|
731
|
+
exitCode: 2,
|
|
732
|
+
reason: 'uninstall-env-unsupported',
|
|
733
|
+
refusals,
|
|
734
|
+
message: `${refusals.map((r) => r.message).join('\n')}\nNothing was removed.`,
|
|
735
|
+
};
|
|
736
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0-nightly.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
|
|
6
6
|
"type": "module",
|