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