@ours.network/install 0.18.0-nightly.4 → 0.18.0-nightly.6
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 +34 -13
- package/install.sh +3 -0
- package/lib/components.mjs +12 -13
- package/lib/detect.mjs +3 -3
- package/lib/effects.mjs +72 -10
- package/lib/extras.mjs +14 -17
- package/lib/logic.mjs +8 -17
- package/lib/marketplace.mjs +104 -0
- package/lib/orchestrate-uninstall.mjs +2 -2
- package/lib/orchestrate.mjs +226 -38
- package/lib/plan.mjs +9 -9
- package/lib/rerun.mjs +7 -7
- package/lib/target.mjs +25 -17
- package/lib/uninstall.mjs +19 -20
- package/lib/usage.mjs +3 -2
- package/package.json +1 -1
package/lib/orchestrate.mjs
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// ask(prompt, default) -> boolean (never called when assumeYes)
|
|
24
24
|
// now() -> number
|
|
25
25
|
|
|
26
|
-
import { join } from 'node:path';
|
|
26
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
27
27
|
import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
|
|
28
28
|
import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
|
|
29
29
|
import {
|
|
@@ -37,12 +37,31 @@ import { configJournal, reportRollback } from './journal.mjs';
|
|
|
37
37
|
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
38
38
|
import { detectPlatform, resolveChannel } from './logic.mjs';
|
|
39
39
|
import { daemonEnv } from './effects.mjs';
|
|
40
|
+
import {
|
|
41
|
+
buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
|
|
42
|
+
validateChannelVersion,
|
|
43
|
+
} from './marketplace.mjs';
|
|
40
44
|
import { USAGE } from './usage.mjs';
|
|
41
45
|
import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
|
|
42
46
|
|
|
43
47
|
export const EXIT_OK = 0;
|
|
44
48
|
export const EXIT_REFUSED = 2;
|
|
45
49
|
|
|
50
|
+
export async function resolveExactSuite(args, effects) {
|
|
51
|
+
const packages = {};
|
|
52
|
+
for (const key of ['mcp', 'claude-code', 'codex']) {
|
|
53
|
+
const version = await effects.resolvePackageVersion(`@ours.network/${key}`, args.channel);
|
|
54
|
+
const checked = validateChannelVersion(version, args.channel);
|
|
55
|
+
if (!checked.ok) return { ok: false, reason: `${key}: ${checked.reason}` };
|
|
56
|
+
packages[key] = checked.version;
|
|
57
|
+
}
|
|
58
|
+
const versions = new Set(Object.values(packages));
|
|
59
|
+
if (versions.size !== 1) {
|
|
60
|
+
return { ok: false, reason: `the ${args.channel} dist-tags are not lockstep (${Object.entries(packages).map(([k, v]) => `${k}=${v}`).join(', ')})` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, channel: args.channel, version: versions.values().next().value, packages };
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
/**
|
|
47
66
|
* A dry run prints what it WOULD do and performs no mutation. The prefix is the
|
|
48
67
|
* existing installer's, kept so the two flows read the same.
|
|
@@ -66,6 +85,104 @@ async function perform(effects, dryRun, label, thunk) {
|
|
|
66
85
|
|
|
67
86
|
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
68
87
|
|
|
88
|
+
function semverMajor(version) {
|
|
89
|
+
const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
|
|
90
|
+
return match ? Number(match[1]) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function incompatibleUpgrade(target, cliDependencies) {
|
|
94
|
+
if (target.action !== 'update' || !target.daemonVersion) return null;
|
|
95
|
+
const runningMajor = semverMajor(target.daemonVersion);
|
|
96
|
+
const targetRange = cliDependencies?.['@ours.network/sdk'];
|
|
97
|
+
const targetMajor = semverMajor(targetRange);
|
|
98
|
+
if (runningMajor === null) {
|
|
99
|
+
return { unknown: true, runningMajor: null, runningVersion: target.daemonVersion };
|
|
100
|
+
}
|
|
101
|
+
if (targetMajor === null) {
|
|
102
|
+
return { unknown: true, runningMajor, runningVersion: target.daemonVersion };
|
|
103
|
+
}
|
|
104
|
+
return runningMajor !== targetMajor
|
|
105
|
+
? { unknown: false, runningMajor, runningVersion: target.daemonVersion, targetMajor, targetRange }
|
|
106
|
+
: null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
|
|
110
|
+
const dir = target.stateDir;
|
|
111
|
+
const configPath = join(dir, 'config.json');
|
|
112
|
+
if (mismatch.unknown) {
|
|
113
|
+
effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
|
|
114
|
+
effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
|
|
115
|
+
return { refused: { reason: 'compatibility-unknown', exitCode: EXIT_REFUSED } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
effects.out(warn(
|
|
119
|
+
`ours: daemon v${mismatch.runningVersion} cannot be restored by the requested major v${mismatch.targetMajor}; major upgrades are intentionally incompatible.`,
|
|
120
|
+
));
|
|
121
|
+
if (resolve(dir) === resolve(effects.home) || dirname(resolve(dir)) === resolve(dir)) {
|
|
122
|
+
effects.out(info(`Automatic purge is not available for the broad state path ${dir}. Back it up and remove it manually.`));
|
|
123
|
+
return { refused: { reason: 'incompatible-major-broad-path', exitCode: EXIT_REFUSED } };
|
|
124
|
+
}
|
|
125
|
+
// Backups live one directory below a non-daemon container. Putting a copied
|
|
126
|
+
// state beside ~/.ours under another `.ours*` name makes daemon discovery see
|
|
127
|
+
// the backup as a second live target on the next installer run.
|
|
128
|
+
const backupPath = join(
|
|
129
|
+
dirname(dir),
|
|
130
|
+
'.ours-backups',
|
|
131
|
+
`${basename(dir)}-before-v${mismatch.targetMajor}-${effects.now()}`,
|
|
132
|
+
);
|
|
133
|
+
if (args.dryRun) {
|
|
134
|
+
effects.out(info(`[dry-run] would ask to stop the old daemon, copy its complete state to ${backupPath}, remove its service/state, and initialize v${mismatch.targetMajor}.`));
|
|
135
|
+
return { refused: { reason: 'incompatible-major-dry-run', exitCode: EXIT_REFUSED } };
|
|
136
|
+
}
|
|
137
|
+
if (args.assumeYes) {
|
|
138
|
+
effects.out(info('This purge is never accepted through OURS_ASSUME_YES. Re-run interactively to confirm the backup and reset.'));
|
|
139
|
+
return { refused: { reason: 'incompatible-major-unattended', exitCode: EXIT_REFUSED } };
|
|
140
|
+
}
|
|
141
|
+
if (effects.readJson(join(dir, 'ours-cli-daemon.json')) === null) {
|
|
142
|
+
effects.out(info('The daemon is not CLI-managed. Stop its external launcher, back up and remove its state/service, then re-run the installer.'));
|
|
143
|
+
return { refused: { reason: 'incompatible-major-external', exitCode: EXIT_REFUSED } };
|
|
144
|
+
}
|
|
145
|
+
const confirmed = await effects.ask(
|
|
146
|
+
`Back up all daemon state to ${backupPath}, purge the incompatible daemon and service, and install v${mismatch.targetMajor}?`,
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
if (!confirmed) {
|
|
150
|
+
effects.out(info(`Nothing was changed. Back up ${dir}, remove the old daemon service/state, and re-run when ready.`));
|
|
151
|
+
return { refused: { reason: 'incompatible-major-declined', exitCode: EXIT_REFUSED } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
|
|
155
|
+
'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
156
|
+
));
|
|
157
|
+
try {
|
|
158
|
+
await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
try {
|
|
161
|
+
await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
162
|
+
effects.out(ok('backup failed, but the old daemon was started again'));
|
|
163
|
+
} catch {
|
|
164
|
+
effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
|
|
170
|
+
'ours', ['daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
|
|
171
|
+
));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
|
|
175
|
+
effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
|
|
176
|
+
} catch {
|
|
177
|
+
effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
|
|
178
|
+
}
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
await perform(effects, false, `remove incompatible state at ${dir}`, () => effects.removeDir(dir));
|
|
182
|
+
effects.out(ok(`backup retained at ${backupPath}`));
|
|
183
|
+
return { purged: true, backupPath };
|
|
184
|
+
}
|
|
185
|
+
|
|
69
186
|
/**
|
|
70
187
|
* A step that is allowed to fail without ending the run.
|
|
71
188
|
*
|
|
@@ -102,7 +219,7 @@ function pairFor(plan, target) {
|
|
|
102
219
|
/**
|
|
103
220
|
* Which daemon is this run for?
|
|
104
221
|
*
|
|
105
|
-
* Never asks for a
|
|
222
|
+
* Never asks for a path, but when several daemons are detected
|
|
106
223
|
* it shows them and lets the operator pick, because choosing from what was found
|
|
107
224
|
* is not prompting for a state directory.
|
|
108
225
|
*
|
|
@@ -144,7 +261,7 @@ export async function runSelectionPhase(args, effects) {
|
|
|
144
261
|
const chosen = resolveSelection(answer, plan);
|
|
145
262
|
if (chosen.action === 'invalid') {
|
|
146
263
|
// Refused rather than guessed. Interpreting an unrecognised answer as a path
|
|
147
|
-
// would
|
|
264
|
+
// would reintroduce the forbidden "type a state directory" prompt through the
|
|
148
265
|
// through the back door.
|
|
149
266
|
effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
|
|
150
267
|
effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
|
|
@@ -158,10 +275,10 @@ export async function runSelectionPhase(args, effects) {
|
|
|
158
275
|
}
|
|
159
276
|
|
|
160
277
|
/**
|
|
161
|
-
*
|
|
278
|
+
* Run the daemon half of an installation and return the target decision plus step
|
|
162
279
|
* outcomes, or a refusal.
|
|
163
280
|
*/
|
|
164
|
-
export async function runDaemonPhase(args, effects) {
|
|
281
|
+
export async function runDaemonPhase(args, effects, exactSuite = null) {
|
|
165
282
|
const target = await resolveTarget({
|
|
166
283
|
stateDir: args.stateDir,
|
|
167
284
|
port: args.port,
|
|
@@ -184,7 +301,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
184
301
|
}
|
|
185
302
|
|
|
186
303
|
const dir = target.stateDir;
|
|
187
|
-
|
|
304
|
+
let creating = target.action === 'create';
|
|
188
305
|
effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
|
|
189
306
|
if (target.stalePidRecord) {
|
|
190
307
|
effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
|
|
@@ -203,8 +320,8 @@ export async function runDaemonPhase(args, effects) {
|
|
|
203
320
|
// daemon's broker is its own record, and re-asking would invite an operator to
|
|
204
321
|
// change it from a screen that is not about changing it. The broker question
|
|
205
322
|
// stays in v3: it is orthogonal to --state-dir/--port, so it
|
|
206
|
-
// does not violate
|
|
207
|
-
//
|
|
323
|
+
// does not violate the rule that state-directory and port values never appear
|
|
324
|
+
// in prompts.
|
|
208
325
|
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
209
326
|
|
|
210
327
|
const steps = [];
|
|
@@ -212,10 +329,26 @@ export async function runDaemonPhase(args, effects) {
|
|
|
212
329
|
// The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
|
|
213
330
|
// adapter each harness spawns. Both are required, but only `ours daemon`
|
|
214
331
|
// participates in lifecycle or service management.
|
|
215
|
-
const mcpPkg =
|
|
332
|
+
const mcpPkg = exactSuite?.packages?.mcp
|
|
333
|
+
? `@ours.network/mcp@${exactSuite.packages.mcp}`
|
|
334
|
+
: componentSpec(componentByKey('mcp'), args.channel);
|
|
335
|
+
// The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
|
|
336
|
+
// connector packages it has no nightly dist-tag. Keep this untagged on every
|
|
337
|
+
// installer channel, and inspect that package's SDK dependency for the gate.
|
|
338
|
+
const cliPkg = '@ours.network/cli';
|
|
339
|
+
if (!creating && target.daemonVersion) {
|
|
340
|
+
const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
|
|
341
|
+
if (mismatch) {
|
|
342
|
+
const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
|
|
343
|
+
if (prepared.refused) return { target, refused: prepared.refused, steps };
|
|
344
|
+
creating = prepared.purged === true;
|
|
345
|
+
target.backupPath = prepared.backupPath;
|
|
346
|
+
target.action = 'create';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
216
349
|
await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
|
|
217
350
|
steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
|
|
218
|
-
await perform(effects, args.dryRun,
|
|
351
|
+
await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
|
|
219
352
|
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
220
353
|
|
|
221
354
|
// The config file — merged, never rewritten, and untouched when it already
|
|
@@ -249,8 +382,11 @@ export async function runDaemonPhase(args, effects) {
|
|
|
249
382
|
|
|
250
383
|
try {
|
|
251
384
|
if (creating) {
|
|
252
|
-
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
|
|
385
|
+
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath], { stream: true }));
|
|
253
386
|
steps.push({ id: 'start', changed: true });
|
|
387
|
+
} else {
|
|
388
|
+
await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'restart', '--config', configPath], { stream: true }));
|
|
389
|
+
steps.push({ id: 'restart', changed: true });
|
|
254
390
|
}
|
|
255
391
|
|
|
256
392
|
const service = await runServicePhase(args, effects, dir, target.port);
|
|
@@ -343,7 +479,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
343
479
|
}
|
|
344
480
|
|
|
345
481
|
/**
|
|
346
|
-
*
|
|
482
|
+
* Install the boot service, including the legacy-unit case.
|
|
347
483
|
*
|
|
348
484
|
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
349
485
|
* the file. `--force` is passed ONLY here, only for a unit
|
|
@@ -430,11 +566,11 @@ export async function askComponents(args, effects) {
|
|
|
430
566
|
}
|
|
431
567
|
|
|
432
568
|
/**
|
|
433
|
-
*
|
|
569
|
+
* A component that fails is reported with its retry command and
|
|
434
570
|
* the run CONTINUES — a failed component is never a reason to undo a successful
|
|
435
571
|
* one, or to undo the daemon.
|
|
436
572
|
*/
|
|
437
|
-
export async function runComponentPhase(args, effects, target) {
|
|
573
|
+
export async function runComponentPhase(args, effects, target, exactSuite = null) {
|
|
438
574
|
const dir = target.stateDir;
|
|
439
575
|
const endpoint = `http://127.0.0.1:${target.port}`;
|
|
440
576
|
const isDefaultStateDir = dir === join(effects.home, '.ours');
|
|
@@ -453,13 +589,15 @@ export async function runComponentPhase(args, effects, target) {
|
|
|
453
589
|
continue;
|
|
454
590
|
}
|
|
455
591
|
try {
|
|
456
|
-
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
|
|
592
|
+
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }));
|
|
457
593
|
} catch (error) {
|
|
458
594
|
// Reported with its reason and the exact manual command; the run continues.
|
|
459
595
|
// The retry carries the CHANNEL — a nightly run that hands the operator a
|
|
460
596
|
// stable retry command sends them straight into the split-brain install
|
|
461
597
|
// this phase exists to avoid.
|
|
462
|
-
const
|
|
598
|
+
const retrySpec = component.key === 'mcp' && exactSuite?.packages?.mcp
|
|
599
|
+
? `@ours.network/mcp@${exactSuite.packages.mcp}` : componentSpec(component, args.channel);
|
|
600
|
+
const retry = `npm i -g ${retrySpec}`;
|
|
463
601
|
effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
464
602
|
effects.out(info(`retry manually: ${retry}`));
|
|
465
603
|
results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
|
|
@@ -468,10 +606,11 @@ export async function runComponentPhase(args, effects, target) {
|
|
|
468
606
|
return summarizeComponentRun(results);
|
|
469
607
|
}
|
|
470
608
|
|
|
471
|
-
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
|
|
609
|
+
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }) {
|
|
472
610
|
if (component.key === 'mcp') {
|
|
473
611
|
const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
|
|
474
|
-
|
|
612
|
+
const exact = exactSuite?.packages?.mcp ? `@ours.network/mcp@${exactSuite.packages.mcp}` : plan.install[3];
|
|
613
|
+
await perform(effects, args.dryRun, `install ${exact}`, () => effects.run('npm', ['i', '-g', exact]));
|
|
475
614
|
if (Object.keys(plan.harnessEnv).length > 0) {
|
|
476
615
|
effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
|
|
477
616
|
}
|
|
@@ -502,14 +641,21 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
502
641
|
}
|
|
503
642
|
}
|
|
504
643
|
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
644
|
+
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
505
645
|
if (plan.changed) {
|
|
646
|
+
journal.snapshot(path);
|
|
506
647
|
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
507
648
|
} else {
|
|
508
649
|
effects.out(ok(`${path} already points here — not touched`));
|
|
509
650
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
651
|
+
try {
|
|
652
|
+
await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
|
|
653
|
+
} catch (error) {
|
|
654
|
+
rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its daemon selection back');
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
effects.out(ok('Telegram connector installed as a durable service on the shared daemon.'));
|
|
658
|
+
return { key: 'tg', state: 'installed', note: 'configured; service running' };
|
|
513
659
|
}
|
|
514
660
|
|
|
515
661
|
const path = coworkConfigPath(effects.home, effects.env);
|
|
@@ -665,7 +811,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
665
811
|
}
|
|
666
812
|
|
|
667
813
|
/**
|
|
668
|
-
*
|
|
814
|
+
* Install harness plugins after the daemon and components are ready.
|
|
669
815
|
*
|
|
670
816
|
* Two things this phase must never do, both inherited rules rather than new
|
|
671
817
|
* ones. It never DRIVES a command it could not identify — an alias or a wrapper
|
|
@@ -675,7 +821,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
675
821
|
* get the exact export line and no promise. Hermes' writer is ours, so its
|
|
676
822
|
* invocation carries the whole pair and the claim is true.
|
|
677
823
|
*/
|
|
678
|
-
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
|
|
824
|
+
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
|
|
679
825
|
effects.out(heading('Harness plugins'));
|
|
680
826
|
const detected = await effects.detectHarnesses();
|
|
681
827
|
for (const h of detected) {
|
|
@@ -700,6 +846,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
700
846
|
});
|
|
701
847
|
|
|
702
848
|
const rows = [];
|
|
849
|
+
const markets = marketplacePaths(effects.home);
|
|
703
850
|
for (const plan of plans) {
|
|
704
851
|
const row = { key: plan.name, label: `${plan.label} plugin` };
|
|
705
852
|
if (plan.action === 'skip') {
|
|
@@ -710,27 +857,65 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
|
|
|
710
857
|
if (plan.action === 'manual') {
|
|
711
858
|
// NEVER a dead end: the plugin is still installable, by hand, and the run
|
|
712
859
|
// says so instead of pretending the harness does not exist.
|
|
860
|
+
let manual = plan.manual;
|
|
861
|
+
if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
|
|
862
|
+
const isClaude = plan.name === 'claude-code';
|
|
863
|
+
const root = isClaude ? markets.claudeRoot : markets.codexRoot;
|
|
864
|
+
const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
|
|
865
|
+
const value = isClaude
|
|
866
|
+
? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
|
|
867
|
+
: buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
|
|
868
|
+
await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
|
|
869
|
+
manual = isClaude
|
|
870
|
+
? [`/plugin marketplace add ${root}`, '/plugin install ours']
|
|
871
|
+
: [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
|
|
872
|
+
}
|
|
713
873
|
effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
|
|
714
|
-
for (const step of
|
|
874
|
+
for (const step of manual) effects.out(info(` ${step}`));
|
|
715
875
|
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
716
876
|
continue;
|
|
717
877
|
}
|
|
718
878
|
|
|
879
|
+
let steps = plan.steps;
|
|
880
|
+
let manual = plan.manual;
|
|
881
|
+
if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
|
|
882
|
+
const isClaude = plan.name === 'claude-code';
|
|
883
|
+
const root = isClaude ? markets.claudeRoot : markets.codexRoot;
|
|
884
|
+
const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
|
|
885
|
+
const value = isClaude
|
|
886
|
+
? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
|
|
887
|
+
: buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
|
|
888
|
+
await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
|
|
889
|
+
if (!isClaude) {
|
|
890
|
+
const current = await effects.codexMarketplace();
|
|
891
|
+
const source = current?.marketplaceSource;
|
|
892
|
+
if (current && !(source?.sourceType === 'local' && source?.source === root)) {
|
|
893
|
+
await perform(effects, args.dryRun, 'remove moving Codex marketplace source', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace']));
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
steps = isClaude
|
|
897
|
+
? [['claude', 'plugin', 'marketplace', 'add', root], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']]
|
|
898
|
+
: [['codex', 'plugin', 'marketplace', 'add', root], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'], ['npm', 'i', '-g', `@ours.network/codex@${exactSuite.packages.codex}`]];
|
|
899
|
+
manual = isClaude
|
|
900
|
+
? [`/plugin marketplace add ${root}`, '/plugin install ours']
|
|
901
|
+
: [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
|
|
902
|
+
}
|
|
903
|
+
|
|
719
904
|
const env = pairFor(plan, target);
|
|
720
905
|
let failed = null;
|
|
721
|
-
for (const step of
|
|
906
|
+
for (const step of steps) {
|
|
722
907
|
const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
|
|
723
908
|
if (!outcome.ok) { failed = outcome; break; }
|
|
724
909
|
}
|
|
725
910
|
if (failed) {
|
|
726
911
|
effects.out(info(`${plan.label} can still be installed by hand:`));
|
|
727
|
-
for (const step of
|
|
912
|
+
for (const step of manual) effects.out(info(` ${step}`));
|
|
728
913
|
rows.push({ ...row, state: 'failed', note: 'install step failed' });
|
|
729
914
|
continue;
|
|
730
915
|
}
|
|
731
916
|
if (plan.envLine) {
|
|
732
|
-
//
|
|
733
|
-
//
|
|
917
|
+
// This harness cannot persist the selected daemon pair. Say exactly what is
|
|
918
|
+
// true and how the operator can configure it explicitly.
|
|
734
919
|
effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
|
|
735
920
|
effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
|
|
736
921
|
}
|
|
@@ -823,13 +1008,9 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
|
|
|
823
1008
|
}
|
|
824
1009
|
|
|
825
1010
|
const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
|
|
826
|
-
if (has('
|
|
1011
|
+
if (has('fleet')) {
|
|
827
1012
|
effects.out('');
|
|
828
1013
|
effects.out(` ${c.bold('Installed but intentionally stopped')}`);
|
|
829
|
-
if (has('tg')) {
|
|
830
|
-
effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
|
|
831
|
-
effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
|
|
832
|
-
}
|
|
833
1014
|
if (has('fleet')) {
|
|
834
1015
|
effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
|
|
835
1016
|
effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
|
|
@@ -917,6 +1098,13 @@ export async function runInstall(argv, effects) {
|
|
|
917
1098
|
// that wrapped the old installer keeps its meaning.
|
|
918
1099
|
if (!runPreflight(effects).ok) return EXIT_OK;
|
|
919
1100
|
|
|
1101
|
+
const exactSuite = await resolveExactSuite(args, effects);
|
|
1102
|
+
if (!exactSuite.ok) {
|
|
1103
|
+
effects.out(warn(`Release suite could not be resolved safely: ${exactSuite.reason}. Nothing was changed.`));
|
|
1104
|
+
return EXIT_REFUSED;
|
|
1105
|
+
}
|
|
1106
|
+
effects.out(ok(`Release channel: ${exactSuite.channel} → exact lockstep suite v${exactSuite.version}`));
|
|
1107
|
+
|
|
920
1108
|
// Which daemon, before anything is decided about it. Only args.stateDir can
|
|
921
1109
|
// change here; every refusal downstream is unaffected.
|
|
922
1110
|
effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
|
|
@@ -924,7 +1112,7 @@ export async function runInstall(argv, effects) {
|
|
|
924
1112
|
if (selection.action === 'refuse') return EXIT_REFUSED;
|
|
925
1113
|
|
|
926
1114
|
effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
|
|
927
|
-
const daemon = await runDaemonPhase(args, effects);
|
|
1115
|
+
const daemon = await runDaemonPhase(args, effects, exactSuite);
|
|
928
1116
|
if (daemon.refused) return EXIT_REFUSED;
|
|
929
1117
|
const target = daemon.target;
|
|
930
1118
|
const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
|
|
@@ -946,8 +1134,8 @@ export async function runInstall(argv, effects) {
|
|
|
946
1134
|
});
|
|
947
1135
|
}
|
|
948
1136
|
|
|
949
|
-
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon;
|
|
950
|
-
const components = await runComponentPhase(args, effects, target);
|
|
1137
|
+
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
|
|
1138
|
+
const components = await runComponentPhase(args, effects, target, exactSuite);
|
|
951
1139
|
for (const component of COMPONENTS) {
|
|
952
1140
|
const state = components.installed.includes(component.key) ? 'installed'
|
|
953
1141
|
: components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
|
|
@@ -957,7 +1145,7 @@ export async function runInstall(argv, effects) {
|
|
|
957
1145
|
state,
|
|
958
1146
|
version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
|
|
959
1147
|
note: components.failed.find((f) => f.key === component.key)?.reason
|
|
960
|
-
?? (state === 'installed' && component.key === 'tg' ? 'configured;
|
|
1148
|
+
?? (state === 'installed' && component.key === 'tg' ? 'configured; service running'
|
|
961
1149
|
: state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
|
|
962
1150
|
});
|
|
963
1151
|
}
|
|
@@ -966,7 +1154,7 @@ export async function runInstall(argv, effects) {
|
|
|
966
1154
|
effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
|
|
967
1155
|
summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
|
|
968
1156
|
effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
|
|
969
|
-
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
|
|
1157
|
+
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite }));
|
|
970
1158
|
effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
|
|
971
1159
|
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
|
|
972
1160
|
summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
|
package/lib/plan.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure planning code, like lib/target.mjs: the orchestrator
|
|
4
4
|
// injects file reads, and every function returns a PLAN the caller renders and
|
|
5
5
|
// executes. Nothing here writes, spawns, or runs systemctl.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
|
|
|
11
11
|
export const DEFAULT_SYSTEMD_UNIT = 'ours.service';
|
|
12
12
|
|
|
13
13
|
// -----------------------------------------------------------------------------
|
|
14
|
-
//
|
|
14
|
+
// Which unit file does this state directory own?
|
|
15
15
|
// -----------------------------------------------------------------------------
|
|
16
16
|
|
|
17
17
|
// 1–32 chars, alphanumeric with interior hyphens/underscores, no dots.
|
|
@@ -59,7 +59,7 @@ export function unitPathForStateDir(stateDir, home) {
|
|
|
59
59
|
* legacy — the unit published ours-mcp wrote: NO marker, ExecStart running
|
|
60
60
|
* ours-mcp. This is the migration blocker. `ours daemon
|
|
61
61
|
* install-service` refuses to overwrite an unmarked unit without
|
|
62
|
-
* --force, so
|
|
62
|
+
* --force, so service installation fails for every existing Linux user.
|
|
63
63
|
* foreign — unmarked and NOT recognisably ours-mcp's. Someone else's file.
|
|
64
64
|
*
|
|
65
65
|
* THE legacy/foreign SPLIT IS NOW THE ENTIRE SAFETY BOUNDARY. A `legacy` unit is
|
|
@@ -85,7 +85,7 @@ export function classifyUnit(text) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Decide what this run should do about the boot service.
|
|
89
89
|
*
|
|
90
90
|
* Returns one of:
|
|
91
91
|
* { action: 'install' } — call the CLI; it does the rest
|
|
@@ -202,9 +202,9 @@ export function legacyReplacedNotice(unitPath, stateDir) {
|
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
204
|
* The CLI invocation that installs the boot service. The unit NAME is not passed:
|
|
205
|
-
* ours-sdk #20 made the CLI derive it from --state-dir itself,
|
|
206
|
-
*
|
|
207
|
-
*
|
|
205
|
+
* ours-sdk #20 made the CLI derive it from --state-dir itself, so the installer
|
|
206
|
+
* neither passes a per-instance unit name nor writes the unit itself: it selects
|
|
207
|
+
* the daemon and the CLI names
|
|
208
208
|
* the unit. One derivation, in one place.
|
|
209
209
|
*/
|
|
210
210
|
export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
@@ -221,7 +221,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
// -----------------------------------------------------------------------------
|
|
224
|
-
//
|
|
224
|
+
// Daemon configuration file
|
|
225
225
|
// -----------------------------------------------------------------------------
|
|
226
226
|
|
|
227
227
|
/**
|
|
@@ -247,7 +247,7 @@ export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
|
-
* The ordered, announced steps for the daemon half of a run
|
|
250
|
+
* The ordered, announced steps for the daemon half of a run. Each is
|
|
251
251
|
* idempotent, and an `update` skips creation entirely: it never moves a port and
|
|
252
252
|
* never creates a second daemon.
|
|
253
253
|
*/
|
package/lib/rerun.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// ours-install v3 — re-running, and a second daemon alongside the first.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pure re-run and coexistence planning, like the earlier stages.
|
|
4
4
|
//
|
|
5
5
|
// Two properties this file exists to make checkable rather than hoped for:
|
|
6
6
|
//
|
|
7
|
-
// IDEMPOTENCE
|
|
7
|
+
// IDEMPOTENCE. Running the installer again with the same answers changes
|
|
8
8
|
// nothing except refreshed npm packages. Not "changes little" — nothing: no
|
|
9
9
|
// config written, no unit rewritten, no systemctl run, no daemon restarted.
|
|
10
10
|
//
|
|
11
|
-
// COEXISTENCE
|
|
11
|
+
// COEXISTENCE. Two daemons share no per-daemon artefact. Everything keyed
|
|
12
12
|
// to a daemon is derived from its state directory, so two state directories
|
|
13
13
|
// produce two of everything.
|
|
14
14
|
|
|
@@ -16,8 +16,8 @@ import { join, resolve } from 'node:path';
|
|
|
16
16
|
import { unitNameForStateDir } from './plan.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Everything that belongs to
|
|
20
|
-
*
|
|
19
|
+
* Everything that belongs to one daemon is derived from its state directory.
|
|
20
|
+
* Listing the artifacts in one place makes "these two daemons share
|
|
21
21
|
* nothing" a property a test can check instead of a claim in a document.
|
|
22
22
|
*
|
|
23
23
|
* `port` is included because it is per-daemon, but note it is NOT what identifies
|
|
@@ -55,7 +55,7 @@ export function daemonCollisions(a, b) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* The per-component coexistence rule
|
|
58
|
+
* The per-component coexistence rule, stated so the screen can never
|
|
59
59
|
* imply something the design does not do.
|
|
60
60
|
*
|
|
61
61
|
* mcp — coexists naturally. Each harness registration carries its own
|
|
@@ -107,7 +107,7 @@ export function summarizeRun(steps) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Did a re-run leave the daemon alone?
|
|
110
|
+
* Did a re-run leave the daemon alone? An update never deletes state,
|
|
111
111
|
* never moves a port, and never creates a second daemon.
|
|
112
112
|
*/
|
|
113
113
|
export function assertUpdateLeftDaemonAlone({ before, after }) {
|