@ours.network/install 0.17.0-nightly.9 → 0.17.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/lib/uninstall.mjs DELETED
@@ -1,467 +0,0 @@
1
- // ours-uninstall v3 — removing ONE daemon, and the non-interactive contract.
2
- //
3
- // Spec: installer-spec-v3 §§8-9. Pure, like every other stage: the caller
4
- // injects the file reads and this returns a plan.
5
- //
6
- // ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
7
- //
8
- // `--state-dir` selects WHICH daemon to remove and defaults to ~/.ours. The
9
- // uninstaller only ever touches that daemon and the components pointing at it.
10
- //
11
- // THE BIAS OF THIS WHOLE FILE IS TOWARD KEEPING THINGS. State is kept by default,
12
- // a component's config file is kept even when its daemon keys are removed, and
13
- // global packages are kept while any other daemon still needs them. The one
14
- // destructive operation, --purge, is separately gated (§8 step 5) because
15
- // it deletes identity keys, and no other step here is irreversible.
16
-
17
- import { join, resolve } from 'node:path';
18
- import { unitNameForStateDir } from './plan.mjs';
19
- import { tgConfigPath, coworkConfigPath } from './components.mjs';
20
-
21
- /**
22
- * Does this directory even look like an ours state directory?
23
- *
24
- * WHY THIS EXISTS. The owner removed the "created by an installer run" gate —
25
- * purge means purge, on any state directory. That was a deliberate ruling and
26
- * this does not reintroduce it: this asks "is this a state directory at all",
27
- * not "is it ours". With provenance gone, the typed path would otherwise be the
28
- * only thing between `ours-uninstall --state-dir ~ --purge` and a deleted home
29
- * directory, and a typed path is no protection against a path typed exactly as
30
- * intended but meant differently.
31
- *
32
- * A directory qualifies if it carries any of the artefacts only a daemon writes.
33
- * Absent all of them, purge refuses and says why — the operator can still delete
34
- * the directory themselves, which is the right place for that decision.
35
- */
36
- export const STATE_DIR_EVIDENCE = ['config.json', 'daemon-token', 'ours-cli-daemon.json', 'root.json'];
37
- export function looksLikeStateDir(stateDir, exists) {
38
- return STATE_DIR_EVIDENCE.some((name) => exists(join(resolve(stateDir), name)));
39
- }
40
-
41
- /**
42
- * A component config file, read so that ABSENT and CORRUPT are different answers.
43
- *
44
- * WHY THIS IS NOT `readJson`. `effects.readJson` swallows every failure into
45
- * `null` (lib/effects.mjs), which is the right shape for the installer — an
46
- * unreadable daemon config there means "nothing recorded", and the run proceeds
47
- * to write one. On the UNINSTALL side that same `null` is a fail-open:
48
- * `componentsPointingHere` reads it as "no connector points at this daemon",
49
- * step 1's refusal never fires, and the daemon is removed out from under a
50
- * connector that may still be using it. The nightly uninstaller refuses here
51
- * (`lib/nightly-uninstall.mjs:22,244`) and has a test pinning that it does.
52
- *
53
- * So this takes `readText` — already on the effects contract — and does the parse
54
- * itself, which keeps the whole decision pure and testable:
55
- *
56
- * absent — no file (readText returned null). Nothing to point anywhere.
57
- * ok — a JSON object.
58
- * corrupt — the file exists and is not a readable JSON object. An EXISTING file
59
- * we cannot parse is the case that must stop the run: we cannot prove
60
- * it does not name this daemon, and "cannot prove" is not "does not".
61
- *
62
- * An empty existing file is CORRUPT, not absent — same as the nightly reader,
63
- * which parses whatever `existsSync` says is there.
64
- *
65
- * THIS DECIDES NOTHING ABOUT CONTENTS. `componentsPointingHere` still reads its
66
- * values through `readJson`, unchanged — deliberately, so this addition cannot
67
- * alter which components are found. The only new outcome is `corrupt`, and the
68
- * only thing that consumes it is a refusal.
69
- */
70
- export function inspectComponentConfig(path, { readText } = {}) {
71
- if (typeof readText !== 'function') return { state: 'unknown', reason: 'no text reader injected' };
72
- const text = readText(path);
73
- if (text === null || text === undefined) return { state: 'absent' };
74
- let parsed;
75
- try {
76
- parsed = JSON.parse(String(text));
77
- } catch (error) {
78
- return { state: 'corrupt', reason: error instanceof Error ? error.message : String(error) };
79
- }
80
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
81
- return { state: 'corrupt', reason: 'expected a JSON object' };
82
- }
83
- return { state: 'ok' };
84
- }
85
-
86
- /**
87
- * §8 step 1 — refuse if a component still points at this daemon.
88
- *
89
- * Read the connector's and cowork's config files; if either names this daemon's
90
- * endpoint or its state directory, list them and stop. Exit 2, nothing removed.
91
- * The operator either repoints them or confirms their removal in the same run.
92
- *
93
- * Matching on EITHER the endpoint or the state directory is deliberate: a
94
- * half-written pair should still be caught, and the whole point of the pair is
95
- * that neither half alone is trustworthy.
96
- */
97
- export function componentsPointingHere({ home, env = {}, endpoint, stateDir, readJson }) {
98
- const dir = resolve(stateDir);
99
- const found = [];
100
- const tg = readJson(tgConfigPath(home, env));
101
- if (tg && (tg.daemonUrl === endpoint || (typeof tg.daemonStateDir === 'string' && resolve(tg.daemonStateDir) === dir))) {
102
- found.push({ key: 'tg', config: tgConfigPath(home, env), endpoint: tg.daemonUrl ?? null, stateDir: tg.daemonStateDir ?? null });
103
- }
104
- const cowork = readJson(coworkConfigPath(home, env));
105
- const block = cowork && typeof cowork.daemon === 'object' && cowork.daemon !== null ? cowork.daemon : null;
106
- if (block && (block.endpoint === endpoint || (typeof block.stateDir === 'string' && resolve(block.stateDir) === dir))) {
107
- found.push({ key: 'cowork', config: coworkConfigPath(home, env), endpoint: block.endpoint ?? null, stateDir: block.stateDir ?? null });
108
- }
109
- return found;
110
- }
111
-
112
- /**
113
- * The other half of step 1, and the reason it is a SEPARATE question.
114
- *
115
- * `componentsPointingHere` answers "does a component point here?". This answers
116
- * "is there a component config I cannot read at all?" — and the two must not be
117
- * collapsed, because they have opposite resolutions. A component that points here
118
- * can be confirmed for removal in the same run; a config that will not parse
119
- * cannot be confirmed away by anybody, because nothing about its contents is
120
- * known. The operator has to fix or move the file first.
121
- *
122
- * Returns [] when no reader capable of telling absent from corrupt was injected.
123
- * That is not a silent pass: planUninstall reports the limit in the plan itself.
124
- */
125
- export function unreadableComponentConfigs({ home, env = {}, readText }) {
126
- if (typeof readText !== 'function') return [];
127
- const out = [];
128
- for (const [key, path] of [['tg', tgConfigPath(home, env)], ['cowork', coworkConfigPath(home, env)]]) {
129
- const read = inspectComponentConfig(path, { readText });
130
- if (read.state === 'corrupt') out.push({ key, config: path, reason: read.reason });
131
- }
132
- return out;
133
- }
134
-
135
- /**
136
- * Strip a component's daemon keys while KEEPING the file. It also holds the
137
- * operator's bot token and settings, and those are not ours to delete.
138
- *
139
- * For cowork, removing the `daemon` block returns it to embedded mode. That is a
140
- * real behaviour change, not a cleanup, so the plan carries `behaviourChange` for
141
- * the screen to state BEFORE it happens.
142
- */
143
- export function planComponentDetach(key, existing) {
144
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : {};
145
- if (key === 'tg') {
146
- const removed = ['daemonUrl', 'daemonStateDir'].filter((k) => k in base);
147
- for (const k of removed) delete base[k];
148
- return { key, removed, config: base, keepsFile: true, behaviourChange: null };
149
- }
150
- const had = 'daemon' in base;
151
- delete base.daemon;
152
- return {
153
- key,
154
- removed: had ? ['daemon'] : [],
155
- config: base,
156
- keepsFile: true,
157
- behaviourChange: had ? 'cowork returns to embedded mode' : null,
158
- };
159
- }
160
-
161
- /**
162
- * §8 steps 3-4 — the boot service, then the daemon itself.
163
- *
164
- * Both delegate their refusals rather than reimplementing them: `ours daemon
165
- * uninstall-service` refuses to remove a unit not marked as CLI-managed, and
166
- * `ours daemon stop` refuses to signal a daemon it did not start. In the second
167
- * case the screen names the external launcher and the run CONTINUES — a daemon
168
- * someone else supervises is not a failure of this uninstall.
169
- */
170
- export function planDaemonRemoval({ stateDir, cliStartedIt }) {
171
- const dir = resolve(stateDir);
172
- const unit = unitNameForStateDir(dir);
173
- 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
- },
180
- cliStartedIt
181
- ? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
182
- : { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
183
- ];
184
- }
185
-
186
- /**
187
- * §8 step 5 — state. Kept unless every gate opens.
188
- *
189
- * --purge given — never the default; deleting identity keys is opt-in.
190
- * interactive — an unattended run never deletes state (§9).
191
- * looks like a state directory — see looksLikeStateDir.
192
- * typed confirmation — the full path, not a y/N. The owner removed the
193
- * provenance condition, not the deliberateness, and with
194
- * provenance gone this is the last thing standing between
195
- * a mistyped command and someone's identity keys.
196
- *
197
- * Returns the exact directory, never a glob or a parent, and only when every gate
198
- * is satisfied.
199
- */
200
- export function planStatePurge({ stateDir, purge = false, assumeYes = false, exists = () => true, typedConfirmation = null }) {
201
- const dir = resolve(stateDir);
202
- const keep = (reason) => ({ action: 'keep', stateDir: dir, reason, hint: 're-run with --purge to delete identities and history' });
203
- if (!purge) return keep('state is kept by default');
204
- if (assumeYes) return keep('state is never deleted non-interactively');
205
- if (!looksLikeStateDir(dir, exists)) {
206
- return keep(`${dir} does not look like an ours state directory (none of ${STATE_DIR_EVIDENCE.join(', ')}); refusing to delete it`);
207
- }
208
- if (typedConfirmation !== dir) {
209
- return {
210
- action: 'confirm-typed',
211
- stateDir: dir,
212
- expected: dir,
213
- prompt: `This permanently deletes ${dir} and everything in it, including the identity keys and message history of every identity there. Those keys exist nowhere else and no peer can give them back.\nType the full path to confirm:`,
214
- };
215
- }
216
- return { action: 'purge', stateDir: dir, paths: [dir] };
217
- }
218
-
219
- /**
220
- * §8 step 6 — global packages are shared. Remove them only when no OTHER state
221
- * directory on this machine still has a daemon config; otherwise keep them and
222
- * say which daemon still needs them.
223
- */
224
- export const CONNECTOR_PACKAGES = { tg: '@ours.network/tg-connector', cowork: '@ours.network/cowork' };
225
-
226
- export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [], detachedComponents = [] }) {
227
- const dir = resolve(stateDir);
228
- const others = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir);
229
- if (others.length > 0) {
230
- return { action: 'keep', packages: [], stillNeededBy: others, reason: `still used by the daemon at ${others[0]}` };
231
- }
232
- // The harness-plugin launchers follow the SAME rule, not a second one: they
233
- // speak to a daemon through ours-mcp, so a second daemon still on the machine
234
- // still needs them, and the one condition above already decides that.
235
- // A connector's package goes only when BOTH conditions hold: the operator
236
- // confirmed removing its attachment in THIS run, and this was the last daemon —
237
- // the same condition already decided once above.
238
- //
239
- // WHY NOT NIGHTLY'S THREE-WAY CHOICE. The nightly uninstaller asks per connector
240
- // for detach / uninstall / reassign:<profile> and removes the package only on
241
- // `uninstall`. v3 has no such lifecycle question and inventing one here would be
242
- // deciding scope in a patch. What v3 DOES have is the operator's explicit yes to
243
- // "remove its attachment too", which is a narrower thing than nightly's
244
- // `uninstall` — so this stays behind the last-daemon condition rather than
245
- // standing on the confirmation alone. A connector detached while another daemon
246
- // survives keeps its package, because that daemon may still be using it.
247
- const connectors = detachedComponents
248
- .map((key) => CONNECTOR_PACKAGES[key])
249
- .filter(Boolean);
250
- return {
251
- action: 'remove',
252
- packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages, ...connectors],
253
- stillNeededBy: [],
254
- };
255
- }
256
-
257
- // -----------------------------------------------------------------------------
258
- // §8 — the harness plugins the installer wrote
259
- // -----------------------------------------------------------------------------
260
-
261
- /**
262
- * The sentinels the plugin installers stamp around everything they write. They
263
- * are the ONLY thing that makes a block removable: without them we would be
264
- * editing a config file on a guess.
265
- */
266
- export const YAML_BLOCK = { start: '# >>> ours.network plugin (managed block)', end: '# <<< ours.network plugin' };
267
- export const MD_BLOCK = { start: '<!-- >>> ours.network plugin (managed block) -->', end: '<!-- <<< ours.network plugin -->' };
268
-
269
- /**
270
- * Remove OUR block from a config file, or refuse.
271
- *
272
- * SAME IDENTIFICATION DISCIPLINE AS THE SYSTEMD UNIT, and for the same reason.
273
- * The installer refuses to overwrite a unit it cannot positively identify rather
274
- * than guessing; this refuses to edit a config it cannot positively identify.
275
- * Three outcomes, and the third is the one that matters:
276
- *
277
- * absent — no start sentinel. The file is not ours to edit; left untouched.
278
- * strip — both sentinels present. Exactly the delimited span is removed.
279
- * refuse — a start sentinel with NO end. v2 deleted to end-of-file here,
280
- * which would take everything the user added after our block with
281
- * it. An unterminated block is damage we cannot bound, so it is
282
- * reported and left alone.
283
- */
284
- export function stripManagedBlock(text, markers) {
285
- if (typeof text !== 'string' || !text.includes(markers.start)) {
286
- return { action: 'absent', reason: 'no ours managed block in this file' };
287
- }
288
- const lines = text.split('\n');
289
- const out = [];
290
- let inside = false;
291
- let closed = false;
292
- for (const line of lines) {
293
- if (!inside && line.includes(markers.start)) { inside = true; continue; }
294
- if (inside) {
295
- if (line.includes(markers.end)) { inside = false; closed = true; }
296
- continue;
297
- }
298
- out.push(line);
299
- }
300
- if (!closed) {
301
- return {
302
- action: 'refuse',
303
- reason: 'the ours managed block has no closing marker; refusing to guess where it ends',
304
- };
305
- }
306
- return { action: 'strip', text: out.join('\n') };
307
- }
308
-
309
- /**
310
- * What the harness-plugin half of an uninstall removes.
311
- *
312
- * `lastDaemon` is the same condition planGlobalPackages decides on, passed in
313
- * rather than recomputed: while another daemon is still on this machine its
314
- * harnesses still need their plugins, so nothing here is touched at all.
315
- *
316
- * Claude Code is deliberately manual-only. Its plugin lives in the in-app
317
- * marketplace, and there is no file on disk we own — so this prints the two
318
- * commands and claims nothing, which is the same never-dead-end contract the
319
- * installer applies to a harness it cannot drive.
320
- *
321
- * Every path is EXACT — the precise file or directory the plugin installer
322
- * writes. Nothing here is a glob, and nothing walks a tree looking for matches.
323
- */
324
- export function planPluginRemoval({ home, env = {}, exists = () => false, lastDaemon = true } = {}) {
325
- const hermesDir = env.HERMES_DIR || join(home, '.hermes');
326
- const codexDir = env.CODEX_DIR || join(home, '.codex');
327
- const skillsDir = env.SKILLS_DIR || join(home, '.agents', 'skills');
328
-
329
- const manual = {
330
- key: 'claude-code',
331
- label: 'Claude Code plugin',
332
- action: 'manual',
333
- reason: "its plugin lives in Claude Code's in-app marketplace, so nothing on disk is ours to remove",
334
- steps: ['/plugin uninstall ours', '/plugin marketplace remove adapt-toolkit/ours-claude-marketplace'],
335
- };
336
- if (!lastDaemon) {
337
- return {
338
- action: 'keep',
339
- reason: 'another daemon on this machine still uses these plugins',
340
- harnesses: [],
341
- manual: [manual],
342
- packages: [],
343
- };
344
- }
345
-
346
- const harnesses = [];
347
- const hermesConfig = join(hermesDir, 'config.yaml');
348
- if (exists(hermesConfig) || exists(hermesDir)) {
349
- harnesses.push({
350
- key: 'hermes',
351
- label: 'Hermes plugin',
352
- blocks: [{ path: hermesConfig, markers: YAML_BLOCK }],
353
- dirs: [
354
- join(hermesDir, 'skills', 'communication', 'ours'),
355
- join(hermesDir, 'skills', 'communication', 'writing-agent-bios'),
356
- ],
357
- files: [join(hermesDir, 'ours-connector.env'), join(hermesDir, 'ours-connector.log')],
358
- pkg: '@ours.network/hermes',
359
- });
360
- }
361
- if (exists(join(codexDir, 'config.toml')) || exists(codexDir)) {
362
- harnesses.push({
363
- key: 'codex',
364
- label: 'Codex plugin',
365
- blocks: [
366
- { path: join(codexDir, 'config.toml'), markers: YAML_BLOCK },
367
- { path: join(codexDir, 'AGENTS.md'), markers: MD_BLOCK },
368
- ],
369
- dirs: [join(skillsDir, 'ours'), join(skillsDir, 'writing-agent-bios')],
370
- files: [],
371
- pkg: '@ours.network/codex',
372
- });
373
- }
374
- return {
375
- action: 'remove',
376
- harnesses,
377
- manual: [manual],
378
- packages: harnesses.map((h) => h.pkg),
379
- };
380
- }
381
-
382
- /**
383
- * The whole §8 order, refusing at step 1 rather than starting and stopping
384
- * half-way.
385
- */
386
- export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null }) {
387
- const dir = resolve(stateDir);
388
- const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
389
- const plugins = planPluginRemoval({ home, env, exists, lastDaemon });
390
-
391
- // BEFORE the pointing question, and not resolvable by confirming anything: a
392
- // component config that will not parse cannot be proven not to name this
393
- // daemon. Fail closed, name the file, remove nothing. This mirrors the nightly
394
- // uninstaller's refusal (lib/nightly-uninstall.mjs:244) rather than inventing a
395
- // second wording for the same event.
396
- const unreadable = unreadableComponentConfigs({ home, env, readText });
397
- if (unreadable.length > 0) {
398
- return {
399
- action: 'refuse',
400
- exitCode: 2,
401
- reason: 'component-config-unreadable',
402
- components: unreadable,
403
- removed: [],
404
- message: `${unreadable.map((c) => `${c.config} is corrupt or unsafe to inspect${c.reason ? `: ${c.reason}` : ''}`).join('; ')}. Refusing to uninstall: a component config that cannot be read cannot be shown not to point at this daemon. Repair or move it, then re-run. Nothing was removed.`,
405
- };
406
- }
407
-
408
- const pointing = componentsPointingHere({ home, env, endpoint, stateDir: dir, readJson });
409
- const unconfirmed = pointing.filter((p) => !confirmedComponents.includes(p.key));
410
- if (unconfirmed.length > 0) {
411
- return {
412
- action: 'refuse',
413
- exitCode: 2,
414
- reason: 'component-still-points-here',
415
- components: unconfirmed,
416
- removed: [],
417
- message: `${unconfirmed.map((p) => p.key).join(' and ')} still point at this daemon. Repoint them, or confirm their removal in the same run. Nothing was removed.`,
418
- };
419
- }
420
- return {
421
- action: 'uninstall',
422
- stateDir: dir,
423
- detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
424
- daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
425
- state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
426
- plugins,
427
- packages: planGlobalPackages({
428
- stateDir: dir,
429
- otherStateDirsWithConfig,
430
- pluginPackages: plugins.packages,
431
- // Only the components the operator explicitly confirmed removing — never
432
- // every component that merely happens to point here.
433
- detachedComponents: pointing.map((p) => p.key).filter((key) => confirmedComponents.includes(key)),
434
- }),
435
- };
436
- }
437
-
438
- // -----------------------------------------------------------------------------
439
- // §9 — the non-interactive contract
440
- // -----------------------------------------------------------------------------
441
-
442
- /**
443
- * What each question answers to under OURS_ASSUME_YES.
444
- *
445
- * The two `false` entries are the point of the table: assume-yes never turns a
446
- * component on that was off, never MOVES one that already exists, and never
447
- * deletes state. It suppresses questions; it does not consent on the operator's
448
- * behalf to anything irreversible or to anything that changes where an existing
449
- * component is pointing.
450
- */
451
- export const NON_INTERACTIVE_ANSWERS = {
452
- daemon: true,
453
- mcp: true,
454
- tg: false,
455
- cowork: false,
456
- repointExistingConnector: false,
457
- purge: false,
458
- };
459
-
460
- /**
461
- * OURS_ASSUME_YES suppresses questions; it NEVER suppresses a refusal. Every
462
- * refusal in this specification applies unchanged in non-interactive mode and
463
- * exits 2 without writing anything.
464
- */
465
- export function refusalSurvivesAssumeYes(refusal) {
466
- return refusal && refusal.action === 'refuse' ? { ...refusal, exitCode: 2 } : refusal;
467
- }
package/lib/usage.mjs DELETED
@@ -1,47 +0,0 @@
1
- // ours-install v3 — the help text.
2
- //
3
- // It lives here rather than in the bin because `--help` is a behaviour with a
4
- // contract (the flags it names must be the flags target.mjs accepts), and a bin
5
- // that is three lines long cannot be the place a contract is asserted.
6
-
7
- export const USAGE = `ours-install — the unified ours.network stack installer.
8
-
9
- Install: npm i -g @ours.network/install && ours-install (recommended)
10
- npx @ours.network/install (one-off)
11
-
12
- ours-install [--state-dir PATH] [--port N] [--dry-run] [--help] [--version]
13
-
14
- Guided setup for the whole stack: the ours daemon, its components (the MCP
15
- server, the Telegram connector, cowork), your harness plugins (Claude Code /
16
- Codex / Hermes), ours-fleet and voice transcription — then one copy-paste
17
- hand-off prompt. You approve each step; re-run any time to add a piece or update.
18
-
19
- --state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
20
- (default ~/.ours). A second state directory is a second daemon.
21
- --port used only when CREATING a daemon. For a daemon that already owns
22
- the state directory the port comes from its own record, and a
23
- --port that disagrees with it is refused rather than corrected.
24
- --dry-run walk the whole flow and print what it WOULD do — change nothing
25
- --help show this help and exit
26
- --version print the installer version and exit
27
-
28
- Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1 ·
29
- OURS_CHANNEL=nightly · OURS_BROKER_URL · OURS_NPM. Docs: https://ours.network`;
30
-
31
- export const UNINSTALL_USAGE = `ours-uninstall — remove one ours daemon and what attaches to it.
32
-
33
- ours-uninstall [--state-dir PATH] [--purge] [--dry-run] [--help] [--version]
34
-
35
- Removes the boot service, stops the daemon, and removes the global packages —
36
- but ONLY when no other daemon on this machine still needs them. A component
37
- still pointing at this daemon stops the run before anything is removed, so a
38
- run that refuses leaves the daemon whole rather than half-dismantled.
39
-
40
- --state-dir which daemon to remove (default ~/.ours). A daemon IS its state
41
- directory, so this is the only thing that names one.
42
- --purge also delete the state directory itself. Never the default, never
43
- done non-interactively, and it asks you to type the full path —
44
- identity keys exist nowhere else and no peer can give them back.
45
- --dry-run print what it WOULD remove and remove nothing
46
- --help show this help and exit
47
- --version print the version and exit`;