@ours.network/install 0.17.0-nightly.11 → 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 -1
- package/lib/orchestrate.mjs +70 -18
- package/lib/plan.mjs +61 -7
- package/lib/target.mjs +38 -5
- package/lib/uninstall.mjs +17 -8
- 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,7 +139,12 @@ 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
149
|
typedConfirmation: null,
|
|
145
150
|
explicitHarnessSelection: contract.engaged,
|
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
|
|
@@ -249,11 +264,18 @@ export async function runDaemonPhase(args, effects) {
|
|
|
249
264
|
|
|
250
265
|
try {
|
|
251
266
|
if (creating) {
|
|
252
|
-
|
|
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) }));
|
|
253
275
|
steps.push({ id: 'start', changed: true });
|
|
254
276
|
}
|
|
255
277
|
|
|
256
|
-
const service = await runServicePhase(args, effects, dir);
|
|
278
|
+
const service = await runServicePhase(args, effects, dir, target.port);
|
|
257
279
|
if (service.refused) {
|
|
258
280
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
259
281
|
// unit file stops the run just as a failed start does, and it stops it with
|
|
@@ -274,7 +296,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
274
296
|
// The nightly flow re-runs `start` and, crucially, tells the two outcomes
|
|
275
297
|
// apart: "the service failed but the daemon is back" is a bad evening, and "the
|
|
276
298
|
// service failed AND it will not come back" is the one that needs a human now.
|
|
277
|
-
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;
|
|
278
300
|
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
|
|
279
301
|
replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
|
|
280
302
|
});
|
|
@@ -282,7 +304,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
282
304
|
effects.out(recovery.recovered
|
|
283
305
|
? ok('your daemon is running again — nothing was committed, and the service is unchanged')
|
|
284
306
|
: warn('and the daemon did NOT come back up — start it yourself before anything else: '
|
|
285
|
-
+ `ours
|
|
307
|
+
+ `OURS_CONFIG=${configPath} ours-mcp start`));
|
|
286
308
|
}
|
|
287
309
|
throw error;
|
|
288
310
|
}
|
|
@@ -302,10 +324,10 @@ export async function runDaemonPhase(args, effects) {
|
|
|
302
324
|
* is REPORTED, never thrown: the caller is already carrying the real error, and
|
|
303
325
|
* losing it to a second one would hide what actually went wrong.
|
|
304
326
|
*/
|
|
305
|
-
async function recoverDaemon(args, effects, dir, configPath) {
|
|
327
|
+
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
306
328
|
if (args.dryRun) return null;
|
|
307
329
|
try {
|
|
308
|
-
await effects.run('ours', ['
|
|
330
|
+
await effects.run('ours-mcp', ['start'], { env: daemonEnv(dir, port) });
|
|
309
331
|
return { recovered: true };
|
|
310
332
|
} catch (recoveryError) {
|
|
311
333
|
return { recovered: false, reason: reason(recoveryError) };
|
|
@@ -348,7 +370,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
348
370
|
* the file — the owner's decision. `--force` is passed ONLY here, only for a unit
|
|
349
371
|
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
350
372
|
*/
|
|
351
|
-
export async function runServicePhase(args, effects, dir) {
|
|
373
|
+
export async function runServicePhase(args, effects, dir, port) {
|
|
352
374
|
const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
|
|
353
375
|
if (plan.action === 'refuse') {
|
|
354
376
|
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
@@ -357,6 +379,14 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
357
379
|
const adopting = plan.action === 'adopt';
|
|
358
380
|
if (adopting) effects.out(info(plan.notice));
|
|
359
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;
|
|
360
390
|
let outcome;
|
|
361
391
|
try {
|
|
362
392
|
outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
|
|
@@ -368,11 +398,16 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
368
398
|
error.servicePlan = plan;
|
|
369
399
|
throw error;
|
|
370
400
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
})();
|
|
376
411
|
return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
|
|
377
412
|
}
|
|
378
413
|
|
|
@@ -631,6 +666,19 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
631
666
|
effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
|
|
632
667
|
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
633
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.
|
|
634
682
|
try {
|
|
635
683
|
const result = await effects.run('ours-mcp', ['create-root', name], { env });
|
|
636
684
|
const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
|
|
@@ -648,11 +696,16 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
648
696
|
}
|
|
649
697
|
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
650
698
|
effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
651
|
-
|
|
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}"'.`));
|
|
652
705
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
653
706
|
}
|
|
654
707
|
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
655
|
-
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}"'.`));
|
|
656
709
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
|
|
657
710
|
}
|
|
658
711
|
}
|
|
@@ -1003,7 +1056,6 @@ export async function runInstall(argv, effects) {
|
|
|
1003
1056
|
state: target.action === 'create' ? 'installed' : 'current',
|
|
1004
1057
|
note: `port ${target.port}`,
|
|
1005
1058
|
}];
|
|
1006
|
-
|
|
1007
1059
|
const components = await runComponentPhase(args, effects, target);
|
|
1008
1060
|
for (const component of COMPONENTS) {
|
|
1009
1061
|
const state = components.installed.includes(component.key) ? 'installed'
|
package/lib/plan.mjs
CHANGED
|
@@ -110,7 +110,38 @@ export function classifyUnit(text) {
|
|
|
110
110
|
* without a question in front of it — it is what keeps --force from becoming a
|
|
111
111
|
* default that spreads to the other cases.
|
|
112
112
|
*/
|
|
113
|
-
export function planServiceInstall({ stateDir, home, readText }) {
|
|
113
|
+
export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
|
|
114
|
+
// THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
|
|
115
|
+
//
|
|
116
|
+
// `ours daemon install-service` in @ours.network/cli builds its adapter with
|
|
117
|
+
// `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
|
|
118
|
+
// factory's FIRST line is
|
|
119
|
+
// if (deps.platform !== 'linux') throw new Error('service management is not
|
|
120
|
+
// supported on <platform>; use an external launcher for `ours daemon serve`')
|
|
121
|
+
// — verified by reading the published 0.4.1 tarball, which contains zero
|
|
122
|
+
// occurrences of launchd, LaunchAgents or plist.
|
|
123
|
+
//
|
|
124
|
+
// So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
|
|
125
|
+
// was told their platform was supported, watched the CLI install, the config
|
|
126
|
+
// write and the daemon start, and then got an exception and a rolled-back
|
|
127
|
+
// config. Skipping the step leaves them a working daemon and one true sentence
|
|
128
|
+
// instead — which is the whole of this change.
|
|
129
|
+
//
|
|
130
|
+
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
131
|
+
// package can install a launchd agent, and pretending otherwise by writing a
|
|
132
|
+
// plist ourselves would put a second service implementation in a second repo.
|
|
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.
|
|
114
145
|
const derived = unitPathForStateDir(stateDir, home);
|
|
115
146
|
if (!derived.ok) {
|
|
116
147
|
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
|
@@ -180,12 +211,35 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
180
211
|
// --json so the caller can read back whether the unit actually CHANGED. The
|
|
181
212
|
// CLI owns that byte-comparison, and an installer that guessed at it would
|
|
182
213
|
// report "nothing changed" on a run that rewrote a unit.
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
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'];
|
|
189
243
|
}
|
|
190
244
|
|
|
191
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
|
@@ -171,15 +171,24 @@ export function planComponentDetach(key, existing) {
|
|
|
171
171
|
export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
172
172
|
const dir = resolve(stateDir);
|
|
173
173
|
const unit = unitNameForStateDir(dir);
|
|
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.
|
|
178
|
+
//
|
|
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
|
+
};
|
|
174
188
|
return [
|
|
175
|
-
|
|
176
|
-
id: 'service',
|
|
177
|
-
unit: unit.ok ? unit.unit : null,
|
|
178
|
-
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
|
|
179
|
-
note: 'refuses a unit not marked as CLI-managed',
|
|
180
|
-
},
|
|
189
|
+
service,
|
|
181
190
|
cliStartedIt
|
|
182
|
-
? { id: 'stop', command: ['ours', '
|
|
191
|
+
? { id: 'stop', command: ['ours-mcp', 'stop'] }
|
|
183
192
|
: { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
|
|
184
193
|
];
|
|
185
194
|
}
|
|
@@ -474,7 +483,7 @@ export function selectHarnesses(plugins, chosen) {
|
|
|
474
483
|
* The whole §8 order, refusing at step 1 rather than starting and stopping
|
|
475
484
|
* half-way.
|
|
476
485
|
*/
|
|
477
|
-
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false }) {
|
|
486
|
+
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
|
|
478
487
|
const dir = resolve(stateDir);
|
|
479
488
|
const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
|
|
480
489
|
const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
|
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",
|