@ours.network/install 0.18.0-nightly.1 → 0.18.0-nightly.2

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
@@ -1,27 +1,59 @@
1
1
  # @ours.network/install — `ours-install`
2
2
 
3
- The guided installer for the ours shared daemon, MCP adapter, harness plugins,
4
- ours-fleet, and optional connectors.
3
+ The all-in-one installer for the ours.network stack. One run installs and
4
+ configures the shared daemon, MCP adapter, cowork, Telegram connector, Fleet,
5
+ and plugins for every safely detected agent harness.
5
6
 
6
7
  ```sh
7
8
  npm install --global @ours.network/install
8
9
  ours-install
9
10
  ```
10
11
 
11
- The installer treats the operator CLI and the MCP adapter as separate packages:
12
+ The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
13
+ progress bar, and asks only for information it cannot safely infer (normally the
14
+ Human identity's display name). Existing daemon conflicts and moving a Telegram
15
+ connector from another daemon still require explicit confirmation.
16
+
17
+ ## What the installer does
18
+
19
+ - Installs `@ours.network/cli`, `@ours.network/mcp`,
20
+ `@ours.network/tg-connector`, `@ours.network/cowork`, and
21
+ `@ours.network/fleet` on one release channel.
22
+ - Configures, starts, and enables the single shared daemon.
23
+ - Creates the daemon's Human identity (historically called the root identity),
24
+ or preserves the existing one on a re-run.
25
+ - Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
26
+ installations.
27
+ - Configures and starts cowork against the shared daemon.
28
+ - Configures Telegram against the same daemon, but does **not** start it.
29
+ - Runs Fleet's host initialization and, when `~/fleet.yaml` is absent, writes a
30
+ conservative stopped starter with `FleetCoordinator`, a `fleet-health`
31
+ watchdog, and a ten-minute `coordinator_health` loop. An existing
32
+ `~/fleet.yaml` is never overwritten.
33
+
34
+ The operator CLI owns daemon configuration, lifecycle, and boot persistence.
35
+ The MCP package is only the stdio adapter spawned by agent harnesses; the
36
+ installer never asks `ours-mcp` to start a daemon.
37
+
38
+ ## What remains stopped
39
+
40
+ Telegram and Fleet are installed but intentionally not started. Review and
41
+ activate them when ready:
12
42
 
13
- - `@ours.network/cli` owns daemon configuration, lifecycle, and boot services.
14
- - `@ours.network/mcp` is the stdio MCP adapter spawned by agent harnesses.
15
-
16
- It configures a selected state directory, installs both packages, starts the
17
- daemon with `ours daemon start`, installs its service with
18
- `ours daemon install-service`, and uses `ours identity create-root` for the Human
19
- identity. It never asks ours-mcp to boot or configure a daemon.
43
+ ```sh
44
+ # After configuring a Telegram bot and route locally:
45
+ ours-tg-connector install-service
46
+
47
+ # After reviewing ~/fleet.yaml:
48
+ ours-fleet doctor
49
+ ours-fleet config
50
+ ours-fleet up
51
+ ours-fleet ls
52
+ ```
20
53
 
21
- The default is one shared daemon at `~/.ours` on port 3050. A non-default daemon
22
- must be selected coherently with a config file or matching port and state
23
- directory. Harnesses receive that selection through `OURS_CONFIG`; there is no
24
- separate per-harness daemon registry.
54
+ The final installer screen repeats these commands and provides a copy-paste
55
+ prompt for Claude Code, Codex, or Hermes. The agent should guide local bot-token
56
+ entry without asking the user to paste the secret into chat.
25
57
 
26
58
  ## Preview and automation
27
59
 
@@ -32,8 +64,13 @@ ours-install --state-dir /absolute/path --port 3070
32
64
  ```
33
65
 
34
66
  Dry-run walks the real plan without writing files, installing packages, starting
35
- processes, or changing services. Non-interactive runs accept defaults but never
36
- bypass selection conflicts or destructive safeguards.
67
+ processes, or changing services. `OURS_ASSUME_YES=1` uses the OS username for a
68
+ new Human identity and asks no ordinary setup questions, but it never bypasses
69
+ selection conflicts, connector moves, or destructive safeguards.
70
+
71
+ A non-default daemon must be selected coherently with a config file or matching
72
+ port and state directory. Harnesses and the generated Fleet role receive that
73
+ selection through `OURS_CONFIG`; there is no per-application daemon.
37
74
 
38
75
  ## Uninstall
39
76
 
@@ -53,7 +90,7 @@ dist-tags. Without an override, the installer's own version selects the channel.
53
90
 
54
91
  ## Environment
55
92
 
56
- - `OURS_ASSUME_YES=1`: accept defaults without prompting.
93
+ - `OURS_ASSUME_YES=1`: accept safe defaults without prompting.
57
94
  - `OURS_INSTALL_DRY_RUN=1`: preview without mutation.
58
95
  - `OURS_NPM`: npm executable.
59
96
  - `OURS_CONFIG`: explicit daemon configuration file.
@@ -6,8 +6,8 @@
6
6
  // contents and the installed versions, and every function returns a plan.
7
7
  //
8
8
  // The three components are the MCP server, the Telegram connector and cowork.
9
- // None of them IS the daemon; all three attach to one. The messenger is out of
10
- // scope here, and ours-fleet sits on top and is not installed here.
9
+ // None of them IS the daemon; all three attach to one. A normal install carries
10
+ // the complete stack, while the messenger remains deliberately out of scope.
11
11
 
12
12
  import { join, resolve } from 'node:path';
13
13
  import { pkgSpec, resolveChannel } from './logic.mjs';
@@ -29,8 +29,8 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
29
29
  // has to be impossible rather than discouraged.
30
30
  export const COMPONENTS = [
31
31
  { key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
32
- { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
33
- { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
32
+ { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: true },
33
+ { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: true },
34
34
  ];
35
35
 
36
36
  /**
@@ -105,10 +105,10 @@ export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? jo
105
105
  // -----------------------------------------------------------------------------
106
106
 
107
107
  /**
108
- * Which components this run installs. Defaults are MCP server yes, connector no,
109
- * cowork no the same answers a non-interactive run takes, so
110
- * `OURS_ASSUME_YES=1 ours-install` produces a daemon plus the MCP server and
111
- * nothing else (spec §9).
108
+ * Which components this run installs. The default is the complete stack: MCP,
109
+ * Telegram and cowork all attach to the same daemon. Explicit programmatic
110
+ * answers are retained for compatibility, but the public installer no longer
111
+ * asks the user to assemble the product one package at a time.
112
112
  *
113
113
  * `installed` marks a component already present so the question reads "keep it?"
114
114
  * — and DECLINING AN ALREADY-INSTALLED COMPONENT NEVER UNINSTALLS IT. Removal is
@@ -203,10 +203,11 @@ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assu
203
203
  changed: changes.length > 0,
204
204
  changes,
205
205
  config: merged,
206
- // Written BEFORE the service: `ours-tg-connector install-service` bakes the
207
- // resolved values into the unit as environment, and environment outranks the
208
- // config file afterwards.
209
- service: ['ours-tg-connector', 'install-service'],
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,
210
211
  untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
211
212
  };
212
213
  if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
package/lib/extras.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
  // terminal. Every function takes what was observed and returns a plan; the
11
11
  // orchestrator (a later PR) is what performs it.
12
12
 
13
- import { join, resolve } from 'node:path';
13
+ import { dirname, join, resolve } from 'node:path';
14
14
  import { pkgSpec } from './logic.mjs';
15
15
 
16
16
  const cfgPath = (stateDir) => join(resolve(stateDir), 'config.json');
@@ -213,28 +213,63 @@ export function restartHints(summary = []) {
213
213
  }
214
214
 
215
215
  // -----------------------------------------------------------------------------
216
- // ours-fleet — the one that needs zero code
216
+ // ours-fleet — installed and staged, never started implicitly
217
217
  // -----------------------------------------------------------------------------
218
218
 
219
- /**
220
- * ours-fleet needs NO change in either repo, and the installer configures
221
- * nothing in it.
222
- *
223
- * `ours-fleet init` takes no daemon argument of any kind and never reads a
224
- * daemon config: it makes its directories, installs its own units and prints a
225
- * next step. Fleet resolves the ours daemon the same way the MCP client does —
226
- * OURS_CONFIG ?? ~/.ours/config.json, then OURS_PORT / OURS_STATE_DIR /
227
- * OURS_API_TOKEN — and it does so PER ROLE, through
228
- * resolveEndpoint({ ...process.env, ...role.env }). Different roles can already
229
- * target different daemons. Temp supervisors inherit the same four names.
230
- *
231
- * So for a non-default state directory the installer's ONLY job is to say the
232
- * one fleet.yaml line that points a role at the daemon this run created. Saying
233
- * it is the whole feature; anything more would be configuring a tool that is
234
- * already correct.
235
- */
236
- export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
219
+ export const fleetConfigPath = (home) => join(resolve(home), 'fleet.yaml');
220
+
221
+ const yamlString = (value) => JSON.stringify(String(value));
222
+
223
+ /** A conservative, useful first fleet: one coordinator, one watchdog, one loop. */
224
+ export function defaultFleetConfig({ home, stateDir, isDefaultStateDir } = {}) {
225
+ const cwd = resolve(home);
237
226
  const config = stateDir ? cfgPath(stateDir) : null;
227
+ const roleEnv = isDefaultStateDir || !config
228
+ ? ''
229
+ : `\n env:\n OURS_CONFIG: ${yamlString(config)}`;
230
+ return `# Generated by ours-install. Review this file before starting Fleet.\n`
231
+ + `defaults:\n`
232
+ + ` harness: codex\n`
233
+ + ` session: acp\n`
234
+ + ` permissions:\n`
235
+ + ` approval: allow\n`
236
+ + ` filesystem: workspace\n`
237
+ + ` unattended: wait\n`
238
+ + ` monitor:\n`
239
+ + ` mode: fleet\n\n`
240
+ + `roles:\n`
241
+ + ` FleetCoordinator:\n`
242
+ + ` identity: FleetCoordinator\n`
243
+ + ` cwd: ${yamlString(cwd)}\n`
244
+ + ` mission: Coordinate durable agent work, delegate bounded tasks, and report material outcomes.\n`
245
+ + ` bio: Fleet coordinator for this host; engage it to assign work or check agent status.\n`
246
+ + ` persona: |\n`
247
+ + ` Keep a concise durable worklog. Preserve user state, verify delegated results,\n`
248
+ + ` and escalate decisions that require new authority. Report material progress only.${roleEnv}\n\n`
249
+ + `watchdogs:\n`
250
+ + ` fleet-health:\n`
251
+ + ` coordinator: FleetCoordinator\n`
252
+ + ` watch: [FleetCoordinator]\n`
253
+ + ` harness: codex\n`
254
+ + ` session: acp\n`
255
+ + ` interval: 10m\n`
256
+ + ` timeout: 8m\n\n`
257
+ + `loops:\n`
258
+ + ` coordinator_health:\n`
259
+ + ` roles: [FleetCoordinator]\n`
260
+ + ` interval: 10m\n`
261
+ + ` initial_delay: 10m\n`
262
+ + ` enabled: true\n`
263
+ + ` prompt: |\n`
264
+ + ` Perform one bounded fleet health pass. Reconcile active work, specialist state,\n`
265
+ + ` declared blockers, and CI. Unstick only safe in-scope work. If nothing material\n`
266
+ + ` changed, complete silently.\n`;
267
+ }
268
+
269
+ export function planFleet({ home, stateDir, isDefaultStateDir, wanted = true, channel = 'latest' } = {}) {
270
+ const config = stateDir ? cfgPath(stateDir) : null;
271
+ const resolvedHome = home ?? (stateDir ? dirname(resolve(stateDir)) : null);
272
+ const path = resolvedHome ? fleetConfigPath(resolvedHome) : null;
238
273
  const plan = {
239
274
  key: 'fleet',
240
275
  label: 'ours-fleet',
@@ -251,12 +286,13 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
251
286
  // this defers to it rather than restating it.
252
287
  install: ['npm', 'i', '-g', pkgSpec('fleet', channel)],
253
288
  init: ['ours-fleet', 'init'],
254
- // Stated as data so a test can pin it: this feature writes no fleet config.
255
- writes: [],
289
+ configPath: path,
290
+ config: resolvedHome ? defaultFleetConfig({ home: resolvedHome, stateDir, isDefaultStateDir }) : null,
291
+ writes: path ? [path] : [],
256
292
  roleEnv: isDefaultStateDir ? {} : { OURS_CONFIG: config },
257
293
  instruction: isDefaultStateDir
258
- ? null
259
- : `fleet roles that should use this daemon need one line in fleet.yaml:\n env: { OURS_CONFIG: ${config} }`,
294
+ ? `review ${path}, then run ours-fleet doctor and ours-fleet up when you are ready`
295
+ : `review ${path}; its coordinator is pinned to this daemon with OURS_CONFIG=${config}`,
260
296
  };
261
297
  return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
262
298
  }
