@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 +15 -1
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +4 -4
- package/assets/release.json +3 -3
- package/assets/scripts/runtime/client-setup.mjs +20 -2
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/sources.json +4 -4
- package/install.mjs +2 -1
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-runtime-repair.mjs +14 -6
- package/lib/effects.mjs +17 -8
- package/lib/orchestrate.mjs +13 -10
- package/lib/server-onboarding.mjs +31 -1
- package/lib/setup-options.mjs +65 -22
- package/lib/setup.mjs +17 -4
- package/lib/usage.mjs +13 -4
- package/package.json +1 -1
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.
|
|
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
|
package/assets/release-lock.json
CHANGED
|
@@ -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.
|
|
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.
|
|
1089
|
-
"resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.
|
|
1090
|
-
"integrity": "sha512-
|
|
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",
|
package/assets/release.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"channel": "nightly",
|
|
4
|
-
"installerVersion": "1.2.1-nightly.
|
|
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.
|
|
28
|
-
"integrity": "sha512-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|
package/assets/sources.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"release": {
|
|
3
3
|
"schema": 1,
|
|
4
4
|
"channel": "nightly",
|
|
5
|
-
"installerVersion": "1.2.1-nightly.
|
|
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.
|
|
29
|
-
"integrity": "sha512-
|
|
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.
|
|
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: ${
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/lib/setup-options.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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;
|
|
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
|
|
45
|
-
|
|
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.
|
|
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",
|