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

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
@@ -24,8 +24,8 @@ connector from another daemon still require explicit confirmation.
24
24
  or preserves the existing one on a re-run.
25
25
  - Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
26
26
  installations.
27
- - Configures and starts cowork against the shared daemon.
28
- - Configures Telegram against the same daemon, but does **not** start it.
27
+ - Configures and starts cowork as a durable shim over the shared daemon.
28
+ - Configures and starts Telegram as a durable shim over the same daemon.
29
29
  - Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
30
30
  conservative stopped starter with `FleetCoordinator`, a `fleet-health`
31
31
  watchdog, and a ten-minute `coordinator_health` loop. An existing
@@ -35,20 +35,21 @@ The operator CLI owns daemon configuration, lifecycle, and boot persistence.
35
35
  The MCP package is only the stdio adapter spawned by agent harnesses; the
36
36
  installer never asks `ours-mcp` to start a daemon.
37
37
 
38
- The external-history storage epoch is a clean breaking reset. The daemon refuses
39
- old packet state without modifying it, and this installer never migrates, purges,
40
- or silently replaces identities, contacts, invites, pending payloads, or history.
41
- Back up any wanted old state and remove it explicitly before starting the new epoch.
38
+ Daemon state is temporarily scoped to its package major version. On a same-major
39
+ update, the installer refreshes the packages and runs `ours daemon restart`; the
40
+ CLI streams structured startup phases until restore is complete instead of
41
+ appearing to hang. A different-major update is detected before package
42
+ replacement. The installer explains the incompatibility and, only in an
43
+ interactive run, offers to stop the CLI-managed daemon, copy the complete state
44
+ directory to a timestamped directory under `~/.ours-backups/`, remove the
45
+ managed service and old state, then initialize the new major. The default answer is no, and
46
+ `OURS_ASSUME_YES` never authorizes this purge.
42
47
 
43
48
  ## What remains stopped
44
49
 
45
- Telegram and Fleet are installed but intentionally not started. Review and
46
- activate them when ready:
50
+ Only Fleet is intentionally not started. Review and activate it when ready:
47
51
 
48
52
  ```sh
49
- # After configuring a Telegram bot and route locally:
50
- ours-tg-connector install-service
51
-
52
53
  # After reviewing ~/fleet.yaml:
53
54
  ours-fleet doctor
54
55
  ours-fleet config
@@ -92,6 +93,8 @@ targets only the explicit state directory.
92
93
 
93
94
  `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) selects the packages' nightly
94
95
  dist-tags. Without an override, the installer's own version selects the channel.
96
+ The operator CLI intentionally has no nightly dist-tag and remains untagged on
97
+ both channels.
95
98
 
96
99
  ## Environment
97
100
 
@@ -203,11 +203,10 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
203
203
  changed: changes.length > 0,
204
204
  changes,
205
205
  config: merged,
206
- // Telegram is intentionally staged but stopped. Its CLI currently couples
207
- // service installation with `enable --now`, so calling it here would violate
208
- // the installer's promise not to launch Telegram before a bot is configured.
209
- // The end screen gives this exact command as the explicit opt-in start step.
210
- service: null,
206
+ // The connector is a durable shim over the shared daemon. Its service command
207
+ // owns the platform-specific unit and `enable --now`; the installer invokes
208
+ // it after committing the coherent daemon selection below.
209
+ service: ['ours-tg-connector', 'install-service'],
211
210
  untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
212
211
  };
213
212
  if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