@@ -266,14 +302,9 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
266
302
  // -----------------------------------------------------------------------------
267
303
 
268
304
  /**
269
- * buildHandoffPrompt is already pure, already renumbers and already drops steps
270
- * for components that were not installed. Re-pointing it is one preamble.
271
- *
272
- * DEFAULT STATE DIRECTORY → THE TEXT IS UNCHANGED, BYTE FOR BYTE. That is the
273
- * overwhelming majority case and it is pinned by a test, because the agent on
274
- * the other end of this prompt configures fleet roles and harness environments,
275
- * and a stray line about a state directory the user never chose is a worse
276
- * outcome than no line at all.
305
+ * buildHandoffPrompt is pure, renumbers automatically, and drops steps for
306
+ * components that were not installed. The default path needs no daemon preamble;
307
+ * a non-default path names the exact config so the assistant cannot guess.
277
308
  */
278
309
  export function buildHandoffPromptV3({
279
310
  identity = false,
@@ -291,16 +322,16 @@ export function buildHandoffPromptV3({
291
322
  }
292
323
  if (fleet) {
293
324
  steps.push(
294
- 'Set up my ours-fleet: ask me what agents I want in my fleet for\n'
295
- + ' PERMANENT use (a name + role/purpose for each), then create and\n'
296
- + ' configure those permanent fleet agents for me.',
325
+ 'Review ~/fleet.yaml with me. It already contains a stopped FleetCoordinator,\n'
326
+ + ' a fleet-health watchdog, and a 10-minute coordinator health loop. Ask\n'
327
+ + ' before changing permissions or starting the fleet.',
297
328
  );
298
329
  }
299
330
  if (telegram) {
300
331
  steps.push(
301
- 'Set up my Telegram bot: ask me for my bot\'s name and its token from\n'
302
- + ' @BotFather, register the bot, create a chat↔agent connection, and\n'
303
- + ' give me the invite link to send.',
332
+ 'Finish my Telegram setup without exposing secrets: ask me for the bot name\n'
333
+ + ' and guide me through entering the @BotFather token locally. Register the\n'
334
+ + ' route, then start the connector only after I approve.',
304
335
  );
305
336
  }
306
337
  if (steps.length === 0) return { text: '', empty: true };
@@ -35,10 +35,10 @@ import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } fro
35
35
  import { summarizeRun } from './rerun.mjs';
36
36
  import { configJournal, reportRollback } from './journal.mjs';
37
37
  import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
38
- import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
38
+ import { detectPlatform, resolveChannel } from './logic.mjs';
39
39
  import { daemonEnv } from './effects.mjs';
40
40
  import { USAGE } from './usage.mjs';
41
- import { ok, info, warn, heading, banner, box, c } from './ui.mjs';
41
+ import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
42
42
 
43
43
  export const EXIT_OK = 0;
44
44
  export const EXIT_REFUSED = 2;
@@ -418,42 +418,15 @@ function readChanged(stdout) {
418
418
  /**
419
419
  * THE QUESTION NOBODY WAS ASKING.
420
420
  *
421
- * `planComponentSelection` has always read an `answers` map and NOTHING EVER
422
- * POPULATED IT. `args.answers` is not produced by parseInstallArgs and is not
423
- * written by any phase, so it was permanently `{}`, every optional component fell
424
- * to its own default, and tg and cowork (both default false) were skipped in
425
- * silence. The screen then printed "cowork — not installed" as though that had
426
- * been a decision.
427
- *
428
- * This was the ONE phase with no question. Harness plugins ask, ours-fleet asks,
429
- * voice asks, the broker asks. Components did not.
430
- *
431
- * IT IS NOT A COWORK BUG, and fixing only the component that got reported would
432
- * have left the identical defect live under a different label: the Telegram
433
- * connector is unreachable by exactly the same route, and nobody noticed only
434
- * because tg is usually configured somewhere else.
435
- *
436
- * A REQUIRED component is never asked — since #74 the MCP package IS the daemon,
437
- * the daemon phase has already installed it, and a question whose only honest
438
- * answer is yes is not a question. Non-interactive runs are unchanged: assumeYes
439
- * takes each component's own default, which is what NON_INTERACTIVE_ANSWERS
440
- * documents (mcp yes, tg no, cowork no), so nothing about the unattended contract
441
- * moves.
421
+ * The public installer has one product, not a package-selection questionnaire:
422
+ * MCP, Telegram, and cowork are all installed. Explicit injected answers remain
423
+ * only as a compatibility/test seam for callers that deliberately omit a piece.
442
424
  */
443
425
  export async function askComponents(args, effects) {
444
- const answers = { ...(args.answers ?? {}) };
445
- if (args.assumeYes) return answers;
446
- const optional = COMPONENTS.filter((c) => !c.required && answers[c.key] === undefined);
447
- if (optional.length === 0) return answers;
448
- effects.out(heading('What else should this daemon carry?'));
449
- for (const component of optional) {
450
- const already = (args.installed ?? {})[component.key] === true;
451
- answers[component.key] = await effects.ask(
452
- already ? `Keep ${component.label}?` : `Install ${component.label}?`,
453
- already || component.default,
454
- );
455
- }
456
- return answers;
426
+ // The product is the complete stack. Asking the operator to reconstruct that
427
+ // product from package names was choice theatre and made unattended installs
428
+ // incomplete. Explicit injected answers remain a test/compatibility seam.
429
+ return { ...(args.answers ?? {}) };
457
430
  }
458
431
 
459
432
  /**
@@ -529,29 +502,14 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
529
502
  }
530
503
  }
531
504
  await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
532
- // ONE UNIT OF WORK: these bytes and the service that reads them. If the
533
- // service does not come up, the connector's config names this daemon while its
534
- // unit still carries the OLD environment — and the phase's own catch would
535
- // report one failed line and let the run print "install complete".
536
- const journal = configJournal(effects, { dryRun: args.dryRun });
537
505
  if (plan.changed) {
538
- // Written BEFORE the service: install-service bakes these values into the
539
- // unit as environment, and environment outranks the config file after.
540
- journal.snapshot(path);
541
506
  await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
542
507
  } else {
543
508
  effects.out(ok(`${path} already points here — not touched`));
544
509
  }
545
- try {
546
- await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
547
- } catch (error) {
548
- // Scoped to THIS component: the daemon came up correctly and is not undone
549
- // by a connector that did not. That is the rule this journal's per-unit scope
550
- // exists to keep.
551
- rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its config back');
552
- throw error;
553
- }
554
- return { key: 'tg', state: 'installed' };
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' };
555
513
  }
556
514
 
557
515
  const path = coworkConfigPath(effects.home, effects.env);
@@ -608,27 +566,11 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
608
566
  */
609
567
  export async function askBroker(args, effects) {
610
568
  const standard = args.brokerUrl;
611
- if (args.assumeYes) return standard;
612
- effects.out(heading('Your broker'));
613
- effects.out(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
614
- effects.out(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
615
- effects.out(info('sees what they say. Almost everyone uses the standard one just press Enter.'));
616
- if (!(await effects.ask('Use a custom broker address?', false))) {
617
- effects.out(ok('using the standard broker.'));
618
- return standard;
619
- }
620
- const entered = String(await effects.askLine('Enter the broker address: ', '')).trim();
621
- const checked = validateBroker(entered);
622
- if (!entered || !checked.ok || checked.empty) {
623
- if (entered) effects.out(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
624
- else effects.out(ok('using the standard broker.'));
625
- return standard;
626
- }
627
- if (await effects.ask(`Use "${checked.value}"? (No = go back to the standard broker)`, true)) {
628
- effects.out(ok(`broker set to ${checked.value}.`));
629
- return checked.value;
630
- }
631
- effects.out(ok('using the standard broker.'));
569
+ // Custom deployments remain available through OURS_BROKER_URL. The ordinary
570
+ // install is intentionally linear and explains the standard safe default.
571
+ effects.out(info(effects.env.OURS_BROKER_URL
572
+ ? 'All services use the end-to-end encrypted broker configured in OURS_BROKER_URL.'
573
+ : 'All services use the standard end-to-end encrypted ours broker.'));
632
574
  return standard;
633
575
  }
634
576
 
@@ -748,24 +690,13 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
748
690
  return [];
749
691
  }
750
692
 
751
- // Asked BEFORE planning, because the plan's `wanted` is the answer. Only a
752
- // harness we can actually drive is worth a question — the others are going to
753
- // print manual steps whatever the operator says.
754
- const answers = {};
755
- if (!args.assumeYes) {
756
- for (const h of detected) {
757
- if (h.status !== 'ok') continue;
758
- answers[h.name] = await effects.ask(`Install the ours plugin into ${h.label ?? h.name}?`, true);
759
- }
760
- }
761
-
762
693
  const plans = planHarnessPlugins({
763
694
  harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
764
695
  stateDir: target.stateDir,
765
696
  isDefaultStateDir,
766
697
  channel: args.channel,
767
- assumeYes: args.assumeYes,
768
- answers,
698
+ assumeYes: true,
699
+ answers: {},
769
700
  });
770
701
 
771
702
  const rows = [];
@@ -808,48 +739,21 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
808
739
  return rows;
809
740
  }
810
741
 
811
- /**
812
- * ours-fleet. The feature that needed zero code anywhere.
813
- *
814
- * `ours-fleet init` takes no daemon argument and reads no daemon config; fleet
815
- * resolves a daemon per role, from the role's own env. So the installer installs
816
- * it, runs its one-time host setup, and — for a non-default state directory —
817
- * SAYS the one fleet.yaml line. Saying it is the whole feature.
818
- */
742
+ /** Install Fleet, initialize its host support, and stage a stopped starter config. */
819
743
  export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
820
744
  effects.out(heading('ours-fleet (your always-online agent team)'));
821
745
  effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
822
746
  effects.out(info('become always-online agents that survive a reboot.'));
823
- const wanted = args.assumeYes ? true : await effects.ask('Install it?', true);
824
747
  const plan = planFleet({
825
- stateDir: target.stateDir, isDefaultStateDir, wanted, channel: args.channel,
748
+ home: effects.home, stateDir: target.stateDir, isDefaultStateDir, wanted: true, channel: args.channel,
826
749
  });
827
750
  if (plan.action === 'skip') {
828
751
  effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
829
752
  return { key: 'fleet', label: plan.label, state: 'skipped' };
830
753
  }
831
754
  const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
832
- // THE PAIR IS PASSED AS DELIBERATE INSURANCE AGAINST AN UNRESOLVED
833
- // CONTRADICTION, not because the question was settled.
834
- //
835
- // Two written analyses disagree, and NEITHER was verified — ours-fleet is not in
836
- // this repo:
837
- // lib/nightly-install.mjs:611 says fleet resolves its daemon from
838
- // OURS_CONFIG / OURS_PORT / OURS_STATE_DIR and has no concept of a registry,
839
- // so an `init` run without the pair points every role at the historical
840
- // default daemon — which, when the selected daemon is not the default, may
841
- // be one the user does not even have.
842
- // lib/extras.mjs:180 says `init` takes no daemon argument of any kind, reads no
843
- // daemon config, and resolves per role through
844
- // resolveEndpoint({ ...process.env, ...role.env }) — so the pair is
845
- // unnecessary here.
846
- // Passing it is harmless if extras.mjs is right and load-bearing if
847
- // nightly-install.mjs is. When the cheap action is safe under both readings and
848
- // the expensive one is only safe under one, take the cheap one. (Coordinator
849
- //
850
- // Passed for EVERY state directory, not only a non-default one, exactly as the
851
- // nightly flow does: `init` is a one-time host setup and the pair is what names
852
- // the daemon it was set up beside.
755
+ // Pass the complete daemon tuple to host initialization. The generated role
756
+ // also carries OURS_CONFIG when the chosen daemon is non-default.
853
757
  const initEnv = daemonEnv(target.stateDir, target.port);
854
758
  const init = install.ok
855
759
  ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1), { env: initEnv }))
@@ -858,9 +762,14 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
858
762
  effects.out(info(`retry manually: ${plan.init.join(' ')}`));
859
763
  return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
860
764
  }
