@ours.network/install 1.2.1-nightly.23 → 1.2.1-nightly.25

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,3 +1,17 @@
1
+ ## Install first, connect the App afterwards
2
+
3
+ ```sh
4
+ ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
5
+ ```
6
+
7
+ This permanent command contains no tunnel credentials or enrollment expiry. On a terminal it asks for the remaining installation choices, including Docker, Podman or native packages. The username is local and has no global uniqueness check. A fresh root is named `<username>@<hostname>`; the optional `--hostname` defaults to the machine name. Messenger stores Name and Surname through its supported profile API. Retried setup retains root identities and existing Messenger profiles.
8
+
9
+ `--disable-fleet-agents-setup` installs the Fleet CLI and saves the choice without opening its agent/model wizard or rewriting existing Fleet configuration. Configure agents in the App after device linking. Complete presets still work unattended; partial presets require a terminal and cannot use `OURS_ASSUME_YES`.
10
+
11
+ The separate `ours-fleet setup-tunnel --file /private/setup-payload` step requires the matching Fleet follow-up release. Its private App payload remains scoped, signed-root-bound and expiring. Then run `ours-fleet link-device` and paste its private single-use code into the App. Do not put the expiring tunnel payload in the permanent installer command.
12
+
13
+ Native packages currently provide server-only installation; full Fleet/App onboarding requires the supported gateway (Docker or Podman). The installer refuses unsupported native full-stack setup before changing anything. This PR does not add a native gateway or release unpublished Fleet commands.
14
+
1
15
  See [one-connection setup, localhost:4050 and systemd migration](GATEWAY_SETUP.md) for the gateway-only client workflow.
2
16
 
3
17
  # @ours.network/install — one installer for the whole stack
@@ -577,4 +591,4 @@ Existing installation records keep their ports and identities. A listener acquir
577
591
  by another process after preflight still causes startup to fail safely; no foreign
578
592
  process is stopped and no retained installation is silently moved.
579
593
 
580
- Workspace setup pins Fleet `1.2.0-nightly.31` with its recorded registry integrity. This release includes the packaged `/fleet` browser entry and persists the available Fleet loopback port used by the workspace tunnel. Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
594
+ Workspace setup pins Fleet `1.2.0-nightly.32` with its recorded registry integrity. This release includes the packaged `/fleet` browser entry, separate `ours-fleet setup-tunnel` and `ours-fleet link-device` commands, and persists the available Fleet loopback port used by the workspace tunnel. Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
package/assets/Dockerfile CHANGED
@@ -23,6 +23,7 @@ COPY --chmod=644 scripts/runtime/runtime-common.mjs /opt/ours/docker/runtime-com
23
23
  COPY --chmod=644 scripts/runtime/check-client.mjs /opt/ours/docker/check-client.mjs
24
24
  COPY --chmod=644 scripts/runtime/check-start.mjs /opt/ours/docker/check-start.mjs
25
25
  COPY --chmod=644 scripts/runtime/client-setup.mjs /opt/ours/docker/client-setup.mjs
26
+ COPY --chmod=644 scripts/runtime/diagnostics.mjs /opt/ours/docker/diagnostics.mjs
26
27
  COPY --chmod=644 scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
27
28
  COPY --chmod=644 scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
28
29
  COPY --chmod=644 scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
@@ -13,7 +13,7 @@
13
13
  "@ours.network/codex": "1.2.0-nightly.10",
14
14
  "@ours.network/cowork": "1.3.3-nightly.20261001.6d49d3a",
15
15
  "@ours.network/daemon": "3.8.1-nightly.7",
16
- "@ours.network/fleet": "1.2.0-nightly.31",
16
+ "@ours.network/fleet": "1.2.0-nightly.32",
17
17
  "@ours.network/mcp": "1.2.0-nightly.10",
18
18
  "@ours.network/messenger-server": "1.0.31-nightly.13",
19
19
  "@ours.network/sdk": "3.8.1-nightly.15",
@@ -1085,9 +1085,9 @@
1085
1085
  }
1086
1086
  },
