@ours.network/install 0.18.0-nightly.5 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,21 @@ npm install --global @ours.network/install
9
9
  ours-install
10
10
  ```
11
11
 
12
+ ### Stable and nightly channels
13
+
14
+ Installing `@ours.network/install@latest` selects the stable channel; installing
15
+ `@ours.network/install@nightly` selects the nightly channel. Before changing the
16
+ machine, the installer resolves `@ours.network/mcp`,
17
+ `@ours.network/claude-code`, and `@ours.network/codex`, verifies that their
18
+ selected dist-tags expose one exact lockstep version, and fails closed if they
19
+ do not. MCP and the Codex launcher are installed by exact version, and local
20
+ Claude Code and Codex marketplace manifests pin the corresponding plugin
21
+ packages to that same version.
22
+
23
+ `OURS_CHANNEL=latest|nightly` (or the legacy `OURS_INSTALL_CHANNEL`) remains an
24
+ explicit override. Without an override, the installed package's own version
25
+ selects the channel.
26
+
12
27
  The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
13
28
  progress bar, and asks only for information it cannot safely infer (normally the
14
29
  Human identity's display name). Existing daemon conflicts and moving a Telegram
@@ -75,8 +90,11 @@ new Human identity and asks no ordinary setup questions, but it never bypasses
75
90
  selection conflicts, connector moves, or destructive safeguards.
76
91
 
77
92
  A non-default daemon must be selected coherently with a config file or matching
78
- port and state directory. Harnesses and the generated Fleet role receive that
79
- selection through `OURS_CONFIG`; there is no per-application daemon.
93
+ port and state directory. Hermes and the generated Fleet role persist that
94
+ selection through `OURS_CONFIG`. Claude Code and Codex plugin registrations
95
+ cannot store an environment value; for those harnesses the installer prints the
96
+ exact `export OURS_CONFIG=...` line that must be added to the shell profile
97
+ before starting the harness. There is no per-application daemon.
80
98
 
81
99
  ## Uninstall
82
100
 
package/install.sh CHANGED
@@ -21,6 +21,9 @@
21
21
  # From a CLONE (a sibling install.mjs is present) it runs that directly. Piped as `curl … | bash`
22
22
  # it installs the published command globally — `npm i -g @ours.network/install` — then runs
23
23
  # `ours-install`. Idempotent: a re-run updates to @latest and runs again.
24
+ # The bootstrap is deliberately stable. For nightly, install the published nightly command
25
+ # directly: `npm i -g @ours.network/install@nightly && ours-install`; that package's own
26
+ # X.Y.Z-nightly.N version selects and exactly resolves the matching stack channel.
24
27
  #
25
28
  # Non-interactive env overrides (all optional) — consumed by the Node installer:
26
29
  # OURS_ASSUME_YES=1 accept every default; never prompt (no tty needed)
@@ -1,7 +1,7 @@
1
1
  // ours-install v3 — component selection and attachment.
2
2
  //
3
- // Spec: installer-spec-v3 §5 (and the repoint half of §7, which cannot be
4
- // separated from attaching the connector without making a silent move possible).
3
+ // Component selection and attachment planning. Repoint decisions stay beside
4
+ // attachment decisions so an existing connector can never move silently.
5
5
  // Pure, like target.mjs and plan.mjs: the caller injects the current file
6
6
  // contents and the installed versions, and every function returns a plan.
7
7
  //
@@ -101,7 +101,7 @@ export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home,
101
101
  export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
102
102
 
103
103
  // -----------------------------------------------------------------------------
104
- // §5 selection
104
+ // Component selection
105
105
  // -----------------------------------------------------------------------------
106
106
 
107
107
  /**
@@ -133,7 +133,7 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
133
133
  }
134
134
 
135
135
  // -----------------------------------------------------------------------------
136
- // §5 — the MCP server
136
+ // MCP server attachment
137
137
  // -----------------------------------------------------------------------------
138
138
 
139
139
  /**
@@ -159,7 +159,7 @@ export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'late
159
159
  }
160
160
 
161
161
  // -----------------------------------------------------------------------------
162
- // §5 / §7 the Telegram connector
162
+ // Telegram connector attachment and repointing
163
163
  // -----------------------------------------------------------------------------
164
164
 
165
165
  /**
@@ -217,12 +217,12 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
217
217
  to: { daemonUrl: endpoint, daemonStateDir: dir },
218
218
  prompt: `The Telegram connector currently uses ${current.daemonUrl ?? 'an unrecorded daemon'} (${current.daemonStateDir ?? 'unrecorded state directory'}).\nPoint it at ${endpoint} (${dir}) instead? This MOVES the connector; it does not add a second one.`,
219
219
  };
220
- // Never repointed without a human, in any mode (spec §9).
220
+ // Never repoint an existing connector without a human, in any mode.
221
221
  return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
222
222
  }
223
223
 
224
224
  // -----------------------------------------------------------------------------
225
- // §5 — cowork
225
+ // cowork attachment
226
226
  // -----------------------------------------------------------------------------
227
227
 
228
228
  /**
@@ -341,7 +341,7 @@ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVe
341
341
  }
342
342
 
343
343
  // -----------------------------------------------------------------------------
344
- // §5 one component failing does not stop the others
344
+ // Component failures are isolated so the remaining components can continue.
345
345
  // -----------------------------------------------------------------------------
346
346
 
347
347
  /**
package/lib/detect.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // ours-install v3 — which daemons are already on this machine, and which one this
2
2
  // run is for.
3
3
  //
4
- // Never ask for a state directory or a port to be TYPED (spec §2). When several
4
+ // Never ask for a state directory or port to be typed. When several
5
5
  // daemons are DETECTED, show them and let one be picked: selecting from what was
6
6
  // found is not prompting for a path.
7
7
  //
@@ -91,7 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
91
91
  /**
92
92
  * A state directory for a daemon this run would CREATE, derived and never typed.
93
93
  *
94
- * Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
94
+ * The UI does not ask for a path, so "create a new one" has to derive somewhere
95
95
  * to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
96
96
  * An operator who wants a specific path still has `--state-dir`, which bypasses
97
97
  * this screen entirely.
@@ -151,7 +151,7 @@ export function planDaemonSelection({
151
151
  *
152
152
  * Deliberately strict: an answer that is not a number in range, or the create
153
153
  * option, is NOT a state directory to be interpreted. Accepting free text here
154
- * would be exactly the "type a path" prompt spec §2 forbids, arriving through the
154
+ * would reintroduce the forbidden "type a path" prompt through the
155
155
  * back door.
156
156
  */
157
157
  export function resolveSelection(answer, { candidates = [], createOption = null } = {}) {
package/lib/effects.mjs CHANGED
@@ -69,9 +69,9 @@ function readTextFile(path) {
69
69
  }
70
70
  }
71
71
 
72
- function installedVersionOf(pkg) {
72
+ function installedVersionOf(pkg, npmBin = 'npm') {
73
73
  try {
74
- const out = execFileSync('npm', ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
74
+ const out = execFileSync(npmBin, ['ls', '-g', '--depth', '0', '--json', pkg], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
75
  return JSON.parse(out)?.dependencies?.[pkg]?.version ?? null;
76
76
  } catch {
77
77
  // Unreadable is NOT "new enough": the cowork gate fails closed on null.
@@ -79,8 +79,8 @@ function installedVersionOf(pkg) {
79
79
  }
80
80
  }
81
81
 
82
- function packageDependenciesOf(pkgSpec) {
83
- const probe = capture('npm', ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
82
+ function packageDependenciesOf(pkgSpec, npmBin = 'npm') {
83
+ const probe = capture(npmBin, ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
84
84
  if (!probe.ok) return null;
85
85
  try {
86
86
  const parsed = JSON.parse(probe.stdout);
@@ -90,6 +90,33 @@ function packageDependenciesOf(pkgSpec) {
90
90
  }
91
91
  }
92
92
 
93
+ function resolvePackageVersion(pkg, channel, npmBin = 'npm') {
94
+ const tag = channel === 'nightly' ? 'nightly' : 'latest';
95
+ const probe = capture(npmBin, ['view', `${pkg}@${tag}`, 'version', '--json'], { timeout: 15_000 });
96
+ if (!probe.ok) return '';
97
+ try {
98
+ const parsed = JSON.parse(probe.stdout);
99
+ return typeof parsed === 'string' ? parsed : '';
100
+ } catch {
101
+ return /^\S+$/.test(probe.stdout.trim()) ? probe.stdout.trim() : '';
102
+ }
103
+ }
104
+
105
+ function codexMarketplace() {
106
+ const probe = capture('codex', ['plugin', 'marketplace', 'list', '--json'], { timeout: 6_000 });
107
+ if (!probe.ok) return null;
108
+ try {
109
+ return JSON.parse(probe.stdout)?.marketplaces?.find((m) => m?.name === 'ours-codex-marketplace') ?? null;
110
+ } catch { return null; }
111
+ }
112
+
113
+ function hasClaudePlugin() {
114
+ const probe = capture('claude', ['plugin', 'list', '--json'], { timeout: 6_000 });
115
+ if (!probe.ok) return false;
116
+ try { return JSON.parse(probe.stdout)?.some((p) => p?.id === 'ours@ours.network') ?? false; }
117
+ catch { return false; }
118
+ }
119
+
93
120
  /**
94
121
  * A read-only command probe that NEVER throws and NEVER inherits stdio.
95
122
  *
@@ -223,6 +250,7 @@ function knownStateDirsIn(home) {
223
250
  * the orchestrator never reaches for a terminal itself.
224
251
  */
225
252
  export function realEffects({ write, ttyFd, env = process.env, home = homedir(), out, version = null } = {}) {
253
+ const npmBin = env.OURS_NPM?.trim() || 'npm';
226
254
  return {
227
255
  home,
228
256
  env,
@@ -297,14 +325,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
297
325
  // effects object a caller constructed and not of whatever ambient shell
298
326
  // the installer happened to start in.
299
327
  const childEnv = { ...env, ...(extraEnv ?? {}) };
300
- const r = spawnSync(cmd, args, {
328
+ const executable = cmd === 'npm' ? npmBin : cmd;
329
+ const r = spawnSync(executable, args, {
301
330
  encoding: 'utf8',
302
331
  stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
303
332
  env: childEnv,
304
333
  });
305
334
  if (r.status !== 0) {
306
335
  const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
307
- throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
336
+ throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
308
337
  }
309
338
  return { ok: true, code: r.status, stdout: r.stdout ?? '' };
310
339
  },
@@ -317,8 +346,11 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
317
346
  const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...env, ...(extraEnv ?? {}) } });
318
347
  return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
319
348
  },
320
- installedVersion: installedVersionOf,
321
- packageDependencies: packageDependenciesOf,
349
+ installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
350
+ packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
351
+ resolvePackageVersion: (pkg, channel) => resolvePackageVersion(pkg, channel, npmBin),
352
+ codexMarketplace,
353
+ hasClaudePlugin,
322
354
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
323
355
  // Never called when assumeYes: the orchestrator takes the default itself.
324
356
  ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
@@ -326,7 +358,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
326
358
  };
327
359
  }
328
360
 
329
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, knownStateDirsIn };
361
+ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
330
362
 
331
363
  // -----------------------------------------------------------------------------
332
364
  // THE PAIR
@@ -335,7 +367,7 @@ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFil
335
367
  /**
336
368
  * The environment that names ONE daemon, for a single child invocation.
337
369
  *
338
- * Spec §2's rule is that a state directory and an endpoint always travel
370
+ * A state directory and its endpoint always travel
339
371
  * together; "endpoint selected, state directory defaulted" must be unreachable.
340
372
  * Every consumer downstream — ours-mcp's proxy, ours-fleet's per-role resolver,
341
373
  * ours-hermes-install — reads these three names and falls back to `~/.ours` for
package/lib/extras.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  // ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
2
2
  //
3
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.
4
+ // copy-paste hand-off prompt.
5
5
  //
6
6
  // The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
7
7
  // stdio adapter with no unit, and these extra phases preserve that boundary.
8
8
  //
9
9
  // Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
10
10
  // terminal. Every function takes what was observed and returns a plan; the
11
- // orchestrator (a later PR) is what performs it.
11
+ // orchestrator performs it.
12
12
 
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { pkgSpec } from './logic.mjs';
@@ -16,7 +16,7 @@ import { pkgSpec } from './logic.mjs';
16
16
  const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
17
17
 
18
18
  // -----------------------------------------------------------------------------
19
- // §5 — harness plugins
19
+ // Harness plugins
20
20
  // -----------------------------------------------------------------------------
21
21
 
22
22
  export const CLAUDE_MARKET = 'adapt-toolkit/ours-claude-marketplace';
@@ -29,13 +29,12 @@ export const HARNESSES = [
29
29
  ];
30
30
 
31
31
  /**
32
- * SPEC §5 PROMISES SOMETHING TWO OF THE THREE REGISTRATIONS CANNOT DO.
32
+ * TWO OF THE THREE REGISTRATIONS CANNOT PERSIST AN ENVIRONMENT VALUE.
33
33
  *
34
- * §5: "For any other state directory the installer registers the harness MCP
35
- * entry with OURS_CONFIG=<state-dir>/config.json in its environment, so the pair
36
- * travels together." planMcpAttachment already returns exactly that harnessEnv
37
- * and the orchestrator only PRINTS it. That is not an oversight to be fixed by
38
- * wiring it up harder; none of the three registrations can carry a value:
34
+ * For a non-default state directory, planMcpAttachment exposes the required
35
+ * OURS_CONFIG value. Claude Code and Codex cannot persist that value in their
36
+ * plugin registrations, so the installer prints an explicit shell export and
37
+ * never claims it was applied automatically:
39
38
  *
40
39
  * Claude Code the marketplace plugin's mcpServers.ours is command+args, with
41
40
  * no env key, and `claude plugin install` injects nothing per
@@ -46,9 +45,7 @@ export const HARNESSES = [
46
45
  * Hermes renderConfigBlock is OUR writer, and now emits an `env:` block
47
46
  * carrying OURS_CONFIG — so for Hermes the pair is real.
48
47
  *
49
- * So §5's guarantee is ALREADY unmet today for every non-default state
50
- * directory, silently: the harness attaches to ~/.ours while the operator was
51
- * told the run targeted somewhere else. The shape is:
48
+ * The supported behavior is:
52
49
  *
53
50
  * default state directory today's behaviour, byte for byte.
54
51
  * Hermes, non-default real: the pair is handed to ours-hermes-install's
@@ -56,7 +53,7 @@ export const HARNESSES = [
56
53
  * as the ours server's own env block.
57
54
  * Claude / Codex, non-def install the plugin (it is still the right plugin)
58
55
  * and PRINT the exact line the operator must add.
59
- * Never claim §5's guarantee in the screen text.
56
+ * Never claim automatic configuration in the screen text.
60
57
  *
61
58
  * Deliberately NOT done: registering a second, user-scoped `ours` MCP server via
62
59
  * `claude mcp add --env`. Two `ours` servers in front of one harness, and which
@@ -94,7 +91,7 @@ const driveSteps = {
94
91
  codex: (channel) => [
95
92
  ['codex', 'plugin', 'marketplace', 'add', CODEX_MARKET],
96
93
  ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'],
97
- // Owner-mandated in v2 and kept: choosing the Codex plugin also installs the
94
+ // Product requirement in v2 and kept: choosing the Codex plugin also installs the
98
95
  // ours-codex live launcher, in the same step.
99
96
  ['npm', 'i', '-g', pkgSpec('codex', channel)],
100
97
  ],
@@ -122,7 +119,7 @@ const driveSteps = {
122
119
  * `env` is what an invocation must carry, and it is EMPTY unless the harness can
123
120
  * genuinely apply it. `envLine` is what the operator is told. `claimsPair` is
124
121
  * false whenever the pair is only printed — the screen text renderer reads it so
125
- * §5's guarantee cannot be claimed where it does not hold.
122
+ * the screen cannot claim that the pair was persisted where it was only printed.
126
123
  */
127
124
  export function planHarnessPlugins({
128
125
  harnesses = [],
@@ -170,7 +167,7 @@ export function planHarnessPlugins({
170
167
  }
171
168
 
172
169
  // -----------------------------------------------------------------------------
173
- // §5 — what the operator has to do BEFORE any of this works
170
+ // Preconditions the operator must satisfy before installation
174
171
  // -----------------------------------------------------------------------------
175
172
 
176
173
  /**
package/lib/logic.mjs CHANGED
@@ -107,7 +107,7 @@ export function resolveChannel(raw, selfVersion = '') {
107
107
 
108
108
  // A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
109
109
  export function isNightlyVersion(version) {
110
- return /-nightly\.\d+/.test(String(version || ''));
110
+ return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-nightly\.(?:0|[1-9]\d*)$/.test(String(version || ''));
111
111
  }
112
112
 
113
113
  // The npm dist-tag to install for one package key under a channel. Looks the key
@@ -207,15 +207,13 @@ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, broker
207
207
  }
208
208
 
209
209
  // ── Rooms / ours-cowork ────────────────────────────────────────────────────────
210
- // ours-cowork was a purely standalone daemon: its shipped 0.4.0 bundle has no
211
- // daemonUrl / daemonStateDir / /api/v1 anywhere and its docs said it "has no
212
- // dependency on another agent daemon". ours-cowork PR #9 (head 030b71df…) adds an
213
- // EXTERNAL daemon mode, so Rooms can now answer the same common-vs-dedicated
214
- // question the Telegram connector does. Its exact contract, as reported:
210
+ // cowork supports an external-daemon mode, so Rooms can use the same
211
+ // common-vs-dedicated selection as the Telegram connector. Its public config
212
+ // contract is:
215
213
  //
216
214
  // ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
217
215
  // absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
218
- // before PR #9 does, and still the safe answer for one already
216
+ // before external mode existed, and still the safe answer for one already
219
217
  // running that way).
220
218
  // present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
221
219
  // stateDir: '<absolute ours-daemon state dir>' }
@@ -236,19 +234,12 @@ export const COWORK_DEFAULT_PORT = 3052;
236
234
  export const COWORK_DAEMON_MODES = ['embedded', 'external'];
237
235
 
238
236
  // Which cowork builds understand the `daemon` block. Its config is a STRICT
239
- // document, so handing an unknown key to a build that predates PR #9 is not a
237
+ // document, so handing an unknown key to a build that predates external mode is not a
240
238
  // harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
241
239
  // Rooms daemon that will not start rather than a warning.
242
240
  //
243
- // The FIRST published cowork that implements the external-daemon mode. Verified
244
- // against the registry rather than taken on trust:
245
- // @ours.network/cowork@nightly = 0.4.1-nightly.20260816.4aaf940
246
- // gitHead 4aaf9406016098704d06b52352f7a38adc2ef160
247
- // dist.shasum 5a6422409b1203a9bcc6aca33965fe47e9a5c17c
248
- // depends on @ours.network/sdk 1.3.1; `latest` still 0.4.0
249
- // and the packed tarball really carries it — dist/daemon.js and dist/cli.js
250
- // contain the mode enum ["embedded","external"], the endpoint+stateDir pairing
251
- // check, OURS_COWORK_DAEMON_MODE/_ENDPOINT/_STATE_DIR, and the daemon-token read.
241
+ // The first published cowork version whose package contains external-daemon
242
+ // config parsing, endpoint/state-directory pairing, and daemon-token loading.
252
243
  export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
253
244
 
254
245
  // Does the cowork build actually on this machine support an external daemon?
@@ -0,0 +1,104 @@
1
+ import { join } from 'node:path';
2
+
3
+ const NUM = '(?:0|[1-9]\\d*)';
4
+ const STABLE_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`);
5
+ const NIGHTLY_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}-nightly\\.${NUM}$`);
6
+
7
+ // npm view --json normally returns a JSON string, while older/custom npm wrappers may
8
+ // return the plain version. Accept exactly one scalar either way; arrays/objects are not
9
+ // a deliberate dist-tag resolution and therefore fail closed.
10
+ export function parseNpmVersion(text) {
11
+ const raw = String(text ?? '').trim();
12
+ if (!raw) return '';
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ return typeof parsed === 'string' ? parsed.trim() : '';
16
+ } catch {
17
+ return /^\S+$/.test(raw) ? raw : '';
18
+ }
19
+ }
20
+
21
+ export function validateChannelVersion(version, channel) {
22
+ const value = String(version ?? '').trim();
23
+ const selected = channel === 'nightly' ? 'nightly' : 'latest';
24
+ const valid = selected === 'nightly'
25
+ ? NIGHTLY_VERSION.test(value)
26
+ : STABLE_VERSION.test(value);
27
+ return valid
28
+ ? { ok: true, version: value, channel: selected }
29
+ : {
30
+ ok: false,
31
+ version: value,
32
+ channel: selected,
33
+ reason: selected === 'nightly'
34
+ ? `expected an exact X.Y.Z-nightly.N version, got ${value || '<empty>'}`
35
+ : `expected an exact stable X.Y.Z version, got ${value || '<empty>'}`,
36
+ };
37
+ }
38
+
39
+ function exactVersion(version, channel) {
40
+ const checked = validateChannelVersion(version, channel);
41
+ if (!checked.ok) throw new Error(checked.reason);
42
+ return checked.version;
43
+ }
44
+
45
+ export function buildClaudeMarketplace(version, channel) {
46
+ const pinned = exactVersion(version, channel);
47
+ return {
48
+ $schema: 'https://json.schemastore.org/claude-code-marketplace.json',
49
+ name: 'ours.network',
50
+ owner: {
51
+ name: 'Adapt Toolkit',
52
+ url: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
53
+ },
54
+ plugins: [{
55
+ name: 'ours',
56
+ displayName: 'ours',
57
+ description: 'Secure agent-to-agent communication channel over ADAPT: self-sovereign pubkey identity, end-to-end encryption.',
58
+ author: { name: 'Adapt Toolkit' },
59
+ homepage: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
60
+ repository: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
61
+ keywords: ['mcp', 'a2a', 'adapt', 'e2e', 'messaging'],
62
+ source: {
63
+ source: 'npm',
64
+ package: '@ours.network/claude-code',
65
+ version: pinned,
66
+ },
67
+ }],
68
+ };
69
+ }
70
+
71
+ export function buildCodexMarketplace(version, channel) {
72
+ const pinned = exactVersion(version, channel);
73
+ return {
74
+ name: 'ours-codex-marketplace',
75
+ interface: { displayName: 'ours.network for Codex' },
76
+ plugins: [{
77
+ name: 'ours',
78
+ source: {
79
+ source: 'npm',
80
+ package: '@ours.network/codex',
81
+ version: pinned,
82
+ registry: 'https://registry.npmjs.org',
83
+ },
84
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
85
+ category: 'Productivity',
86
+ }],
87
+ };
88
+ }
89
+
90
+ export function marketplacePaths(home) {
91
+ const root = join(home, '.ours', 'install', 'marketplaces');
92
+ const claudeRoot = join(root, 'claude-code');
93
+ const codexRoot = join(root, 'codex');
94
+ return {
95
+ claudeRoot,
96
+ claudeManifest: join(claudeRoot, '.claude-plugin', 'marketplace.json'),
97
+ codexRoot,
98
+ codexManifest: join(codexRoot, '.agents', 'plugins', 'marketplace.json'),
99
+ };
100
+ }
101
+
102
+ export function marketplaceJson(value) {
103
+ return JSON.stringify(value, null, 2) + '\n';
104
+ }
@@ -40,7 +40,7 @@ async function perform(effects, dryRun, label, thunk) {
40
40
 
41
41
  /**
42
42
  * Which components are being removed alongside this daemon. Asked once, before
43
- * anything is touched, so the §8 step 1 refusal can be resolved in the same run
43
+ * anything is touched, so a referenced-component refusal can be resolved in the same run
44
44
  * rather than sending the operator away and back.
45
45
  *
46
46
  * Non-interactively the answer is NO — assume-yes never consents to removing
@@ -81,7 +81,7 @@ export async function runUninstall(argv, effects) {
81
81
  effects.out(heading(`ours-uninstall --state-dir ${dir}`));
82
82
  if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
83
83
 
84
- // §9 the documented OURS_UNINSTALL_* contract (item 10.9), read BEFORE any
84
+ // Read the documented OURS_UNINSTALL_* contract before any
85
85
  // file is opened. A variable this uninstaller cannot deliver stops the run
86
86
  // here, naming itself and naming the replacement, rather than being silently
87
87
  // ignored while the operator's script reports success.
@@ -37,12 +37,31 @@ import { configJournal, reportRollback } from './journal.mjs';
37
37
  import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
38
38
  import { detectPlatform, resolveChannel } from './logic.mjs';
39
39
  import { daemonEnv } from './effects.mjs';
40
+ import {
41
+ buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
42
+ validateChannelVersion,
43
+ } from './marketplace.mjs';
40
44
  import { USAGE } from './usage.mjs';
41
45
  import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
42
46
 
43
47
  export const EXIT_OK = 0;
44
48
  export const EXIT_REFUSED = 2;
45
49
 
50
+ export async function resolveExactSuite(args, effects) {
51
+ const packages = {};
52
+ for (const key of ['mcp', 'claude-code', 'codex']) {
53
+ const version = await effects.resolvePackageVersion(`@ours.network/${key}`, args.channel);
54
+ const checked = validateChannelVersion(version, args.channel);
55
+ if (!checked.ok) return { ok: false, reason: `${key}: ${checked.reason}` };
56
+ packages[key] = checked.version;
57
+ }
58
+ const versions = new Set(Object.values(packages));
59
+ if (versions.size !== 1) {
60
+ return { ok: false, reason: `the ${args.channel} dist-tags are not lockstep (${Object.entries(packages).map(([k, v]) => `${k}=${v}`).join(', ')})` };
61
+ }
62
+ return { ok: true, channel: args.channel, version: versions.values().next().value, packages };
63
+ }
64
+
46
65
  /**
47
66
  * A dry run prints what it WOULD do and performs no mutation. The prefix is the
48
67
  * existing installer's, kept so the two flows read the same.
@@ -200,7 +219,7 @@ function pairFor(plan, target) {
200
219
  /**
201
220
  * Which daemon is this run for?
202
221
  *
203
- * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
222
+ * Never asks for a path, but when several daemons are detected
204
223
  * it shows them and lets the operator pick, because choosing from what was found
205
224
  * is not prompting for a state directory.
206
225
  *
@@ -242,7 +261,7 @@ export async function runSelectionPhase(args, effects) {
242
261
  const chosen = resolveSelection(answer, plan);
243
262
  if (chosen.action === 'invalid') {
244
263
  // Refused rather than guessed. Interpreting an unrecognised answer as a path
245
- // would be the "type a state directory" prompt spec §2 forbids, arriving
264
+ // would reintroduce the forbidden "type a state directory" prompt through the
246
265
  // through the back door.
247
266
  effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
248
267
  effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
@@ -256,10 +275,10 @@ export async function runSelectionPhase(args, effects) {
256
275
  }
257
276
 
258
277
  /**
259
- * The daemon half of a run: §§2-4. Returns the target decision plus the step
278
+ * Run the daemon half of an installation and return the target decision plus step
260
279
  * outcomes, or a refusal.
261
280
  */
262
- export async function runDaemonPhase(args, effects) {
281
+ export async function runDaemonPhase(args, effects, exactSuite = null) {
263
282
  const target = await resolveTarget({
264
283
  stateDir: args.stateDir,
265
284
  port: args.port,
@@ -301,8 +320,8 @@ export async function runDaemonPhase(args, effects) {
301
320
  // daemon's broker is its own record, and re-asking would invite an operator to
302
321
  // change it from a screen that is not about changing it. The broker question
303
322
  // stays in v3: it is orthogonal to --state-dir/--port, so it
304
- // does not violate spec §2's "nothing about a state directory or a port
305
- // appears in any prompt".
323
+ // does not violate the rule that state-directory and port values never appear
324
+ // in prompts.
306
325
  if (creating) args.brokerUrl = await askBroker(args, effects);
307
326
 
308
327
  const steps = [];
@@ -310,7 +329,9 @@ export async function runDaemonPhase(args, effects) {
310
329
  // The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
311
330
  // adapter each harness spawns. Both are required, but only `ours daemon`
312
331
  // participates in lifecycle or service management.
313
- const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
332
+ const mcpPkg = exactSuite?.packages?.mcp
333
+ ? `@ours.network/mcp@${exactSuite.packages.mcp}`
334
+ : componentSpec(componentByKey('mcp'), args.channel);
314
335
  // The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
315
336
  // connector packages it has no nightly dist-tag. Keep this untagged on every
316
337
  // installer channel, and inspect that package's SDK dependency for the gate.
@@ -458,7 +479,7 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
458
479
  }
459
480
 
460
481
  /**
461
- * The boot service: §4 step 4, including the legacy-unit case.
482
+ * Install the boot service, including the legacy-unit case.
462
483
  *
463
484
  * A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
464
485
  * the file. `--force` is passed ONLY here, only for a unit
@@ -545,11 +566,11 @@ export async function askComponents(args, effects) {
545
566
  }
546
567
 
547
568
  /**
548
- * Components: §5. A component that fails is reported with its retry command and
569
+ * A component that fails is reported with its retry command and
549
570
  * the run CONTINUES — a failed component is never a reason to undo a successful
550
571
  * one, or to undo the daemon.
551
572
  */
552
- export async function runComponentPhase(args, effects, target) {
573
+ export async function runComponentPhase(args, effects, target, exactSuite = null) {
553
574
  const dir = target.stateDir;
554
575
  const endpoint = `http://127.0.0.1:${target.port}`;