package/lib/effects.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // unit file or the service manager directly.
13
13
 
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
- import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { cpSync, existsSync, readFileSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
17
17
  import { dirname, join, resolve } from 'node:path';
18
18
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
@@ -29,7 +29,12 @@ async function probePort(port, { timeoutMs = 1500 } = {}) {
29
29
  if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
30
30
  const body = await res.json();
31
31
  if (typeof body?.stateDir !== 'string') return { ok: false, reason: 'no stateDir in reply' };
32
- return { ok: true, stateDir: body.stateDir };
32
+ return {
33
+ ok: true,
34
+ stateDir: body.stateDir,
35
+ version: typeof body.version === 'string' ? body.version : null,
36
+ compat: Number.isInteger(body.compat) ? body.compat : null,
37
+ };
33
38
  } catch (error) {
34
39
  return { ok: false, reason: error?.name === 'AbortError' ? 'timed out' : String(error?.message ?? error) };
35
40
  } finally {
@@ -74,6 +79,17 @@ function installedVersionOf(pkg) {
74
79
  }
75
80
  }
76
81
 
82
+ function packageDependenciesOf(pkgSpec) {
83
+ const probe = capture('npm', ['view', pkgSpec, 'dependencies', '--json'], { timeout: 15_000 });
84
+ if (!probe.ok) return null;
85
+ try {
86
+ const parsed = JSON.parse(probe.stdout);
87
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
77
93
  /**
78
94
  * A read-only command probe that NEVER throws and NEVER inherits stdio.
79
95
  *
@@ -221,6 +237,15 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
221
237
  // pure planner already gated four ways, and this deletes exactly that.
222
238
  removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
223
239
  removeFile: (path) => { rmSync(resolve(path), { force: true }); },
240
+ copyDir: (source, destination) => {
241
+ mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
242
+ cpSync(resolve(source), resolve(destination), {
243
+ recursive: true,
244
+ errorOnExist: true,
245
+ force: false,
246
+ preserveTimestamps: true,
247
+ });
248
+ },
224
249
  // Rewrites a config file we do NOT own, so it keeps the file's own mode
225
250
  // rather than imposing 0600: tightening the permissions of somebody else's
226
251
  // ~/.codex/config.toml is a side effect nobody asked this to have.
@@ -266,13 +291,17 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
266
291
  // invocation only and never to the installer's own process: a state
267
292
  // directory selected by one run must not leak into anything the operator
268
293
  // starts afterwards.
269
- run: async (cmd, args, { env: extraEnv = null } = {}) => {
294
+ run: async (cmd, args, { env: extraEnv = null, stream = false } = {}) => {
270
295
  // Always built from this layer's OWN env rather than left to spawnSync's
271
296
  // implicit inheritance, so what a child receives is a property of the
272
297
  // effects object a caller constructed and not of whatever ambient shell
273
298
  // the installer happened to start in.
274
299
  const childEnv = { ...env, ...(extraEnv ?? {}) };
275
- const r = spawnSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: childEnv });
300
+ const r = spawnSync(cmd, args, {
301
+ encoding: 'utf8',
302
+ stdio: stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
303
+ env: childEnv,
304
+ });
276
305
  if (r.status !== 0) {
277
306
  const detail = (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
278
307
  throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
@@ -289,6 +318,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
289
318
  return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
290
319
  },
291
320
  installedVersion: installedVersionOf,
321
+ packageDependencies: packageDependenciesOf,
292
322
  out: out ?? ((line) => process.stdout.write(`${line}\n`)),
293
323
  // Never called when assumeYes: the orchestrator takes the default itself.
294
324
  ask: async (prompt, def = false) => (ttyFd == null ? def : askYesNo(write, ttyFd, ` ${prompt} `, def)),
@@ -296,7 +326,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
296
326
  };
297
327
  }
298
328
 
299
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
329
+ export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, packageDependenciesOf, knownStateDirsIn };
300
330
 
301
331
  // -----------------------------------------------------------------------------
302
332
  // THE PAIR
package/lib/extras.mjs CHANGED
@@ -331,7 +331,7 @@ export function buildHandoffPromptV3({
331
331
  steps.push(
332
332
  'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
333
333
  + ' and guide me through entering the @BotFather token locally. Register the\n'
334
- + ' route, then start the connector only after I approve.',
334
+ + ' route in the connector service that ours-install already started.',
335
335
  );
336
336
  }
337
337
  if (steps.length === 0) return { text: '', empty: true };
@@ -23,7 +23,7 @@
23
23
  // ask(prompt, default) -> boolean (never called when assumeYes)
24
24
  // now() -> number
25
25
 
26
- import { join } from 'node:path';
26
+ import { basename, dirname, join, resolve } from 'node:path';
27
27
  import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
28
28
  import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
29
29
  import {
@@ -66,6 +66,104 @@ async function perform(effects, dryRun, label, thunk) {
66
66
 
67
67
  const reason = (error) => (error instanceof Error ? error.message : String(error));
68
68
 
69
+ function semverMajor(version) {
70
+ const match = /^(?:[~^<>= ]*)(\d+)\./.exec(String(version ?? '').trim());
71
+ return match ? Number(match[1]) : null;
72
+ }
73
+
74
+ function incompatibleUpgrade(target, cliDependencies) {
75
+ if (target.action !== 'update' || !target.daemonVersion) return null;
76
+ const runningMajor = semverMajor(target.daemonVersion);
77
+ const targetRange = cliDependencies?.['@ours.network/sdk'];
78
+ const targetMajor = semverMajor(targetRange);
79
+ if (runningMajor === null) {
80
+ return { unknown: true, runningMajor: null, runningVersion: target.daemonVersion };
81
+ }
82
+ if (targetMajor === null) {
83
+ return { unknown: true, runningMajor, runningVersion: target.daemonVersion };
84
+ }
85
+ return runningMajor !== targetMajor
86
+ ? { unknown: false, runningMajor, runningVersion: target.daemonVersion, targetMajor, targetRange }
87
+ : null;
88
+ }
89
+
90
+ async function prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch) {
91
+ const dir = target.stateDir;
92
+ const configPath = join(dir, 'config.json');
93
+ if (mismatch.unknown) {
94
+ effects.out(warn(`ours: cannot verify whether ${cliPkg} can restore daemon v${mismatch.runningVersion}. Nothing was changed.`));
95
+ effects.out(info('Check npm registry access and re-run; compatibility checks fail closed.'));
96
+ return { refused: { reason: 'compatibility-unknown', exitCode: EXIT_REFUSED } };
97
+ }
98
+
99
+ effects.out(warn(
100
+ `ours: daemon v${mismatch.runningVersion} cannot be restored by the requested major v${mismatch.targetMajor}; major upgrades are intentionally incompatible.`,
101
+ ));
102
+ if (resolve(dir) === resolve(effects.home) || dirname(resolve(dir)) === resolve(dir)) {
103
+ effects.out(info(`Automatic purge is not available for the broad state path ${dir}. Back it up and remove it manually.`));
104
+ return { refused: { reason: 'incompatible-major-broad-path', exitCode: EXIT_REFUSED } };
105
+ }
106
+ // Backups live one directory below a non-daemon container. Putting a copied
107
+ // state beside ~/.ours under another `.ours*` name makes daemon discovery see
108
+ // the backup as a second live target on the next installer run.
109
+ const backupPath = join(
110
+ dirname(dir),
111
+ '.ours-backups',
112
+ `${basename(dir)}-before-v${mismatch.targetMajor}-${effects.now()}`,
113
+ );
114
+ if (args.dryRun) {
115
+ effects.out(info(`[dry-run] would ask to stop the old daemon, copy its complete state to ${backupPath}, remove its service/state, and initialize v${mismatch.targetMajor}.`));
116
+ return { refused: { reason: 'incompatible-major-dry-run', exitCode: EXIT_REFUSED } };
117
+ }
118
+ if (args.assumeYes) {
119
+ effects.out(info('This purge is never accepted through OURS_ASSUME_YES. Re-run interactively to confirm the backup and reset.'));
120
+ return { refused: { reason: 'incompatible-major-unattended', exitCode: EXIT_REFUSED } };
121
+ }
122
+ if (effects.readJson(join(dir, 'ours-cli-daemon.json')) === null) {
123
+ effects.out(info('The daemon is not CLI-managed. Stop its external launcher, back up and remove its state/service, then re-run the installer.'));
124
+ return { refused: { reason: 'incompatible-major-external', exitCode: EXIT_REFUSED } };
125
+ }
126
+ const confirmed = await effects.ask(
127
+ `Back up all daemon state to ${backupPath}, purge the incompatible daemon and service, and install v${mismatch.targetMajor}?`,
128
+ false,
129
+ );
130
+ if (!confirmed) {
131
+ effects.out(info(`Nothing was changed. Back up ${dir}, remove the old daemon service/state, and re-run when ready.`));
132
+ return { refused: { reason: 'incompatible-major-declined', exitCode: EXIT_REFUSED } };
133
+ }
134
+
135
+ await perform(effects, false, 'stop the incompatible daemon', () => effects.run(
136
+ 'ours', ['daemon', 'stop', '--state-dir', dir, '--config', configPath], { stream: true },
137
+ ));
138
+ try {
139
+ await perform(effects, false, `back up complete daemon state to ${backupPath}`, () => effects.copyDir(dir, backupPath));
140
+ } catch (error) {
141
+ try {
142
+ await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
143
+ effects.out(ok('backup failed, but the old daemon was started again'));
144
+ } catch {
145
+ effects.out(warn(`backup failed and the old daemon did not restart; its state is still untouched at ${dir}`));
146
+ }
147
+ throw error;
148
+ }
149
+ try {
150
+ await perform(effects, false, 'remove the incompatible daemon boot service', () => effects.run(
151
+ 'ours', ['daemon', 'uninstall-service', '--yes', '--state-dir', dir, '--config', configPath], { stream: true },
152
+ ));
153
+ } catch (error) {
154
+ try {
155
+ await effects.run('ours', ['daemon', 'start', '--state-dir', dir, '--config', configPath], { stream: true });
156
+ effects.out(ok(`service removal failed, but the old daemon was started again; backup retained at ${backupPath}`));
157
+ } catch {
158
+ effects.out(warn(`service removal failed and the old daemon did not restart; state remains at ${dir} and the backup is at ${backupPath}`));
159
+ }
160
+ throw error;
161
+ }
162
+ await perform(effects, false, `remove incompatible state at ${dir}`, () => effects.removeDir(dir));
163
+ effects.out(ok(`backup retained at ${backupPath}`));
164
+ return { purged: true, backupPath };
165
+ }
166
+
69
167
  /**
70
168
  * A step that is allowed to fail without ending the run.
71
169
  *
@@ -184,7 +282,7 @@ export async function runDaemonPhase(args, effects) {
184
282
  }
185
283
 
186
284
  const dir = target.stateDir;
187
- const creating = target.action === 'create';
285
+ let creating = target.action === 'create';
188
286
  effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
189
287
  if (target.stalePidRecord) {
190
288
  effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
@@ -213,9 +311,23 @@ export async function runDaemonPhase(args, effects) {
213
311
  // adapter each harness spawns. Both are required, but only `ours daemon`
214
312
  // participates in lifecycle or service management.
215
313
  const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
314
+ // The CLI intentionally publishes only `latest`; unlike the lockstep MCP and
315
+ // connector packages it has no nightly dist-tag. Keep this untagged on every
316
+ // installer channel, and inspect that package's SDK dependency for the gate.
317
+ const cliPkg = '@ours.network/cli';
318
+ if (!creating && target.daemonVersion) {
319
+ const mismatch = incompatibleUpgrade(target, effects.packageDependencies(cliPkg));
320
+ if (mismatch) {
321
+ const prepared = await prepareIncompatibleUpgrade(args, effects, target, cliPkg, mismatch);
322
+ if (prepared.refused) return { target, refused: prepared.refused, steps };
323
+ creating = prepared.purged === true;
324
+ target.backupPath = prepared.backupPath;
325
+ target.action = 'create';
326
+ }
327
+ }
216
328
  await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
217
329
  steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
218
- await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
330
+ await perform(effects, args.dryRun, `ours CLI installed (npm i -g ${cliPkg})`, () => effects.run('npm', ['i', '-g', cliPkg]));
219
331
  steps.push({ id: 'cli', changed: true, packageRefresh: true });
220
332
 
221
333
  // The config file — merged, never rewritten, and untouched when it already
@@ -249,8 +361,11 @@ export async function runDaemonPhase(args, effects) {
249
361
 
250
362
  try {
251
363
  if (creating) {
252
- await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
364
+ await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath], { stream: true }));
253
365
  steps.push({ id: 'start', changed: true });
366
+ } else {
367
+ await perform(effects, args.dryRun, `restart the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'restart', '--config', configPath], { stream: true }));
368
+ steps.push({ id: 'restart', changed: true });
254
369
  }
255
370
 
256
371
  const service = await runServicePhase(args, effects, dir, target.port);
@@ -502,14 +617,21 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
502
617
  }
503
618
  }
504
619
  await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
620
+ const journal = configJournal(effects, { dryRun: args.dryRun });
505
621
  if (plan.changed) {
622
+ journal.snapshot(path);
506
623
  await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
507
624
  } else {
508
625
  effects.out(ok(`${path} already points here — not touched`));
509
626
  }
510
- effects.out(ok('Telegram connector installed and configured, but not started.'));
511
- effects.out(info('After adding a bot, start it explicitly with: ours-tg-connector install-service'));
512
- return { key: 'tg', state: 'installed', note: 'configured; stopped' };
627
+ try {
628
+ await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
629
+ } catch (error) {
630
+ rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its daemon selection back');
631
+ throw error;
632
+ }
633
+ effects.out(ok('Telegram connector installed as a durable service on the shared daemon.'));
634
+ return { key: 'tg', state: 'installed', note: 'configured; service running' };
513
635
  }
514
636
 
515
637
  const path = coworkConfigPath(effects.home, effects.env);
@@ -823,13 +945,9 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
823
945
  }
824
946
 
825
947
  const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
826
- if (has('tg') || has('fleet')) {
948
+ if (has('fleet')) {
827
949
  effects.out('');
828
950
  effects.out(` ${c.bold('Installed but intentionally stopped')}`);
829
- if (has('tg')) {
830
- effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
831
- effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
832
- }
833
951
  if (has('fleet')) {
834
952
  effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
835
953
  effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
@@ -946,7 +1064,7 @@ export async function runInstall(argv, effects) {
946
1064
  });
947
1065
  }
948
1066
 
949
- effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
1067
+ effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; run both shims as durable services.'));
950
1068
  const components = await runComponentPhase(args, effects, target);
951
1069
  for (const component of COMPONENTS) {
952
1070
  const state = components.installed.includes(component.key) ? 'installed'
@@ -957,7 +1075,7 @@ export async function runInstall(argv, effects) {
957
1075
  state,
958
1076
  version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
959
1077
  note: components.failed.find((f) => f.key === component.key)?.reason
960
- ?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
1078
+ ?? (state === 'installed' && component.key === 'tg' ? 'configured; service running'
961
1079
  : state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
962
1080
  });
963
1081
  }
package/lib/target.mjs CHANGED
@@ -176,7 +176,11 @@ export function classifyProbe(probe, targetStateDir) {
176
176
  if (!samePath(probe.stateDir, targetStateDir)) {
177
177
  return { kind: 'foreign', reason: 'daemon owns a different state directory', stateDir: resolve(probe.stateDir) };
178
178
  }
179
- return { kind: 'present', stateDir: resolve(probe.stateDir) };
179
+ return {
180
+ kind: 'present',
181
+ stateDir: resolve(probe.stateDir),
182
+ daemonVersion: typeof probe.version === 'string' ? probe.version : null,
183
+ };
180
184
  }
181
185
 
182
186
  /**
@@ -327,7 +331,13 @@ export async function resolveTarget({ stateDir, port = null, portExplicit = fals
327
331
  message: `--port ${port} disagrees with port ${found.port}, where the daemon for ${target} is actually running`,
328
332
  };
329
333
  }
330
- return { action: 'update', port: found.port, stateDir: target, config: found.config };
334
+ return {
335
+ action: 'update',
336
+ port: found.port,
337
+ stateDir: target,
338
+ config: found.config,
339
+ daemonVersion: found.daemonVersion ?? null,
340
+ };
331
341
  }
332
342
 
333
343
  // Creating. Only now is a port chosen.
package/lib/usage.mjs CHANGED
@@ -13,8 +13,9 @@ export const USAGE = `ours-install — the unified ours.network stack installer.
13
13
 
14
14
  Progress-driven setup for the whole stack: one shared daemon, MCP, Telegram,
15
15
  cowork, detected harness plugins (Claude Code / Codex / Hermes), a Human
16
- identity, and ours-fleet. The daemon and cowork start; Telegram and Fleet are
17
- staged but stopped. The installer asks only for information it cannot infer and
16
+ identity, and ours-fleet. The daemon, Telegram connector, and cowork shim start
17
+ as durable services; only Fleet is staged but stopped. The installer asks only
18
+ for information it cannot infer and
18
19
  ends with exact next commands plus a copy-paste agent hand-off prompt.
19
20
 
20
21
  --state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.18.0-nightly.4",
3
+ "version": "0.18.0-nightly.5",
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",