@ours.network/install 0.17.0-nightly.15 → 0.17.0-nightly.16
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 +6 -7
- package/lib/detect.mjs +10 -13
- package/lib/extras.mjs +8 -9
- package/lib/logic.mjs +11 -8
- package/lib/orchestrate.mjs +52 -26
- package/lib/plan.mjs +23 -45
- package/package.json +1 -1
package/lib/components.mjs
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The three components are the MCP server, the Telegram connector and cowork.
|
|
9
9
|
// None of them IS the daemon; all three attach to one. The messenger is out of
|
|
10
|
-
// scope
|
|
11
|
-
// installed here.
|
|
10
|
+
// scope here, and ours-fleet sits on top and is not installed here.
|
|
12
11
|
|
|
13
12
|
import { join, resolve } from 'node:path';
|
|
14
13
|
import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
@@ -45,7 +44,7 @@ export const COMPONENTS = [
|
|
|
45
44
|
* over. Every install path for these three now goes through here.
|
|
46
45
|
*
|
|
47
46
|
* All three publish a real `nightly` dist-tag (verified against the registry,
|
|
48
|
-
*
|
|
47
|
+
* so none of these pins can 404. `pkgSpec` falls back to `latest`
|
|
49
48
|
* for anything unmapped rather than inventing a tag, so an unknown component
|
|
50
49
|
* degrades to today's behaviour instead of failing.
|
|
51
50
|
*
|
|
@@ -88,10 +87,10 @@ export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
|
|
|
88
87
|
* daemon side, and anything the installer clears here is gone. The installer's
|
|
89
88
|
* entire business with the connector is three keys in one config file.
|
|
90
89
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
90
|
+
* It also keeps a future route migration possible — moving each route's packet
|
|
91
|
+
* into the shared daemon has to read this registry to know which daemon identity
|
|
92
|
+
* corresponds to which route, and an installer that trampled it would have
|
|
93
|
+
* destroyed the mapping.
|
|
95
94
|
*/
|
|
96
95
|
export const TG_STATE_DIR_NAME = '.ours-telegram';
|
|
97
96
|
export const TG_REGISTRY_FILES = ['bots.json'];
|
package/lib/detect.mjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// ours-install v3 — which daemons are already on this machine, and which one this
|
|
2
2
|
// run is for.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the user pick. Selection from what was found is not prompting for a path.
|
|
4
|
+
// Never ask for a state directory or a port to be TYPED (spec §2). When several
|
|
5
|
+
// daemons are DETECTED, show them and let one be picked: selecting from what was
|
|
6
|
+
// found is not prompting for a path.
|
|
8
7
|
//
|
|
9
8
|
// Pure, like target.mjs and plan.mjs: the caller injects the directory listing and
|
|
10
9
|
// the file reads, so the whole decision is testable without a filesystem.
|
|
11
10
|
//
|
|
12
|
-
// DETECTION, NOT A REGISTRY.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// confidently offering a daemon that is gone.
|
|
11
|
+
// DETECTION, NOT A REGISTRY. This is built from what is on disk rather than from a
|
|
12
|
+
// persisted list: a stored list is a second source of truth that goes stale against
|
|
13
|
+
// the daemons that really exist, which is how an installer ends up confidently
|
|
14
|
+
// offering a daemon that is gone.
|
|
17
15
|
|
|
18
16
|
import { basename, join, resolve } from 'node:path';
|
|
19
17
|
|
|
@@ -93,8 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
|
|
|
93
91
|
/**
|
|
94
92
|
* A state directory for a daemon this run would CREATE, derived and never typed.
|
|
95
93
|
*
|
|
96
|
-
* Spec §2 forbids asking for a path,
|
|
97
|
-
* added "pick from what was found". So "create a new one" has to derive somewhere
|
|
94
|
+
* Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
|
|
98
95
|
* to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
|
|
99
96
|
* An operator who wants a specific path still has `--state-dir`, which bypasses
|
|
100
97
|
* this screen entirely.
|
|
@@ -113,8 +110,8 @@ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
|
|
|
113
110
|
export const SELECT_CREATE = '__create__';
|
|
114
111
|
|
|
115
112
|
// The nightly flow kept a registry of daemon profiles here. v3 does not read it —
|
|
116
|
-
// detection replaced it
|
|
117
|
-
//
|
|
113
|
+
// detection replaced it — so anyone who used that flow has a file describing
|
|
114
|
+
// daemons that nothing consults any more.
|
|
118
115
|
//
|
|
119
116
|
// It is NOT deleted. Quietly removing a file that describes someone's daemons is
|
|
120
117
|
// not an installer's business, and the file is harmless. But leaving it looking
|
package/lib/extras.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// was an oversight, not a decision.
|
|
3
|
+
// The v3 installer keeps harness plugins, ours-fleet, voice setup and the
|
|
4
|
+
// copy-paste hand-off prompt; spec v3's silence about them was an oversight.
|
|
6
5
|
//
|
|
7
6
|
// They are not four more screens carried across, because v2 and v3 disagree
|
|
8
7
|
// about which package IS the daemon. In v2 @ours.network/mcp is the daemon and
|
|
@@ -52,7 +51,7 @@ export const HARNESSES = [
|
|
|
52
51
|
*
|
|
53
52
|
* So §5's guarantee is ALREADY unmet today for every non-default state
|
|
54
53
|
* directory, silently: the harness attaches to ~/.ours while the operator was
|
|
55
|
-
* told the run targeted somewhere else.
|
|
54
|
+
* told the run targeted somewhere else. The shape is:
|
|
56
55
|
*
|
|
57
56
|
* default state directory today's behaviour, byte for byte.
|
|
58
57
|
* Hermes, non-default real: the pair is handed to ours-hermes-install's
|
|
@@ -270,7 +269,7 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
|
|
|
270
269
|
// -----------------------------------------------------------------------------
|
|
271
270
|
|
|
272
271
|
/**
|
|
273
|
-
* WHY THE INSTALLER OWNS THE RESTART
|
|
272
|
+
* WHY THE INSTALLER OWNS THE RESTART.
|
|
274
273
|
*
|
|
275
274
|
* cmdVoiceSetup computes `managed = runningPid() !== null` from ours-mcp's OWN
|
|
276
275
|
* pid record. A v3 daemon is started by `ours daemon start`, which writes
|
|
@@ -283,8 +282,8 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
|
|
|
283
282
|
* CLI-started daemon, which is right. But v2's entire restart protocol
|
|
284
283
|
* (restartHandled, the exit-2 branch, "voice setup performed the required
|
|
285
284
|
* restart") can only fire for a `managed` daemon, so carrying it across would
|
|
286
|
-
* ship a branch that can never be taken.
|
|
287
|
-
*
|
|
285
|
+
* ship a branch that can never be taken. Nothing else owns that beat, and the
|
|
286
|
+
* installer is the only process that knows the daemon is CLI-managed.
|
|
288
287
|
*
|
|
289
288
|
* One phase, AFTER the component phase — voice-setup is an `ours-mcp`
|
|
290
289
|
* subcommand and v3 installs @ours.network/mcp as a COMPONENT, so it is not on
|
|
@@ -337,8 +336,8 @@ export function planVoice({
|
|
|
337
336
|
// TOUCHES THIS NEXT — the reason the installer owns this beat at all may have
|
|
338
337
|
// just evaporated: cmdVoiceSetup computes `managed = runningPid() !== null`
|
|
339
338
|
// 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
|
|
341
|
-
//
|
|
339
|
+
// SDK-CLI daemon it never did, which is why the installer took the restart.
|
|
340
|
+
// If voice-setup now classifies the daemon as managed it
|
|
342
341
|
// will run its own restart protocol, and this becomes a second restart rather
|
|
343
342
|
// than the only one. Not changed here: that is a behaviour question for
|
|
344
343
|
// packages/core, not a rename.
|
package/lib/logic.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function canonHarnesses(raw) {
|
|
|
26
26
|
return { names, unknown };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// ── Release CHANNEL / npm dist-tag selection
|
|
29
|
+
// ── Release CHANNEL / npm dist-tag selection ───────────────────────────────────
|
|
30
30
|
// The installer normally installs everything at @latest (stable). Setting
|
|
31
31
|
// OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install each package's
|
|
32
32
|
// PRERELEASE dist-tag instead — for the packages that publish one.
|
|
@@ -37,8 +37,8 @@ export function canonHarnesses(raw) {
|
|
|
37
37
|
// from this repo by .github/workflows/scripts/bump-versions.sh)
|
|
38
38
|
// · fleet → `nightly` (its own repo,
|
|
39
39
|
// adapt-toolkit/ours-fleet, publishes a nightly dist-tag of its own)
|
|
40
|
-
// · cowork (rooms) → `nightly` (
|
|
41
|
-
//
|
|
40
|
+
// · cowork (rooms) → `nightly` (cowork aligns
|
|
41
|
+
// with every other service rather than keeping its
|
|
42
42
|
// historical `next` tag)
|
|
43
43
|
//
|
|
44
44
|
// WHY FLEET FOLLOWS THE CHANNEL NOW. It used to be pinned to @latest with the note
|
|
@@ -49,7 +49,7 @@ export function canonHarnesses(raw) {
|
|
|
49
49
|
// channel exists to prevent.
|
|
50
50
|
//
|
|
51
51
|
// WHY COWORK STILL NEEDS ITS OWN ENTRY. It historically published its prerelease
|
|
52
|
-
// line as `next`; the
|
|
52
|
+
// line as `next`; the decision is that it aligns with every
|
|
53
53
|
// other service and publishes `nightly` instead. The entry stays because the map,
|
|
54
54
|
// not a hardcoded string, is what makes such a change one line — and because an
|
|
55
55
|
// UNMAPPED package deliberately falls back to `latest` rather than a guessed tag.
|
|
@@ -60,7 +60,7 @@ export function canonHarnesses(raw) {
|
|
|
60
60
|
// build that predates it — the same architecture-boundary mismatch this whole
|
|
61
61
|
// mechanism exists to prevent, pointed at Rooms instead of Telegram.
|
|
62
62
|
//
|
|
63
|
-
// SEQUENCING — load-bearing, and not yet satisfied.
|
|
63
|
+
// SEQUENCING — load-bearing, and not yet satisfied. cowork
|
|
64
64
|
// publishes NO `nightly` dist-tag at all (its tags are latest=0.4.0 and
|
|
65
65
|
// next=0.3.7-nightly.20260815.80ea770). `npm i -g @ours.network/cowork@nightly`
|
|
66
66
|
// therefore 404s today, and a 404 fails the WHOLE install. So the nightly
|
|
@@ -81,7 +81,7 @@ const PKG_CHANNEL_TAGS = {
|
|
|
81
81
|
codex: { nightly: 'nightly' },
|
|
82
82
|
hermes: { nightly: 'nightly' },
|
|
83
83
|
fleet: { nightly: 'nightly' },
|
|
84
|
-
cowork: { nightly: 'nightly' }, // aligned with every other service
|
|
84
|
+
cowork: { nightly: 'nightly' }, // aligned with every other service
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
// Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
|
|
@@ -600,7 +600,10 @@ export function parseVersion(text) {
|
|
|
600
600
|
export function parseStatus(text) {
|
|
601
601
|
const s = String(text || '');
|
|
602
602
|
const bm = s.match(/^\s*broker:\s*(\S+)/m);
|
|
603
|
-
|
|
603
|
+
// `api:` is the current line and `url:` the historical one. Both are matched
|
|
604
|
+
// because a newly installed CLI can be asked to report on an older running
|
|
605
|
+
// daemon, and a parser that knows only today's wording reads that as "no port".
|
|
606
|
+
const pm = s.match(/(?:api|url):\s*https?:\/\/[^:\s/]+:(\d+)/i);
|
|
604
607
|
return {
|
|
605
608
|
broker: bm ? bm[1] : null,
|
|
606
609
|
port: pm ? Number.parseInt(pm[1], 10) : null,
|
|
@@ -644,7 +647,7 @@ export function detectPlatform({ platform, release = '', env = {} } = {}) {
|
|
|
644
647
|
// tell the user plainly + how to fix, and ALWAYS still offer a manual path
|
|
645
648
|
// 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
|
|
646
649
|
// 'absent' — genuinely not installed → this harness is skipped (with a note)
|
|
647
|
-
// The golden rule
|
|
650
|
+
// The golden rule: 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
|
|
648
651
|
// prints a manual-install path so the component still gets installed.
|
|
649
652
|
export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
|
|
650
653
|
if (versionOk) return { status: 'ok', detail: 'real program' };
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -100,7 +100,7 @@ function pairFor(plan, target) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Which daemon is this run for?
|
|
103
|
+
* Which daemon is this run for?
|
|
104
104
|
*
|
|
105
105
|
* Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
|
|
106
106
|
* it shows them and lets the operator pick, because choosing from what was found
|
|
@@ -209,15 +209,18 @@ export async function runDaemonPhase(args, effects) {
|
|
|
209
209
|
|
|
210
210
|
// Asked ONCE, and only when this run is creating the daemon — an existing
|
|
211
211
|
// daemon's broker is its own record, and re-asking would invite an operator to
|
|
212
|
-
// change it from a screen that is not about changing it.
|
|
213
|
-
//
|
|
212
|
+
// change it from a screen that is not about changing it. The broker question
|
|
213
|
+
// stays in v3: it is orthogonal to --state-dir/--port, so it
|
|
214
214
|
// does not violate spec §2's "nothing about a state directory or a port
|
|
215
215
|
// appears in any prompt".
|
|
216
216
|
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
217
217
|
|
|
218
218
|
const steps = [];
|
|
219
219
|
|
|
220
|
-
//
|
|
220
|
+
// The daemon is the ours-sdk CLI; ours-mcp is the per-session MCP server the
|
|
221
|
+
// harness spawns, and it reaches the daemon over the HTTP API. Both are
|
|
222
|
+
// installed here and both are required: an install without ours-mcp leaves a
|
|
223
|
+
// machine with a daemon and no MCP at all.
|
|
221
224
|
//
|
|
222
225
|
// This used to install @ours.network/cli, because the daemon used to be
|
|
223
226
|
// `ours daemon serve`. That daemon does not mount /mcp — the SDK serves that
|
|
@@ -230,13 +233,15 @@ export async function runDaemonPhase(args, effects) {
|
|
|
230
233
|
//
|
|
231
234
|
// The order therefore inverts: the MCP package was a COMPONENT installed after
|
|
232
235
|
// this phase, and it is now the thing this phase starts.
|
|
233
|
-
const
|
|
234
|
-
await perform(effects, args.dryRun, `
|
|
235
|
-
steps.push({ id: '
|
|
236
|
+
const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
|
|
237
|
+
await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
|
|
238
|
+
steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
|
|
239
|
+
await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
|
|
240
|
+
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
236
241
|
|
|
237
242
|
// The config file — merged, never rewritten, and untouched when it already
|
|
238
|
-
// matches. No provenance marker is written:
|
|
239
|
-
//
|
|
243
|
+
// matches. No provenance marker is written: --purge works on any state
|
|
244
|
+
// directory, so a `createdBy` key would have no consumer, and
|
|
240
245
|
// an unread key in a user's config file is future confusion for nothing.
|
|
241
246
|
const configPath = join(dir, 'config.json');
|
|
242
247
|
const merged = planDaemonConfig(
|
|
@@ -254,6 +259,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
254
259
|
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
255
260
|
// conditions for it.
|
|
256
261
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
262
|
+
let serviceUnsupported = null;
|
|
257
263
|
if (merged.changed) {
|
|
258
264
|
journal.snapshot(configPath);
|
|
259
265
|
await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
|
|
@@ -264,18 +270,12 @@ export async function runDaemonPhase(args, effects) {
|
|
|
264
270
|
|
|
265
271
|
try {
|
|
266
272
|
if (creating) {
|
|
267
|
-
|
|
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) }));
|
|
273
|
+
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
|
|
275
274
|
steps.push({ id: 'start', changed: true });
|
|
276
275
|
}
|
|
277
276
|
|
|
278
277
|
const service = await runServicePhase(args, effects, dir, target.port);
|
|
278
|
+
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
279
279
|
if (service.refused) {
|
|
280
280
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
281
281
|
// unit file stops the run just as a failed start does, and it stops it with
|
|
@@ -304,12 +304,12 @@ export async function runDaemonPhase(args, effects) {
|
|
|
304
304
|
effects.out(recovery.recovered
|
|
305
305
|
? ok('your daemon is running again — nothing was committed, and the service is unchanged')
|
|
306
306
|
: warn('and the daemon did NOT come back up — start it yourself before anything else: '
|
|
307
|
-
+ `
|
|
307
|
+
+ `ours daemon start --config ${configPath}`));
|
|
308
308
|
}
|
|
309
309
|
throw error;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
return { target, steps };
|
|
312
|
+
return { target, steps, serviceUnsupported };
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/**
|
|
@@ -327,7 +327,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
327
327
|
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
328
328
|
if (args.dryRun) return null;
|
|
329
329
|
try {
|
|
330
|
-
await effects.run('ours
|
|
330
|
+
await effects.run('ours', ['daemon', 'start', '--config', configPath]);
|
|
331
331
|
return { recovered: true };
|
|
332
332
|
} catch (recoveryError) {
|
|
333
333
|
return { recovered: false, reason: reason(recoveryError) };
|
|
@@ -367,11 +367,24 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
367
367
|
* The boot service: §4 step 4, including the legacy-unit case.
|
|
368
368
|
*
|
|
369
369
|
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
370
|
-
* the file
|
|
370
|
+
* the file. `--force` is passed ONLY here, only for a unit
|
|
371
371
|
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
372
372
|
*/
|
|
373
373
|
export async function runServicePhase(args, effects, dir, port) {
|
|
374
|
-
const plan = planServiceInstall({
|
|
374
|
+
const plan = planServiceInstall({
|
|
375
|
+
stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
|
|
376
|
+
});
|
|
377
|
+
// NOT a refusal and NOT a failure: the daemon is running and correct, and only
|
|
378
|
+
// the boot service could not be installed — `ours daemon install-service` throws
|
|
379
|
+
// on any non-linux platform. The run CONTINUES, says so, and the summary marks
|
|
380
|
+
// it, because the person this hurts reboots in a fortnight and finds nothing
|
|
381
|
+
// listening.
|
|
382
|
+
if (plan.action === 'unsupported') {
|
|
383
|
+
effects.out(warn(`ours: ${plan.message}`));
|
|
384
|
+
effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
|
|
385
|
+
effects.out(info('Nothing else in this run depends on the boot service.'));
|
|
386
|
+
return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
|
|
387
|
+
}
|
|
375
388
|
if (plan.action === 'refuse') {
|
|
376
389
|
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
377
390
|
return { refused: plan };
|
|
@@ -608,7 +621,7 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
608
621
|
}
|
|
609
622
|
|
|
610
623
|
/**
|
|
611
|
-
* The broker question (v2's Step 0a, kept
|
|
624
|
+
* The broker question (v2's Step 0a, deliberately kept).
|
|
612
625
|
*
|
|
613
626
|
* Consent-first, with the undo built in: a mistaken custom address is one
|
|
614
627
|
* keystroke back to the standard broker, because the alternative is an operator
|
|
@@ -671,6 +684,9 @@ export function runPreflight(effects) {
|
|
|
671
684
|
return { ok: false, platform: plat };
|
|
672
685
|
}
|
|
673
686
|
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
687
|
+
if (effects.platform?.platform && effects.platform.platform !== 'linux') {
|
|
688
|
+
effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available — you will start it yourself after a reboot.`));
|
|
689
|
+
}
|
|
674
690
|
const version = String(effects.nodeVersion ?? '0');
|
|
675
691
|
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
676
692
|
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
@@ -681,7 +697,7 @@ export function runPreflight(effects) {
|
|
|
681
697
|
}
|
|
682
698
|
|
|
683
699
|
/**
|
|
684
|
-
* The human identity: `ours-mcp create-root
|
|
700
|
+
* The human identity: `ours-mcp create-root`.
|
|
685
701
|
*
|
|
686
702
|
* It needs `ours-mcp` on PATH and a reachable daemon, so under v3 it lands here
|
|
687
703
|
* — after the component phase installed the MCP server, not back in the daemon
|
|
@@ -743,7 +759,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
743
759
|
// by environment instead, so the pair travels as an env prefix rather than a
|
|
744
760
|
// flag. A retry command that omits it would start the DEFAULT daemon — which
|
|
745
761
|
// is how someone ends up with an identity on a daemon they did not choose.
|
|
746
|
-
effects.out(info(`Fix: run '
|
|
762
|
+
effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'OURS_CONFIG=${env.OURS_CONFIG} ours-mcp create-root "${name}"'.`));
|
|
747
763
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
748
764
|
}
|
|
749
765
|
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
@@ -876,7 +892,6 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
876
892
|
// Passing it is harmless if extras.mjs is right and load-bearing if
|
|
877
893
|
// nightly-install.mjs is. When the cheap action is safe under both readings and
|
|
878
894
|
// the expensive one is only safe under one, take the cheap one. (Coordinator
|
|
879
|
-
// ruling, 2026-08-17.)
|
|
880
895
|
//
|
|
881
896
|
// Passed for EVERY state directory, not only a non-default one, exactly as the
|
|
882
897
|
// nightly flow does: `init` is a one-time host setup and the pair is what names
|
|
@@ -1110,6 +1125,17 @@ export async function runInstall(argv, effects) {
|
|
|
1110
1125
|
state: target.action === 'create' ? 'installed' : 'current',
|
|
1111
1126
|
note: `port ${target.port}`,
|
|
1112
1127
|
}];
|
|
1128
|
+
// The skip must not read as success: same "needs attention" mark a failed
|
|
1129
|
+
// component gets, so the closing screen cannot say everything is clean.
|
|
1130
|
+
if (daemon.serviceUnsupported) {
|
|
1131
|
+
summary.push({
|
|
1132
|
+
key: 'service',
|
|
1133
|
+
label: 'Boot service',
|
|
1134
|
+
state: 'failed',
|
|
1135
|
+
note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1113
1139
|
const components = await runComponentPhase(args, effects, target);
|
|
1114
1140
|
for (const component of COMPONENTS) {
|
|
1115
1141
|
const state = components.installed.includes(component.key) ? 'installed'
|
package/lib/plan.mjs
CHANGED
|
@@ -94,10 +94,9 @@ export function classifyUnit(text) {
|
|
|
94
94
|
* informational line naming it
|
|
95
95
|
* { action: 'refuse', exitCode: 2, … } — unknown unit, or unusable state dir
|
|
96
96
|
*
|
|
97
|
-
* Adoption of a legacy unit is SILENT
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* is not a warning.
|
|
97
|
+
* Adoption of a legacy unit is SILENT: no prompt, no question, so an upgrading
|
|
98
|
+
* user has no manual step. The one line of output exists so the replacement is not
|
|
99
|
+
* literally invisible; it does not block and it is not a warning.
|
|
101
100
|
*
|
|
102
101
|
* BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
|
|
103
102
|
* safety boundary between a stranger's file and a silent rewrite. It must stay
|
|
@@ -130,18 +129,20 @@ export function planServiceInstall({ stateDir, home, readText, platform = 'linux
|
|
|
130
129
|
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
131
130
|
// package can install a launchd agent, and pretending otherwise by writing a
|
|
132
131
|
// plist ourselves would put a second service implementation in a second repo.
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
// `ours daemon install-service` supports Linux/systemd only: its adapter throws
|
|
133
|
+
// for any other platform, so calling it would fail the run rather than degrade.
|
|
134
|
+
// Skip it and say so; the daemon itself is unaffected.
|
|
135
|
+
if (platform && platform !== 'linux') {
|
|
136
|
+
return {
|
|
137
|
+
action: 'unsupported',
|
|
138
|
+
platform,
|
|
139
|
+
reason: 'no-service-manager',
|
|
140
|
+
message: platform === 'darwin'
|
|
141
|
+
? 'installing a boot service is not available on macOS — the ours CLI can only manage a Linux user systemd service'
|
|
142
|
+
: `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
|
|
143
|
+
manual: ['ours', 'daemon', 'serve', '--config'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
145
146
|
const derived = unitPathForStateDir(stateDir, home);
|
|
146
147
|
if (!derived.ok) {
|
|
147
148
|
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
|
@@ -211,35 +212,12 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
|
211
212
|
// --json so the caller can read back whether the unit actually CHANGED. The
|
|
212
213
|
// CLI owns that byte-comparison, and an installer that guessed at it would
|
|
213
214
|
// report "nothing changed" on a run that rewrote a unit.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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'];
|
|
215
|
+
// --json so the caller can read back whether the unit actually CHANGED rather
|
|
216
|
+
// than assuming it did. --force is reachable only through the explicit argument
|
|
217
|
+
// above, and the CLI refuses to overwrite a unit it did not write.
|
|
218
|
+
const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
|
|
219
|
+
if (adoptLegacyUnit) cmd.push('--force');
|
|
220
|
+
return cmd;
|
|
243
221
|
}
|
|
244
222
|
|
|
245
223
|
// -----------------------------------------------------------------------------
|
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.16",
|
|
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",
|