555
576
  const isDefaultStateDir = dir === join(effects.home, '.ours');
@@ -568,13 +589,15 @@ export async function runComponentPhase(args, effects, target) {
568
589
  continue;
569
590
  }
570
591
  try {
571
- results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
592
+ results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }));
572
593
  } catch (error) {
573
594
  // Reported with its reason and the exact manual command; the run continues.
574
595
  // The retry carries the CHANNEL — a nightly run that hands the operator a
575
596
  // stable retry command sends them straight into the split-brain install
576
597
  // this phase exists to avoid.
577
- const retry = `npm i -g ${componentSpec(component, args.channel)}`;
598
+ const retrySpec = component.key === 'mcp' && exactSuite?.packages?.mcp
599
+ ? `@ours.network/mcp@${exactSuite.packages.mcp}` : componentSpec(component, args.channel);
600
+ const retry = `npm i -g ${retrySpec}`;
578
601
  effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
579
602
  effects.out(info(`retry manually: ${retry}`));
580
603
  results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
@@ -583,10 +606,11 @@ export async function runComponentPhase(args, effects, target) {
583
606
  return summarizeComponentRun(results);
584
607
  }
585
608
 
586
- async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
609
+ async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir, exactSuite }) {
587
610
  if (component.key === 'mcp') {
588
611
  const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
589
- await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
612
+ const exact = exactSuite?.packages?.mcp ? `@ours.network/mcp@${exactSuite.packages.mcp}` : plan.install[3];
613
+ await perform(effects, args.dryRun, `install ${exact}`, () => effects.run('npm', ['i', '-g', exact]));
590
614
  if (Object.keys(plan.harnessEnv).length > 0) {
591
615
  effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
592
616
  }
@@ -787,7 +811,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
787
811
  }
788
812
 
789
813
  /**
790
- * The harness plugins (spec §5's other half).
814
+ * Install harness plugins after the daemon and components are ready.
791
815
  *
792
816
  * Two things this phase must never do, both inherited rules rather than new
793
817
  * ones. It never DRIVES a command it could not identify — an alias or a wrapper
@@ -797,7 +821,7 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
797
821
  * get the exact export line and no promise. Hermes' writer is ours, so its
798
822
  * invocation carries the whole pair and the claim is true.
799
823
  */
800
- export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
824
+ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite = null }) {
801
825
  effects.out(heading('Harness plugins'));
802
826
  const detected = await effects.detectHarnesses();
803
827
  for (const h of detected) {
@@ -822,6 +846,7 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
822
846
  });
