@ours.network/install 0.17.0-nightly.2 → 0.17.0-nightly.3
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 +112 -30
- package/install.mjs +352 -83
- package/lib/logic.mjs +325 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,41 +43,98 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
|
|
|
43
43
|
answers `--version` promptly. A shell alias / hanging wrapper is **never called** (that would
|
|
44
44
|
hang the run) — it's reported plainly with a fix, and a manual-install path is always offered.
|
|
45
45
|
If neither harness exists it says so and exits.
|
|
46
|
-
2. **
|
|
47
|
-
the **broker** (end-to-end encrypted; the broker never sees message content — almost everyone
|
|
48
|
-
just presses Enter) and the **port** (probes `3050`; only asks if it's busy; never hands out
|
|
49
|
-
`3051`, reserved for the Telegram connector). Applied once, then the stack is built with it.
|
|
50
|
-
3. **Four consent gates**, each paced with a clean `✓ … No problems.` line + an explicit
|
|
46
|
+
2. **Five consent gates**, each paced with a clean `✓ … No problems.` line + an explicit
|
|
51
47
|
**Continue?** — never a start-twice-then-ask, never a silent failure:
|
|
52
|
-
- **1/
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
`
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
- **1/5 the shared ours daemon** — its own step, before every consumer, and it owns its own
|
|
49
|
+
configuration: the **broker** (end-to-end encrypted; the broker never sees message content —
|
|
50
|
+
almost everyone just presses Enter) and the **listen port**, which is now an explicit
|
|
51
|
+
question rather than one you only hear about when `3050` is busy. The default is still
|
|
52
|
+
`3050` (the next free port when that is taken), so **Enter and every non-interactive run
|
|
53
|
+
land exactly where they always did**. The answer is validated (a real port, not reserved by
|
|
54
|
+
another component, not already in use, not already claimed by another daemon in this run),
|
|
55
|
+
persisted to `~/.ours/config.json`, and it is the endpoint everything below is wired to.
|
|
56
|
+
Then: write config → optional voice setup → install/start ONCE → boot service. On a re-run
|
|
57
|
+
it reuses the running config (no re-ask) and only updates when you say yes. Complete voice
|
|
58
|
+
setup is kept without prompting. Missing/incomplete setup is offered before the first start
|
|
59
|
+
or pending update restart, then delegated to the canonical `ours-mcp voice-setup` provider
|
|
60
|
+
selector and hidden API-key prompt. Accepted setup owns the one restart/readiness
|
|
61
|
+
transaction; declining or already-ready setup preserves the normal core lifecycle. The
|
|
62
|
+
secret is written atomically to mode-`0600` config; a failed daemon reload rolls back.
|
|
63
|
+
- **2/5 harness plugins** — the installer **drives the plugin CLIs itself**
|
|
61
64
|
(`claude plugin marketplace add …` + `claude plugin install ours@ours.network`;
|
|
62
65
|
`codex plugin marketplace add …` + `codex plugin add ours@ours-codex-marketplace`). Choosing
|
|
63
66
|
Codex also installs the `ours-codex` live launcher in the same step. Any failure / alias
|
|
64
67
|
prints the exact manual commands and continues — it **never dead-ends**.
|
|
65
|
-
- **3/
|
|
68
|
+
- **3/5 ours-fleet** — makes your harnesses persistent, always-online agent teams that survive
|
|
66
69
|
a reboot; runs `ours-fleet init`. Default **Yes**.
|
|
67
|
-
- **4/
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
- **4/5 Telegram connector** — install-only (no bot tokens here), then a question asked
|
|
71
|
+
**independently of every other consumer**: use the shared daemon from step 1, or run against
|
|
72
|
+
its **own dedicated daemon**? Default (and Enter, and non-interactive) is the shared one.
|
|
73
|
+
A dedicated daemon is provisioned with its own port, its own state directory
|
|
74
|
+
(`~/.ours-tg`) and its own boot unit (`ours-tg.service`), and the connector is wired to
|
|
75
|
+
that endpoint. Then optionally installed as a boot service itself.
|
|
76
|
+
- **5/5 Rooms (ours-cowork)** — durable mission rooms. Default **No**. Configures its own
|
|
77
|
+
surface (the deployment's broker, its state directory `~/.ours-cowork`, and its loopback
|
|
78
|
+
**console port** — default `3052`, validated the same way), then asks the same
|
|
79
|
+
shared-vs-dedicated daemon question, defaulting to shared. A dedicated Rooms daemon gets
|
|
80
|
+
`~/.ours-rooms` and `ours-rooms.service`. Then `ours-cowork install-service`. See
|
|
81
|
+
**Daemon topology** for when the question is asked at all.
|
|
82
|
+
3. **Summary + hand-off** — a recap (skipped/failed rows call out the fix), then a **literal
|
|
83
|
+
copy-paste prompt** (human identity + fleet + Telegram + Rooms) with the steps for any
|
|
84
|
+
skipped/failed component dropped out. Copied to the clipboard where supported.
|
|
72
85
|
|
|
73
86
|
The human identity is created idempotently after the daemon becomes reachable. Because
|
|
74
87
|
`curl … | bash` gives the script its input over the pipe, every prompt is read from the
|
|
75
88
|
controlling terminal (`/dev/tty`), so the flow still works piped.
|
|
76
89
|
|
|
77
|
-
##
|
|
90
|
+
## Daemon topology
|
|
78
91
|
|
|
79
|
-
A clean deployment installs, configures and starts **one** ours daemon first, then wires
|
|
80
|
-
client to that same daemon.
|
|
92
|
+
A clean deployment installs, configures and starts **one shared** ours daemon first, then wires
|
|
93
|
+
every client to that same daemon. That is the default and the backward-compatible answer.
|
|
94
|
+
|
|
95
|
+
**Optional isolation.** The Telegram connector and Rooms may each instead be given their own
|
|
96
|
+
daemon, chosen independently of one another. Isolation is only real when all three of these are
|
|
97
|
+
separate, which is what the installer provisions:
|
|
98
|
+
|
|
99
|
+
| | shared | dedicated (Telegram) | dedicated (Rooms) |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| listen port | your step-1 answer | its own, validated against every other daemon in the run | likewise |
|
|
102
|
+
| state directory | `~/.ours` | `~/.ours-tg` — the daemon's API token lives here | `~/.ours-rooms` |
|
|
103
|
+
| boot unit | `ours.service` | `ours-tg.service` (via `OURS_SERVICE_NAME`) | `ours-rooms.service` |
|
|
104
|
+
| config file | `~/.ours/config.json` | `~/.ours-tg/config.json` | `~/.ours-rooms/config.json` |
|
|
105
|
+
|
|
106
|
+
Without a distinct service name, `ours-mcp install-service` would write the **same** unit for
|
|
107
|
+
both and the second daemon would silently overwrite the first's port and state directory. See
|
|
108
|
+
`packages/core/src/service-instance.ts`; a daemon with no instance name keeps exactly the
|
|
109
|
+
historical `ours.service` / `solutions.adaptframework.ours`.
|
|
110
|
+
|
|
111
|
+
**Rooms has a third answer.** `ours-cowork` used to host its own daemon, always. It now also
|
|
112
|
+
supports an **external** one, so Rooms answers the same shared-vs-dedicated question — plus
|
|
113
|
+
`embedded`, cowork's own, which is what every install predating that support runs.
|
|
114
|
+
|
|
115
|
+
Its config carries an optional `daemon` block. Absent means embedded; external is
|
|
116
|
+
`{ mode: 'external', endpoint, stateDir }` and **requires both halves**, because cowork stores no
|
|
117
|
+
token and its SDK reads `<stateDir>/daemon-token`. Env equivalents are `OURS_COWORK_DAEMON_MODE`
|
|
118
|
+
/ `_ENDPOINT` / `_STATE_DIR`, and the service unit carries only those — never a token.
|
|
119
|
+
|
|
120
|
+
Two things follow from cowork's boot being **fail-closed** (an unreachable endpoint, a non-ours
|
|
121
|
+
daemon, or a mismatched state directory aborts startup, with no embedded fallback):
|
|
122
|
+
|
|
123
|
+
- A build that **predates** the external mode is never handed a block. The check is on the
|
|
124
|
+
**installed version**, not the channel: `COWORK_EXTERNAL_MIN_VERSION` in `lib/logic.mjs` is
|
|
125
|
+
pinned to `0.4.1-nightly.20260816.4aaf940`, the first published cowork that implements it
|
|
126
|
+
(verified against the registry *and* the tarball's contents). A channel-only gate would have
|
|
127
|
+
accepted an earlier nightly of the same core version. `cowork@latest` (0.4.0) predates the
|
|
128
|
+
mode, so the stable installer keeps Rooms embedded, names the build it found and the version it
|
|
129
|
+
needed, and points at `OURS_CHANNEL=nightly`. An unreadable version keeps Rooms embedded too —
|
|
130
|
+
it never guesses.
|
|
131
|
+
- An install **already running embedded** is never migrated behind the user's back — headless
|
|
132
|
+
runs leave it exactly as it is, and interactive runs ask first.
|
|
133
|
+
|
|
134
|
+
Note the two different `stateDir` keys: the top-level one is cowork's own private state;
|
|
135
|
+
`daemon.stateDir` is the **ours daemon's** state directory. Confusing them fails closed at boot.
|
|
136
|
+
|
|
137
|
+
What wiring a client to a daemon takes differs per client:
|
|
81
138
|
|
|
82
139
|
- **The harness plugins** need nothing extra: each is `ours-mcp proxy`, which reads the daemon's
|
|
83
140
|
own config (`OURS_CONFIG`, else `~/.ours/config.json`). `autoStart` is off by default, so a proxy
|
|
@@ -104,9 +161,27 @@ while the summary claims success.
|
|
|
104
161
|
|
|
105
162
|
## Release channel
|
|
106
163
|
|
|
107
|
-
`OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) installs
|
|
108
|
-
|
|
109
|
-
|
|
164
|
+
`OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) installs each package's **own** prerelease
|
|
165
|
+
dist-tag. The tag is not the same string everywhere, so the mapping is per package:
|
|
166
|
+
|
|
167
|
+
| package | stable channel | nightly channel |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `mcp`, `tg-connector`, `claude-code`, `codex`, `hermes` | `latest` | `nightly` |
|
|
170
|
+
| `fleet` | `latest` | `nightly` |
|
|
171
|
+
| `cowork` (Rooms) | `latest` | `nightly` |
|
|
172
|
+
|
|
173
|
+
`fleet` follows the channel: it publishes its own `nightly` dist-tag, and the nightly stack needs
|
|
174
|
+
the fleet build carrying the SDK integration. A nightly installer that quietly installed stable
|
|
175
|
+
fleet is the same split-brain deployment the channel exists to prevent.
|
|
176
|
+
|
|
177
|
+
`cowork` publishes `nightly` alongside every other service (it previously used `next`). The
|
|
178
|
+
nightly channel must reach that line, because the external-daemon mode the Rooms step configures
|
|
179
|
+
ships there; taking `latest` would pair a config carrying a `daemon` block with a build that
|
|
180
|
+
predates it.
|
|
181
|
+
|
|
182
|
+
A package with no mapping for the selected channel installs `@latest` rather than a guessed tag,
|
|
183
|
+
because a 404 fails the *whole* install — which is also why the nightly installer must not be
|
|
184
|
+
published before every package it names actually has the tag it will ask for.
|
|
110
185
|
|
|
111
186
|
**With no explicit selection the installer follows its own version.** A published nightly build
|
|
112
187
|
carries the `-nightly.N` suffix the release bump stamps, so `npm i -g @ours.network/install@nightly`
|
|
@@ -140,8 +215,14 @@ is reported and left unchanged.
|
|
|
140
215
|
| `OURS_CONFIG` | daemon config file location (default `~/.ours/config.json`) |
|
|
141
216
|
| `OURS_STATE_DIR` | daemon state directory (default `~/.ours`) — also what the Telegram connector is told to expect |
|
|
142
217
|
| `OURS_TG_CONFIG` | Telegram connector config file location (default `~/.ours-telegram/config.json`) |
|
|
218
|
+
| `OURS_COWORK_CONFIG` | Rooms config file location (default `~/.ours-cowork/config.json`) |
|
|
219
|
+
| `OURS_COWORK_DAEMON_MODE` / `_ENDPOINT` / `_STATE_DIR` | cowork's own env equivalents of its `daemon` block (read by cowork, not set by the installer) |
|
|
143
220
|
| `OURS_CHANNEL` | `nightly` or `latest`; unset follows the installer's own version |
|
|
144
221
|
|
|
222
|
+
A non-interactive run takes the shared daemon on its existing default port and installs no
|
|
223
|
+
dedicated daemon and no Rooms — the topology is unchanged from before this flow existed. It also
|
|
224
|
+
never converts an existing embedded Rooms install to an external daemon.
|
|
225
|
+
|
|
145
226
|
## Uninstall
|
|
146
227
|
|
|
147
228
|
The companion `uninstall.sh` reverses what the installers created — same thin-bootstrap +
|
|
@@ -183,10 +264,11 @@ OURS_UNINSTALL_DAEMON=yes \
|
|
|
183
264
|
- This package is **not published to npm** (`private: true`); it ships as the hosted
|
|
184
265
|
`install.sh` bootstrap plus the `install.mjs` Node installer (and its `lib/`), and exposes the
|
|
185
266
|
`ours-install` bin. The pieces it installs — the daemon (`@ours.network/mcp`), the harness
|
|
186
|
-
plugins via each marketplace, `@ours.network/fleet`,
|
|
187
|
-
published components.
|
|
267
|
+
plugins via each marketplace, `@ours.network/fleet`, `@ours.network/tg-connector`, and
|
|
268
|
+
`@ours.network/cowork` — are the published components.
|
|
188
269
|
- **Idempotent + safe to re-run.** A re-run adds a skipped piece, re-points the plugins, or (only
|
|
189
270
|
when you say yes) updates a component; an already-current daemon is left untouched, its running
|
|
190
271
|
port and complete voice setup are reused everywhere — and the Telegram connector's daemon
|
|
191
|
-
selection
|
|
192
|
-
copy-paste hand-off; provider keys
|
|
272
|
+
selection, a dedicated daemon's config, and the Rooms config are each rewritten only if they
|
|
273
|
+
actually changed. Bot tokens and fleet roles remain in the copy-paste hand-off; provider keys
|
|
274
|
+
never enter that prompt or agent chat.
|
package/install.mjs
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
// ours.network — the unified `ours-install` experience (the real UX behind install.sh's thin
|
|
3
3
|
// bootstrap, and the `ours-install` command once the stack is on the machine).
|
|
4
4
|
//
|
|
5
|
-
// ONE installer for the WHOLE stack —
|
|
6
|
-
// Codex / Hermes) + ours-fleet + the Telegram connector — for someone who
|
|
7
|
-
// Codex, and/or Hermes.
|
|
5
|
+
// ONE installer for the WHOLE stack — the shared ours daemon + the harness plugins (Claude Code /
|
|
6
|
+
// Codex / Hermes) + ours-fleet + the Telegram connector + Rooms (ours-cowork) — for someone who
|
|
7
|
+
// ALREADY has Claude, Codex, and/or Hermes.
|
|
8
|
+
//
|
|
9
|
+
// TOPOLOGY. Step 1 installs and configures ONE shared daemon and the user picks its listen port
|
|
10
|
+
// there; every consumer below is wired to that endpoint. The Telegram connector may instead be
|
|
11
|
+
// given its OWN daemon — its own port, state directory and boot unit — chosen independently, and
|
|
12
|
+
// defaulting to the shared one so Enter and non-interactive runs keep the historical topology.
|
|
13
|
+
// Rooms answers the same question, plus a third answer the connector has no use for: keeping
|
|
14
|
+
// cowork's own EMBEDDED daemon, which is what every pre-PR#9 cowork install runs (see step 5).
|
|
8
15
|
// Its whole job: install the stack cleanly, then hand back ONE copy-paste prompt the user drops
|
|
9
16
|
// into their agent to finish remaining configuration conversationally. Voice API credentials are
|
|
10
17
|
// the one guided secret flow: interactive, masked, optional, and written atomically with mode 0600.
|
|
@@ -28,15 +35,19 @@ import {
|
|
|
28
35
|
detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
|
|
29
36
|
voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
|
|
30
37
|
DEFAULT_PORT, resolveChannel, pkgSpec,
|
|
38
|
+
validateDaemonPort, planPorts, dedicatedDaemonPaths, DEDICATED_INSTANCES,
|
|
39
|
+
coworkConfigPath, planCoworkConfig, COWORK_DEFAULT_PORT, coworkDaemonMode,
|
|
40
|
+
coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
|
|
31
41
|
} from './lib/logic.mjs';
|
|
32
42
|
import { atomicWriteConfig } from './lib/config.mjs';
|
|
33
43
|
|
|
34
44
|
const NPM = process.env.OURS_NPM || 'npm';
|
|
35
|
-
// Release channel: OURS_CHANNEL=nightly installs
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
45
|
+
// Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
|
|
46
|
+
// @nightly for mcp/tg-connector/fleet/the plugin launchers, and @latest for cowork,
|
|
47
|
+
// and @next for cowork, whose repo has always called its prerelease line `next`
|
|
48
|
+
// (see PKG_CHANNEL_TAGS in lib/logic.mjs). With no explicit
|
|
49
|
+
// selection the installer follows its OWN channel, so a nightly installer builds a
|
|
50
|
+
// nightly stack instead of silently mixing tags across an architecture boundary.
|
|
40
51
|
const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
|
|
41
52
|
const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
|
|
42
53
|
let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
|
|
@@ -49,10 +60,11 @@ const line = (s = '') => sink(`${s}\n`);
|
|
|
49
60
|
const say = (s) => sink(`ours: ${s}\n`);
|
|
50
61
|
|
|
51
62
|
// --- external command helpers (never throw; the installer degrades, it doesn't crash) ----------
|
|
52
|
-
function run(bin, args, { capture = false, timeout } = {}) {
|
|
63
|
+
function run(bin, args, { capture = false, timeout, env } = {}) {
|
|
53
64
|
const r = spawnSync(bin, args, {
|
|
54
65
|
encoding: 'utf8',
|
|
55
66
|
timeout,
|
|
67
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
56
68
|
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
57
69
|
});
|
|
58
70
|
const timedOut = !!(r.error && (r.error.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'));
|
|
@@ -97,9 +109,13 @@ function daemonLifecycleState() {
|
|
|
97
109
|
return /^\s*pid:\s*\d+/m.test(status.out) ? 'managed' : 'external';
|
|
98
110
|
}
|
|
99
111
|
const daemonRunning = () => daemonLifecycleState() !== 'stopped';
|
|
112
|
+
// The installed version of a global package, INCLUDING any prerelease suffix. The
|
|
113
|
+
// suffix is not cosmetic here: the Rooms daemon guard compares against an exact
|
|
114
|
+
// `0.4.1-nightly.<date>.<sha>` floor, and truncating at the dash would make every
|
|
115
|
+
// 0.4.1 nightly look alike — including ones published before the mode existed.
|
|
100
116
|
const globalVersion = (pkg) => {
|
|
101
117
|
const ls = run(NPM, ['ls', '-g', pkg], { capture: true }).out;
|
|
102
|
-
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@([0-
|
|
118
|
+
const m = ls.match(new RegExp(pkg.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&') + '@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)'));
|
|
103
119
|
return m ? m[1] : '';
|
|
104
120
|
};
|
|
105
121
|
|
|
@@ -194,10 +210,13 @@ const USAGE = `ours-install — the unified ours.network stack installer.
|
|
|
194
210
|
|
|
195
211
|
ours-install [--dry-run] [--help] [--version]
|
|
196
212
|
|
|
197
|
-
Guided ~3-minute setup for the whole stack: ours
|
|
198
|
-
plugins (Claude Code + Codex + Hermes), ours-fleet,
|
|
199
|
-
copy-paste hand-off prompt. You approve
|
|
200
|
-
or update.
|
|
213
|
+
Guided ~3-minute setup for the whole stack: the shared ours daemon (you pick its
|
|
214
|
+
port), the harness plugins (Claude Code + Codex + Hermes), ours-fleet, the Telegram
|
|
215
|
+
connector, and Rooms (ours-cowork) — then one copy-paste hand-off prompt. You approve
|
|
216
|
+
each step; re-run any time to add a piece or update.
|
|
217
|
+
|
|
218
|
+
Telegram can share the daemon from step 1 or be given its own (its own port, state
|
|
219
|
+
directory and boot service); Enter keeps the shared one.
|
|
201
220
|
|
|
202
221
|
--dry-run walk the whole flow and print what it WOULD do — install/change nothing
|
|
203
222
|
--help show this help and exit
|
|
@@ -296,56 +315,44 @@ async function main() {
|
|
|
296
315
|
line('');
|
|
297
316
|
cont();
|
|
298
317
|
|
|
299
|
-
// ============================================================================================
|
|
300
|
-
// STEP 0 — the two config questions (asked ONCE, up front). SKIPPED entirely when a daemon is
|
|
301
|
-
// already configured (update path reuses its port/broker; delta #1859).
|
|
302
|
-
// ============================================================================================
|
|
303
318
|
const status0 = parseStatus(daemonStatusText());
|
|
304
319
|
let chosenBroker; // undefined = keep default / existing
|
|
305
320
|
let chosenPort = status0.port || DEFAULT_PORT;
|
|
306
321
|
const configFirst = !daemonInstalled;
|
|
307
322
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
// Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
|
|
321
|
-
const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
|
|
322
|
-
if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
|
|
323
|
-
else line(ok('using the standard broker.'));
|
|
324
|
-
} else {
|
|
325
|
-
if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
326
|
-
else line(ok('using the standard broker.'));
|
|
327
|
-
}
|
|
328
|
-
} else {
|
|
329
|
-
line(ok('using the standard broker.'));
|
|
330
|
-
}
|
|
323
|
+
// Every port this run has committed to, so a later daemon can't be handed one an
|
|
324
|
+
// earlier daemon claimed. A live bind probe cannot see these — nothing is
|
|
325
|
+
// listening on them yet — which is exactly why they're tracked by hand.
|
|
326
|
+
const claimedPorts = [];
|
|
327
|
+
// The finished topology, for the end-of-run cross-check. Each entry is one thing that
|
|
328
|
+
// will try to BIND a port, named so a collision can be reported in the user's terms.
|
|
329
|
+
const topology = [];
|
|
330
|
+
const claimPort = (port, label) => {
|
|
331
|
+
if (!Number.isInteger(port)) return;
|
|
332
|
+
if (!claimedPorts.includes(port)) claimedPorts.push(port);
|
|
333
|
+
if (label) topology.push({ label, port });
|
|
334
|
+
};
|
|
331
335
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
336
|
+
// Ask for ONE daemon's port, validate it, and keep asking until the answer is
|
|
337
|
+
// usable. Enter (and every non-interactive run) takes `def` unchanged — that is
|
|
338
|
+
// what keeps the historical behaviour and scripted installs identical.
|
|
339
|
+
const askDaemonPort = (prompt, def) => {
|
|
340
|
+
let candidate = def;
|
|
341
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
342
|
+
const raw = ask(` ${prompt} ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
343
|
+
const v = validateDaemonPort(raw, { fallback: candidate, isTaken: portTakenSync, taken: claimedPorts });
|
|
344
|
+
if (v.ok) return v.port;
|
|
345
|
+
line(warn(`${v.reason}.`));
|
|
346
|
+
if (!interactive) break; // no one to re-ask; fall through to a suggestion
|
|
347
|
+
candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
348
|
+
line(info(`Suggesting ${candidate} instead.`));
|
|
344
349
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
350
|
+
// Out of attempts (or headless): take the first genuinely free port rather
|
|
351
|
+
// than persisting one we know is unusable.
|
|
352
|
+
const fallback = suggestPort(candidate, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
353
|
+
line(ok(`Using port ${fallback}.`));
|
|
354
|
+
return fallback;
|
|
355
|
+
};
|
|
349
356
|
|
|
350
357
|
// Track outcomes for the summary + hand-off.
|
|
351
358
|
const summary = [];
|
|
@@ -432,13 +439,57 @@ async function main() {
|
|
|
432
439
|
};
|
|
433
440
|
|
|
434
441
|
// ============================================================================================
|
|
435
|
-
// STEP 1 /
|
|
442
|
+
// STEP 1 / 5 — the SHARED ours daemon. Its own visible step, and it owns its own configuration
|
|
443
|
+
// (broker + listen port) rather than a nameless "quick settings" preamble: every consumer below
|
|
444
|
+
// is wired to the endpoint chosen HERE, so the choice belongs to the step that makes it.
|
|
445
|
+
// Config-first within the step: choose → write config → optional voice → start ONCE.
|
|
436
446
|
// ============================================================================================
|
|
437
|
-
line(heading('1/
|
|
438
|
-
line(info('This is the piece that lets your agents talk to each other securely.
|
|
439
|
-
line(info('
|
|
447
|
+
line(heading('1/5 — the shared ours daemon'));
|
|
448
|
+
line(info('This is the piece that lets your agents talk to each other securely. The harness'));
|
|
449
|
+
line(info('plugins, ours-fleet and the Telegram connector all connect to it — Telegram can be'));
|
|
450
|
+
line(info('given its own instead, later — and so can Rooms.'));
|
|
440
451
|
const before = parseVersion(versionBefore);
|
|
441
452
|
|
|
453
|
+
if (configFirst) {
|
|
454
|
+
// Broker (owner edit #1: SECURE wording; owner edit #2: self-host → website only).
|
|
455
|
+
line('');
|
|
456
|
+
line(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
|
|
457
|
+
line(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
|
|
458
|
+
line(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
|
|
459
|
+
const custom = yes(' Use a custom broker address?', false);
|
|
460
|
+
if (custom) {
|
|
461
|
+
line(info(`(Only needed if you run your own broker. More at ${SELFHOST_URL}.)`));
|
|
462
|
+
const entered = ask(' Enter the broker address: ', '');
|
|
463
|
+
const v = validateBroker(entered);
|
|
464
|
+
if (entered && v.ok && !v.empty) {
|
|
465
|
+
// Undo safety net: a mistaken custom entry is one keystroke back to the standard broker.
|
|
466
|
+
const keep = yes(` Use "${v.value}"? (No = go back to the standard broker)`, true);
|
|
467
|
+
if (keep) { chosenBroker = v.value; line(ok(`broker set to ${chosenBroker}.`)); }
|
|
468
|
+
else line(ok('using the standard broker.'));
|
|
469
|
+
} else {
|
|
470
|
+
if (entered) line(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
471
|
+
else line(ok('using the standard broker.'));
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
line(ok('using the standard broker.'));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Listen port. ALWAYS asked now, so the shared daemon's endpoint is a decision the
|
|
478
|
+
// user makes rather than one they only hear about when 3050 happens to be busy.
|
|
479
|
+
// The default is still 3050 (the next free port when it is taken), so Enter and
|
|
480
|
+
// every non-interactive run land exactly where they always did.
|
|
481
|
+
line('');
|
|
482
|
+
line(info('The daemon listens on a local port. Everything else in this install is pointed at it.'));
|
|
483
|
+
const portDefault = portTakenSync(DEFAULT_PORT) ? suggestPort(DEFAULT_PORT + 1, portTakenSync) : DEFAULT_PORT;
|
|
484
|
+
if (portDefault !== DEFAULT_PORT) line(info(`The standard port (${DEFAULT_PORT}) is already in use on your machine.`));
|
|
485
|
+
chosenPort = askDaemonPort('Which local port should the shared daemon use?', portDefault);
|
|
486
|
+
line(ok(`Shared daemon: port ${chosenPort}, broker ${chosenBroker ? 'custom' : 'standard'}.`));
|
|
487
|
+
line('');
|
|
488
|
+
} else {
|
|
489
|
+
line(ok(`Shared daemon already configured — port ${chosenPort}. Keeping it.`));
|
|
490
|
+
}
|
|
491
|
+
claimPort(chosenPort, 'the shared ours daemon');
|
|
492
|
+
|
|
442
493
|
if (!daemonInstalled) {
|
|
443
494
|
const goCore = yes(' Install and start it?', true);
|
|
444
495
|
if (!goCore) {
|
|
@@ -628,10 +679,10 @@ async function main() {
|
|
|
628
679
|
}
|
|
629
680
|
|
|
630
681
|
// ============================================================================================
|
|
631
|
-
// STEP 2 /
|
|
682
|
+
// STEP 2 / 5 — harness plugins (Claude Code + Codex + Hermes). The installer drives the plugin
|
|
632
683
|
// CLIs for Claude/Codex; Hermes installs via npm + ours-hermes-install (no CLI driving).
|
|
633
684
|
// ============================================================================================
|
|
634
|
-
line(heading('2/
|
|
685
|
+
line(heading('2/5 — harness plugins'));
|
|
635
686
|
line(info('These teach Claude Code, Codex, and Hermes the ours skills, so you can just talk to your'));
|
|
636
687
|
line(info("agent to message people and set things up. I'll install them for you — no commands to type."));
|
|
637
688
|
for (const h of harnesses) {
|
|
@@ -644,9 +695,9 @@ async function main() {
|
|
|
644
695
|
}
|
|
645
696
|
|
|
646
697
|
// ============================================================================================
|
|
647
|
-
// STEP 3 /
|
|
698
|
+
// STEP 3 / 5 — ours-fleet. Appealing wording (owner edit #4); default YES.
|
|
648
699
|
// ============================================================================================
|
|
649
|
-
line(heading('3/
|
|
700
|
+
line(heading('3/5 — ours-fleet (your always-online agent team)'));
|
|
650
701
|
line(info('This makes your harnesses PERSISTENT: Claude Code and Codex stop being just a terminal'));
|
|
651
702
|
line(info('session and become always-online daemons that survive a reboot. Stand up your own team'));
|
|
652
703
|
line(info('of always-online developers, combine harnesses, run several Claude Codes, and link them'));
|
|
@@ -674,22 +725,82 @@ async function main() {
|
|
|
674
725
|
}
|
|
675
726
|
cont(goFleet);
|
|
676
727
|
|
|
677
|
-
//
|
|
728
|
+
// The broker the WHOLE deployment shares, whichever daemon a consumer talks to.
|
|
729
|
+
const sharedBroker = () => resolveSharedBroker({
|
|
730
|
+
chosenBroker,
|
|
731
|
+
statusBroker: status0.broker,
|
|
732
|
+
configBroker: readConfigObject().brokerUrl,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// Provision a daemon that belongs to ONE consumer: its own config file, its own
|
|
736
|
+
// state directory, its own port, and — via core's OURS_SERVICE_NAME — its own boot
|
|
737
|
+
// unit, so `install-service` cannot overwrite the shared daemon's. Returns the
|
|
738
|
+
// endpoint + state dir to wire that consumer to, and whether it came up.
|
|
739
|
+
async function provisionDedicatedDaemon({ instance, port, label }) {
|
|
740
|
+
const { stateDir, configPath: cfgPath, serviceName } = dedicatedDaemonPaths(homedir(), instance);
|
|
741
|
+
const env = { OURS_CONFIG: cfgPath, OURS_STATE_DIR: stateDir, OURS_SERVICE_NAME: serviceName };
|
|
742
|
+
// The dedicated daemon has to exist as a package before it can be started; on a
|
|
743
|
+
// fresh machine step 1 already installed it, but a re-run that skipped core has not.
|
|
744
|
+
await actSpin(`ensuring ${spec('mcp')}…`, `npm i -g ${spec('mcp')}`, () => runAsync(NPM, ['i', '-g', spec('mcp')]));
|
|
745
|
+
const desired = { port, stateDir, serviceName };
|
|
746
|
+
const broker = sharedBroker();
|
|
747
|
+
if (broker) desired.brokerUrl = broker;
|
|
748
|
+
let existing = {};
|
|
749
|
+
try { existing = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
|
|
750
|
+
const sameAlready = existing.port === port && existing.stateDir === stateDir && existing.serviceName === serviceName;
|
|
751
|
+
if (sameAlready) {
|
|
752
|
+
line(ok(`The ${label} daemon is already configured on port ${port} — no change.`));
|
|
753
|
+
// Nothing to change AND it is already up: do not touch it. `install-service` STOPS
|
|
754
|
+
// the daemon before rewriting the unit, so re-running it here would bounce a healthy
|
|
755
|
+
// daemon for no reason.
|
|
756
|
+
if (!DRY && run('ours-mcp', ['status'], { capture: true, env }).ok) {
|
|
757
|
+
line(ok(`Dedicated ${label} daemon already running on port ${port} — left alone.`));
|
|
758
|
+
return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: true, port };
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
await act(`write ${cfgPath} (dedicated ${label} daemon, port ${port}, state ${stateDir})`, async () => {
|
|
762
|
+
atomicWriteConfig(cfgPath, mergeConfig(existing, desired));
|
|
763
|
+
return { ok: true };
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const started = await act(`ours-mcp start (dedicated ${label} daemon, port ${port})`, async () => run('ours-mcp', ['start'], { env }));
|
|
767
|
+
const svc = await act(`ours-mcp install-service (dedicated ${label} daemon, unit ours-${serviceName})`, async () => run('ours-mcp', ['install-service'], { env }));
|
|
768
|
+
// Same recovery as the shared daemon: install-service STOPS the daemon before it
|
|
769
|
+
// writes the unit, so a failure there leaves nothing listening on this port.
|
|
770
|
+
let running = started.ok;
|
|
771
|
+
if (!DRY && !svc.ok) {
|
|
772
|
+
running = run('ours-mcp', ['status'], { capture: true, env }).ok;
|
|
773
|
+
if (!running) {
|
|
774
|
+
line(info(`the boot-service step stopped the ${label} daemon before it failed — restarting it.`));
|
|
775
|
+
running = run('ours-mcp', ['start'], { env }).ok;
|
|
776
|
+
}
|
|
777
|
+
line(warn(`the ${label} daemon has no boot service — retry '${c.cyan(`OURS_CONFIG=${cfgPath} OURS_SERVICE_NAME=${serviceName} ours-mcp install-service`)}'.`));
|
|
778
|
+
}
|
|
779
|
+
if (running || DRY) line(ok(`Dedicated ${label} daemon ready on port ${port} (state ${stateDir}, unit ours-${serviceName}).`));
|
|
780
|
+
else line(warn(`could not start the dedicated ${label} daemon — run '${c.cyan(`OURS_CONFIG=${cfgPath} ours-mcp start`)}'.`));
|
|
781
|
+
return { endpoint: daemonEndpoint(port), stateDir, serviceName, configPath: cfgPath, running: running || DRY, port };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Ask one consumer whether it uses the COMMON daemon or gets its own. Enter and
|
|
785
|
+
// every non-interactive run answer "common" — the historical topology.
|
|
786
|
+
const askDaemonMode = (what) => {
|
|
787
|
+
line(info(`${what} can share the daemon from step 1, or run against its own isolated one.`));
|
|
788
|
+
line(info('Sharing is right for almost everyone — press Enter. A dedicated daemon gets its own'));
|
|
789
|
+
line(info('port, state directory and boot service, and does not see the shared daemon\'s identities.'));
|
|
790
|
+
return yes(` Give ${what} its OWN dedicated daemon?`, false) ? 'dedicated' : 'common';
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
// Give the Telegram connector the daemon it was assigned: that daemon's loopback
|
|
678
794
|
// endpoint, the state directory that endpoint's API token belongs to, and — for a
|
|
679
795
|
// pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
|
|
680
796
|
// Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
|
|
681
797
|
// so the caller can warn about a service unit that froze an older selection.
|
|
682
|
-
async function writeTgDaemonConfig({
|
|
798
|
+
async function writeTgDaemonConfig({ endpoint, stateDir }) {
|
|
683
799
|
const path = tgConfigPath(process.env, homedir());
|
|
684
|
-
const stateDir = daemonStateDir();
|
|
685
800
|
const desired = {
|
|
686
|
-
daemonUrl:
|
|
801
|
+
daemonUrl: endpoint,
|
|
687
802
|
daemonStateDir: stateDir,
|
|
688
|
-
brokerUrl:
|
|
689
|
-
chosenBroker,
|
|
690
|
-
statusBroker: status0.broker,
|
|
691
|
-
configBroker: readConfigObject().brokerUrl,
|
|
692
|
-
}),
|
|
803
|
+
brokerUrl: sharedBroker(),
|
|
693
804
|
};
|
|
694
805
|
let existing = {};
|
|
695
806
|
try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
|
|
@@ -708,36 +819,52 @@ async function main() {
|
|
|
708
819
|
}
|
|
709
820
|
|
|
710
821
|
// ============================================================================================
|
|
711
|
-
// STEP 4 /
|
|
822
|
+
// STEP 4 / 5 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
|
|
712
823
|
// ============================================================================================
|
|
713
|
-
line(heading('4/
|
|
824
|
+
line(heading('4/5 — Telegram connector'));
|
|
714
825
|
line(info('This bridges a Telegram bot to your Ours node, so you can talk to your agent from'));
|
|
715
826
|
line(info("Telegram. (You'll set up the actual bot later, with your agent — not here.)"));
|
|
716
827
|
const goTg = yes(' Install it?', false);
|
|
717
828
|
if (goTg) {
|
|
718
829
|
await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
|
|
719
|
-
//
|
|
830
|
+
// WHICH daemon — asked independently of every other consumer, and answered
|
|
831
|
+
// "common" by Enter / non-interactive so the historical topology is the default.
|
|
832
|
+
line('');
|
|
833
|
+
const tgMode = askDaemonMode('the Telegram connector');
|
|
834
|
+
let tgDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir(), port: chosenPort, mode: 'common' };
|
|
835
|
+
if (tgMode === 'dedicated') {
|
|
836
|
+
const instance = DEDICATED_INSTANCES.telegram;
|
|
837
|
+
const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
838
|
+
const port = askDaemonPort('Which local port should the Telegram daemon use?', suggested);
|
|
839
|
+
claimPort(port, 'the dedicated Telegram daemon');
|
|
840
|
+
const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Telegram' });
|
|
841
|
+
tgDaemon = { ...provisioned, mode: 'dedicated' };
|
|
842
|
+
} else {
|
|
843
|
+
line(ok(`Telegram will use the shared daemon on port ${chosenPort}.`));
|
|
844
|
+
}
|
|
845
|
+
// POINT IT AT THAT DAEMON — BEFORE it is started or installed as a service.
|
|
720
846
|
// The connector never inherits ~/.ours/config.json (its SDK reports configPath:
|
|
721
847
|
// null unless told otherwise), and `install-service` bakes whatever it resolves
|
|
722
848
|
// into the unit as environment variables that outrank the file from then on. So
|
|
723
849
|
// the daemon's identity has to be in its config BEFORE either happens. See
|
|
724
850
|
// planTgDaemonConfig for why all three keys are written.
|
|
725
|
-
const tgConfigured = await writeTgDaemonConfig(
|
|
851
|
+
const tgConfigured = await writeTgDaemonConfig(tgDaemon);
|
|
852
|
+
const where = tgDaemon.mode === 'dedicated' ? `its own daemon on port ${tgDaemon.port}` : `the shared daemon on port ${tgDaemon.port}`;
|
|
726
853
|
const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
|
|
727
854
|
if (asService) {
|
|
728
855
|
const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
|
|
729
|
-
if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at
|
|
856
|
+
if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at ${where}. No problems.`));
|
|
730
857
|
else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
|
|
731
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · daemon ${
|
|
858
|
+
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
|
|
732
859
|
} else {
|
|
733
|
-
line(ok(`Telegram connector installed, pointed at
|
|
860
|
+
line(ok(`Telegram connector installed, pointed at ${where}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
|
|
734
861
|
// A connector already installed as a service froze its OLD daemon selection into
|
|
735
862
|
// the unit's environment, which outranks the file we just wrote. Config alone
|
|
736
863
|
// cannot repair that — say so plainly rather than let it look fixed.
|
|
737
864
|
if (tgConfigured.changed && tgConfigured.hadPrevious) {
|
|
738
865
|
line(warn(`if you previously ran '${c.cyan('ours-tg-connector install-service')}', re-run it — the old service froze the previous daemon selection in its unit.`));
|
|
739
866
|
}
|
|
740
|
-
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · daemon ${
|
|
867
|
+
record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · ${tgDaemon.mode} daemon ${tgDaemon.port}` });
|
|
741
868
|
}
|
|
742
869
|
} else {
|
|
743
870
|
line(info('skipped cleanly.'));
|
|
@@ -745,6 +872,146 @@ async function main() {
|
|
|
745
872
|
}
|
|
746
873
|
cont(goTg);
|
|
747
874
|
|
|
875
|
+
// ============================================================================================
|
|
876
|
+
// STEP 5 / 5 — Rooms (ours-cowork). Two independent things get configured here.
|
|
877
|
+
//
|
|
878
|
+
// ITS OWN SURFACE — the deployment broker, its private state directory, and its loopback
|
|
879
|
+
// console/REST port. Those it has always had.
|
|
880
|
+
//
|
|
881
|
+
// WHICH DAEMON — ours-cowork used to host its own, always. It now supports an EXTERNAL ours
|
|
882
|
+
// daemon (cowork PR #9), so Rooms answers the same common-vs-dedicated question the Telegram
|
|
883
|
+
// connector does, plus a third state the connector does not have: EMBEDDED, cowork's own.
|
|
884
|
+
// Contract (see logic.mjs): the `daemon` block is optional; absent means embedded; external is
|
|
885
|
+
// { mode:'external', endpoint, stateDir } and REQUIRES both halves, because cowork holds no
|
|
886
|
+
// token and its SDK reads <stateDir>/daemon-token.
|
|
887
|
+
//
|
|
888
|
+
// Boot is FAIL-CLOSED on an unreachable endpoint, a non-ours daemon, or a mismatched state
|
|
889
|
+
// directory — there is no embedded fallback. So an install that is ALREADY running embedded is
|
|
890
|
+
// never migrated behind the user's back: non-interactively it is left exactly as it is, and
|
|
891
|
+
// interactively the question is asked plainly before anything is written.
|
|
892
|
+
// ============================================================================================
|
|
893
|
+
line(heading('5/5 — Rooms (ours-cowork)'));
|
|
894
|
+
line(info('Durable mission rooms: a room keeps its own ordered history, and people and agents'));
|
|
895
|
+
line(info('join it as seats. It serves a local web console, and reaches everyone through the'));
|
|
896
|
+
line(info('same broker as the rest of your install.'));
|
|
897
|
+
const goRooms = yes(' Install it?', false);
|
|
898
|
+
if (goRooms) {
|
|
899
|
+
await actSpin(`installing ${spec('cowork')}…`, `npm i -g ${spec('cowork')}`, () => runAsync(NPM, ['i', '-g', spec('cowork')]));
|
|
900
|
+
const roomsStateDir = join(homedir(), '.ours-cowork');
|
|
901
|
+
const cfgPath = coworkConfigPath(process.env, homedir());
|
|
902
|
+
let existingRooms = {};
|
|
903
|
+
try { existingRooms = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* absent or unreadable */ }
|
|
904
|
+
const restDefault = Number.isInteger(existingRooms.rest?.port) ? existingRooms.rest.port : COWORK_DEFAULT_PORT;
|
|
905
|
+
line('');
|
|
906
|
+
line(info('Rooms serves a console on a loopback port — 127.0.0.1 only, never exposed.'));
|
|
907
|
+
// COWORK_DEFAULT_PORT is in RESERVED_PORTS (so no ours daemon can be handed it),
|
|
908
|
+
// so validate this one against the daemon ports only.
|
|
909
|
+
const roomsPort = (() => {
|
|
910
|
+
let candidate = restDefault;
|
|
911
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
912
|
+
const raw = ask(` Which local port should the Rooms console use? ${c.gray(`[Enter for ${candidate}]`)}: `, String(candidate));
|
|
913
|
+
const v = validateDaemonPort(raw, {
|
|
914
|
+
fallback: candidate, isTaken: (p) => (p === restDefault ? false : portTakenSync(p)),
|
|
915
|
+
taken: claimedPorts, reserved: [],
|
|
916
|
+
});
|
|
917
|
+
if (v.ok) return v.port;
|
|
918
|
+
line(warn(`${v.reason}.`));
|
|
919
|
+
if (!interactive) break;
|
|
920
|
+
candidate = suggestPort(v.port + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
921
|
+
line(info(`Suggesting ${candidate} instead.`));
|
|
922
|
+
}
|
|
923
|
+
return candidate;
|
|
924
|
+
})();
|
|
925
|
+
claimPort(roomsPort, 'the Rooms console');
|
|
926
|
+
|
|
927
|
+
// WHICH DAEMON. `undefined` means "leave whatever is there alone" — the answer for an
|
|
928
|
+
// existing embedded install nobody asked to migrate.
|
|
929
|
+
const wasEmbedded = coworkDaemonMode(existingRooms) === 'embedded';
|
|
930
|
+
const hadConfig = existsSync(cfgPath);
|
|
931
|
+
// Ask the BUILD, not the channel: this runs after the install above, so the version
|
|
932
|
+
// read here is the one actually on the machine.
|
|
933
|
+
const coworkVersion = globalVersion('@ours.network/cowork');
|
|
934
|
+
const externalSupported = coworkSupportsExternalDaemon(coworkVersion);
|
|
935
|
+
let roomsDaemon;
|
|
936
|
+
let roomsDaemonLabel;
|
|
937
|
+
if (!externalSupported) {
|
|
938
|
+
// The build we just installed predates cowork's external-daemon mode. Its config
|
|
939
|
+
// is a strict document and its boot fails closed, so writing a selection it cannot
|
|
940
|
+
// honour would break Rooms rather than degrade it.
|
|
941
|
+
line(info(`This Rooms build${coworkVersion ? ` (${coworkVersion})` : ''} hosts its own daemon; pointing it at the shared`));
|
|
942
|
+
line(info(`one needs ${COWORK_EXTERNAL_MIN_VERSION} or newer. Re-run with ${c.cyan('OURS_CHANNEL=nightly')} to get it.`));
|
|
943
|
+
roomsDaemonLabel = 'embedded';
|
|
944
|
+
} else if (hadConfig && wasEmbedded && !interactive) {
|
|
945
|
+
// Fail-closed boot makes this migration a real risk; never do it unasked.
|
|
946
|
+
line(info('Rooms already runs its own embedded daemon — leaving that alone.'));
|
|
947
|
+
line(info(`To point it at this install's daemon, re-run ${c.cyan('ours-install')} in a terminal.`));
|
|
948
|
+
roomsDaemonLabel = 'embedded (unchanged)';
|
|
949
|
+
} else {
|
|
950
|
+
line('');
|
|
951
|
+
const roomsMode = askDaemonMode('Rooms');
|
|
952
|
+
if (roomsMode === 'dedicated') {
|
|
953
|
+
const instance = DEDICATED_INSTANCES.rooms;
|
|
954
|
+
const suggested = suggestPort(chosenPort + 1, (p) => claimedPorts.includes(p) || portTakenSync(p));
|
|
955
|
+
const port = askDaemonPort('Which local port should the Rooms daemon use?', suggested);
|
|
956
|
+
claimPort(port, 'the dedicated Rooms daemon');
|
|
957
|
+
const provisioned = await provisionDedicatedDaemon({ instance, port, label: 'Rooms' });
|
|
958
|
+
roomsDaemon = { endpoint: provisioned.endpoint, stateDir: provisioned.stateDir };
|
|
959
|
+
roomsDaemonLabel = `dedicated daemon ${port}`;
|
|
960
|
+
} else {
|
|
961
|
+
roomsDaemon = { endpoint: daemonEndpoint(chosenPort), stateDir: daemonStateDir() };
|
|
962
|
+
roomsDaemonLabel = `common daemon ${chosenPort}`;
|
|
963
|
+
line(ok(`Rooms will use the shared daemon on port ${chosenPort}.`));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const roomsPlan = planCoworkConfig(existingRooms, {
|
|
968
|
+
brokerUrl: sharedBroker(),
|
|
969
|
+
stateDir: roomsStateDir,
|
|
970
|
+
restPort: roomsPort,
|
|
971
|
+
daemon: roomsDaemon,
|
|
972
|
+
});
|
|
973
|
+
if (roomsPlan.error) {
|
|
974
|
+
// Only reachable if a daemon selection lost half of itself; a half-written block
|
|
975
|
+
// would fail closed at cowork's boot, so refuse rather than write it.
|
|
976
|
+
line(warn(`not changing the Rooms daemon selection — ${roomsPlan.error}.`));
|
|
977
|
+
} else if (roomsPlan.changed) {
|
|
978
|
+
await act(`write ${cfgPath} (console port ${roomsPort}, state ${roomsStateDir}${roomsDaemon ? `, daemon ${roomsDaemon.endpoint}` : ''})`, async () => {
|
|
979
|
+
atomicWriteConfig(cfgPath, roomsPlan.text);
|
|
980
|
+
return { ok: true };
|
|
981
|
+
});
|
|
982
|
+
if (roomsDaemon) line(ok(`Rooms configured to use ${roomsDaemonLabel} (${roomsDaemon.endpoint}, state ${roomsDaemon.stateDir}).`));
|
|
983
|
+
} else {
|
|
984
|
+
line(ok(`Rooms is already configured for this deployment (console port ${roomsPort}) — no change.`));
|
|
985
|
+
}
|
|
986
|
+
const svc = await act('ours-cowork install-service (starts on boot)', async () => run('ours-cowork', ['install-service']));
|
|
987
|
+
if (svc.ok) {
|
|
988
|
+
line(ok(`Rooms ready — console at ${c.cyan(`http://127.0.0.1:${roomsPort}/`)}, sharing your broker. No problems.`));
|
|
989
|
+
} else {
|
|
990
|
+
line(warn(`Rooms installed, but its service didn't start — retry '${c.cyan('ours-cowork install-service')}'.`));
|
|
991
|
+
line(info(`You can also run it in the foreground: '${c.cyan('ours-cowork web')}'.`));
|
|
992
|
+
}
|
|
993
|
+
record({
|
|
994
|
+
key: 'rooms',
|
|
995
|
+
label: 'Rooms (ours-cowork)',
|
|
996
|
+
state: svc.ok ? 'installed' : 'failed',
|
|
997
|
+
version: coworkVersion,
|
|
998
|
+
note: svc.ok ? `console ${roomsPort} · ${roomsDaemonLabel}` : 'ours-cowork install-service failed',
|
|
999
|
+
});
|
|
1000
|
+
} else {
|
|
1001
|
+
line(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
1002
|
+
record({ key: 'rooms', label: 'Rooms (ours-cowork)', state: 'skipped' });
|
|
1003
|
+
}
|
|
1004
|
+
cont(goRooms);
|
|
1005
|
+
|
|
1006
|
+
// Last guard on the whole topology: no two daemons in this install may share a port.
|
|
1007
|
+
// Each answer was validated as it was given, but only the finished plan proves the set.
|
|
1008
|
+
const portPlan = planPorts(topology);
|
|
1009
|
+
if (!portPlan.ok) {
|
|
1010
|
+
for (const d of portPlan.duplicates) {
|
|
1011
|
+
line(warn(`port ${d.port} ended up claimed by both ${d.labels[0]} and ${d.labels[1]} — one of them will fail to bind.`));
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
748
1015
|
return endScreen({ ttyFd, summary, chosenPort, chosenBroker });
|
|
749
1016
|
}
|
|
750
1017
|
|
|
@@ -823,7 +1090,9 @@ function endScreen({ ttyFd, summary, chosenPort, chosenBroker }) {
|
|
|
823
1090
|
const has = (k) => summary.some((r) => r.key === k && (r.state === 'installed' || r.state === 'current'));
|
|
824
1091
|
if (has('core')) {
|
|
825
1092
|
const identityDone = has('identity');
|
|
826
|
-
const { text, empty } = buildHandoffPrompt({
|
|
1093
|
+
const { text, empty } = buildHandoffPrompt({
|
|
1094
|
+
identity: !identityDone, fleet: has('fleet'), telegram: has('telegram'), rooms: has('rooms'),
|
|
1095
|
+
});
|
|
827
1096
|
if (empty) {
|
|
828
1097
|
// Nothing left to finish (identity created in-install, no fleet/Telegram). Don't show an empty box.
|
|
829
1098
|
line('');
|
package/lib/logic.mjs
CHANGED
|
@@ -28,18 +28,61 @@ export function canonHarnesses(raw) {
|
|
|
28
28
|
|
|
29
29
|
// ── Release CHANNEL / npm dist-tag selection (owner 2026-07-17) ─────────────────
|
|
30
30
|
// The installer normally installs everything at @latest (stable). Setting
|
|
31
|
-
// OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install
|
|
32
|
-
// for the packages that
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
31
|
+
// OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install each package's
|
|
32
|
+
// PRERELEASE dist-tag instead — for the packages that publish one.
|
|
33
|
+
//
|
|
34
|
+
// The prerelease tag is NOT the same string everywhere, which is why this is a
|
|
35
|
+
// per-package map rather than one global tag:
|
|
36
|
+
// · mcp, tg-connector, claude-code, codex, hermes → `nightly` (lockstep-published
|
|
37
|
+
// from this repo by .github/workflows/scripts/bump-versions.sh)
|
|
38
|
+
// · fleet → `nightly` (its own repo,
|
|
39
|
+
// adapt-toolkit/ours-fleet, publishes a nightly dist-tag of its own)
|
|
40
|
+
// · cowork (rooms) → `nightly` (owner decision
|
|
41
|
+
// 2026-08-16: cowork aligns with every other service rather than keeping its
|
|
42
|
+
// historical `next` tag)
|
|
43
|
+
//
|
|
44
|
+
// WHY FLEET FOLLOWS THE CHANNEL NOW. It used to be pinned to @latest with the note
|
|
45
|
+
// "ours-fleet publishes no nightly tag". It does publish one, and the nightly stack
|
|
46
|
+
// needs the fleet build carrying the SDK integration — the same architecture
|
|
47
|
+
// boundary that made a mixed tg-connector fatal applies here. A nightly installer
|
|
48
|
+
// that silently installs stable fleet is exactly the split-brain deployment the
|
|
49
|
+
// channel exists to prevent.
|
|
50
|
+
//
|
|
51
|
+
// WHY COWORK STILL NEEDS ITS OWN ENTRY. It historically published its prerelease
|
|
52
|
+
// line as `next`; the owner's decision on 2026-08-16 is that it aligns with every
|
|
53
|
+
// other service and publishes `nightly` instead. The entry stays because the map,
|
|
54
|
+
// not a hardcoded string, is what makes such a change one line — and because an
|
|
55
|
+
// UNMAPPED package deliberately falls back to `latest` rather than a guessed tag.
|
|
56
|
+
//
|
|
57
|
+
// The nightly channel MUST reach cowork's prerelease line: the external-daemon
|
|
58
|
+
// mode the Rooms step configures ships there (cowork PR #9), so a nightly
|
|
59
|
+
// installer taking `latest` would pair a config carrying a `daemon` block with a
|
|
60
|
+
// build that predates it — the same architecture-boundary mismatch this whole
|
|
61
|
+
// mechanism exists to prevent, pointed at Rooms instead of Telegram.
|
|
62
|
+
//
|
|
63
|
+
// SEQUENCING — load-bearing, and not yet satisfied. As of 2026-08-16 cowork
|
|
64
|
+
// publishes NO `nightly` dist-tag at all (its tags are latest=0.4.0 and
|
|
65
|
+
// next=0.3.7-nightly.20260815.80ea770). `npm i -g @ours.network/cowork@nightly`
|
|
66
|
+
// therefore 404s today, and a 404 fails the WHOLE install. So the nightly
|
|
67
|
+
// installer must not be published until cowork's release flow has actually
|
|
68
|
+
// published a `nightly` artifact carrying PR #9. Verify the tag exists AND that
|
|
69
|
+
// the tarball contains the implementation — never trust the tag alone. See
|
|
70
|
+
// coworkSupportsExternalDaemon for the guard that keeps a build without the mode
|
|
71
|
+
// from ever being handed a daemon block.
|
|
36
72
|
export const DEFAULT_CHANNEL = 'latest';
|
|
37
73
|
|
|
38
|
-
//
|
|
39
|
-
// the
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
74
|
+
// Per-package dist-tag by channel. A package absent from this map, or missing a
|
|
75
|
+
// key for the selected channel, installs @latest — this never guesses a tag that
|
|
76
|
+
// might not exist, because a 404 fails the WHOLE install.
|
|
77
|
+
const PKG_CHANNEL_TAGS = {
|
|
78
|
+
mcp: { nightly: 'nightly' },
|
|
79
|
+
'tg-connector': { nightly: 'nightly' },
|
|
80
|
+
'claude-code': { nightly: 'nightly' },
|
|
81
|
+
codex: { nightly: 'nightly' },
|
|
82
|
+
hermes: { nightly: 'nightly' },
|
|
83
|
+
fleet: { nightly: 'nightly' },
|
|
84
|
+
cowork: { nightly: 'nightly' }, // aligned with every other service (owner, 2026-08-16)
|
|
85
|
+
};
|
|
43
86
|
|
|
44
87
|
// Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
|
|
45
88
|
// (incl. undefined/'') falls back to the installer's OWN channel — never guesses a tag.
|
|
@@ -67,14 +110,14 @@ export function isNightlyVersion(version) {
|
|
|
67
110
|
return /-nightly\.\d+/.test(String(version || ''));
|
|
68
111
|
}
|
|
69
112
|
|
|
70
|
-
// The npm dist-tag to install for one package key under a channel.
|
|
71
|
-
//
|
|
113
|
+
// The npm dist-tag to install for one package key under a channel. Looks the key
|
|
114
|
+
// up in PKG_CHANNEL_TAGS; anything unmapped — including an unknown package —
|
|
115
|
+
// falls back to 'latest' rather than inventing a tag that would 404.
|
|
72
116
|
export function pkgTag(pkgKey, channel = DEFAULT_CHANNEL) {
|
|
73
117
|
const key = String(pkgKey || '').replace(/^@ours\.network\//, '');
|
|
74
|
-
if (STABLE_ONLY_PKGS.has(key)) return 'latest';
|
|
75
118
|
const ch = resolveChannel(channel);
|
|
76
|
-
if (ch ===
|
|
77
|
-
return 'latest';
|
|
119
|
+
if (ch === DEFAULT_CHANNEL) return 'latest';
|
|
120
|
+
return PKG_CHANNEL_TAGS[key]?.[ch] ?? 'latest';
|
|
78
121
|
}
|
|
79
122
|
|
|
80
123
|
// Full `@ours.network/<key>@<tag>` spec for `npm i -g`, honoring the channel.
|
|
@@ -83,11 +126,88 @@ export function pkgSpec(pkgKey, channel = DEFAULT_CHANNEL) {
|
|
|
83
126
|
return `@ours.network/${key}@${pkgTag(key, channel)}`;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
|
-
//
|
|
87
|
-
|
|
129
|
+
// Ports other components in the stack own, which the installer must never hand a
|
|
130
|
+
// daemon: 3051 is the Telegram connector's, 3052 is ours-cowork's loopback console
|
|
131
|
+
// (its config default; see COWORK_DEFAULT_PORT).
|
|
132
|
+
export const RESERVED_PORTS = [3051, 3052];
|
|
88
133
|
export const DEFAULT_PORT = 3050;
|
|
89
134
|
export const DEFAULT_BROKER = 'wss://broker1.ours.network';
|
|
90
135
|
|
|
136
|
+
// ── Daemon topology: one COMMON daemon, plus optional DEDICATED ones ───────────
|
|
137
|
+
// Every consumer defaults to the common daemon on the common port — that is the
|
|
138
|
+
// backward-compatible answer and what Enter / non-interactive mode picks. A
|
|
139
|
+
// consumer may instead be given its OWN daemon, which needs three things to be
|
|
140
|
+
// genuinely isolated and not merely differently-addressed:
|
|
141
|
+
// · its own PORT (nothing else may bind it)
|
|
142
|
+
// · its own STATE DIRECTORY (the daemon's API token lives there; sharing one
|
|
143
|
+
// state dir between two daemons corrupts both)
|
|
144
|
+
// · its own SERVICE NAME (ours-mcp's boot unit — without a distinct name,
|
|
145
|
+
// `install-service` overwrites the common daemon's
|
|
146
|
+
// unit; see packages/core/src/service-instance.ts)
|
|
147
|
+
// The config file is separate too, since ours-mcp resolves port + stateDir from
|
|
148
|
+
// whatever OURS_CONFIG points at.
|
|
149
|
+
export const DAEMON_MODES = ['common', 'dedicated'];
|
|
150
|
+
|
|
151
|
+
// Fixed instance names, one per consumer that can own a daemon. These are what
|
|
152
|
+
// core validates and turns into `ours-<name>.service`, so they must satisfy its
|
|
153
|
+
// rules (alphanumeric, no separators at the ends).
|
|
154
|
+
export const DEDICATED_INSTANCES = { telegram: 'tg', rooms: 'rooms' };
|
|
155
|
+
|
|
156
|
+
// Where a dedicated daemon's private config + state live. Derived from the
|
|
157
|
+
// instance name so two consumers can never be handed the same directory.
|
|
158
|
+
export function dedicatedDaemonPaths(home, instance) {
|
|
159
|
+
const name = String(instance || '').trim();
|
|
160
|
+
const stateDir = `${home}/.ours-${name}`;
|
|
161
|
+
return { stateDir, configPath: `${stateDir}/config.json`, serviceName: name };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Normalize a daemon-mode answer. Anything unrecognized (including empty and
|
|
165
|
+
// non-interactive) is 'common' — the backward-compatible default.
|
|
166
|
+
export function resolveDaemonMode(raw) {
|
|
167
|
+
const v = String(raw || '').trim().toLowerCase();
|
|
168
|
+
return v === 'dedicated' || v === 'own' || v === 'separate' ? 'dedicated' : 'common';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Validate a port the user picked for a daemon, against the reserved list AND
|
|
172
|
+
// every port this install has already committed to. `isTaken(port)` probes a real
|
|
173
|
+
// bind; `taken` is the set of ports already chosen in THIS run, which a live probe
|
|
174
|
+
// cannot see (nothing is listening on them yet). Returns
|
|
175
|
+
// { ok, port, reason } — ok=false means "ask again", never "silently substitute".
|
|
176
|
+
export function validateDaemonPort(input, { fallback = DEFAULT_PORT, isTaken = () => false, taken = [], reserved = RESERVED_PORTS } = {}) {
|
|
177
|
+
// Stricter than parsePort on purpose: this answer becomes a persisted listen port,
|
|
178
|
+
// so "3.5.1" must be a question repeated, not silently accepted as port 3.
|
|
179
|
+
if (!/^\d+$/.test(String(input ?? '').trim())) {
|
|
180
|
+
return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
|
|
181
|
+
}
|
|
182
|
+
const parsed = parsePort(input, fallback);
|
|
183
|
+
if (!parsed.ok) return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
|
|
184
|
+
const port = parsed.port;
|
|
185
|
+
if (reserved.includes(port)) {
|
|
186
|
+
return { ok: false, port, reason: `port ${port} is reserved by another part of the stack` };
|
|
187
|
+
}
|
|
188
|
+
if (taken.includes(port)) {
|
|
189
|
+
return { ok: false, port, reason: `port ${port} is already being used by another daemon in this install` };
|
|
190
|
+
}
|
|
191
|
+
if (isTaken(port)) {
|
|
192
|
+
return { ok: false, port, reason: `port ${port} is already in use on this machine` };
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, port, reason: '' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The whole install's port plan, checked as a set. Returns { ok, duplicates } so
|
|
198
|
+
// the caller can refuse a topology where two daemons would fight over one port
|
|
199
|
+
// even though each looked fine on its own.
|
|
200
|
+
export function planPorts(entries = []) {
|
|
201
|
+
const seen = new Map();
|
|
202
|
+
const duplicates = [];
|
|
203
|
+
for (const { label, port } of entries) {
|
|
204
|
+
if (!Number.isInteger(port)) continue;
|
|
205
|
+
if (seen.has(port)) duplicates.push({ port, labels: [seen.get(port), label] });
|
|
206
|
+
else seen.set(port, label);
|
|
207
|
+
}
|
|
208
|
+
return { ok: duplicates.length === 0, duplicates };
|
|
209
|
+
}
|
|
210
|
+
|
|
91
211
|
// ── Handing the Telegram connector the ONE shared daemon ───────────────────────
|
|
92
212
|
// The connector has its OWN config file and never inherits the daemon's. Two
|
|
93
213
|
// generations of it are in the wild and the installer must satisfy BOTH, because
|
|
@@ -161,6 +281,186 @@ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, broker
|
|
|
161
281
|
return { changed: true, text: mergeConfig(base, next), previous };
|
|
162
282
|
}
|
|
163
283
|
|
|
284
|
+
// ── Rooms / ours-cowork ────────────────────────────────────────────────────────
|
|
285
|
+
// ours-cowork was a purely standalone daemon: its shipped 0.4.0 bundle has no
|
|
286
|
+
// daemonUrl / daemonStateDir / /api/v1 anywhere and its docs said it "has no
|
|
287
|
+
// dependency on another agent daemon". ours-cowork PR #9 (head 030b71df…) adds an
|
|
288
|
+
// EXTERNAL daemon mode, so Rooms can now answer the same common-vs-dedicated
|
|
289
|
+
// question the Telegram connector does. Its exact contract, as reported:
|
|
290
|
+
//
|
|
291
|
+
// ~/.ours-cowork/config.json carries an OPTIONAL `daemon` block.
|
|
292
|
+
// absent ⇒ EMBEDDED — cowork hosts its own daemon (what every install
|
|
293
|
+
// before PR #9 does, and still the safe answer for one already
|
|
294
|
+
// running that way).
|
|
295
|
+
// present ⇒ { mode: 'external', endpoint: 'http://127.0.0.1:<port>',
|
|
296
|
+
// stateDir: '<absolute ours-daemon state dir>' }
|
|
297
|
+
// External REQUIRES both endpoint and stateDir. cowork never stores or asks for
|
|
298
|
+
// a token — its SDK reads <stateDir>/daemon-token, which is why the state
|
|
299
|
+
// directory is part of the selection rather than derivable from the endpoint.
|
|
300
|
+
// Env equivalents: OURS_COWORK_DAEMON_MODE / _ENDPOINT / _STATE_DIR, and the
|
|
301
|
+
// service unit carries only those — never a token.
|
|
302
|
+
// Boot is FAIL-CLOSED: an unavailable endpoint, a non-ours daemon, or a
|
|
303
|
+
// stateDir that does not match it aborts startup. There is no embedded
|
|
304
|
+
// fallback, so writing this block is a real commitment and must never be done
|
|
305
|
+
// to an install that did not ask for it.
|
|
306
|
+
//
|
|
307
|
+
// NOTE the two different `stateDir` keys. The TOP-LEVEL one is cowork's own
|
|
308
|
+
// private state. `daemon.stateDir` is the OURS daemon's state directory, where
|
|
309
|
+
// that daemon's API token lives. Confusing them fails closed at boot.
|
|
310
|
+
export const COWORK_DEFAULT_PORT = 3052;
|
|
311
|
+
export const COWORK_DAEMON_MODES = ['embedded', 'external'];
|
|
312
|
+
|
|
313
|
+
// Which cowork builds understand the `daemon` block. Its config is a STRICT
|
|
314
|
+
// document, so handing an unknown key to a build that predates PR #9 is not a
|
|
315
|
+
// harmless no-op — and cowork's boot is fail-closed, so the failure surfaces as a
|
|
316
|
+
// Rooms daemon that will not start rather than a warning.
|
|
317
|
+
//
|
|
318
|
+
// The FIRST published cowork that implements the external-daemon mode. Verified
|
|
319
|
+
// against the registry rather than taken on trust:
|
|
320
|
+
// @ours.network/cowork@nightly = 0.4.1-nightly.20260816.4aaf940
|
|
321
|
+
// gitHead 4aaf9406016098704d06b52352f7a38adc2ef160
|
|
322
|
+
// dist.shasum 5a6422409b1203a9bcc6aca33965fe47e9a5c17c
|
|
323
|
+
// depends on @ours.network/sdk 1.3.1; `latest` still 0.4.0
|
|
324
|
+
// and the packed tarball really carries it — dist/daemon.js and dist/cli.js
|
|
325
|
+
// contain the mode enum ["embedded","external"], the endpoint+stateDir pairing
|
|
326
|
+
// check, OURS_COWORK_DAEMON_MODE/_ENDPOINT/_STATE_DIR, and the daemon-token read.
|
|
327
|
+
export const COWORK_EXTERNAL_MIN_VERSION = '0.4.1-nightly.20260816.4aaf940';
|
|
328
|
+
|
|
329
|
+
// Does the cowork build actually on this machine support an external daemon?
|
|
330
|
+
//
|
|
331
|
+
// This is deliberately a VERSION check and not a channel check. A channel gate
|
|
332
|
+
// would answer "yes" for any nightly install, including one made before this
|
|
333
|
+
// version was published — and a `daemon` block handed to a build without the mode
|
|
334
|
+
// meets a strict config and a fail-closed boot, i.e. Rooms that will not start.
|
|
335
|
+
// The version is read after the install, so it describes what is really there.
|
|
336
|
+
// An unreadable version yields -1 below and therefore "no", which keeps Rooms
|
|
337
|
+
// embedded rather than guessing.
|
|
338
|
+
export function coworkSupportsExternalDaemon(installedVersion = '', minVersion = COWORK_EXTERNAL_MIN_VERSION) {
|
|
339
|
+
if (!minVersion) return false; // no published build supports it yet
|
|
340
|
+
return compareVersions(String(installedVersion || ''), minVersion) >= 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Semver precedence, enough for a published-release floor: x.y.z numerically,
|
|
344
|
+
// then prerelease rules — a release outranks a prerelease of the same core
|
|
345
|
+
// version, and two prereleases compare identifier by identifier (numeric parts
|
|
346
|
+
// numerically, so nightly.20260815 < nightly.20260816). Returns -1 / 0 / 1, and
|
|
347
|
+
// -1 for anything unparseable, so garbage NEVER claims to be new enough.
|
|
348
|
+
export function compareVersions(a, b) {
|
|
349
|
+
const split = (v) => {
|
|
350
|
+
const m = String(v ?? '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
351
|
+
return m ? { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
|
|
352
|
+
};
|
|
353
|
+
const x = split(a);
|
|
354
|
+
const y = split(b);
|
|
355
|
+
if (!x || !y) return -1;
|
|
356
|
+
for (let i = 0; i < 3; i++) {
|
|
357
|
+
if (x.core[i] !== y.core[i]) return x.core[i] > y.core[i] ? 1 : -1;
|
|
358
|
+
}
|
|
359
|
+
if (x.pre === null && y.pre === null) return 0;
|
|
360
|
+
if (x.pre === null) return 1; // 1.0.0 outranks 1.0.0-nightly.1
|
|
361
|
+
if (y.pre === null) return -1;
|
|
362
|
+
const xs = x.pre.split('.');
|
|
363
|
+
const ys = y.pre.split('.');
|
|
364
|
+
for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
|
|
365
|
+
const xi = xs[i];
|
|
366
|
+
const yi = ys[i];
|
|
367
|
+
if (xi === undefined) return -1; // a shorter identifier set is lower
|
|
368
|
+
if (yi === undefined) return 1;
|
|
369
|
+
const xn = /^\d+$/.test(xi);
|
|
370
|
+
const yn = /^\d+$/.test(yi);
|
|
371
|
+
if (xn && yn) {
|
|
372
|
+
if (Number(xi) !== Number(yi)) return Number(xi) > Number(yi) ? 1 : -1;
|
|
373
|
+
} else if (xn !== yn) {
|
|
374
|
+
return xn ? -1 : 1; // numeric identifiers rank below alphanumeric
|
|
375
|
+
} else if (xi !== yi) {
|
|
376
|
+
return xi > yi ? 1 : -1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return 0;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Which daemon an existing cowork config is set up for. No block ⇒ embedded.
|
|
383
|
+
export function coworkDaemonMode(existing) {
|
|
384
|
+
const block = existing && typeof existing === 'object' ? existing.daemon : null;
|
|
385
|
+
if (!block || typeof block !== 'object') return 'embedded';
|
|
386
|
+
return block.mode === 'external' ? 'external' : 'embedded';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Build the external `daemon` block. Returns { ok, block, reason } — external is
|
|
390
|
+
// refused without BOTH halves rather than written half-formed, because a partial
|
|
391
|
+
// block fails closed at cowork's boot and the user would only find out then.
|
|
392
|
+
export function coworkDaemonBlock({ endpoint, stateDir } = {}) {
|
|
393
|
+
const e = String(endpoint || '').trim();
|
|
394
|
+
const s = String(stateDir || '').trim();
|
|
395
|
+
if (!e || !s) {
|
|
396
|
+
return { ok: false, block: null, reason: 'an external daemon needs BOTH an endpoint and its state directory' };
|
|
397
|
+
}
|
|
398
|
+
return { ok: true, block: { mode: 'external', endpoint: e, stateDir: s }, reason: '' };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The cowork config file (mirrors its docs/03-configuration.md: OURS_COWORK_CONFIG,
|
|
402
|
+
// else <home>/.ours-cowork/config.json).
|
|
403
|
+
export function coworkConfigPath(env = {}, home = '') {
|
|
404
|
+
return env.OURS_COWORK_CONFIG || `${home}/.ours-cowork/config.json`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Decide whether cowork's config needs a write, and what to write. Same contract
|
|
408
|
+
// as planTgDaemonConfig: { changed, text, previous }, unchanged ⇒ nothing written,
|
|
409
|
+
// so a re-run is a no-op. Its `rest` block is merged rather than replaced, so an
|
|
410
|
+
// operator's explicit `rest.enabled: false` survives a port update.
|
|
411
|
+
// `daemon` is three-valued on purpose:
|
|
412
|
+
// undefined — do not touch the daemon selection at all (an existing embedded
|
|
413
|
+
// install this run was not asked to migrate)
|
|
414
|
+
// null — EMBEDDED: remove any block, cowork hosts its own daemon again
|
|
415
|
+
// {endpoint, stateDir} — EXTERNAL: point it at that ours daemon
|
|
416
|
+
export function planCoworkConfig(existing, { brokerUrl, stateDir, restPort, daemon } = {}) {
|
|
417
|
+
const base = existing && typeof existing === 'object' ? existing : {};
|
|
418
|
+
const baseRest = base.rest && typeof base.rest === 'object' ? base.rest : {};
|
|
419
|
+
const previous = {
|
|
420
|
+
brokerUrl: typeof base.brokerUrl === 'string' ? base.brokerUrl : '',
|
|
421
|
+
stateDir: typeof base.stateDir === 'string' ? base.stateDir : '',
|
|
422
|
+
restPort: Number.isInteger(baseRest.port) ? baseRest.port : null,
|
|
423
|
+
daemonMode: coworkDaemonMode(base),
|
|
424
|
+
daemonEndpoint: base.daemon?.endpoint ?? '',
|
|
425
|
+
daemonStateDir: base.daemon?.stateDir ?? '',
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
let nextDaemon; // undefined ⇒ leave the block alone
|
|
429
|
+
let daemonChanged = false;
|
|
430
|
+
if (daemon === null) {
|
|
431
|
+
nextDaemon = null;
|
|
432
|
+
daemonChanged = previous.daemonMode !== 'embedded';
|
|
433
|
+
} else if (daemon !== undefined) {
|
|
434
|
+
const built = coworkDaemonBlock(daemon);
|
|
435
|
+
if (!built.ok) return { changed: false, text: '', previous, error: built.reason };
|
|
436
|
+
nextDaemon = built.block;
|
|
437
|
+
daemonChanged = previous.daemonMode !== 'external'
|
|
438
|
+
|| previous.daemonEndpoint !== built.block.endpoint
|
|
439
|
+
|| previous.daemonStateDir !== built.block.stateDir;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const changed = daemonChanged
|
|
443
|
+
|| (brokerUrl !== undefined && previous.brokerUrl !== brokerUrl)
|
|
444
|
+
|| (stateDir !== undefined && previous.stateDir !== stateDir)
|
|
445
|
+
|| (restPort !== undefined && previous.restPort !== restPort);
|
|
446
|
+
if (!changed) return { changed: false, text: '', previous };
|
|
447
|
+
|
|
448
|
+
const patch = { version: 1 };
|
|
449
|
+
if (brokerUrl !== undefined) patch.brokerUrl = brokerUrl;
|
|
450
|
+
if (stateDir !== undefined) patch.stateDir = stateDir;
|
|
451
|
+
if (restPort !== undefined) patch.rest = { ...baseRest, enabled: baseRest.enabled ?? true, port: restPort };
|
|
452
|
+
if (nextDaemon !== undefined) patch.daemon = nextDaemon;
|
|
453
|
+
const merged = mergeConfig(base, patch);
|
|
454
|
+
// mergeConfig keeps every key it is handed; an EMBEDDED selection has to drop
|
|
455
|
+
// the block outright, since `daemon: null` is not the same as no block.
|
|
456
|
+
if (nextDaemon === null) {
|
|
457
|
+
const obj = JSON.parse(merged);
|
|
458
|
+
delete obj.daemon;
|
|
459
|
+
return { changed: true, text: JSON.stringify(obj, null, 2) + '\n', previous };
|
|
460
|
+
}
|
|
461
|
+
return { changed: true, text: merged, previous };
|
|
462
|
+
}
|
|
463
|
+
|
|
164
464
|
// suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
|
|
165
465
|
// scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
|
|
166
466
|
// port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
|
|
@@ -365,7 +665,7 @@ export function harnessAvailable(status) { return status === 'ok'; }
|
|
|
365
665
|
// instruction for a piece they don't have. The human identity is normally created DURING install,
|
|
366
666
|
// so its step is included ONLY as a fallback (identity: true) when in-install creation was skipped
|
|
367
667
|
// or failed. Returns { text, empty } — empty is true when there is nothing left to finish.
|
|
368
|
-
export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false } = {}) {
|
|
668
|
+
export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false, rooms = false } = {}) {
|
|
369
669
|
const steps = [];
|
|
370
670
|
if (identity) {
|
|
371
671
|
steps.push(
|
|
@@ -387,6 +687,13 @@ export function buildHandoffPrompt({ identity = false, fleet = false, telegram =
|
|
|
387
687
|
' give me the invite link to send.',
|
|
388
688
|
);
|
|
389
689
|
}
|
|
690
|
+
if (rooms) {
|
|
691
|
+
steps.push(
|
|
692
|
+
'Set up my first Rooms mission room: ask me what the room is for and what\n' +
|
|
693
|
+
' to call it, create it, then walk me through inviting the people and\n' +
|
|
694
|
+
' agents who should have a seat.',
|
|
695
|
+
);
|
|
696
|
+
}
|
|
390
697
|
if (steps.length === 0) return { text: '', empty: true };
|
|
391
698
|
const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
|
|
392
699
|
const text =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.17.0-nightly.
|
|
3
|
+
"version": "0.17.0-nightly.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
|
|
6
6
|
"type": "module",
|