@ours.network/install 0.17.0-nightly.12 → 0.17.0-nightly.13
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/components.mjs +11 -2
- package/lib/extras.mjs +11 -2
- package/lib/orchestrate-uninstall.mjs +6 -20
- package/lib/orchestrate.mjs +72 -52
- package/lib/plan.mjs +41 -19
- package/lib/target.mjs +38 -5
- package/lib/uninstall.mjs +16 -26
- package/package.json +1 -1
package/lib/components.mjs
CHANGED
|
@@ -23,8 +23,12 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
|
23
23
|
// effects.installedVersion('@ours.network/mcp@nightly') return null forever,
|
|
24
24
|
// which fails the cowork version floor CLOSED and blanks the version column —
|
|
25
25
|
// a silent regression that looks like "cowork is too old".
|
|
26
|
+
// `required` is not a stronger default — it is the absence of a choice. The MCP
|
|
27
|
+
// package IS the daemon now (the daemon phase installs and starts it), so an
|
|
28
|
+
// operator who declined it would have no daemon at all, which is not a decision
|
|
29
|
+
// anyone means to make. Declining has to be impossible rather than discouraged.
|
|
26
30
|
export const COMPONENTS = [
|
|
27
|
-
{ key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
|
|
31
|
+
{ key: 'mcp', label: 'ours daemon + MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
|
|
28
32
|
{ key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
|
|
29
33
|
{ key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
|
|
30
34
|
];
|
|
@@ -114,7 +118,12 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
|
|
|
114
118
|
return COMPONENTS.map((component) => {
|
|
115
119
|
const already = installed[component.key] === true;
|
|
116
120
|
const answer = assumeYes ? component.default : answers[component.key];
|
|
117
|
-
|
|
121
|
+
// A required component ignores the answer entirely, including an explicit no.
|
|
122
|
+
// The daemon phase has already installed and started it by the time this runs;
|
|
123
|
+
// "skip" here would only produce a screen that contradicts the machine.
|
|
124
|
+
const wanted = component.required
|
|
125
|
+
? true
|
|
126
|
+
: (answer === undefined ? (already || component.default) : answer === true);
|
|
118
127
|
return {
|
|
119
128
|
...component,
|
|
120
129
|
already,
|
package/lib/extras.mjs
CHANGED
|
@@ -333,10 +333,19 @@ export function planVoice({
|
|
|
333
333
|
// The beat. Only owed when the config actually changed: an unchanged config
|
|
334
334
|
// is not a reason to bounce a daemon somebody else may be using.
|
|
335
335
|
restartOwed: Boolean(configChanged),
|
|
336
|
-
restart
|
|
336
|
+
// ours-mcp's restart, because ours-mcp is the daemon now. NOTE FOR WHOEVER
|
|
337
|
+
// TOUCHES THIS NEXT — the reason the installer owns this beat at all may have
|
|
338
|
+
// just evaporated: cmdVoiceSetup computes `managed = runningPid() !== null`
|
|
339
|
+
// from ours-mcp's OWN pid record, and an ours-mcp daemon HAS one. Under the
|
|
340
|
+
// SDK-CLI daemon it never did, which is why the restart had no owner and the
|
|
341
|
+
// installer took it. If voice-setup now classifies the daemon as managed it
|
|
342
|
+
// will run its own restart protocol, and this becomes a second restart rather
|
|
343
|
+
// than the only one. Not changed here: that is a behaviour question for
|
|
344
|
+
// packages/core, not a rename.
|
|
345
|
+
restart: configChanged && config ? ['ours-mcp', 'restart'] : null,
|
|
337
346
|
// Never rolls the daemon back: voice-setup leaves the prior config intact on
|
|
338
347
|
// its own failure path, so the recovery is a retry, not an undo.
|
|
339
|
-
retryHint: config ? `ours
|
|
348
|
+
retryHint: config ? `OURS_CONFIG=${config} ours-mcp restart` : null,
|
|
340
349
|
port: Number.isInteger(port) ? port : null,
|
|
341
350
|
};
|
|
342
351
|
}
|
|
@@ -139,9 +139,13 @@ export async function runUninstall(argv, effects) {
|
|
|
139
139
|
readJson: effects.readJson,
|
|
140
140
|
readText: effects.readText,
|
|
141
141
|
exists: effects.exists,
|
|
142
|
-
|
|
142
|
+
// EITHER record proves a managed daemon: ours-cli-daemon.json is what
|
|
143
|
+
// `ours daemon start` wrote yesterday, daemon.pid is what `ours-mcp start`
|
|
144
|
+
// writes today. Reading only one makes an uninstall on the other kind decline
|
|
145
|
+
// to stop a daemon it could have stopped.
|
|
146
|
+
cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
|
|
147
|
+
|| effects.readText(join(dir, 'daemon.pid')) !== null,
|
|
143
148
|
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
144
|
-
platform: effects.platform?.platform,
|
|
145
149
|
typedConfirmation: null,
|
|
146
150
|
explicitHarnessSelection: contract.engaged,
|
|
147
151
|
});
|
|
@@ -160,7 +164,6 @@ export async function runUninstall(argv, effects) {
|
|
|
160
164
|
// removal makes that true. If step 3 or 4 fails, the operator is left with a
|
|
161
165
|
// stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
|
|
162
166
|
// bytes no longer describe.
|
|
163
|
-
let unsupportedService = null;
|
|
164
167
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
165
168
|
const detached = [];
|
|
166
169
|
for (const component of plan.detach) {
|
|
@@ -180,13 +183,6 @@ export async function runUninstall(argv, effects) {
|
|
|
180
183
|
// 3-4. The boot service, then the daemon. Both delegate their refusals.
|
|
181
184
|
try {
|
|
182
185
|
for (const step of plan.daemon) {
|
|
183
|
-
if (step.id === 'service-unsupported') {
|
|
184
|
-
// Said, not skipped silently: the operator is entitled to know that the
|
|
185
|
-
// thing they may have expected to be removed was never installed.
|
|
186
|
-
effects.out(warn(`ours: ${step.note}`));
|
|
187
|
-
unsupportedService = step;
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
186
|
if (step.command === null) {
|
|
191
187
|
effects.out(info(`${step.note} — nothing signalled`));
|
|
192
188
|
continue;
|
|
@@ -213,7 +209,6 @@ export async function runUninstall(argv, effects) {
|
|
|
213
209
|
const packages = planGlobalPackages({
|
|
214
210
|
stateDir: dir,
|
|
215
211
|
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
216
|
-
platform: effects.platform?.platform,
|
|
217
212
|
pluginPackages: pluginOutcome.packages,
|
|
218
213
|
// The plugin packages are recomputed; the CONNECTOR packages are not. They
|
|
219
214
|
// are decided by what the operator confirmed detaching, which the plugin
|
|
@@ -229,15 +224,6 @@ export async function runUninstall(argv, effects) {
|
|
|
229
224
|
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
230
225
|
}
|
|
231
226
|
}
|
|
232
|
-
// SAID AT THE END, where a closing screen is actually read, and not only in the
|
|
233
|
-
// line it scrolled past during the run. The same rule as the install side: an
|
|
234
|
-
// uninstall that quietly did less than the operator believes is the thing to
|
|
235
|
-
// avoid, and "nothing of ours was there to remove" is only reassuring if it is
|
|
236
|
-
// stated.
|
|
237
|
-
if (unsupportedService) {
|
|
238
|
-
effects.out(warn(`Boot service: NOT removed — ${unsupportedService.note}.`));
|
|
239
|
-
effects.out(info('If you start the daemon yourself at login, remove that arrangement by hand.'));
|
|
240
|
-
}
|
|
241
227
|
return EXIT_OK;
|
|
242
228
|
}
|
|
243
229
|
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './p
|
|
|
29
29
|
import {
|
|
30
30
|
COMPONENTS,
|
|
31
31
|
planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
|
|
32
|
-
tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
|
|
32
|
+
tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
|
|
33
33
|
} from './components.mjs';
|
|
34
34
|
import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
|
|
35
35
|
import { summarizeRun } from './rerun.mjs';
|
|
@@ -176,6 +176,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
176
176
|
portExplicit: args.portExplicit,
|
|
177
177
|
probe: effects.probe,
|
|
178
178
|
readJson: effects.readJson,
|
|
179
|
+
readText: effects.readText,
|
|
179
180
|
isTaken: effects.isTaken,
|
|
180
181
|
});
|
|
181
182
|
|
|
@@ -216,8 +217,22 @@ export async function runDaemonPhase(args, effects) {
|
|
|
216
217
|
|
|
217
218
|
const steps = [];
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
|
|
220
|
+
// THE DAEMON IS ours-mcp, SO ITS PACKAGE COMES FIRST.
|
|
221
|
+
//
|
|
222
|
+
// This used to install @ours.network/cli, because the daemon used to be
|
|
223
|
+
// `ours daemon serve`. That daemon does not mount /mcp — the SDK serves that
|
|
224
|
+
// route only when a host supplies an `mcp` option, and @ours.network/cli calls
|
|
225
|
+
// startDaemon() with no arguments — so every MCP client, including every harness
|
|
226
|
+
// session through `ours-mcp proxy`, got the daemon's own 404. The one
|
|
227
|
+
// MCP-capable daemon in the stack is ours-mcp's, which supplies the server
|
|
228
|
+
// factory, the transport constructor and the notification sink
|
|
229
|
+
// (packages/core/src/serve.ts).
|
|
230
|
+
//
|
|
231
|
+
// The order therefore inverts: the MCP package was a COMPONENT installed after
|
|
232
|
+
// this phase, and it is now the thing this phase starts.
|
|
233
|
+
const daemonPkg = componentSpec(componentByKey('mcp'), args.channel);
|
|
234
|
+
await perform(effects, args.dryRun, `ours daemon installed (npm i -g ${daemonPkg})`, () => effects.run('npm', ['i', '-g', daemonPkg]));
|
|
235
|
+
steps.push({ id: 'daemon-package', changed: true, packageRefresh: true });
|
|
221
236
|
|
|
222
237
|
// The config file — merged, never rewritten, and untouched when it already
|
|
223
238
|
// matches. No provenance marker is written: the owner ruled that --purge works
|
|
@@ -239,7 +254,6 @@ export async function runDaemonPhase(args, effects) {
|
|
|
239
254
|
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
240
255
|
// conditions for it.
|
|
241
256
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
242
|
-
let serviceUnsupported = null;
|
|
243
257
|
if (merged.changed) {
|
|
244
258
|
journal.snapshot(configPath);
|
|
245
259
|
await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
|
|
@@ -250,12 +264,18 @@ export async function runDaemonPhase(args, effects) {
|
|
|
250
264
|
|
|
251
265
|
try {
|
|
252
266
|
if (creating) {
|
|
253
|
-
|
|
267
|
+
// `start`, not `serve`: start backgrounds the daemon and polls until the port
|
|
268
|
+
// is open, which is the same observable readiness the old path had. `serve`
|
|
269
|
+
// runs in the foreground and would never return.
|
|
270
|
+
//
|
|
271
|
+
// Selected by ENVIRONMENT rather than flags — ours-mcp reads OURS_CONFIG /
|
|
272
|
+
// OURS_PORT / OURS_STATE_DIR — and daemonEnv is the one place that pair is
|
|
273
|
+
// built, so the daemon this run created is the daemon that starts.
|
|
274
|
+
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, target.port) }));
|
|
254
275
|
steps.push({ id: 'start', changed: true });
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
const service = await runServicePhase(args, effects, dir);
|
|
258
|
-
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
278
|
+
const service = await runServicePhase(args, effects, dir, target.port);
|
|
259
279
|
if (service.refused) {
|
|
260
280
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
261
281
|
// unit file stops the run just as a failed start does, and it stops it with
|
|
@@ -276,7 +296,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
276
296
|
// The nightly flow re-runs `start` and, crucially, tells the two outcomes
|
|
277
297
|
// apart: "the service failed but the daemon is back" is a bad evening, and "the
|
|
278
298
|
// service failed AND it will not come back" is the one that needs a human now.
|
|
279
|
-
const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath) : null;
|
|
299
|
+
const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
|
|
280
300
|
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
|
|
281
301
|
replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
|
|
282
302
|
});
|
|
@@ -284,12 +304,12 @@ export async function runDaemonPhase(args, effects) {
|
|
|
284
304
|
effects.out(recovery.recovered
|
|
285
305
|
? ok('your daemon is running again — nothing was committed, and the service is unchanged')
|
|
286
306
|
: warn('and the daemon did NOT come back up — start it yourself before anything else: '
|
|
287
|
-
+ `ours
|
|
307
|
+
+ `OURS_CONFIG=${configPath} ours-mcp start`));
|
|
288
308
|
}
|
|
289
309
|
throw error;
|
|
290
310
|
}
|
|
291
311
|
|
|
292
|
-
return { target, steps
|
|
312
|
+
return { target, steps };
|
|
293
313
|
}
|
|
294
314
|
|
|
295
315
|
/**
|
|
@@ -304,10 +324,10 @@ export async function runDaemonPhase(args, effects) {
|
|
|
304
324
|
* is REPORTED, never thrown: the caller is already carrying the real error, and
|
|
305
325
|
* losing it to a second one would hide what actually went wrong.
|
|
306
326
|
*/
|
|
307
|
-
async function recoverDaemon(args, effects, dir, configPath) {
|
|
327
|
+
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
308
328
|
if (args.dryRun) return null;
|
|
309
329
|
try {
|
|
310
|
-
await effects.run('ours', ['
|
|
330
|
+
await effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, port) });
|
|
311
331
|
return { recovered: true };
|
|
312
332
|
} catch (recoveryError) {
|
|
313
333
|
return { recovered: false, reason: reason(recoveryError) };
|
|
@@ -350,20 +370,8 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
350
370
|
* the file — the owner's decision. `--force` is passed ONLY here, only for a unit
|
|
351
371
|
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
352
372
|
*/
|
|
353
|
-
export async function runServicePhase(args, effects, dir) {
|
|
354
|
-
const plan = planServiceInstall({
|
|
355
|
-
stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
|
|
356
|
-
});
|
|
357
|
-
// NOT a refusal and NOT a failure: the daemon is running and correct, and only
|
|
358
|
-
// the boot service could not be installed. So the run CONTINUES — but it says
|
|
359
|
-
// so, and the summary marks it, because the person this hurts is the one who
|
|
360
|
-
// reboots in a fortnight and finds nothing listening.
|
|
361
|
-
if (plan.action === 'unsupported') {
|
|
362
|
-
effects.out(warn(`ours: ${plan.message}`));
|
|
363
|
-
effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
|
|
364
|
-
effects.out(info('Nothing else in this run depends on the boot service.'));
|
|
365
|
-
return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
|
|
366
|
-
}
|
|
373
|
+
export async function runServicePhase(args, effects, dir, port) {
|
|
374
|
+
const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
|
|
367
375
|
if (plan.action === 'refuse') {
|
|
368
376
|
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
369
377
|
return { refused: plan };
|
|
@@ -371,6 +379,14 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
371
379
|
const adopting = plan.action === 'adopt';
|
|
372
380
|
if (adopting) effects.out(info(plan.notice));
|
|
373
381
|
const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
|
|
382
|
+
// The unit's bytes BEFORE, so "did it change?" survives the loss of --json.
|
|
383
|
+
// `ours daemon install-service --json` used to answer that itself; ours-mcp's
|
|
384
|
+
// takes no flags and reports nothing machine-readable. Assuming `changed: true`
|
|
385
|
+
// would make every re-run claim it rewrote the unit, which turns the honest
|
|
386
|
+
// "everything already correct" line into one that never appears — a screen that
|
|
387
|
+
// is wrong in the reassuring direction. So the installer does the comparison it
|
|
388
|
+
// used to delegate: it already reads this file to classify it.
|
|
389
|
+
const unitBefore = plan.unitPath ? effects.readText(plan.unitPath) : null;
|
|
374
390
|
let outcome;
|
|
375
391
|
try {
|
|
376
392
|
outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
|
|
@@ -382,11 +398,16 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
382
398
|
error.servicePlan = plan;
|
|
383
399
|
throw error;
|
|
384
400
|
}
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
401
|
+
// Ours now, by the same byte comparison the CLI used to do. A dry run changed
|
|
402
|
+
// nothing by definition; an unreadable file either side is treated as "changed",
|
|
403
|
+
// which is the safe direction for a summary line.
|
|
404
|
+
const changed = args.dryRun
|
|
405
|
+
? false
|
|
406
|
+
: (() => {
|
|
407
|
+
const after = plan.unitPath ? effects.readText(plan.unitPath) : null;
|
|
408
|
+
if (unitBefore === null || after === null) return true;
|
|
409
|
+
return unitBefore !== after;
|
|
410
|
+
})();
|
|
390
411
|
return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
|
|
391
412
|
}
|
|
392
413
|
|
|
@@ -608,12 +629,6 @@ export function runPreflight(effects) {
|
|
|
608
629
|
return { ok: false, platform: plat };
|
|
609
630
|
}
|
|
610
631
|
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
611
|
-
// Supported is still true — the daemon runs here. What is NOT available is the
|
|
612
|
-
// boot service, and saying "supported" without that qualification is what let a
|
|
613
|
-
// Mac user walk into a run that could not finish.
|
|
614
|
-
if (effects.platform?.platform && effects.platform.platform !== 'linux') {
|
|
615
|
-
effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available yet — you will start it yourself after a reboot.`));
|
|
616
|
-
}
|
|
617
632
|
const version = String(effects.nodeVersion ?? '0');
|
|
618
633
|
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
619
634
|
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
@@ -651,6 +666,19 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
651
666
|
effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
|
|
652
667
|
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
653
668
|
}
|
|
669
|
+
// `ours-mcp create-root` — and it works BECAUSE the daemon is ours-mcp.
|
|
670
|
+
//
|
|
671
|
+
// It opens an MCP streamable-HTTP session to http://127.0.0.1:<port>/mcp. A
|
|
672
|
+
// daemon started by `ours daemon serve` does not mount that route (the SDK
|
|
673
|
+
// serves /mcp only when an `mcp` option is supplied, and @ours.network/cli calls
|
|
674
|
+
// startDaemon() with no arguments), which is why this exact command returned the
|
|
675
|
+
// daemon's own 404 body — "Not found" — on the first real install. An ours-mcp
|
|
676
|
+
// daemon supplies that option (packages/core/src/serve.ts), so the route exists
|
|
677
|
+
// and the command succeeds.
|
|
678
|
+
//
|
|
679
|
+
// Calling the SDK CLI's `ours identity create-root` instead would work too, over
|
|
680
|
+
// /api/v1/ — but it would mean installing and driving a second client purely to
|
|
681
|
+
// create an identity, on a daemon that already speaks the first one.
|
|
654
682
|
try {
|
|
655
683
|
const result = await effects.run('ours-mcp', ['create-root', name], { env });
|
|
656
684
|
const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
|
|
@@ -668,11 +696,16 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
668
696
|
}
|
|
669
697
|
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
670
698
|
effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
671
|
-
|
|
699
|
+
// The hint NAMES THIS DAEMON, and it has to keep doing that through the
|
|
700
|
+
// mechanism change. `ours daemon start` took --config; ours-mcp is selected
|
|
701
|
+
// by environment instead, so the pair travels as an env prefix rather than a
|
|
702
|
+
// flag. A retry command that omits it would start the DEFAULT daemon — which
|
|
703
|
+
// is how someone ends up with an identity on a daemon they did not choose.
|
|
704
|
+
effects.out(info(`Fix: run 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp start', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
|
|
672
705
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
673
706
|
}
|
|
674
707
|
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
675
|
-
effects.out(info(`Retry any time: 'ours-mcp create-root "${name}"'.`));
|
|
708
|
+
effects.out(info(`Retry any time: 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
|
|
676
709
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
|
|
677
710
|
}
|
|
678
711
|
}
|
|
@@ -1023,19 +1056,6 @@ export async function runInstall(argv, effects) {
|
|
|
1023
1056
|
state: target.action === 'create' ? 'installed' : 'current',
|
|
1024
1057
|
note: `port ${target.port}`,
|
|
1025
1058
|
}];
|
|
1026
|
-
// THE SKIP MUST NOT READ AS SUCCESS. A line that scrolls past is not a warning
|
|
1027
|
-
// — the summary is where someone looks, so an uninstallable boot service gets
|
|
1028
|
-
// the same "needs attention" mark a failed component gets. If this screen read
|
|
1029
|
-
// clean on macOS we would have replaced a failed install with a quieter lie.
|
|
1030
|
-
if (daemon.serviceUnsupported) {
|
|
1031
|
-
summary.push({
|
|
1032
|
-
key: 'service',
|
|
1033
|
-
label: 'Boot service',
|
|
1034
|
-
state: 'failed',
|
|
1035
|
-
note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} yet — start the daemon yourself after a reboot`,
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
1059
|
const components = await runComponentPhase(args, effects, target);
|
|
1040
1060
|
for (const component of COMPONENTS) {
|
|
1041
1061
|
const state = components.installed.includes(component.key) ? 'installed'
|
package/lib/plan.mjs
CHANGED
|
@@ -130,19 +130,18 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
|
|
|
130
130
|
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
131
131
|
// package can install a launchd agent, and pretending otherwise by writing a
|
|
132
132
|
// plist ourselves would put a second service implementation in a second repo.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
133
|
+
// THE macOS SKIP IS GONE, AND THAT IS A CONSEQUENCE OF THE DAEMON CHANGE.
|
|
134
|
+
//
|
|
135
|
+
// It existed because `ours daemon install-service` refuses on any non-linux
|
|
136
|
+
// platform — createLinuxUserSystemdAdapter() throws on its first line, and the
|
|
137
|
+
// whole @ours.network/cli package contains no launchd support. That was true and
|
|
138
|
+
// is no longer the relevant question: the unit is now installed by ours-mcp's
|
|
139
|
+
// own install-service, which handles systemd AND launchd
|
|
140
|
+
// (packages/core/src/cli.ts writes ~/Library/LaunchAgents and runs launchctl
|
|
141
|
+
// bootstrap). So macOS gets a real boot service rather than a named gap.
|
|
142
|
+
//
|
|
143
|
+
// A gap list that warns about something already fixed is as misleading as one
|
|
144
|
+
// that hides something broken, which is why the warning goes with the skip.
|
|
146
145
|
const derived = unitPathForStateDir(stateDir, home);
|
|
147
146
|
if (!derived.ok) {
|
|
148
147
|
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
|
@@ -212,12 +211,35 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
212
211
|
// --json so the caller can read back whether the unit actually CHANGED. The
|
|
213
212
|
// CLI owns that byte-comparison, and an installer that guessed at it would
|
|
214
213
|
// report "nothing changed" on a run that rewrote a unit.
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
214
|
+
// ours-mcp's install-service, NOT the SDK CLI's, and the difference is not
|
|
215
|
+
// cosmetic. `ours daemon install-service` writes a unit whose ExecStart runs
|
|
216
|
+
// `ours daemon serve` — a daemon that does not mount /mcp — so the boot service
|
|
217
|
+
// would resurrect exactly the daemon the /mcp 404 came from. ours-mcp's writes
|
|
218
|
+
// ExecStart=<node> <ours-mcp> serve (packages/core/src/service-instance.ts:106),
|
|
219
|
+
// and it handles launchd as well as systemd.
|
|
220
|
+
//
|
|
221
|
+
// It takes no arguments: like `start`, it is selected by OURS_CONFIG /
|
|
222
|
+
// OURS_PORT / OURS_STATE_DIR, and it BAKES those resolved values into the unit.
|
|
223
|
+
// The caller passes the pair as an environment, which is why this returns a bare
|
|
224
|
+
// command and the orchestrator supplies daemonEnv.
|
|
225
|
+
//
|
|
226
|
+
// WHAT IS LOST, stated rather than discovered later. ours-mcp's install-service
|
|
227
|
+
// takes NO flags:
|
|
228
|
+
//
|
|
229
|
+
// · no --json, so the caller cannot read back whether the unit actually
|
|
230
|
+
// changed. `changed` is now assumed true, which is the safe direction for a
|
|
231
|
+
// summary line but is an assumption where it used to be an answer.
|
|
232
|
+
// · no --force, and no marker check either: it writes the unit file
|
|
233
|
+
// unconditionally. The SDK CLI refused to overwrite a unit it had not
|
|
234
|
+
// marked, and that refusal was the backstop behind classifyUnit. It is gone.
|
|
235
|
+
// classifyUnit's `foreign` refusal is now the ONLY thing standing between a
|
|
236
|
+
// stranger's unit file and an overwrite, so `adoptLegacyUnit` no longer
|
|
237
|
+
// changes the COMMAND — it records that the caller already decided, and the
|
|
238
|
+
// decision is enforced entirely by refusing to get here at all.
|
|
239
|
+
//
|
|
240
|
+
// That is a real reduction in defence in depth and it belongs in the PR, not in
|
|
241
|
+
// a comment nobody reads.
|
|
242
|
+
return ['ours-mcp', 'install-service'];
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
// -----------------------------------------------------------------------------
|
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; `ours-mcp start` writes 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
|
@@ -168,37 +168,27 @@ export function planComponentDetach(key, existing) {
|
|
|
168
168
|
* case the screen names the external launcher and the run CONTINUES — a daemon
|
|
169
169
|
* someone else supervises is not a failure of this uninstall.
|
|
170
170
|
*/
|
|
171
|
-
export function planDaemonRemoval({ stateDir, cliStartedIt
|
|
171
|
+
export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
172
172
|
const dir = resolve(stateDir);
|
|
173
173
|
const unit = unitNameForStateDir(dir);
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
174
|
+
// ours-mcp's own uninstall-service, for the same reason install-service is:
|
|
175
|
+
// the unit that exists was written by ours-mcp, and `ours daemon
|
|
176
|
+
// uninstall-service` refuses a unit it did not mark. It takes no flags and is
|
|
177
|
+
// selected by the environment, like every other ours-mcp lifecycle command.
|
|
177
178
|
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
command: null,
|
|
188
|
-
platform,
|
|
189
|
-
continues: true,
|
|
190
|
-
note: `no boot service was installed on ${platform === 'darwin' ? 'macOS' : platform} — the ours CLI can only manage a Linux user systemd service, so there is nothing of ours to remove here`,
|
|
191
|
-
}
|
|
192
|
-
: {
|
|
193
|
-
id: 'service',
|
|
194
|
-
unit: unit.ok ? unit.unit : null,
|
|
195
|
-
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
|
|
196
|
-
note: 'refuses a unit not marked as CLI-managed',
|
|
197
|
-
};
|
|
179
|
+
// The macOS skip that briefly lived here is gone with the daemon change:
|
|
180
|
+
// ours-mcp's uninstall-service handles launchd as well as systemd, so there is
|
|
181
|
+
// no platform on which this declines to try.
|
|
182
|
+
const service = {
|
|
183
|
+
id: 'service',
|
|
184
|
+
unit: unit.ok ? unit.unit : null,
|
|
185
|
+
command: ['ours-mcp', 'uninstall-service'],
|
|
186
|
+
note: 'removes the unit ours-mcp installed',
|
|
187
|
+
};
|
|
198
188
|
return [
|
|
199
189
|
service,
|
|
200
190
|
cliStartedIt
|
|
201
|
-
? { id: 'stop', command: ['ours', '
|
|
191
|
+
? { id: 'stop', command: ['ours-mcp', 'stop'] }
|
|
202
192
|
: { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
|
|
203
193
|
];
|
|
204
194
|
}
|
|
@@ -531,7 +521,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
|
|
|
531
521
|
action: 'uninstall',
|
|
532
522
|
stateDir: dir,
|
|
533
523
|
detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
|
|
534
|
-
daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt
|
|
524
|
+
daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
|
|
535
525
|
state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
|
|
536
526
|
plugins,
|
|
537
527
|
packages: planGlobalPackages({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.17.0-nightly.
|
|
3
|
+
"version": "0.17.0-nightly.13",
|
|
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",
|