823
847
 
824
848
  const rows = [];
849
+ const markets = marketplacePaths(effects.home);
825
850
  for (const plan of plans) {
826
851
  const row = { key: plan.name, label: `${plan.label} plugin` };
827
852
  if (plan.action === 'skip') {
@@ -832,27 +857,65 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
832
857
  if (plan.action === 'manual') {
833
858
  // NEVER a dead end: the plugin is still installable, by hand, and the run
834
859
  // says so instead of pretending the harness does not exist.
860
+ let manual = plan.manual;
861
+ if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
862
+ const isClaude = plan.name === 'claude-code';
863
+ const root = isClaude ? markets.claudeRoot : markets.codexRoot;
864
+ const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
865
+ const value = isClaude
866
+ ? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
867
+ : buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
868
+ await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
869
+ manual = isClaude
870
+ ? [`/plugin marketplace add ${root}`, '/plugin install ours']
871
+ : [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
872
+ }
835
873
  effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
836
- for (const step of plan.manual) effects.out(info(` ${step}`));
874
+ for (const step of manual) effects.out(info(` ${step}`));
837
875
  rows.push({ ...row, state: 'skipped', note: plan.reason });
838
876
  continue;
839
877
  }
840
878
 
879
+ let steps = plan.steps;
880
+ let manual = plan.manual;
881
+ if (exactSuite && (plan.name === 'claude-code' || plan.name === 'codex')) {
882
+ const isClaude = plan.name === 'claude-code';
883
+ const root = isClaude ? markets.claudeRoot : markets.codexRoot;
884
+ const manifest = isClaude ? markets.claudeManifest : markets.codexManifest;
885
+ const value = isClaude
886
+ ? buildClaudeMarketplace(exactSuite.packages['claude-code'], exactSuite.channel)
887
+ : buildCodexMarketplace(exactSuite.packages.codex, exactSuite.channel);
888
+ await perform(effects, args.dryRun, `write exact ${plan.name} marketplace ${manifest}`, () => effects.writeJson(manifest, marketplaceJson(value)));
889
+ if (!isClaude) {
890
+ const current = await effects.codexMarketplace();
891
+ const source = current?.marketplaceSource;
892
+ if (current && !(source?.sourceType === 'local' && source?.source === root)) {
893
+ await perform(effects, args.dryRun, 'remove moving Codex marketplace source', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace']));
894
+ }
895
+ }
896
+ steps = isClaude
897
+ ? [['claude', 'plugin', 'marketplace', 'add', root], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']]
898
+ : [['codex', 'plugin', 'marketplace', 'add', root], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace'], ['npm', 'i', '-g', `@ours.network/codex@${exactSuite.packages.codex}`]];
899
+ manual = isClaude
900
+ ? [`/plugin marketplace add ${root}`, '/plugin install ours']
901
+ : [`codex plugin marketplace add ${root}`, 'codex plugin add ours@ours-codex-marketplace', `npm i -g @ours.network/codex@${exactSuite.packages.codex}`];
902
+ }
903
+
841
904
  const env = pairFor(plan, target);
842
905
  let failed = null;
843
- for (const step of plan.steps) {
906
+ for (const step of steps) {
844
907
  const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
845
908
  if (!outcome.ok) { failed = outcome; break; }
846
909
  }
847
910
  if (failed) {
848
911
  effects.out(info(`${plan.label} can still be installed by hand:`));
849
- for (const step of plan.manual) effects.out(info(` ${step}`));
912
+ for (const step of manual) effects.out(info(` ${step}`));
850
913
  rows.push({ ...row, state: 'failed', note: 'install step failed' });
851
914
  continue;
852
915
  }
853
916
  if (plan.envLine) {
854
- // The honest line. §5's promise does NOT hold for this harness, and the
855
- // screen says exactly what is true and exactly what to do about it.
917
+ // This harness cannot persist the selected daemon pair. Say exactly what is
918
+ // true and how the operator can configure it explicitly.
856
919
  effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
857
920
  effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
858
921
  }
@@ -1035,6 +1098,13 @@ export async function runInstall(argv, effects) {
1035
1098
  // that wrapped the old installer keeps its meaning.
1036
1099
  if (!runPreflight(effects).ok) return EXIT_OK;
1037
1100
 
1101
+ const exactSuite = await resolveExactSuite(args, effects);
1102
+ if (!exactSuite.ok) {
1103
+ effects.out(warn(`Release suite could not be resolved safely: ${exactSuite.reason}. Nothing was changed.`));
1104
+ return EXIT_REFUSED;
1105
+ }
1106
+ effects.out(ok(`Release channel: ${exactSuite.channel} → exact lockstep suite v${exactSuite.version}`));
1107
+
1038
1108
  // Which daemon, before anything is decided about it. Only args.stateDir can
1039
1109
  // change here; every refusal downstream is unaffected.
1040
1110
  effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
@@ -1042,7 +1112,7 @@ export async function runInstall(argv, effects) {
1042
1112
  if (selection.action === 'refuse') return EXIT_REFUSED;
1043
1113
 
1044
1114
  effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
1045
- const daemon = await runDaemonPhase(args, effects);
1115
+ const daemon = await runDaemonPhase(args, effects, exactSuite);
1046
1116
  if (daemon.refused) return EXIT_REFUSED;
1047
1117
  const target = daemon.target;
1048
1118
  const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
@@ -1065,7 +1135,7 @@ export async function runInstall(argv, effects) {
1065
1135
  }
1066
1136
 
1067
1137
  effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
1068
- const components = await runComponentPhase(args, effects, target);
1138
+ const components = await runComponentPhase(args, effects, target, exactSuite);
1069
1139
  for (const component of COMPONENTS) {
1070
1140
  const state = components.installed.includes(component.key) ? 'installed'
1071
1141
  : components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
@@ -1084,7 +1154,7 @@ export async function runInstall(argv, effects) {
1084
1154
  effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
1085
1155
  summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
1086
1156
  effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
1087
- summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
1157
+ summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir, exactSuite }));
1088
1158
  effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
1089
1159
  summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
1090
1160
  summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
package/lib/plan.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // ours-install v3 — daemon creation and boot-service installation.
2
2
  //
3
- // Spec: installer-spec-v3 §§3-4. Pure, like lib/target.mjs: the orchestrator
3
+ // Pure planning code, like lib/target.mjs: the orchestrator
4
4
  // injects file reads, and every function returns a PLAN the caller renders and
5
5
  // executes. Nothing here writes, spawns, or runs systemctl.
6
6
 
@@ -11,7 +11,7 @@ export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
11
11
  export const DEFAULT_SYSTEMD_UNIT = 'ours.service';
12
12
 
13
13
  // -----------------------------------------------------------------------------
14
- // §4 — which unit file does this state directory own?
14
+ // Which unit file does this state directory own?
15
15
  // -----------------------------------------------------------------------------
16
16
 
17
17
  // 1–32 chars, alphanumeric with interior hyphens/underscores, no dots.
@@ -59,7 +59,7 @@ export function unitPathForStateDir(stateDir, home) {
59
59
  * legacy — the unit published ours-mcp wrote: NO marker, ExecStart running
60
60
  * ours-mcp. This is the migration blocker. `ours daemon
61
61
  * install-service` refuses to overwrite an unmarked unit without
62
- * --force, so spec §4 step 4 fails for every existing Linux user.
62
+ * --force, so service installation fails for every existing Linux user.
63
63
  * foreign — unmarked and NOT recognisably ours-mcp's. Someone else's file.
64
64
  *
65
65
  * THE legacy/foreign SPLIT IS NOW THE ENTIRE SAFETY BOUNDARY. A `legacy` unit is
@@ -85,7 +85,7 @@ export function classifyUnit(text) {
85
85
  }
86
86
 
87
87
  /**
88
- * What this run should do about the boot service (spec §4 step 4).
88
+ * Decide what this run should do about the boot service.
89
89
  *
90
90
  * Returns one of:
91
91
  * { action: 'install' } — call the CLI; it does the rest
@@ -202,9 +202,9 @@ export function legacyReplacedNotice(unitPath, stateDir) {
202
202
 
203
203
  /**
204
204
  * The CLI invocation that installs the boot service. The unit NAME is not passed:
205
- * ours-sdk #20 made the CLI derive it from --state-dir itself, which is why spec
206
- * §4's "the installer must either pass a per-instance unit name or write the unit
207
- * itself" no longer applies — neither, it selects the daemon and the CLI names
205
+ * ours-sdk #20 made the CLI derive it from --state-dir itself, so the installer
206
+ * neither passes a per-instance unit name nor writes the unit itself: it selects
207
+ * the daemon and the CLI names
208
208
  * the unit. One derivation, in one place.
209
209
  */
210
210
  export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
@@ -221,7 +221,7 @@ export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
221
221
  }
222
222
 
223
223
  // -----------------------------------------------------------------------------
224
- // §3(a) step 2 / §4 step 2 — the daemon config file
224
+ // Daemon configuration file
225
225
  // -----------------------------------------------------------------------------
226
226
 
227
227
  /**
@@ -247,7 +247,7 @@ export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
247
247
  }
248
248
 
249
249
  /**
250
- * The ordered, announced steps for the daemon half of a run (spec §4). Each is
250
+ * The ordered, announced steps for the daemon half of a run. Each is
251
251
  * idempotent, and an `update` skips creation entirely: it never moves a port and
252
252
  * never creates a second daemon.
253
253
  */
package/lib/rerun.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  // ours-install v3 — re-running, and a second daemon alongside the first.
2
2
  //
3
- // Spec: installer-spec-v3 §§6-7. Pure, like the earlier stages.
3
+ // Pure re-run and coexistence planning, like the earlier stages.
4
4
  //
5
5
  // Two properties this file exists to make checkable rather than hoped for:
6
6
  //
7
- // IDEMPOTENCE (§6). Running the installer again with the same answers changes
7
+ // IDEMPOTENCE. Running the installer again with the same answers changes
8
8
  // nothing except refreshed npm packages. Not "changes little" — nothing: no
9
9
  // config written, no unit rewritten, no systemctl run, no daemon restarted.
10
10
  //
11
- // COEXISTENCE (§7). Two daemons share no per-daemon artefact. Everything keyed
11
+ // COEXISTENCE. Two daemons share no per-daemon artefact. Everything keyed
12
12
  // to a daemon is derived from its state directory, so two state directories
13
13
  // produce two of everything.
14
14
 
@@ -16,8 +16,8 @@ import { join, resolve } from 'node:path';
16
16
  import { unitNameForStateDir } from './plan.mjs';
17
17
 
18
18
  /**
19
- * Everything that belongs to ONE daemon, derived from its state directory (spec
20
- * §7's table). Listing them in one place is what makes "these two daemons share
19
+ * Everything that belongs to one daemon is derived from its state directory.
20
+ * Listing the artifacts in one place makes "these two daemons share
21
21
  * nothing" a property a test can check instead of a claim in a document.
22
22
  *
23
23
  * `port` is included because it is per-daemon, but note it is NOT what identifies
@@ -55,7 +55,7 @@ export function daemonCollisions(a, b) {
55
55
  }
56
56
 
57
57
  /**
58
- * The per-component coexistence rule (spec §7), stated so the screen can never
58
+ * The per-component coexistence rule, stated so the screen can never
59
59
  * imply something the design does not do.
60
60
  *
61
61
  * mcp — coexists naturally. Each harness registration carries its own
@@ -107,7 +107,7 @@ export function summarizeRun(steps) {
107
107
  }
108
108
 
109
109
  /**
110
- * Did a re-run leave the daemon alone? Spec §3(a): update never deletes state,
110
+ * Did a re-run leave the daemon alone? An update never deletes state,
111
111
  * never moves a port, and never creates a second daemon.
112
112
  */
113
113
  export function assertUpdateLeftDaemonAlone({ before, after }) {
package/lib/target.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // ours-install v3 — argument handling and daemon detection.
2
2
  //
3
- // Spec: installer-spec-v3 §§1-3. Everything here is PURE: the orchestrator
3
+ // Pure argument handling and daemon-target selection. The orchestrator
4
4
  // injects the probe, the file reads and the port check, so the whole decision
5
5
  // table is testable without a socket, a daemon or a filesystem.
6
6
  //
@@ -19,17 +19,15 @@ export const FREE_PORT_SPAN = 1000;
19
19
 
20
20
  // Ports that are other components' DEFAULTS, not facts about the machine: 3051
21
21
  // is the Telegram connector's, 3052 is cowork's loopback console. The free-port
22
- // search skips them; an explicit --port is still honoured as typed (spec §2).
22
+ // search skips them; an explicit --port is still honoured exactly as typed.
23
23
  //
24
- // NOTE, deliberately not silently reconciled: lib/logic.mjs's RESERVED_PORTS is
25
- // [3051] on this branch, while spec §2 states [3051, 3052] citing the
26
- // dev4/version-plumbing branch. This constant follows the spec. The two should
27
- // be merged once someone decides whether cowork's 3052 belongs in the shipped
28
- // list — flagged rather than assumed.
24
+ // Keep both component defaults out of automatic selection: 3051 belongs to the
25
+ // Telegram connector and 3052 to cowork. Explicit operator selections remain
26
+ // valid.
29
27
  export const INSTALL_RESERVED_PORTS = [3051, 3052];
30
28
 
31
29
  // The CLI-owned PID record that proves a daemon belongs to a state directory
32
- // even when nothing is recorded in its config (spec §1).
30
+ // even when nothing is recorded in its config.
33
31
  export const CLI_PID_RECORD = 'ours-cli-daemon.json';
34
32
  // The SAME record, written by a different daemon. `ours daemon start` writes
35
33
  // ours-cli-daemon.json; prerelease ours-mcp wrote daemon.pid (packages/core
@@ -77,7 +75,7 @@ export function samePath(a, b) {
77
75
  }
78
76
 
79
77
  // -----------------------------------------------------------------------------
80
- // §2 — arguments
78
+ // Arguments
81
79
  // -----------------------------------------------------------------------------
82
80
 
83
81
  const VALUE_FLAGS = new Set(['--state-dir', '--port']);
@@ -143,14 +141,14 @@ export function parseInstallArgs(argv = [], env = {}, { home = homedir() } = {})
143
141
  }
144
142
 
145
143
  // -----------------------------------------------------------------------------
146
- // §§1, 3 — is there a daemon at this state directory?
144
+ // Is there a daemon at this state directory?
147
145
  // -----------------------------------------------------------------------------
148
146
 
149
147
  /**
150
148
  * The port to probe first: the one recorded in <state-dir>/config.json, else the
151
149
  * built-in default. An explicit --port does NOT change where we look — the
152
150
  * question is which daemon owns this directory, and that is answered by the
153
- * directory's own record, not by what the operator typed (spec §2 step 2).
151
+ * directory's own record, not by what the operator typed.
154
152
  */
155
153
  export function candidatePort(config) {
156
154
  const recorded = config && typeof config.port === 'number' && Number.isFinite(config.port) ? config.port : null;
@@ -158,7 +156,7 @@ export function candidatePort(config) {
158
156
  }
159
157
 
160
158
  /**
161
- * Classify one probe result against the target state directory (spec §3).
159
+ * Classify one probe result against the target state directory.
162
160
  *
163
161
  * present — an ours daemon answered and reports THIS state directory
164
162
  * foreign — something answered, but it is not an ours daemon, or it is one
@@ -246,12 +244,12 @@ export async function findDaemon({ stateDir, probe, readJson, readText }) {
246
244
  }
247
245
  // A FOREIGN DAEMON ON A PORT WE GUESSED IS NOT A REASON TO REFUSE.
248
246
  //
249
- // AMENDS #56. As first written, any foreign answer on the candidate port
247
+ // Regression guard. As first written, any foreign answer on the candidate port
250
248
  // refused the run. But a state directory with no recorded port has told us
251
249
  // nothing, so the candidate is the built-in default — which, on any machine
252
250
  // that already runs a daemon, is where the FIRST daemon answers. The result
253
251
  // was that a second daemon could never be created while the first was up:
254
- // §7 coexistence was unreachable, and the refusal's own advice ("re-run with
252
+ // coexistence was unreachable, and the refusal's own advice ("re-run with
255
253
  // --port for a free port") could not work either, because an explicit --port
256
254
  // deliberately does not change where we look.
257
255
  //
@@ -280,7 +278,7 @@ export async function findDaemon({ stateDir, probe, readJson, readText }) {
280
278
  }
281
279
 
282
280
  // -----------------------------------------------------------------------------
283
- // §2 — the derived-port rule
281
+ // Derived-port rule
284
282
  // -----------------------------------------------------------------------------
285
283
 
286
284
  /**
package/lib/uninstall.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // ours-uninstall v3 — removing ONE daemon, and the non-interactive contract.
2
2
  //
3
- // Spec: installer-spec-v3 §§8-9. Pure, like every other stage: the caller
3
+ // Pure uninstall planning, like every other stage: the caller
4
4
  // injects the file reads and this returns a plan.
5
5
  //
6
6
  // ours-uninstall [--state-dir PATH] [--purge] [--dry-run]
@@ -11,7 +11,7 @@
11
11
  // THE BIAS OF THIS WHOLE FILE IS TOWARD KEEPING THINGS. State is kept by default,
12
12
  // a component's config file is kept even when its daemon keys are removed, and
13
13
  // global packages are kept while any other daemon still needs them. The one
14
- // destructive operation, --purge, is separately gated (§8 step 5) because
14
+ // destructive operation, --purge, is separately gated because
15
15
  // it deletes identity keys, and no other step here is irreversible.
16
16
 
17
17
  import { join, resolve } from 'node:path';
@@ -22,9 +22,9 @@ import { canonHarnesses } from './logic.mjs';
22
22
  /**
23
23
  * Does this directory even look like an ours state directory?
24
24
  *
25
- * WHY THIS EXISTS. The owner removed the "created by an installer run" gate —
26
- * purge means purge, on any state directory. That was a deliberate ruling and
27
- * this does not reintroduce it: this asks "is this a state directory at all",
25
+ * Purge applies to any explicitly selected ours state directory, not only one
26
+ * created by this installer. This does not add a provenance gate: it asks
27
+ * "is this a state directory at all",
28
28
  * not "is it ours". With provenance gone, the typed path would otherwise be the
29
29
  * only thing between `ours-uninstall --state-dir ~ --purge` and a deleted home
30
30
  * directory, and a typed path is no protection against a path typed exactly as
@@ -85,7 +85,7 @@ export function inspectComponentConfig(path, { readText } = {}) {
85
85
  }
86
86
 
87
87
  /**
88
- * §8 step 1 — refuse if a component still points at this daemon.
88
+ * Refuse if a component still points at this daemon.
89
89
  *
90
90
  * Read the connector's and cowork's config files; if either names this daemon's
91
91
  * endpoint or its state directory, list them and stop. Exit 2, nothing removed.
@@ -160,7 +160,7 @@ export function planComponentDetach(key, existing) {
160
160
  }
161
161
 
162
162
  /**
163
- * §8 steps 3-4 — the boot service, then the daemon itself.
163
+ * Stop the boot service, then the daemon itself.
164
164
  *
165
165
  * Both delegate their refusals rather than reimplementing them: `ours daemon
166
166
  * uninstall-service` refuses to remove a unit not marked as CLI-managed, and
@@ -186,14 +186,13 @@ export function planDaemonRemoval({ stateDir, cliStartedIt }) {
186
186
  }
187
187
 
188
188
  /**
189
- * §8 step 5 — state. Kept unless every gate opens.
189
+ * State is kept unless every purge gate opens.
190
190
  *
191
191
  * --purge given — never the default; deleting identity keys is opt-in.
192
- * interactive — an unattended run never deletes state (§9).
192
+ * interactive — an unattended run never deletes state.
193
193
  * looks like a state directory — see looksLikeStateDir.
194
- * typed confirmation — the full path, not a y/N. The owner removed the
195
- * provenance condition, not the deliberateness, and with
196
- * provenance gone this is the last thing standing between
194
+ * typed confirmation — the full path, not a y/N. Without a provenance
195
+ * condition, this is the last thing standing between
197
196
  * a mistyped command and someone's identity keys.
198
197
  *
199
198
  * Returns the exact directory, never a glob or a parent, and only when every gate
@@ -219,7 +218,7 @@ export function planStatePurge({ stateDir, purge = false, assumeYes = false, exi
219
218
  }
220
219
 
221
220
  /**
222
- * §8 step 6 — global packages are shared. Remove them only when no OTHER state
221
+ * Global packages are shared. Remove them only when no other state
223
222
  * directory on this machine still has a daemon config; otherwise keep them and
224
223
  * say which daemon still needs them.
225
224
  */
@@ -257,7 +256,7 @@ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pl
257
256
  }
258
257
 
259
258
  // -----------------------------------------------------------------------------
260
- // §8 the harness plugins the installer wrote
259
+ // Harness plugins written by the installer
261
260
  // -----------------------------------------------------------------------------
262
261
 
263
262
  /**
@@ -397,7 +396,7 @@ export function planPluginRemoval({ home, env = {}, exists = () => false, lastDa
397
396
  }
398
397
 
399
398
  /**
400
- * §8 WHICH harnesses to detach (inventory item 9.5).
399
+ * Decide which harnesses to detach using the documented environment contract.
401
400
  *
402
401
  * The nightly uninstaller let the operator choose: a `checkboxSelect` picker on a
403
402
  * terminal, `OURS_UNINSTALL` without one, and NOTHING removed when it had
@@ -472,7 +471,7 @@ export function selectHarnesses(plugins, chosen) {
472
471
  }
473
472
 
474
473
  /**
475
- * The whole §8 order, refusing at step 1 rather than starting and stopping
474
+ * Preserve the full uninstall order, refusing before any mutation rather than stopping
476
475
  * half-way.
477
476
  */
478
477
  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' }) {
@@ -528,7 +527,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
528
527
  }
529
528
 
530
529
  // -----------------------------------------------------------------------------
531
- // §9 — the non-interactive contract
530
+ // Non-interactive behavior
532
531
  // -----------------------------------------------------------------------------
533
532
 
534
533
  /**
@@ -565,7 +564,7 @@ export function refusalSurvivesAssumeYes(refusal) {
565
564
  }
566
565
 
567
566
  // -----------------------------------------------------------------------------
568
- // §9 — the OURS_UNINSTALL_* contract (inventory item 10.9)
567
+ // Documented OURS_UNINSTALL_* environment contract
569
568
  // -----------------------------------------------------------------------------
570
569
 
571
570
  /**
@@ -592,13 +591,13 @@ export function refusalSurvivesAssumeYes(refusal) {
592
591
  * that matters most.
593
592
  * OURS_UNINSTALL_TELEGRAM
594
593
  * OURS_UNINSTALL_ROOMS at the value `detach`, which is exactly the
595
- * confirmation §8 step 1 asks a human for and which an
594
+ * confirmation that the interactive flow asks a human for and which an
596
595
  * unattended run otherwise cannot give, so today the
597
596
  * run refuses instead of detaching.
598
597
  *
599
598
  * REFUSED
600
599
  * OURS_UNINSTALL_DATA=yes v3 never deletes state without a human
601
- * present (§9), and that rule protects private
600
+ * present; that rule protects private
602
601
  * keys that exist nowhere else.
603
602
  * OURS_UNINSTALL_PROFILE names an entry in the daemon registry. v3
604
603
  * has no registry; the selector is --state-dir.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.18.0-nightly.5",
3
+ "version": "1.0.0",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet, harness plugins, Human identity, stopped Fleet starter, progress UI, and guided next steps.",
6
6
  "type": "module",