861
- effects.out(ok('ours-fleet ready the core ours plugin discovers every option through `ours-fleet docs`.'));
765
+ if (effects.readText(plan.configPath) === null) {
766
+ await perform(effects, args.dryRun, `write starter fleet config ${plan.configPath}`, () => effects.writeText(plan.configPath, plan.config));
767
+ } else {
768
+ effects.out(ok(`${plan.configPath} already exists — not touched`));
769
+ }
770
+ effects.out(ok('ours-fleet installed and initialized; no fleet roles were started.'));
862
771
  if (plan.instruction) effects.out(info(plan.instruction));
863
- return { key: 'fleet', label: plan.label, state: 'installed', note: 'CLI + core-plugin discovery' };
772
+ return { key: 'fleet', label: plan.label, state: 'installed', note: `configured at ${plan.configPath}; stopped` };
864
773
  }
865
774
 
866
775
  /** Voice configuration belongs to the shared daemon, not the MCP adapter. */
@@ -914,6 +823,20 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
914
823
  }
915
824
 
916
825
  const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
826
+ if (has('tg') || has('fleet')) {
827
+ effects.out('');
828
+ 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
+ if (has('fleet')) {
834
+ effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
835
+ effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
836
+ effects.out(` ${c.cyan('ours-fleet ls')}`);
837
+ }
838
+ }
839
+
917
840
  const { text, empty } = buildHandoffPromptV3({
918
841
  identity: !has('identity'),
919
842
  fleet: has('fleet'),
@@ -927,7 +850,7 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
927
850
  } else {
928
851
  effects.out('');
929
852
  effects.out(` ${c.gray('─'.repeat(64))}`);
930
- effects.out(` ${c.bold('ONE LAST STEP')} — copy the prompt below and paste it into your agent.`);
853
+ effects.out(` ${c.bold('ONE LAST STEP')} — paste this prompt into Claude Code, Codex, or Hermes.`);
931
854
  effects.out(` ${c.gray('─'.repeat(64))}`);
932
855
  effects.out('');
933
856
  effects.out(box(text.split('\n'), 'paste this into your agent'));
@@ -987,6 +910,7 @@ export async function runInstall(argv, effects) {
987
910
  effects.out(banner());
988
911
  effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
989
912
  if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
913
+ effects.out(progress(1, 8, 'Check the host', 'Verify the platform and Node.js before changing anything.'));
990
914
 
991
915
  // An unsupported platform is not a refusal of an incoherent selection, it is a
992
916
  // machine this cannot run on. v2 exited 0 there and so does this, so a script
@@ -995,9 +919,11 @@ export async function runInstall(argv, effects) {
995
919
 
996
920
  // Which daemon, before anything is decided about it. Only args.stateDir can
997
921
  // change here; every refusal downstream is unaffected.
922
+ effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
998
923
  const selection = await runSelectionPhase(args, effects);
999
924
  if (selection.action === 'refuse') return EXIT_REFUSED;
1000
925
 
926
+ effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
1001
927
  const daemon = await runDaemonPhase(args, effects);
1002
928
  if (daemon.refused) return EXIT_REFUSED;
1003
929
  const target = daemon.target;
@@ -1020,6 +946,7 @@ export async function runInstall(argv, effects) {
1020
946
  });
1021
947
  }
1022
948
 
949
+ effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
1023
950
  const components = await runComponentPhase(args, effects, target);
1024
951
  for (const component of COMPONENTS) {
1025
952
  const state = components.installed.includes(component.key) ? 'installed'
@@ -1029,13 +956,18 @@ export async function runInstall(argv, effects) {
1029
956
  label: component.label,
1030
957
  state,
1031
958
  version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
1032
- note: components.failed.find((f) => f.key === component.key)?.reason,
959
+ note: components.failed.find((f) => f.key === component.key)?.reason
960
+ ?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
961
+ : state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
1033
962
  });
1034
963
  }
1035
964
  const mcpReady = components.installed.includes('mcp');
1036
965
 
966
+ effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
1037
967
  summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
968
+ effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
1038
969
  summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
970
+ effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
1039
971
  summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
1040
972
  summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
1041
973
 
@@ -1046,6 +978,7 @@ export async function runInstall(argv, effects) {
1046
978
  for (const failure of components.failed) {
1047
979
  effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
1048
980
  }
981
+ effects.out(progress(8, 8, 'Finish', 'Summarize what is running, what is stopped, and the exact next commands.'));
1049
982
  await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
1050
983
  return EXIT_OK;
1051
984
  }
package/lib/ui.mjs CHANGED
@@ -63,6 +63,21 @@ export const info = (s) => ` ${c.gray('•')} ${s}`;
63
63
  export const warn = (s) => ` ${c.yellow('!')} ${s}`;
64
64
  export const why = (s) => ` ${c.gray('why: ' + s)}`;
65
65
 
66
+ // --- install progress --------------------------------------------------------------------------
67
+ // A stable progress bar rather than cursor animation. It stays readable in CI/curl logs,
68
+ // while a real terminal gets colour through the ordinary theme helpers. The explanatory
69
+ // sentence is part of the progress event so a user always knows what the current stage does.
70
+ export function progress(current, total, label, explanation = '') {
71
+ const count = Math.max(1, Number.parseInt(total, 10) || 1);
72
+ const position = Math.min(count, Math.max(0, Number.parseInt(current, 10) || 0));
73
+ const width = 24;
74
+ const filled = Math.round((position / count) * width);
75
+ const bar = c.cyan('█'.repeat(filled)) + c.gray('·'.repeat(width - filled));
76
+ const percent = String(Math.round((position / count) * 100)).padStart(3);
77
+ const detail = explanation ? `\n ${c.gray(explanation)}` : '';
78
+ return `\n ${bar} ${c.bold(`${percent}%`)} ${c.bold(label)}${detail}`;
79
+ }
80
+
66
81
  // --- framed panel -------------------------------------------------------------------------------
67
82
  // A boxed block for the one moment that must stand out (the post-first-install next steps).
68
83
  // Content lines must be PLAIN strings (no ANSI) so the width math stays honest — only the frame
package/lib/usage.mjs CHANGED
@@ -11,10 +11,11 @@ export const USAGE = `ours-install — the unified ours.network stack installer.
11
11
 
12
12
  ours-install [--state-dir PATH] [--port N] [--dry-run] [--help] [--version]
13
13
 
14
- Guided setup for the whole stack: the ours daemon, its components (the MCP
15
- server, the Telegram connector, cowork), your harness plugins (Claude Code /
16
- Codex / Hermes), ours-fleet and voice transcription then one copy-paste
17
- hand-off prompt. You approve each step; re-run any time to add a piece or update.
14
+ Progress-driven setup for the whole stack: one shared daemon, MCP, Telegram,
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
18
+ ends with exact next commands plus a copy-paste agent hand-off prompt.
18
19
 
19
20
  --state-dir the daemon's STATE DIRECTORY, which is what identifies a daemon
20
21
  (default ~/.ours). A second state directory is a second daemon.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.18.0-nightly.1",
3
+ "version": "0.18.0-nightly.2",
4
4
  "private": false,
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).",
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",
7
7
  "bin": {
8
8
  "ours-install": "install.mjs"