@ours.network/install 1.2.1-nightly.1 → 1.2.1-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,141 +1,59 @@
1
- # @ours.network/install — `ours-install`
1
+ # @ours.network/install — one installer for the whole stack
2
2
 
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.
3
+ Requires Node.js 22+ and npm. Install the selected release channel:
6
4
 
7
5
  ```sh
8
- npm install --global @ours.network/install
6
+ npm install -g @ours.network/install@nightly
9
7
  ours-install
10
8
  ```
11
9
 
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
-
27
- The normal flow uses one daemon at `~/.ours` on port 3050, shows an eight-stage
28
- progress bar, and asks only for information it cannot safely infer (normally the
29
- Human identity's display name). Existing daemon conflicts and moving a Telegram
30
- connector from another daemon still require explicit confirmation.
31
-
32
- ## What the installer does
33
-
34
- - Installs `@ours.network/cli`, `@ours.network/mcp`,
35
- `@ours.network/tg-connector`, `@ours.network/cowork`, and
36
- `@ours.network/fleet` on one release channel.
37
- - Configures, starts, and enables the single shared daemon with a CLI-managed
38
- user systemd service on Linux or LaunchAgent on macOS.
39
- - Creates the daemon's Human identity (historically called the root identity),
40
- or preserves the existing one on a re-run.
41
- - Installs the ours plugin into safely detected Claude Code, Codex, and Hermes
42
- installations.
43
- - Configures and starts cowork as a durable shim over the shared daemon.
44
- - Configures and starts Telegram as a durable shim over the same daemon.
45
- - Runs Fleet's native initialization through prepared settings or its interactive wizard. Fleet owns and
46
- publishes its v2 configuration, including subscriptions, models, roles,
47
- templates, and permissions. The installer does not start Fleet roles.
48
-
49
- The operator CLI owns daemon configuration, lifecycle, and boot persistence.
50
- The MCP package is only the stdio adapter spawned by agent harnesses; the
51
- installer never asks `ours-mcp` to start a daemon.
52
-
53
- Daemon state is temporarily scoped to its package major version. On a same-major
54
- update, the installer refreshes the packages and runs `ours daemon restart`; the
55
- CLI streams structured startup phases until restore is complete instead of
56
- appearing to hang. A different-major update is detected before package
57
- replacement. The installer explains the incompatibility and, only in an
58
- interactive run, offers to stop the CLI-managed daemon, copy the complete state
59
- directory to a timestamped directory under `~/.ours-backups/`, remove the
60
- managed service and old state, then initialize the new major. The default answer is no, and
61
- `OURS_ASSUME_YES` never authorizes this purge.
62
-
63
- ## What remains stopped
64
-
65
- Only Fleet is intentionally not started. Review and activate it when ready:
10
+ With no arguments, the console form collects the installation choices: server
11
+ and clients (default), runtime mode, installation directory, install/update,
12
+ Human identity name, ports, client integrations and Fleet settings. Linux x64
13
+ recommends native mode; macOS and Windows recommend Docker. On Windows run the
14
+ installer inside WSL with Docker Desktop integration. This recommendation concerns
15
+ packaging and isolation, not a guarantee that emulated x64 is faster on ARM Macs.
66
16
 
67
- ```sh
68
- # After completing Fleet's wizard and reviewing ~/fleet.yaml:
69
- ours-fleet doctor
70
- ours-fleet config
71
- ours-fleet up
72
- ours-fleet ls
73
- ```
74
-
75
- The final installer screen repeats these commands and provides a copy-paste
76
- prompt for Claude Code, Codex, or Hermes. The agent should guide local bot-token
77
- entry without asking the user to paste the secret into chat.
78
-
79
- ## Preview and automation
17
+ The same installer accepts complete CLI presets. It reports missing answers before
18
+ changing the machine and never opens hidden prompts when arguments are supplied.
19
+ Both forms show the same preparation, identity, update and readiness progress.
80
20
 
81
21
  ```sh
82
- ours-install --dry-run
83
- OURS_ASSUME_YES=1 ours-install
84
- ours-install --state-dir /absolute/path --port 3070
22
+ # Whole stack, with prepared Fleet settings and no interactive prompts:
23
+ ours-install --mode docker --state-dir "$HOME/ours-docker" \
24
+ --identity-name "Your Name" --integrations codex,fleet \
25
+ --fleet-settings "$HOME/fleet-settings.json"
26
+
27
+ # Native server only; native and packages mean the same mode:
28
+ ours-install server --mode native --state-dir "$HOME/ours-native" \
29
+ --identity-name "Your Name"
30
+
31
+ # Clients connected to an existing server:
32
+ ours-install client --config /private/client/profile.json \
33
+ --integrations codex,fleet --fleet-settings /private/fleet-settings.json
34
+
35
+ # Update a retained complete installation and its clients:
36
+ ours-install all update --mode docker --state-dir "$HOME/ours-docker" \
37
+ --identity-name "Your Name" --integrations codex,fleet \
38
+ --fleet-settings "$HOME/fleet-settings.json" --compatible
85
39
  ```
86
40
 
87
- Dry-run walks the real plan without writing files, installing packages, starting
88
- processes, or changing services. `OURS_ASSUME_YES=1` uses the OS username for a
89
- new Human identity and asks no ordinary setup questions, but it never bypasses
90
- selection conflicts, connector moves, or destructive safeguards.
91
-
92
- A non-default daemon must be selected coherently with a config file or matching
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.
98
-
99
- `OURS_CONFIG` may also name a prepared private host profile containing the
100
- complete `endpoint`, `expectedInstanceId`, and absolute `credentialPath` tuple.
101
- The profile and credential must already be regular current-user files with
102
- private permissions. In this mode the installer verifies `/selection` before
103
- sending the credential to `/version`, installs only MCP, detected Claude/Codex
104
- plugins, and Fleet client support, and never creates, starts, stops, or services
105
- a host daemon. Telegram, cowork, messenger, daemon voice, and daemon state stay
106
- Compose-owned. If `OURS_CONFIG` is unset, the same profile is discovered at
107
- `~/.ours/config.json`; a legacy config there keeps the existing local behavior.
108
-
109
- ## Uninstall
110
-
111
- ```sh
112
- ours-uninstall --state-dir "$HOME/.ours"
113
- ours-uninstall --state-dir "$HOME/.ours" --purge
114
- ```
115
-
116
- The uninstaller delegates service and daemon removal to the `ours` CLI. Identity
117
- state is retained by default. Purging requires the existing destructive gates and
118
- targets only the explicit state directory.
119
-
120
- When `OURS_CONFIG` selects a host profile, uninstall removes only selected
121
- client attachments. The operator-owned profile and shared credential are kept
122
- even with `--purge`, and no Compose daemon, service, or state is touched.
123
-
124
- ## Release channel
125
-
126
- `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) selects the packages' nightly
127
- dist-tags. Without an override, the installer's own version selects the channel.
128
- The operator CLI intentionally has no nightly dist-tag and remains untagged on
129
- both channels.
41
+ `server` and `client` are presets of the same flow, not separate interactive
42
+ installers. `--integrations none` explicitly skips client integrations. Use
43
+ `--dry-run` with a complete preset to preview it, and `--help` for all options.
44
+ Fleet's own wizard is available only in interactive setup; CLI Fleet setup requires
45
+ its JSON settings file. Fleet stays stopped until you review and activate it.
130
46
 
131
- ## Environment
47
+ The server installs the daemon/SDK/CLI, main MCP, Telegram, Cowork and Messenger.
48
+ It starts the daemon, preserves the existing Human identity (or creates it once),
49
+ then starts its applications. Full-stack setup issues a separate local client
50
+ credential and configures the selected Codex, Claude Code and Fleet integrations.
51
+ Existing identity keys and names are retained on update. An update's storage
52
+ compatibility must be reviewed before supplying `--compatible`.
132
53
 
133
- - `OURS_ASSUME_YES=1`: accept safe defaults without prompting.
134
- - `OURS_INSTALL_DRY_RUN=1`: preview without mutation.
135
- - `OURS_NPM`: npm executable.
136
- - `OURS_CONFIG`: explicit legacy daemon config or prepared private host profile.
137
- - `OURS_STATE_DIR`: explicit daemon state directory.
138
- - `OURS_CHANNEL`: `latest` or `nightly`.
54
+ The installer embeds exact component versions and SHA-512 values for its release.
55
+ An existing server's retained release selects matching local clients. Secrets are
56
+ read from protected files, and client setup never copies the daemon API master.
139
57
 
140
58
  ## Selected network installations (2.0)
141
59
 
@@ -145,8 +63,8 @@ SHA-512 values. Acquisition validates the ours dependency graph before activatio
145
63
  A development override without a release binding is not a qualified product release.
146
64
 
147
65
  ```sh
148
- ours-install server install --mode docker --state-dir /private/ours-install
149
- ours-install server install --mode packages --state-dir /private/ours-install
66
+ ours-install server install --mode docker --state-dir /private/ours-install --identity-name "Your Name"
67
+ ours-install server install --mode packages --state-dir /private/ours-install --identity-name "Your Name"
150
68
  ours-install server status --state-dir /private/ours-install
151
69
  ours-install server stop --state-dir /private/ours-install
152
70
  ours-install server start --state-dir /private/ours-install
@@ -169,6 +87,17 @@ Linux/WSL or a launchd GUI user domain on macOS. Source builds also require Git,
169
87
  Python 3, a C/C++ toolchain, make, and the selected repositories' own prerequisites.
170
88
  Docker mode requires a working Docker engine and Compose 2.35+; its build image
171
89
  contains the source-build prerequisites. No bind or Docker socket mounts are used.
90
+ Docker is recommended for macOS and Windows. Install and start Docker Desktop
91
+ on those hosts; on Linux, install Docker Engine and the Compose plugin. If Docker
92
+ is unavailable, the installer explains how to install/start it or use
93
+ `server install --mode packages --state-dir <new-empty-directory>` instead.
94
+ Native Windows installations require WSL with a working systemd user manager.
95
+ The installer never switches an existing installation's mode automatically.
96
+ Server installation shows each stage before it starts, streams Docker builds and
97
+ native package acquisition, and reports completion only after service readiness.
98
+ If a Docker service fails, the error includes its last 50 log lines (bounded in
99
+ size) and a command to inspect the logs. A failed daemon prevents consumers from
100
+ starting; a failed consumer does not prevent checks of unrelated consumers.
172
101
  Server runtime installation selects SDK/CLI, main MCP, Telegram, Cowork and
173
102
  Messenger. Main MCP is injected into the daemon, not started as a second daemon.
174
103
  The daemon starts first; consumers are checked through their owning readiness
@@ -219,7 +148,7 @@ client-owned installer settings:
219
148
  ```
220
149
 
221
150
  ```sh
222
- ours-install client install --config /private/client/profile.json
151
+ ours-install client install --config /private/client/profile.json --integrations codex,claude-code
223
152
  ```
224
153
 
225
154
  Optional `sourcesPath` and `fleetSettingsPath` values are absolute or resolved
@@ -269,7 +198,7 @@ For a managed server, maintenance runs on the server machine through the install
269
198
  ours-install server backup server snapshot --state-dir /path/to/installation
270
199
  ours-install server restore server snapshot --state-dir /path/to/installation
271
200
  ours-install server rebuild --state-dir /path/to/installation
272
- ours-install server update --state-dir /path/to/installation --compatible
201
+ ours-install server update --mode docker --state-dir /path/to/installation --identity-name "Your Name" --compatible
273
202
  ```
274
203
 
275
204
  Backup and restore support the full server or an individual `daemon`, `telegram`,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.1",
4
+ "installerVersion": "1.2.1-nightly.2",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.3",
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.1",
5
+ "installerVersion": "1.2.1-nightly.2",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.3",
package/install.mjs CHANGED
@@ -3,14 +3,14 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
 
5
5
  import { realEffects } from './lib/effects.mjs';
6
- import { runInstall } from './lib/orchestrate.mjs';
6
+ import { runSetup } from './lib/setup.mjs';
7
7
  import { closeSync, makeWriter, openTty } from './lib/ui.mjs';
8
8
 
9
9
  const version = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
10
10
  const ttyFd = openTty();
11
11
 
12
12
  try {
13
- const code = await runInstall(process.argv.slice(2), realEffects({
13
+ const code = await runSetup(process.argv.slice(2), realEffects({
14
14
  write: makeWriter(ttyFd),
15
15
  ttyFd,
16
16
  env: process.env,
@@ -2,10 +2,16 @@ import { readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
  import { validateInstallation } from './plan.mjs';
5
+ import { info, ok, warn } from './ui.mjs';
5
6
 
6
7
  /** The caller holds the installation lock; installation.json owns retry state. */
7
8
  export async function serverBuildTransition(record, args, effects) {
8
9
  validateInstallation(record, record.root);
10
+ const stage = async (label, action) => {
11
+ effects.out?.(info(label));
12
+ try { const result = await action(); effects.out?.(ok(`${label} complete`)); return result; }
13
+ catch (error) { effects.out?.(warn(`Server ${args.operation} stopped during ${label.toLowerCase()}.`)); throw error; }
14
+ };
9
15
  const path = join(record.root, 'installation.json');
10
16
  const save = transition => {
11
17
  record = { ...record, buildTransition: transition };
@@ -14,9 +20,9 @@ export async function serverBuildTransition(record, args, effects) {
14
20
  };
15
21
  let transition = record.buildTransition;
16
22
  if (!transition) {
17
- const candidate = await effects.prepareServerBuild(record, args);
23
+ const candidate = await stage('Prepare the updated runtime', () => effects.prepareServerBuild(record, args));
18
24
  try {
19
- await effects.checkServerBuild(record, candidate, !!args.compatible, args.operation);
25
+ await stage('Verify package and stored-state compatibility', () => effects.checkServerBuild(record, candidate, !!args.compatible, args.operation));
20
26
  transition = { operation: args.operation, candidate, compatible: !!args.compatible,
21
27
  ...(args.sources ? { sourcePolicyHash: createHash('sha256').update(readFileSync(args.sources)).digest('hex') } : {}),
22
28
  runningServices: await effects.serverLifecycle(record, 'status'), phase: 'prepared' };
@@ -34,19 +40,19 @@ export async function serverBuildTransition(record, args, effects) {
34
40
  const { candidate, runningServices } = transition;
35
41
  // A failed readiness check can leave some new services running. Every retry
36
42
  // excludes those writers again and retains the original requested running set.
37
- await effects.retireServerBuildRuntime(record);
43
+ await stage('Stop services before updating stored state', () => effects.retireServerBuildRuntime(record));
38
44
  if (transition.phase === 'prepared') {
39
- await effects.updateServerBuildState(record, candidate, transition.compatible, transition.operation);
45
+ await stage('Update stored state while retaining identities and credentials', () => effects.updateServerBuildState(record, candidate, transition.compatible, transition.operation));
40
46
  transition = { ...transition, phase: 'state-updated' };
41
47
  save(transition);
42
48
  }
43
49
  if (transition.phase === 'state-updated') {
44
- await effects.publishServerBuild(record, candidate);
50
+ await stage('Activate the prepared runtime', () => effects.publishServerBuild(record, candidate));
45
51
  transition = { ...transition, phase: 'runtime-activated' };
46
52
  save(transition);
47
53
  }
48
- await effects.validateServerBuildState(record);
49
- await effects.serverLifecycle(record, 'start', runningServices);
54
+ await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
55
+ await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
50
56
  const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
51
57
  delete completed.buildTransition;
52
58
  effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');
package/lib/effects.mjs CHANGED
@@ -13,12 +13,13 @@
13
13
 
14
14
  import { spawnSync, execFileSync } from 'node:child_process';
15
15
  import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
- import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
16
+ import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { randomUUID, createHash } from 'node:crypto';
19
19
  import { dirname, join, resolve } from 'node:path';
20
20
  import { maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
21
21
  import { validateHostProfile } from './target.mjs';
22
+ import { createServerOnboarding } from './server-onboarding.mjs';
22
23
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
23
24
  import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
24
25
  import { classifyHarnessProbe } from './logic.mjs';
@@ -333,7 +334,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
333
334
  version,
334
335
  interactive: ttyFd != null,
335
336
  // Preflight reads the machine rather than asking the orchestrator to.
336
- platform: { platform: osPlatform(), release: osRelease() },
337
+ platform: { platform: osPlatform(), release: osRelease(), arch: osArch() },
337
338
  nodeVersion: process.versions.node,
338
339
  exists: (path) => existsSync(path),
339
340
  knownStateDirs: () => knownStateDirsIn(home),
@@ -413,7 +414,12 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
413
414
  stdio: [...(stream ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe']), ...(installationLockFd === null ? [] : [installationLockFd])],
414
415
  env: childEnv,
415
416
  });
416
- if (r.error || (r.status !== 0 && !allowCodes.includes(r.status))) {
417
+ if (r.error) {
418
+ const error = new Error(`${executable} could not start (${r.error.code ?? 'launch error'})`, { cause: r.error });
419
+ error.code = r.error.code;
420
+ throw error;
421
+ }
422
+ if (r.status !== 0 && !allowCodes.includes(r.status)) {
417
423
  const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
418
424
  throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
419
425
  }
@@ -533,12 +539,30 @@ export function networkEffects(effects) {
533
539
  OURS_MESSENGER_PORT: String(record.messengerPort ?? 8420),
534
540
  OURS_MESSENGER_IDENTITY: record.messengerIdentity ?? '',
535
541
  });
536
- const compose = (record, args, options = {}) => effects.run('docker', [
542
+ const composeArgs = (record, args) => [
537
543
  'compose', '--project-directory', record.workDir, '--file', join(record.workDir,
538
544
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
539
545
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
540
546
  '--project-name', record.project, ...args,
541
- ], { ...options, env: { ...baseEnv(record), ...options.env } });
547
+ ];
548
+ const compose = (record, args, options = {}) => effects.run('docker', composeArgs(record, args),
549
+ { ...options, env: { ...baseEnv(record), ...options.env } });
550
+ const dockerStartupError = async (record, service, cause) => {
551
+ const args = ['logs', '--no-color', '--tail', '50', '--timestamps', service];
552
+ const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
553
+ const command = `OURS_DAEMON_ID=${quote(record.instanceId)} docker ${composeArgs(record, args).map(quote).join(' ')}`;
554
+ let detail;
555
+ try {
556
+ const logs = await compose(record, args);
557
+ // Limit terminal diagnostics; container output must not inject terminal controls.
558
+ detail = (logs.stdout ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
559
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').trim().slice(-6000);
560
+ detail = detail ? `Recent ${service} logs:\n${detail}` : 'The container produced no readable logs.';
561
+ } catch {
562
+ detail = 'Container logs could not be read.';
563
+ }
564
+ return new Error(`Docker service "${service}" failed to start or become healthy.\n${detail}\nStartup error: ${cause.message}\nInspect logs: ${command}`, { cause });
565
+ };
542
566
  const bin = (record, name) => join(record.workDir, 'node_modules', '.bin', name);
543
567
  const localEnv = (record, service = 'daemon') => {
544
568
  const paths = installationPaths(record);
@@ -566,6 +590,7 @@ export function networkEffects(effects) {
566
590
  }
567
591
  };
568
592
  return {
593
+ ...createServerOnboarding(effects, { compose, localEnv, bin }),
569
594
  sourcePolicyHash(path) {
570
595
  return createHash('sha256').update(readFileSync(path)).digest('hex');
571
596
  },
@@ -601,7 +626,7 @@ export function networkEffects(effects) {
601
626
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
602
627
  return { schema: 2, root, mode, instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES], port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
603
628
  },
604
- async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest } = {}) {
629
+ async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName } = {}) {
605
630
  if (existing) {
606
631
  privateDirectory(record.root);
607
632
  assertPrivateRegularFile(join(record.root, 'installation.json'), 'selection');
@@ -609,10 +634,34 @@ export function networkEffects(effects) {
609
634
  if (env.OURS_DAEMON_ID && env.OURS_DAEMON_ID !== record.instanceId) throw new Error('Conflicting instance ID');
610
635
  }
611
636
  if (record.mode === 'docker') {
612
- await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
613
- const version = await effects.run('docker', ['compose', 'version', '--short']);
637
+ const nativeRoot = existing ? '/path/to/new-empty-directory' : record.root;
638
+ const quotedRoot = `'${String(nativeRoot).replaceAll("'", "'\\''")}'`;
639
+ const quotedName = `'${String(identityName ?? record.messengerIdentity ?? 'Your Name').replaceAll("'", "'\\''")}'`;
640
+ const recovery = [
641
+ 'Please install Docker Desktop on macOS/Windows, or Docker Engine with the Compose plugin on Linux, and start Docker before retrying.',
642
+ 'Docker is recommended for macOS and Windows.',
643
+ `Alternatively, use native installation: ours-install server install --mode packages --state-dir ${quotedRoot} --identity-name ${quotedName}`,
644
+ 'Native mode requires systemd user services on Linux/WSL or a launchd GUI session on macOS.',
645
+ ...(existing ? ['Keep this existing Docker installation in Docker mode; use a separate empty directory for a new native installation.'] : []),
646
+ ].join('\n');
647
+ try {
648
+ await effects.run('docker', ['info', '--format', '{{.ServerVersion}}']);
649
+ } catch (cause) {
650
+ const problem = cause.code === 'ENOENT'
651
+ ? 'Docker command was not found in PATH.'
652
+ : `Docker Engine is not reachable. Start Docker and check that your user can access it.\nDetails: ${cause.message}`;
653
+ throw new Error(`${problem}\n${recovery}`, { cause });
654
+ }
655
+ let version;
656
+ try {
657
+ version = await effects.run('docker', ['compose', 'version', '--short']);
658
+ } catch (cause) {
659
+ throw new Error(`Docker Compose 2.35 or newer is required, but the Compose plugin could not run. Update Docker Desktop or install the Docker Compose plugin.\n${recovery}`, { cause });
660
+ }
614
661
  const match = /^v?(\d+)\.(\d+)/.exec(version.stdout.trim());
615
- if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) throw new Error('Docker Compose 2.35 or newer is required');
662
+ if (!match || Number(match[1]) < 2 || (Number(match[1]) === 2 && Number(match[2]) < 35)) {
663
+ throw new Error(`Docker Compose 2.35 or newer is required. Update Docker Desktop or the Docker Compose plugin.\n${recovery}`);
664
+ }
616
665
  if (operation !== 'status') {
617
666
  // Compose clients can disappear while their Engine-owned command continues.
618
667
  const active = await effects.run('docker', ['ps', '--filter', `label=com.docker.compose.project=${record.project}`, '--filter', 'label=com.docker.compose.oneoff=True', '--format', '{{.ID}}']);
@@ -659,7 +708,7 @@ export function networkEffects(effects) {
659
708
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
660
709
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
661
710
  const image = await effects.run('docker', ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
662
- if (image.code !== 0) await compose(record, ['build', 'daemon']);
711
+ if (image.code !== 0) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
663
712
  if (runtimeOnly) return;
664
713
  await compose(record, ['run', '--rm', '--no-deps', '-T', 'prepare', 'prepare']);
665
714
  } else {
@@ -667,8 +716,8 @@ export function networkEffects(effects) {
667
716
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
668
717
  ensurePrivateDirectory(sourceRoot);
669
718
  try {
670
- await effects.run(process.execPath, [join(record.workDir, 'scripts/build/build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir, OURS_SOURCE_ROOT: sourceRoot } });
671
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: record.workDir });
719
+ await effects.run(process.execPath, [join(record.workDir, 'scripts/build/build.mjs')], { stream: true, cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir, OURS_SOURCE_ROOT: sourceRoot } });
720
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: record.workDir });
672
721
  await effects.run(process.execPath, [join(record.workDir, 'scripts/build/record-build.mjs')], { cwd: record.workDir, env: { OURS_BUILD_ROOT: record.workDir } });
673
722
  writePrivateNew(join(record.workDir, '.packages-ready'), 'ready\n');
674
723
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
@@ -1061,13 +1110,19 @@ export function networkEffects(effects) {
1061
1110
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1062
1111
  return;
1063
1112
  }
1064
- if (selected.includes('daemon')) await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', 'daemon']);
1113
+ const start = async service => {
1114
+ effects.out(`Starting ${service}; waiting for readiness...`);
1115
+ try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1116
+ catch (cause) { throw await dockerStartupError(record, service, cause); }
1117
+ effects.out(`${service} is ready.`);
1118
+ };
1119
+ if (selected.includes('daemon')) await start('daemon');
1065
1120
  const failures = [];
1066
1121
  for (const service of selected.filter(s => s !== 'daemon')) {
1067
- try { await compose(record, ['up', '-d', '--no-build', '--no-deps', '--wait', service]); }
1068
- catch { failures.push(service); }
1122
+ try { await start(service); }
1123
+ catch (error) { failures.push({ service, error }); }
1069
1124
  }
1070
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1125
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.map(f => f.service).join(', ')}. Check the selected application prerequisites.\n${failures.map(f => f.error.message).join('\n\n')}`);
1071
1126
  return;
1072
1127
  }
1073
1128
  return nativeLifecycle(record, operation, selected, { effects, localEnv, ownerCommand, bin });
@@ -1090,7 +1145,7 @@ export function networkEffects(effects) {
1090
1145
  // Full metadata and authenticated capability validation follows before publication.
1091
1146
  return validateHostProfile({ endpoint: url.origin, expectedInstanceId: selection.instanceId, credentialPath: resolve(credentialPath) });
1092
1147
  },
1093
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath }) {
1148
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1094
1149
  const root = join(home, '.ours-client');
1095
1150
  const configPath = join(root, 'profile.json');
1096
1151
  const credentialPath = join(root, 'credential');
@@ -1101,13 +1156,13 @@ export function networkEffects(effects) {
1101
1156
  const credential = readFileSync(profile.credentialPath, 'utf8');
1102
1157
  if (!credential.trim()) throw new Error('Client credential is empty');
1103
1158
  // Read every supplied input before any publication. Existing setup settings win on retry.
1104
- const sources = current ? null : resolvedSources
1159
+ const sources = current && !refresh ? null : resolvedSources
1105
1160
  ? Buffer.from(`${JSON.stringify(resolvedSources, null, 2)}\n`)
1106
1161
  : readFileSync(sourcesPath);
1107
- const fleetSettings = !current && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1162
+ const fleetSettings = (!current || refresh) && fleetSettingsPath ? readFileSync(fleetSettingsPath) : null;
1108
1163
  if (fleetSettings) JSON.parse(fleetSettings.toString());
1109
1164
  ensurePrivateDirectory(root);
1110
- if (current) {
1165
+ if (current && !refresh) {
1111
1166
  assertPrivateRegularFile(credentialPath, 'managed credential');
1112
1167
  if (readFileSync(credentialPath, 'utf8') !== credential) atomicWriteConfig(credentialPath, credential);
1113
1168
  return { configPath, profile: validateHostProfile(current), settings: current.installer };
@@ -1122,12 +1177,13 @@ export function networkEffects(effects) {
1122
1177
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1123
1178
  return { configPath, profile: validateHostProfile(saved), settings };
1124
1179
  },
1125
- async acquireClientPackages(configPath, sourcesPath, integrations) {
1180
+ async acquireClientPackages(configPath, sourcesPath, integrations, { refresh = false } = {}) {
1126
1181
  const manifest = JSON.parse(readFileSync(sourcesPath, 'utf8'));
1127
1182
  // Public SDK client APIs are actual integration dependencies; Fleet also owns CLI usage.
1128
1183
  const selected = [...new Set(['sdk', ...(integrations.includes('fleet') ? ['cli'] : []), ...integrations])];
1129
1184
  const packages = selectSourcePackages(manifest, 'client', selected);
1130
- const root = join(home, '.ours-client-install', createHash('sha256').update(configPath).digest('hex').slice(0, 16));
1185
+ const selectionKey = refresh ? JSON.stringify([configPath, manifest, integrations]) : configPath;
1186
+ const root = join(home, '.ours-client-install', createHash('sha256').update(selectionKey).digest('hex').slice(0, 16));
1131
1187
  const hasGit = Object.values(packages).some(selection => selection.source);
1132
1188
  await effects.run('npm', ['--version']);
1133
1189
  if (hasGit) {
@@ -1147,12 +1203,12 @@ export function networkEffects(effects) {
1147
1203
  const sourceRoot = join(root, `build-${randomUUID()}`);
1148
1204
  ensurePrivateDirectory(sourceRoot);
1149
1205
  try {
1150
- await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/build/build.mjs')], { cwd: root, env: { OURS_BUILD_ROOT: root, OURS_SOURCE_ROOT: sourceRoot, OURS_BUILD_PACKAGES: selected.join(',') } });
1206
+ await effects.run(process.execPath, [join(INSTALLER_ASSETS, 'scripts/build/build.mjs')], { stream: true, cwd: root, env: { OURS_BUILD_ROOT: root, OURS_SOURCE_ROOT: sourceRoot, OURS_BUILD_PACKAGES: selected.join(',') } });
1151
1207
  } finally { rmSync(sourceRoot, { recursive: true, force: true }); }
1152
1208
  } else {
1153
1209
  writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'ours-native-clients', private: true, dependencies: Object.fromEntries(Object.entries(packages).map(([name, selection]) => [name, selection.version])) }), { mode: 0o600 });
1154
1210
  }
1155
- await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: root });
1211
+ await effects.run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { stream: true, cwd: root });
1156
1212
  verifyReleaseGraph(root, manifest, { requiredPackages: Object.keys(packages) });
1157
1213
  writePrivateNew(join(root, '.packages-ready'), 'ready\n');
1158
1214
  }
@@ -1365,5 +1421,5 @@ async function nativeLifecycle(record, operation, selected, { effects, localEnv,
1365
1421
  failures.push(service);
1366
1422
  }
1367
1423
  }
1368
- if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check owning prerequisites; no identities were created.`);
1424
+ if (failures.length) throw new Error(`Application readiness failed: ${failures.join(', ')}. Check the selected application prerequisites.`);
1369
1425
  }
@@ -0,0 +1,43 @@
1
+ // Structural preflight adapted from @ours.network/fleet src/init-wizard.ts
2
+ // (readInitSettings/generateSetup), shipped in Fleet 1.2.0-nightly.1.
3
+ // This checks complete answers before installer effects, without duplicating
4
+ // Fleet's evolving model catalog. Fleet init remains authoritative for model
5
+ // membership, exact capability arrays, and its packaged preset validation.
6
+ const WORKS = ['development', 'review', 'coordination'];
7
+ const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'];
8
+ const REASONING = { quick: 'low', balanced: 'medium', thorough: 'high' };
9
+ const fail = message => { throw new Error(`Fleet settings ${message}`); };
10
+ function exactKeys(value, keys, label) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value)
12
+ || Object.keys(value).sort().join('\0') !== [...keys].sort().join('\0'))
13
+ fail(`${label} must contain exactly ${keys.join(', ')}`);
14
+ }
15
+ function uniqueSelection(value, choices, label) {
16
+ if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length
17
+ || value.some(item => !choices.includes(item)))
18
+ fail(`${label} must be a nonempty unique selection of ${choices.join(', ')}`);
19
+ }
20
+
21
+ export function validateFleetSettings(value) {
22
+ exactKeys(value, ['subscriptions', 'assignmentStrategy', 'models', 'reasoning'], 'answers');
23
+ uniqueSelection(value.subscriptions, ['codex', 'claude'], 'subscriptions');
24
+ if (!['one-model', 'per-job'].includes(value.assignmentStrategy)) fail('assignmentStrategy must be one-model or per-job');
25
+ if (typeof value.reasoning !== 'string' || !Object.hasOwn(REASONING, value.reasoning)) fail('reasoning must be quick, balanced, or thorough');
26
+ exactKeys(value.models, WORKS, 'models');
27
+ const tuples = new Set();
28
+ for (const work of WORKS) {
29
+ const model = value.models[work];
30
+ exactKeys(model, ['harness', 'session', 'model', 'efforts'], `${work} model`);
31
+ if (!['codex', 'claude-code'].includes(model.harness)) fail(`${work} harness must be codex or claude-code`);
32
+ if (model.session !== 'acp') fail(`${work} session must be acp`);
33
+ if (typeof model.model !== 'string' || !model.model.trim()) fail(`${work} model must be a nonempty string`);
34
+ uniqueSelection(model.efforts, EFFORTS, `${work} efforts`);
35
+ if (!model.efforts.includes(REASONING[value.reasoning])) fail(`${work} efforts do not support the selected reasoning`);
36
+ if (!value.subscriptions.includes(model.harness === 'codex' ? 'codex' : 'claude'))
37
+ fail(`${work} uses a harness outside the selected subscriptions`);
38
+ tuples.add(JSON.stringify([model.harness, model.session, model.model]));
39
+ }
40
+ if (value.assignmentStrategy === 'one-model' && tuples.size !== 1)
41
+ fail('one-model assignment requires the same model for development, review, and coordination');
42
+ return value;
43
+ }