1087
1087
  "node_modules/@ours.network/fleet": {
1088
- "version": "1.2.0-nightly.31",
1089
- "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.31.tgz",
1090
- "integrity": "sha512-45oIV09WCsK5yEZF8IKU8LEJjC4Hk26XG/OeA+BqxSFvQWEXoHhMhLeabP5Jc9QcDcEmtH5AeYKXRvCXTeb+YA==",
1088
+ "version": "1.2.0-nightly.32",
1089
+ "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.32.tgz",
1090
+ "integrity": "sha512-FZRE+G25dc2qgDYbM3Dx76lw1Sws5OTF5KHPaMGTL5oun2SgYcNMhVKvwULZWSOlyrpggdMc3IFmp4eZSZTahg==",
1091
1091
  "license": "FSL-1.1-Apache-2.0",
1092
1092
  "dependencies": {
1093
1093
  "@agentclientprotocol/sdk": "^1.3.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.23",
4
+ "installerVersion": "1.2.1-nightly.25",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.15",
@@ -24,8 +24,8 @@
24
24
  "integrity": "sha512-hDosXHgQ+vyzPdSxqTz6vCaOCn0LMTmOL4TJiWplkJhzyw2c586RckXJbqNJsTa0mmodvLxW0ewZyQ/xJVIeXA=="
25
25
  },
26
26
  "@ours.network/fleet": {
27
- "version": "1.2.0-nightly.31",
28
- "integrity": "sha512-45oIV09WCsK5yEZF8IKU8LEJjC4Hk26XG/OeA+BqxSFvQWEXoHhMhLeabP5Jc9QcDcEmtH5AeYKXRvCXTeb+YA=="
27
+ "version": "1.2.0-nightly.32",
28
+ "integrity": "sha512-FZRE+G25dc2qgDYbM3Dx76lw1Sws5OTF5KHPaMGTL5oun2SgYcNMhVKvwULZWSOlyrpggdMc3IFmp4eZSZTahg=="
29
29
  },
30
30
  "@ours.network/mcp": {
31
31
  "version": "1.2.0-nightly.10",
@@ -1,4 +1,5 @@
1
1
  import { readBuildRecords, initializeBuildMarker } from '../maintenance/build-context.mjs';
2
+ import { redactDiagnostic } from './diagnostics.mjs';
2
3
  import { spawnSync } from 'node:child_process';
3
4
  import { randomBytes } from 'node:crypto';
4
5
  import {
@@ -12,6 +13,7 @@ const DELIVERY_FILES = [
12
13
  '/credentials/messenger/daemon-token',
13
14
  ];
14
15
  const fail = (message) => { throw new Error(message); };
16
+ let diagnosticStage = 'validation';
15
17
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
18
 
17
19
  function privatePath(path, directory, exact = directory ? 0o700 : 0o600) {
@@ -128,10 +130,13 @@ function prepare() {
128
130
 
129
131
  function finishDaemonSetup() {
130
132
  const state = '/var/lib/ours';
133
+ diagnosticStage = 'daemon-provenance';
131
134
  initializeBuildMarker(`${state}/.ours-provenance`, readBuildRecords('/opt/ours'));
135
+ diagnosticStage = 'mcp-directory';
132
136
  privatePath('/var/lib/ours-mcp', true);
133
137
  const mcpProfile = { endpoint: 'http://127.0.0.1:3050', expectedInstanceId: process.env.OURS_DAEMON_ID, credentialPath: `${state}/daemon-token` };
134
138
  const mcpPath = '/var/lib/ours-mcp/profile.json';
139
+ diagnosticStage = 'mcp-profile';
135
140
  if (!existingPrivate(mcpPath, false)) atomicJson(mcpPath, mcpProfile);
136
141
  else if (JSON.stringify(jsonObject(mcpPath)) !== JSON.stringify(mcpProfile)) fail('Existing MCP profile differs from the selected daemon');
137
142
  }
@@ -155,10 +160,19 @@ async function telegramInput() {
155
160
  }
156
161
 
157
162
  function cliJson(args) {
163
+ diagnosticStage = 'official-cli';
158
164
  const result = spawnSync(process.execPath, [existsSync('/opt/ours/node_modules/@ours.network/daemon/dist/cli.js') ? '/opt/ours/node_modules/@ours.network/daemon/dist/cli.js' : '/opt/ours/node_modules/@ours.network/cli/dist/cli.js', ...args], {
159
165
  encoding: 'utf8', timeout: 60_000, maxBuffer: 1024 * 1024,
160
166
  });
161
- if (result.status !== 0) fail('Official OURS CLI operation failed');
167
+ if (result.error || result.status !== 0) {
168
+ // --json writes failures on stderr. Successful credential stdout is never
169
+ // forwarded, even if a later operation exits unsuccessfully.
170
+ let reason = 'CLI supplied no structured error reason';
171
+ for (const line of String(result.stderr ?? '').split('\n').reverse()) {
172
+ try { const value = JSON.parse(line); if (typeof value?.error?.message === 'string') { reason = value.error.message; break; } } catch {}
173
+ }
174
+ fail(`Official OURS CLI operation failed (${args[1]}; exit=${result.status ?? 'none'}; signal=${result.signal ?? 'none'}; system=${result.error?.code ?? 'none'}): ${reason}`);
175
+ }
162
176
  try { return JSON.parse(result.stdout); }
163
177
  catch { fail('Official OURS CLI returned malformed JSON'); }
164
178
  }
@@ -192,6 +206,10 @@ try {
192
206
  else if (['access-init', 'access-issue', 'access-replace'].includes(operation)) access(operation, process.argv[3]);
193
207
  else fail('usage: client-setup.mjs prepare | telegram-input | access-init | access-issue [OUTPUT] | access-replace');
194
208
  } catch (error) {
195
- console.error(`OURS client setup refused: ${error.message}`);
209
+ // Structured stderr retains the failing stage across sensitive Compose
210
+ // capture. The installer redacts secrets before exposing this envelope.
211
+ const message = redactDiagnostic(error.message);
212
+ console.error(JSON.stringify({ oursInstallerError: { stage: diagnosticStage, message } }));
213
+ console.error(`OURS client setup refused: ${message}`);
196
214
  process.exit(1);
197
215
  }
@@ -0,0 +1,72 @@
1
+ /** Retain actionable failure details without emitting credential output. */
2
+ export function redactDiagnostic(value, env = process.env) {
3
+ let text = String(value ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
4
+ for (const [key, secret] of Object.entries(env)) {
5
+ if (/(?:token|password|secret|api.?key|private.?key|invitation|enrollment|authorization|credential|access.?token|refresh.?token)/i.test(key) && typeof secret === 'string' && secret)
6
+ text = text.split(secret).join('[redacted]');
7
+ }
8
+ return text
9
+ .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, '[redacted private key]')
10
+ .replace(/(Bearer\s+)[^\s"',;]+/gi, '$1[redacted]')
11
+ .replace(/((?:["']?(?:api[-_]?token|token|password|secret|api[-_]?key|private[-_]?key|invitation|enrollment|authorization|credential|access.?token|refresh.?token)["']?)\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;}]+)/gi, '$1[redacted]')
12
+ .replace(/(--(?:setup-workspace|password|token|secret|api-key|private-key|invitation|enrollment)(?:=|\s+))\S+/gi, '$1[redacted]')
13
+ .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/g, '$1[redacted]@')
14
+ .replace(/\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[redacted credential]')
15
+ .replace(/[A-Za-z0-9_+/=-]{43,}/g, '[redacted opaque value]');
16
+ }
17
+
18
+ const bounded = text => text.length <= 65536 ? text : text.slice(0, 65536) + '\n[Diagnostic output exceeds 64 KiB; remainder omitted.]';
19
+ function credentialFailure(stderr) {
20
+ // Credential stdout can be a successful issuance followed by failure: never
21
+ // include it. Only a structured error envelope or shipped helper refusal is
22
+ // accepted from stderr; arbitrary lines/warnings remain private.
23
+ const lines = String(stderr ?? '').split('\n').reverse();
24
+ for (const line of lines) {
25
+ try {
26
+ const value = JSON.parse(line);
27
+ if (typeof value?.oursInstallerError?.message === 'string') {
28
+ const detail = value.oursInstallerError;
29
+ const stage = /^[a-z][a-z-]{0,40}$/.test(detail.stage ?? '') ? detail.stage : 'credential-operation';
30
+ return `Stage: ${stage}\nReason: ${detail.message}`;
31
+ }
32
+ if (typeof value?.error?.message === 'string') return `Reason: ${value.error.message}`;
33
+ } catch {}
34
+ }
35
+ const prefix = 'OURS client setup refused: ';
36
+ const legacy = lines.find(line => line.startsWith(prefix));
37
+ if (legacy) return `Reason: ${legacy.slice(prefix.length)}`;
38
+ return 'The credential operation provided no structured reason. Its raw output was withheld because it can contain credentials.';
39
+ }
40
+
41
+ export function commandFailure(command, args, result, { sensitive = false, cwd, env = process.env } = {}) {
42
+ const visibleArgs = [...args];
43
+ if (sensitive) {
44
+ for (let i = 0; i < visibleArgs.length - 1; i++) if (['-e', '--eval'].includes(visibleArgs[i])) visibleArgs[i + 1] = '[captured installer helper]';
45
+ }
46
+ const details = [`Command: ${command} ${visibleArgs.join(' ')}${result.status != null ? ` exited ${result.status}` : ''}`];
47
+ if (cwd) details.push(`Working directory: ${cwd}`);
48
+ if (result.status !== null && result.status !== undefined) details.push(`Exit code: ${result.status}`);
49
+ if (result.signal) details.push(`Signal: ${result.signal}`);
50
+ if (result.error?.code) details.push(`System error: ${result.error.code}`);
51
+ if (result.error?.code === 'ETIMEDOUT') details.push('Reason: command timed out.');
52
+ if (sensitive) details.push(credentialFailure(result.stderr));
53
+ else {
54
+ const stderr = String(result.stderr ?? '').trim(), stdout = String(result.stdout ?? '').trim();
55
+ if (stderr) details.push(`Standard error:\n${stderr}`);
56
+ if (stdout) details.push(`Standard output:\n${stdout}`);
57
+ if (!stderr && !stdout) details.push('No captured command output. Streaming commands print their output above.');
58
+ }
59
+ details.push('Keep installation data intact. Include this error and the preceding installation stage when requesting help.');
60
+ return new Error(bounded(redactDiagnostic(details.join('\n'), env)), result.error ? { cause: result.error } : undefined);
61
+ }
62
+
63
+ export function installerFailure(error, env = process.env) {
64
+ const details = []; const seen = new Set();
65
+ for (let cause = error; cause && !seen.has(cause) && seen.size < 8; cause = cause.cause) {
66
+ seen.add(cause);
67
+ const message = cause instanceof Error ? cause.message : String(cause);
68
+ if (!details.includes(message)) details.push(message);
69
+ if (typeof cause.code === 'string' && /^[A-Z_0-9]+$/.test(cause.code)) details.push(`System error: ${cause.code}`);
70
+ }
71
+ return bounded(redactDiagnostic(details.join('\nCaused by: '), env));
72
+ }
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.23",
5
+ "installerVersion": "1.2.1-nightly.25",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.15",
@@ -25,8 +25,8 @@
25
25
  "integrity": "sha512-hDosXHgQ+vyzPdSxqTz6vCaOCn0LMTmOL4TJiWplkJhzyw2c586RckXJbqNJsTa0mmodvLxW0ewZyQ/xJVIeXA=="
26
26
  },
27
27
  "@ours.network/fleet": {
28
- "version": "1.2.0-nightly.31",
29
- "integrity": "sha512-45oIV09WCsK5yEZF8IKU8LEJjC4Hk26XG/OeA+BqxSFvQWEXoHhMhLeabP5Jc9QcDcEmtH5AeYKXRvCXTeb+YA=="
28
+ "version": "1.2.0-nightly.32",
29
+ "integrity": "sha512-FZRE+G25dc2qgDYbM3Dx76lw1Sws5OTF5KHPaMGTL5oun2SgYcNMhVKvwULZWSOlyrpggdMc3IFmp4eZSZTahg=="
30
30
  },
31
31
  "@ours.network/mcp": {
32
32
  "version": "1.2.0-nightly.10",
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "@ours.network/fleet": {
81
81
  "type": "npm",
82
- "version": "1.2.0-nightly.31"
82
+ "version": "1.2.0-nightly.32"
83
83
  },
84
84
  "@ours.network/mcp": {
85
85
  "type": "npm",
package/install.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readFileSync } from 'node:fs';
4
4
 
5
5
  import { realEffects } from './lib/effects.mjs';
6
+ import { installerFailure } from './lib/diagnostics.mjs';
6
7
  import { runWorkspaceSetup } from './lib/workspace-setup.mjs';
7
8
  import { runSetup } from './lib/setup.mjs';
8
9
  import { closeSync, makeWriter, openTty } from './lib/ui.mjs';
@@ -22,7 +23,7 @@ try {
22
23
  }));
23
24
  process.exitCode = code;
24
25
  } catch (error) {
25
- process.stderr.write(`ours-install: ${error instanceof Error ? error.message : String(error)}\n`);
26
+ process.stderr.write(`ours-install: ${installerFailure(error)}\n`);
26
27
  process.exitCode = 1;
27
28
  } finally {
28
29
  if (ttyFd != null) {
@@ -0,0 +1 @@
1
+ export { redactDiagnostic, commandFailure, installerFailure } from '../assets/scripts/runtime/diagnostics.mjs';
@@ -1,4 +1,4 @@
1
- import { runContainer } from './container-engine.mjs';
1
+ import { engineName, runContainer } from './container-engine.mjs';
2
2
  /** Qualify cached images before reuse; repair only the known root-owned 0600 policy. */
3
3
  import { existsSync, readdirSync, lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, realpathSync, openSync, closeSync, fstatSync, fchmodSync, constants } from 'node:fs';
4
4
  import { join } from 'node:path';
@@ -112,16 +112,24 @@ export async function qualifyDockerRuntime(record, effects) {
112
112
  }
113
113
  };
114
114
  const policy = readFileSync(record.sourcesPath), expectedHash = digest(policy);
115
- const check = (report, mode) => {
116
- if (!report || report.mode !== mode || report.uid !== 0 || report.gid !== 0 || !report.regular || report.links !== 1 || report.policyHash !== expectedHash) fail('image policy differs from retained selection or expected permissions');
115
+ const check = (report, mode, stage) => {
116
+ const expected = { mode, uid: 0, gid: 0, regular: true, links: 1, policyHash: expectedHash };
117
+ const mismatched = Object.keys(expected).filter(key => report?.[key] !== expected[key]);
118
+ if (mismatched.length) {
119
+ // Only fixed field names, a bounded numeric mode and a comparison boolean
120
+ // enter diagnostics. Never disclose policy content, hashes or probe output.
121
+ const observedMode = Number.isInteger(report?.mode) && report.mode >= 0 && report.mode <= 0o7777
122
+ ? report.mode.toString(8).padStart(4, '0') : 'invalid';
123
+ fail(`image policy verification failed (stage=${stage}; engine=${engineName(record)}; mismatched=${mismatched.join(',')}; mode=${observedMode}; expectedMode=${mode.toString(8).padStart(4, '0')}; policyMatch=${report?.policyHash === expectedHash})`);
124
+ }
117
125
  };
118
126
  const original = await inspect(tag);
119
127
  const ordinary = await probe(original.Id, `${uid}:${gid}`);
120
- if (ordinary.code === 0) { check(ordinary.report, 0o644); return; }
128
+ if (ordinary.code === 0) { check(ordinary.report, 0o644, 'ordinary'); return; }
121
129
  if (ordinary.code !== 74) fail('cached image is unusable; automatic repair only supports the known source-policy permission defect');
122
130
  const privileged = await probe(original.Id, '0:0');
123
131
  if (privileged.code !== 0) fail('original release graph could not be verified');
124
- check(privileged.report, 0o600);
132
+ check(privileged.report, 0o600, 'privileged');
125
133
  effects.out?.('Repairing cached Docker image permissions; keeping the selected packages and stored data.');
126
134
  const directory = mkdtempSync(join(record.root, '.image-permissions-'));
127
135
  const name = `ours-permission-repair-${randomUUID()}`, baseTag = `${name}:base`, candidateTag = `${name}:candidate`;
@@ -138,7 +146,7 @@ export async function qualifyDockerRuntime(record, effects) {
138
146
  || candidate.RootFS.Layers.length !== original.RootFS.Layers.length + 1) fail('repair changed image execution settings or ancestry');
139
147
  const verified = await probe(candidate.Id, `${uid}:${gid}`);
140
148
  if (verified.code !== 0) fail('repaired image did not pass runtime verification');
141
- check(verified.report, 0o644);
149
+ check(verified.report, 0o644, 'repaired');
142
150
  if (!isDeepStrictEqual(verified.report.records, privileged.report.records)) fail('repair changed build provenance');
143
151
  if ((await inspect(tag)).Id !== original.Id) fail('selected image changed during repair; retry setup');
144
152
  await runContainer(effects, record, ['image', 'tag', candidate.Id, tag]);
package/lib/effects.mjs CHANGED
@@ -15,6 +15,7 @@ import { engineName, bindPodman, runContainer, nativeBuildCommands } from './con
15
15
  // unit file or the service manager directly.
16
16
 
17
17
  import { publishClientCli } from './client-cli.mjs';
18
+ import { commandFailure } from './diagnostics.mjs';
18
19
  import { spawnSync, execFileSync } from 'node:child_process';
19
20
  import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
21
  import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
@@ -422,13 +423,12 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
422
423
  env: childEnv,
423
424
  });
424
425
  if (r.error) {
425
- const error = new Error(`${executable} could not start (${r.error.code ?? 'launch error'})`, { cause: r.error });
426
+ const error = commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
426
427
  error.code = r.error.code;
427
428
  throw error;
428
429
  }
429
430
  if (r.status !== 0 && !allowCodes.includes(r.status)) {
430
- const detail = sensitive ? '' : (r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('; ');
431
- throw new Error(`${executable} ${args.join(' ')} exited ${r.status}${detail ? `: ${detail}` : ''}`);
431
+ throw commandFailure(executable, args, r, { sensitive, cwd, env: childEnv });
432
432
  }
433
433
  return { ok: true, code: r.status, stdout: r.stdout ?? '' };
434
434
  },
@@ -442,7 +442,8 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
442
442
  delete childEnv.OURS_INSTALLER_LOCK_FD;
443
443
  if (installationLockFd !== null) childEnv.OURS_INSTALLER_LOCK_FD = '3';
444
444
  const r = spawnSync(cmd, args, { stdio: ['inherit', 'inherit', 'inherit', ...(installationLockFd === null ? [] : [installationLockFd])], env: childEnv });
445
- return { ok: !r.error && r.status === 0, code: r.status ?? -1 };
445
+ if (r.error || r.status !== 0) throw commandFailure(cmd, args, r, { env: childEnv });
446
+ return { ok: true, code: r.status };
446
447
  },
447
448
  installedVersion: (pkg) => installedVersionOf(pkg, npmBin),
448
449
  packageDependencies: (spec) => packageDependenciesOf(spec, npmBin),
@@ -1040,7 +1041,15 @@ export function networkEffects(effects) {
1040
1041
  },
1041
1042
  async serverAccess(record, operation, { output, migrate = false } = {}) {
1042
1043
  if (record.mode === 'docker') {
1043
- if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', 'access', operation], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
1044
+ // Retained images contain the old helper that discarded CLI errors.
1045
+ // Execute the shipped installer helper against the SAME selected image
1046
+ // and Compose volume/user/environment contract, without retag/rebuild.
1047
+ const diagnosticsUrl = `data:text/javascript;base64,${readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/diagnostics.mjs')).toString('base64')}`;
1048
+ const helper = `process.argv.splice(1, 0, '/opt/ours/docker/client-setup.mjs');\n` + readFileSync(join(INSTALLER_ASSETS, 'scripts/runtime/client-setup.mjs'), 'utf8')
1049
+ .replace("'../maintenance/build-context.mjs'", "'/opt/ours/maintenance/build-context.mjs'")
1050
+ .replace("'./diagnostics.mjs'", JSON.stringify(diagnosticsUrl));
1051
+ const accessArgs = (...args) => ['--entrypoint', 'node', 'access', '--input-type=module', '-e', helper, '--', ...args];
1052
+ if (!output) return compose(record, ['run', '--rm', '--no-deps', '-T', ...accessArgs(operation)], { sensitive: true, env: { OURS_ACCESS_MIGRATE: migrate ? '1' : '0' } });
1044
1053
  privateDirectory(dirname(output));
1045
1054
  if (existsSync(output)) throw new Error('Credential output already exists');
1046
1055
  const name = `${record.project}-issue-${randomUUID()}`;
@@ -1048,7 +1057,7 @@ export function networkEffects(effects) {
1048
1057
  const staging = join(dirname(output), `.ours-issued-${randomUUID()}`);
1049
1058
  ensurePrivateDirectory(staging);
1050
1059
  try {
1051
- await compose(record, ['run', '--name', name, '--no-deps', '-T', 'access', 'access-issue', issuedPath], { sensitive: true });
1060
+ await compose(record, ['run', '--name', name, '--no-deps', '-T', ...accessArgs('access-issue', issuedPath)], { sensitive: true });
1052
1061
  const file = join(staging, 'credential');
1053
1062
  await container(record, ['cp', `${name}:${issuedPath}`, file], { sensitive: true });
1054
1063
  assertPrivateRegularFile(file, 'issued credential');
@@ -1288,7 +1297,7 @@ export function networkEffects(effects) {
1288
1297
  if (discovery.ok) return validateGatewayDiscovery(base, await discovery.json(), resolve(credentialPath));
1289
1298
  throw new Error(`Gateway discovery answered HTTP ${discovery.status}; enable the gateway. Direct daemon fallback is not supported.`);
1290
1299
  },
1291
- importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh = false }) {
1300
+ importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup = false, refresh = false }) {
1292
1301
  profile = validateGatewayClientProfile(profile);
1293
1302
  const root = join(home, '.ours-client');
1294
1303
  const configPath = join(root, 'profile.json');
@@ -1314,7 +1323,7 @@ export function networkEffects(effects) {
1314
1323
  atomicWriteConfig(configPath, JSON.stringify(saved, null, 2) + '\n');
1315
1324
  return { configPath, profile: validateGatewayClientProfile(saved), settings: current.installer };
1316
1325
  }
1317
- const settings = { sourcesPath: join(root, 'sources.json'), integrations };
1326
+ const settings = { sourcesPath: join(root, 'sources.json'), integrations, ...(disableFleetAgentsSetup ? { disableFleetAgentsSetup: true } : {}) };
1318
1327
  if (fleetSettings) settings.fleetSettingsPath = join(root, 'fleet-settings.json');
1319
1328
  // Publish the profile last: clients cannot select incomplete imported inputs.
1320
1329
  atomicWriteConfig(settings.sourcesPath, sources);
@@ -45,6 +45,7 @@ import {
45
45
  validateChannelVersion,
46
46
  } from './marketplace.mjs';
47
47
  import { USAGE } from './usage.mjs';
48
+ import { installerFailure } from './diagnostics.mjs';
48
49
  import { ok, info, warn, heading, banner, box, c, progress } from './ui.mjs';
49
50
 
50
51
  export const EXIT_OK = 0;
@@ -89,21 +90,17 @@ async function perform(effects, dryRun, label, thunk) {
89
90
  const reason = (error) => (error instanceof Error ? error.message : String(error));
90
91
 
91
92
  function clientDiagnostic(error) {
92
- return reason(error)
93
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
94
- .replace(/\bBearer\s+[^\s,;]+/gi, 'Bearer [redacted]')
95
- .replace(/((?:api[_-]?key|api[_-]?token|access[_-]?token|refresh[_-]?token|token|password|secret|authorization|credential)\s*["']?\s*[:=]\s*["']?)[^\s"',;]+/gi, '$1[redacted]')
96
- .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[redacted]@')
97
- .replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 700);
93
+ return installerFailure(error);
98
94
  }
99
95
 
100
96
  function clientRetry(effects, imported, integrations) {
101
97
  effects.out(info(`Saved server connection: ${imported.configPath}.`));
102
- if (integrations.includes('fleet') && !imported.settings.fleetSettingsPath) {
98
+ if (integrations.includes('fleet') && !imported.settings.disableFleetAgentsSetup && !imported.settings.fleetSettingsPath) {
103
99
  effects.out(info('Saved profile and settings retained. Run ours-install, choose Connect to an existing server, and reuse the saved profile to finish the interactive Fleet configuration.'));
104
100
  return;
105
101
  }
106
102
  const args = ['client', 'install', '--config', imported.configPath, '--integrations', integrations.join(',') || 'none', '--sources', imported.settings.sourcesPath];
103
+ if (imported.settings.disableFleetAgentsSetup) args.push('--disable-fleet-agents-setup');
107
104
  if (imported.settings.fleetSettingsPath) args.push('--fleet-settings', imported.settings.fleetSettingsPath);
108
105
  const command = `ours-install client install ${args.slice(2).map((value, i) => i % 2 === 0 ? value : shellQuote(value)).join(' ')}`;
109
106
  effects.out(info(`Saved profile and settings retained; re-run ${command}`));
@@ -1011,6 +1008,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
1011
1008
  return { key: 'fleet', label: plan.label, state: 'skipped' };
1012
1009
  }
1013
1010
  const install = args.acquiredFleet ? { ok: true } : await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
1011
+ if (args.disableFleetAgentsSetup) {
1012
+ if (!install.ok) return { key: 'fleet', label: plan.label, state: 'failed', note: 'Fleet installation failed' };
1013
+ effects.out(ok('Fleet CLI installed; agent and model setup deferred to the App. Existing Fleet configuration was retained.'));
1014
+ return { key: 'fleet', label: plan.label, state: 'installed', note: 'agents setup deferred' };
1015
+ }
1014
1016
  // Fleet owns its v2 manifest, roles, models and permissions. With no prepared
1015
1017
  // settings, its wizard keeps the user's terminal while this invocation carries
1016
1018
  // the selected daemon; --settings makes the same call strictly noninteractive.
@@ -1474,7 +1476,8 @@ export async function runClientCommand(command, effects) {
1474
1476
  }
1475
1477
  if (!Array.isArray(integrations) || integrations.some(name => !['codex', 'claude-code', 'fleet'].includes(name)) || new Set(integrations).size !== integrations.length) throw new InstallUsageError('installer.integrations must be an array of codex, claude-code and/or fleet; use an empty array for CLI only');
1476
1478
  let fleetSettingsPath = command.preset ? command.fleetSettingsPath : settings?.fleetSettingsPath;
1477
- if (command.nonInteractive && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1479
+ const disableFleetAgentsSetup = command.disableFleetAgentsSetup ?? settings?.disableFleetAgentsSetup ?? false;
1480
+ if (command.nonInteractive && !disableFleetAgentsSetup && integrations.includes('fleet') && !fleetSettingsPath) throw new InstallUsageError('Fleet in CLI mode requires --fleet-settings; no interactive wizard will be opened');
1478
1481
  if (fleetSettingsPath !== undefined && (typeof fleetSettingsPath !== 'string' || !fleetSettingsPath))
1479
1482
  throw new InstallUsageError('installer.fleetSettingsPath must be a non-empty path when supplied');
1480
1483
  if (fleetSettingsPath) fleetSettingsPath = resolve(settingsBase, fleetSettingsPath);
@@ -1492,7 +1495,7 @@ export async function runClientCommand(command, effects) {
1492
1495
  }
1493
1496
  effects.out(progress(0, 4, 'Client configuration', 'Prepare the selected integrations and private connection profile.'));
1494
1497
  if (profile.serverUrl && integrations.includes('fleet')) await effects.qualifyGatewayClient({ profile, sourcesPath, sources: resolvedSources, integrations, refresh: !!command.preset });
1495
- const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, refresh: !!command.preset });
1498
+ const imported = effects.importClientProfile({ profile, sourcesPath, sources: resolvedSources, integrations, fleetSettingsPath, disableFleetAgentsSetup, refresh: !!command.preset });
1496
1499
  let phase = 'Validate saved server connection';
1497
1500
  try {
1498
1501
  await effects.verifyHostProfile(imported.configPath);
@@ -1500,7 +1503,7 @@ export async function runClientCommand(command, effects) {
1500
1503
  phase = 'Acquire client packages';
1501
1504
  const exactSuite = await effects.acquireClientPackages(imported.configPath, imported.settings.sourcesPath, integrations, { refresh: !!command.preset });
1502
1505
  const args = { assumeYes: true, dryRun: false, channel: 'latest', clientIntegrations: integrations,
1503
- acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath };
1506
+ acquiredFleet: exactSuite.fleetBin, fleetSettingsPath: imported.settings.fleetSettingsPath, disableFleetAgentsSetup: imported.settings.disableFleetAgentsSetup };
1504
1507
  const target = { mode: 'host-profile', managed: true, configPath: imported.configPath, profile: imported.profile, endpoint: imported.profile.endpoint };
1505
1508
  effects.out(progress(2, 4, 'Client integrations', 'Register the selected agent integrations.'));
1506
1509
  phase = 'Register client integrations';
@@ -115,6 +115,36 @@ try {
115
115
  effects.out?.(`Human identity ${after.root.name} is ready.`);
116
116
  return { name: after.root.name, cid: after.root.cid, created: true };
117
117
  },
118
+ async serverEnsureHumanProfile(record, human) {
119
+ validateRecord(record);
120
+ for (const key of ['name', 'surname']) if (typeof human[key] !== 'string' || !human[key].trim() || human[key].length > 100 || /[\x00-\x1f\x7f]/.test(human[key])) throw new Error('Invalid Messenger human profile');
121
+ // Messenger owns the profile lifecycle. Never rename the daemon root or
122
+ // overwrite a profile already present on a retained installation.
123
+ const script = `
124
+ import { readFileSync } from 'node:fs';
125
+ const selection = JSON.parse(process.argv[1]);
126
+ const token = readFileSync(selection.credentialPath, 'utf8').trim();
127
+ const base = selection.base;
128
+ async function request(path, value) {
129
+ const response = await fetch(base + '/api/' + path, { method: value === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { 'X-Ours-Api-Token': token, ...(value === undefined ? {} : { 'Content-Type': 'application/json', Origin: selection.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }) });
130
+ if (!response.ok) { await response.body?.cancel(); throw new Error('Messenger human profile operation failed (HTTP ' + response.status + ')'); }
131
+ return response.json();
132
+ }
133
+ const identity = await request('identity');
134
+ if (identity.humanProfile) process.stdout.write('Retained Messenger Name and Surname.');
135
+ else { await request('identity/profile', selection.human); process.stdout.write('Messenger Name and Surname initialized.'); }
136
+ `;
137
+ const docker = record.mode === 'docker';
138
+ const selection = { base: `http://127.0.0.1:${docker ? 8420 : record.messengerPort}`,
139
+ origin: `http://127.0.0.1:${record.messengerPort}`,
140
+ credentialPath: docker ? '/credentials/messenger/daemon-token' : installationPaths(record).credentials.messenger,
141
+ human: { name: human.name, surname: human.surname } };
142
+ const args = ['--input-type=module', '-e', script, JSON.stringify(selection)];
143
+ const result = docker ? await compose(record, ['exec', '-T', 'messenger', 'node', ...args])
144
+ : await effects.run(process.execPath, args, { env: localEnv(record), sensitive: true });
145
+ if (result.code !== undefined && result.code !== 0) throw new Error('Messenger human profile initialization failed');
146
+ effects.out?.(result.stdout);
147
+ },
118
148
  async prepareLocalClient(record, integrations, fleetSettingsPath) {
119
149
  validateRecord(record);
120
150
  if (!Array.isArray(integrations) || new Set(integrations).size !== integrations.length
@@ -139,7 +169,7 @@ try {
139
169
  if (!current) {
140
170
  effects.out?.('Issuing a separate local client credential with the retained server authority.');
141
171
  try { await effects.serverAccess(record, 'access-issue', { output: credential }); }
142
- catch { throw new Error('Client credential issuance failed; existing profiles were retained'); }
172
+ catch (cause) { throw new Error('Client credential issuance failed; existing profiles were retained', { cause }); }
143
173
  }
144
174
  const retainedCredential = current?.credentialPath ?? credential;
145
175
  const stat = privatePath(retainedCredential);
@@ -1,3 +1,4 @@
1
+ import { hostname } from 'node:os';
1
2
  import { serverBase } from './gateway.mjs';
2
3
  import { join, resolve, isAbsolute, dirname } from 'node:path';
3
4
 
@@ -5,10 +6,11 @@ const scopes = ['all', 'server', 'client'];
5
6
  const integrations = ['codex', 'claude-code', 'fleet'];
6
7
  const valueFlags = new Map(Object.entries({
7
8
  '--container-engine': 'containerEngine', '--server-url': 'serverUrl', '--scope': 'scope', '--action': 'operation', '--mode': 'mode', '--state-dir': 'stateDir',
9
+ '--username': 'localUsername', '--name': 'name', '--surname': 'surname', '--hostname': 'hostname',
8
10
  '--identity-name': 'identityName', '--integrations': 'integrations', '--fleet-settings': 'fleetSettingsPath',
9
11
  '--config': 'config', '--sources': 'sources', '--migrate-from': 'migrateFrom', '--port': 'port', '--cowork-port': 'coworkPort', '--messenger-port': 'messengerPort',
10
12
  }));
11
- const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate']]);
13
+ const boolFlags = new Map([['--compatible', 'compatible'], ['--dry-run', 'dryRun'], ['--migrate', 'migrate'], ['--disable-fleet-agents-setup', 'disableFleetAgentsSetup']]);
12
14
  const allowed = new Set([...valueFlags.values(), ...boolFlags.values(), 'interactive', 'explicitPorts']);
13
15
  const defaults = { port: 3050, coworkPort: 3052, messengerPort: 8420 };
14
16
  const paths = ['stateDir', 'config', 'sources', 'fleetSettingsPath', 'migrateFrom'];
@@ -32,12 +34,28 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
32
34
  const client = options.scope !== 'server';
33
35
  if (options.migrateFrom !== undefined && (!server || options.operation !== 'install')) throw new Error('--migrate-from requires server or all scope and operation install');
34
36
  if (options.migrateFrom !== undefined && (!nonempty(options.migrateFrom) || !isAbsolute(options.migrateFrom))) throw new Error('--migrate-from requires an absolute daemon config path');
37
+ const suppliedHuman = ['localUsername', 'name', 'surname'].some(key => options[key] !== undefined);
38
+ if (suppliedHuman) {
39
+ for (const key of ['localUsername', 'name', 'surname']) if (!nonempty(options[key])) throw new Error(`Missing human profile option: ${key}`);
40
+ if (!/^[a-z0-9][a-z0-9-]{0,29}$/.test(options.localUsername)) throw new Error('Local username must contain 1–30 lowercase letters, digits or hyphens');
41
+ for (const key of ['name', 'surname']) {
42
+ if (options[key].trim().length > 100) throw new Error(`${key} must contain at most 100 characters`);
43
+ options[key] = options[key].trim();
44
+ }
45
+ options.hostname = options.hostname?.trim() || defaultHostname();
46
+ if (!/^[a-z0-9][a-z0-9-]{0,29}$/.test(options.hostname)) throw new Error('Hostname must contain 1–30 lowercase letters, digits or hyphens');
47
+ const identity = `${options.localUsername}@${options.hostname}`;
48
+ if (options.migrateFrom === undefined && options.identityName !== undefined && options.identityName !== identity) throw new Error('--identity-name conflicts with username and hostname');
49
+ if (options.migrateFrom === undefined) options.identityName = identity;
50
+ } else if (options.hostname !== undefined) throw new Error('--hostname requires username, name and surname');
51
+ if (options.disableFleetAgentsSetup !== undefined && typeof options.disableFleetAgentsSetup !== 'boolean') throw new Error('disableFleetAgentsSetup must be a boolean');
52
+ if (options.disableFleetAgentsSetup && options.fleetSettingsPath) throw new Error('--disable-fleet-agents-setup conflicts with --fleet-settings');
35
53
  const missing = [];
36
54
  if (server) for (const [key, flag] of [['mode', '--mode'], ['stateDir', '--state-dir'], ...(options.migrateFrom === undefined ? [['identityName', '--identity-name']] : [])]) if (!nonempty(options[key])) missing.push(flag);
37
55
  if (client && options.integrations === undefined) missing.push('--integrations (use none to skip)');
38
56
  if (!server && !nonempty(options.config)) missing.push('--config');
39
57
  if (server && (options.operation === 'update' || options.migrateFrom !== undefined) && options.compatible !== true) missing.push('--compatible');
40
- if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
58
+ if (Array.isArray(options.integrations) && options.integrations.includes('fleet') && !interactive && !options.disableFleetAgentsSetup && !nonempty(options.fleetSettingsPath)) missing.push('--fleet-settings');
41
59
  if (missing.length) throw new Error(`Missing required setup options: ${missing.join(', ')}`);
42
60
  if (options.mode === 'native') options.mode = 'packages';
43
61
  if (server && !['packages', 'docker'].includes(options.mode)) throw new Error('Mode must be packages (or native) or docker');
@@ -47,7 +65,7 @@ export function validateSetupOptions(input, { interactive = input?.interactive =
47
65
  options.serverUrl = serverBase(options.serverUrl);
48
66
  }
49
67
  if (server && options.config !== undefined) throw new Error('--config is only valid for client scope');
50
- if (!server) for (const key of ['mode', 'stateDir', 'identityName', ...Object.keys(defaults), 'compatible', 'migrate']) {
68
+ if (!server) for (const key of ['mode', 'stateDir', 'identityName', 'localUsername', 'name', 'surname', 'hostname', ...Object.keys(defaults), 'compatible', 'migrate']) {
51
69
  if (options[key] !== undefined) throw new Error(`${key} is only valid for server or all scope`);
52
70
  }
53
71
  if (!client && (options.integrations !== undefined || options.fleetSettingsPath !== undefined)) throw new Error('--integrations and --fleet-settings require all or client scope');
@@ -124,27 +142,31 @@ export function parseSetupArgs(argv, { home, validate = true } = {}) {
124
142
  return validate ? validateSetupOptions(expandPaths(options, home), { interactive: false }) : expandPaths(options, home);
125
143
  }
126
144
 
127
- export async function collectSetupOptions(effects) {
145
+ export function defaultHostname() {
146
+ return hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 30) || 'workspace';
147
+ }
148
+
149
+ export async function collectSetupOptions(effects, presets = {}) {
128
150
  if (effects.interactive !== true) throw new Error('Interactive setup requires a TTY; provide the complete CLI options instead');
129
- const options = { interactive: true, explicitPorts: [] };
151
+ const options = { ...presets, interactive: true, explicitPorts: presets.explicitPorts ?? [] };
130
152
  const scopeChoices = [
131
153
  { value: 'all', label: 'Everything on this computer' },
132
154
  { value: 'server', label: 'Server only' },
133
155
  { value: 'client', label: 'Connect this computer to an existing server' },
134
156
  ];
135
157
  effects.out('Choose what this computer should do. Everything runs the server here and connects your selected apps; client-only uses a server you already have.');
136
- options.scope = await effects.select('What would you like to set up?', scopeChoices, 'all');
158
+ options.scope ??= await effects.select('What would you like to set up?', scopeChoices, 'all');
137
159
  if (!scopes.includes(options.scope)) throw new Error('Scope must be all, server, or client');
138
160
  let existing;
139
161
  let pendingMigration;
140
162
  if (options.scope !== 'client') {
141
163
  const recommendedRoot = join(effects.home, '.ours-install');
142
164
  effects.out(`Stores the server programs and data, including identities and messages. The recommended folder is ${recommendedRoot}; choose another only if you want to manage its location yourself.`);
143
- const location = await effects.select('Where should server programs and data be stored?', [
165
+ const location = options.stateDir ? 'preset' : await effects.select('Where should server programs and data be stored?', [
144
166
  { value: 'recommended', label: `Use the recommended folder (${recommendedRoot})` },
145
167
  { value: 'custom', label: 'Choose another folder' },
146
168
  ], 'recommended');
147
- options.stateDir = location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
169
+ options.stateDir ??= location === 'recommended' ? recommendedRoot : await effects.askLine('Folder for server programs and data: ', recommendedRoot);
148
170
  options.stateDir = expandPaths({ stateDir: options.stateDir }, effects.home).stateDir;
149
171
  existing = effects.readJson(join(options.stateDir, 'installation.json'));
150
172
  const migrationJournal = existing?.legacyMigrationSource ? effects.readJson(join(options.stateDir, 'legacy-migration.json')) : null;
@@ -156,7 +178,7 @@ export async function collectSetupOptions(effects) {
156
178
  ? 'This folder already contains an installation. Update selects this installer’s release while retaining state; repair reinstalls the retained package selection.'
157
179
  : options.scope === 'client' ? 'Install connects your selected apps. Update refreshes their packages while keeping the server connection.'
158
180
  : 'Install creates a managed installation in the selected folder. Update requires an installation already recorded there.');
159
- options.operation = !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
181
+ options.operation ??= !existing && options.scope !== 'client' ? 'install' : await effects.select('What should happen?', [
160
182
  { value: 'install', label: pendingMigration ? 'Resume the unfinished migration' : existing ? 'Repair this installation' : 'Install and configure' },
161
183
  { value: 'update', label: 'Update an existing installation' },
162
184
  ], existing && !pendingMigration ? 'update' : 'install');
@@ -166,7 +188,8 @@ export async function collectSetupOptions(effects) {
166
188
  const legacyState = nonempty(legacy?.stateDir) ? legacy.stateDir
167
189
  : legacy && legacy.stateDir === undefined && nonempty(effects.readJson(join(effects.home, '.ours', 'root.json'))?.name)
168
190
  ? join(effects.home, '.ours') : null;
169
- if (pendingMigration) options.migrateFrom = pendingMigration;
191
+ if (options.migrateFrom) { /* Explicit selection is validated below. */ }
192
+ else if (pendingMigration) options.migrateFrom = pendingMigration;
170
193
  else if (legacyState) {
171
194
  effects.out(`Found an existing ours installation at ${legacyState}. Upgrade it to keep its identities, messages and settings. The old server will stop; its original state is kept as a recovery copy.`);
172
195
  if (await effects.ask('Upgrade this existing ours installation and keep its data?', true)) options.migrateFrom = defaultConfig;
@@ -190,41 +213,61 @@ export async function collectSetupOptions(effects) {
190
213
  if (!nonempty(root?.name)) throw new Error('Cannot read the existing Human identity name; choose the configuration file for a complete existing installation');
191
214
  options.identityName = root.name;
192
215
  effects.out(`Your existing Human identity, ${root.name}, will be retained; no replacement identity will be created. Migration keeps the original state, but compatibility with an older release cannot be guaranteed automatically.`);
193
- options.compatible = await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
216
+ options.compatible ??= await effects.ask('Proceed with this release and keep the original state as a recovery copy?', false);
194
217
  if (!options.compatible) throw new Error('Migration requires explicit compatibility confirmation (--compatible)');
195
218
  }
196
219
  }
197
220
  if (options.scope !== 'client') {
198
221
  const recommendation = recommendedMode({ ...effects.platform, arch: effects.platform?.arch ?? process.arch });
199
222
  effects.out(`${recommendation.reason} Native runs directly on this computer; Docker runs in containers and needs Docker installed and running. An existing installation must keep its current mode.`);
200
- options.mode = await effects.select('How should the server run?', [
201
- { value: 'packages', label: 'Native packages' }, { value: 'docker', label: 'Docker' },
202
- ], existing?.mode ?? recommendation.mode);
223
+ const runtime = options.mode ?? await effects.select('How should the server run?', [
224
+ { value: 'packages', label: 'Native packages' }, { value: 'docker', label: 'Docker' }, { value: 'podman', label: 'Podman' },
225
+ ], existing?.mode === 'docker' && existing?.containerEngine === 'podman' ? 'podman' : existing?.mode ?? recommendation.mode);
226
+ options.mode = runtime === 'podman' ? 'docker' : runtime;
227
+ if (runtime === 'podman') options.containerEngine = 'podman';
203
228
  if (!options.migrateFrom) {
204
- effects.out(existing ? 'Existing identities and names are retained. This name is used only if a Human identity needs to be created.' : 'Choose the name other people and agents should see for your Human identity.');
205
- options.identityName = await effects.askLine('What name should others see? ', existing?.messengerIdentity ?? effects.username?.() ?? 'me');
229
+ if (existing?.messengerIdentity) {
230
+ effects.out(`Existing Human identity ${existing.messengerIdentity} and Messenger profile will be retained.`);
231
+ // Supplied profile inputs never rename a retained root.
232
+ if (!['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) options.identityName ??= existing.messengerIdentity;
233
+ } else if (options.identityName === undefined || options.localUsername !== undefined) {
234
+ effects.out('Choose a local username (no global uniqueness check). Name and Surname appear in Messenger; the optional host name identifies this workspace.');
235
+ options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
236
+ options.name ??= await effects.askLine('Name: ', '');
237
+ options.surname ??= await effects.askLine('Surname: ', '');
238
+ options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
239
+ }
240
+ }
241
+ if (['localUsername', 'name', 'surname'].some(key => options[key] !== undefined)) {
242
+ // Partial profile presets still collect missing fields on retained and
243
+ // migration paths; these inputs never replace their authoritative root.
244
+ effects.out('Name and Surname initialize a missing Messenger profile; an existing profile is retained.');
245
+ options.localUsername ??= await effects.askLine('Local username: ', (effects.username?.() ?? 'me').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 30));
246
+ options.name ??= await effects.askLine('Name: ', '');
247
+ options.surname ??= await effects.askLine('Surname: ', '');
248
+ options.hostname ??= await effects.askLine('Host / machine name (optional): ', '');
206
249
  }
207
- for (const [key, fallback] of Object.entries(defaults)) options[key] = existing?.[key] ?? fallback;
250
+ for (const [key, fallback] of Object.entries(defaults)) options[key] ??= existing?.[key] ?? fallback;
208
251
  if (options.operation === 'update') {
209
252
  effects.out('The update retains identities and messages and creates a recovery backup before changing stored state. Proceed only if you accept this release for your existing data; compatibility is not automatically guaranteed.');
210
- options.compatible = await effects.ask('Proceed with this update and keep a recovery backup?', false);
253
+ options.compatible ??= await effects.ask('Proceed with this update and keep a recovery backup?', false);
211
254
  }
212
255
  } else {
213
256
  const savedProfile = join(effects.home, '.ours-client', 'profile.json');
214
257
  effects.out('A connection profile identifies the server and its private access credential. Reuse your saved connection or select a profile supplied by the server owner.');
215
- const connection = await effects.select('Which server connection should this computer use?', [
258
+ const connection = options.config ? 'preset' : await effects.select('Which server connection should this computer use?', [
216
259
  { value: 'saved', label: 'Use the saved server connection' }, { value: 'file', label: 'Choose a connection profile file' },
217
260
  ], effects.readJson(savedProfile) ? 'saved' : 'file');
218
- options.config = connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
261
+ options.config ??= connection === 'saved' ? savedProfile : await effects.askLine('Connection profile file: ', savedProfile);
219
262
  }
220
263
  if (options.scope !== 'server') {
221
264
  const detected = typeof effects.detectHarnesses === 'function' ? await effects.detectHarnesses() : [];
222
265
  const selected = integrations.filter(name => name === 'fleet' || detected.some(item => item.name === name && item.status === 'ok'));
223
266
  effects.out('Choose the apps to connect. Detected agent apps are selected by default; Fleet configures persistent agents but leaves them stopped. Use Space to toggle choices, then Enter to continue. You can select none.');
224
- options.integrations = await effects.multiselect('Which integrations should be configured?', [
267
+ options.integrations ??= await effects.multiselect('Which integrations should be configured?', [
225
268
  { value: 'codex', label: 'Codex' }, { value: 'claude-code', label: 'Claude Code' }, { value: 'fleet', label: 'Fleet — persistent agents' },
226
269
  ], selected);
227
- if (options.integrations.includes('fleet')) {
270
+ if (options.integrations.includes('fleet') && !options.disableFleetAgentsSetup && !options.fleetSettingsPath) {
228
271
  effects.out('Fleet needs model and agent settings. Its guided setup asks for these later; a prepared settings file applies your existing choices. Fleet roles will not start automatically.');
229
272
  const fleetMode = await effects.select('How should Fleet be configured?', [
230
273
  { value: 'wizard', label: 'Configure interactively with Fleet' }, { value: 'file', label: 'Use a prepared settings file' },
package/lib/setup.mjs CHANGED
@@ -11,6 +11,7 @@ import { isCancel } from './prompt.mjs';
11
11
  import { USAGE } from './usage.mjs';
12
12
  import { validateIdentityName } from './server-onboarding.mjs';
13
13
  import { validateFleetSettings } from './fleet-settings.mjs';
14
+ import { installerFailure } from './diagnostics.mjs';
14
15
 
15
16
  const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
16
17
 
@@ -114,6 +115,9 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
114
115
  const result = await server({ ...plan, role: 'server', operation: plan.operation, sourcePolicy: plan.sourcePolicy }, effects);
115
116
  if (result !== 0) return result;
116
117
  const record = validateInstallation(effects.readJson(join(plan.stateDir, 'installation.json')), plan.stateDir);
118
+ if (plan.name && plan.surname && plan.operation === 'install') {
119
+ await effects.serverEnsureHumanProfile(record, { name: plan.name, surname: plan.surname });
120
+ }
117
121
  if (plan.operation === 'update') {
118
122
  effects.out(progress(0, 1, 'Retained identities', 'Verify the Human identity after state restoration; existing names and keys are retained.'));
119
123
  const running = await effects.serverLifecycle(record, 'status', ['daemon']);
@@ -132,11 +136,12 @@ export async function executeSetupPlan(plan, effects, { server = runServerComman
132
136
  if (plan.scope !== 'server') {
133
137
  const result = await client({ role: 'client', operation: 'install', config: clientConfig,
134
138
  integrations: plan.integrations, fleetSettingsPath: plan.fleetSettingsPath, sourcePolicy: clientPolicy,
135
- preset: true, nonInteractive: !plan.interactive }, effects);
139
+ disableFleetAgentsSetup: plan.disableFleetAgentsSetup, preset: true, nonInteractive: !plan.interactive }, effects);
136
140
  if (result !== 0) return result;
137
141
  }
142
+ if (plan.disableFleetAgentsSetup && plan.integrations?.includes('fleet')) effects.out(info('Fleet is installed. Run ours-fleet setup-tunnel with the private expiring App setup file, then ours-fleet link-device. Configure agents and models in the App after linking.'));
138
143
  effects.out(ok(`Requested ${plan.operation} completed. Existing identities were retained.`));
139
- if (plan.integrations?.includes('fleet')) effects.out(info('Fleet is configured but stopped. Review its settings, then run ours-fleet doctor, ours-fleet config and ours-fleet up.'));
144
+ if (plan.integrations?.includes('fleet') && !plan.disableFleetAgentsSetup) effects.out(info('Fleet is configured but stopped. Review its settings, then run ours-fleet doctor, ours-fleet config and ours-fleet up.'));
140
145
  return 0;
141
146
  }
142
147
 
@@ -148,12 +153,20 @@ export async function runSetup(argv, effects) {
148
153
  if (argv[0] === 'server' && maintenance.has(argv[1])) return await runServerCommand(parseNetworkArgs(argv), effects);
149
154
  if (!argv.length && effects.env?.OURS_ASSUME_YES) throw new InstallUsageError('OURS_ASSUME_YES cannot fill an interactive setup plan. Supply complete CLI presets for unattended installation.');
150
155
  if (!argv.length) { effects.out(banner()); effects.out(heading('Interactive setup')); }
151
- const options = argv.length ? parseSetupArgs(argv, { home: effects.home }) : await collectSetupOptions(effects);
156
+ let options;
157
+ if (argv.length) {
158
+ const presets = parseSetupArgs(argv, { home: effects.home, validate: false });
159
+ try { options = validateSetupOptions(presets, { interactive: false }); }
160
+ catch (error) {
161
+ if (!effects.interactive || effects.env?.OURS_ASSUME_YES || !/^Missing (required setup options|human profile option):/.test(error.message)) throw error;
162
+ options = await collectSetupOptions(effects, presets);
163
+ }
164
+ } else options = await collectSetupOptions(effects);
152
165
  const plan = await prepareSetupPlan(options, effects);
153
166
  return await executeSetupPlan(plan, effects);
154
167
  } catch (error) {
155
168
  if (isCancel(error)) { effects.out(warn('Installation cancelled.')); return 130; }
156
- effects.out(warn(`ours-install: ${error.message}`));
169
+ effects.out(warn(`ours-install: ${installerFailure(error, effects.env)}`));
157
170
  return 2;
158
171
  }
159
172
  }
package/lib/usage.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  // Public setup and maintenance commands.
2
- export const USAGE = `ours-install — interactive setup or complete CLI presets.
2
+ export const USAGE = `ours-install — interactive setup with optional CLI presets.
3
3
 
4
4
  ours-install
5
5
  Opens the console form. Choose all (server + clients), server, or client;
6
6
  runtime, installation directory, identity, integrations and Fleet settings.
7
7
  Docker provides the full-stack gateway; native mode is server-only. Windows uses WSL.
8
8
 
9
+ Install now, configure Fleet agents in the App after linking:
10
+ ours-install --username alex --name Alex --surname Taylor --disable-fleet-agents-setup
11
+ Remaining installation choices are prompted on a terminal. No enrollment credentials or expiry.
12
+
9
13
  Full stack, with every required answer preset (no prompts):
10
14
  ours-install --mode docker --state-dir /private/ours --identity-name "Your Name" --integrations codex,fleet --fleet-settings /private/fleet.json
11
15
 
@@ -24,9 +28,14 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
24
28
  --mode docker|native packages is also accepted for native mode
25
29
  --container-engine docker|podman server container backend (default Docker); retained for maintenance
26
30
  --state-dir PATH installation root; required for all/server
31
+ --username USERNAME local username; no global uniqueness check
32
+ --name NAME Messenger given name
33
+ --surname SURNAME Messenger family name
34
+ --hostname NAME optional host/workspace name; defaults to this machine
35
+ --disable-fleet-agents-setup install Fleet CLI without its roles/model wizard
27
36
  --identity-name NAME desired Human name for a fresh server; existing root is retained
28
37
  --integrations LIST codex,claude-code,fleet; use none to skip clients explicitly
29
- --fleet-settings PATH JSON settings for Fleet; mandatory for CLI presets selecting Fleet
38
+ --fleet-settings PATH JSON settings for Fleet; required unless Fleet agents setup is disabled
30
39
  --config PATH complete connection profile for client-only setup
31
40
  --sources PATH explicit full development source policy override
32
41
  --port N public gateway port for Docker (default 3050; e.g. 4050)
@@ -41,8 +50,8 @@ export const USAGE = `ours-install — interactive setup or complete CLI presets
41
50
  --help, -h show help
42
51
  --version, -V print installer version
43
52
 
44
- CLI presets must be complete and never open the interactive form or a Fleet wizard.
45
- Missing answers are reported before installation. Both input modes run the same
53
+ Partial CLI presets on a terminal ask for the remaining choices. Without a terminal,
54
+ or with OURS_ASSUME_YES, supply complete CLI presets; missing answers fail before installation. Both input modes run the same
46
55
  installer with preparation, identity restoration, update and readiness progress.
47
56
  Fleet is configured but left stopped for operator review.
48
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.23",
3
+ "version": "1.2.1-nightly.25",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",