@ours.network/install 0.17.0-nightly.9 → 0.17.1

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/install.sh CHANGED
@@ -21,6 +21,9 @@
21
21
  # From a CLONE (a sibling install.mjs is present) it runs that directly. Piped as `curl … | bash`
22
22
  # it installs the published command globally — `npm i -g @ours.network/install` — then runs
23
23
  # `ours-install`. Idempotent: a re-run updates to @latest and runs again.
24
+ # The bootstrap is deliberately stable. For nightly, install the published nightly command
25
+ # directly: `npm i -g @ours.network/install@nightly && ours-install`; that package's own
26
+ # X.Y.Z-nightly.N version selects and exactly resolves the matching stack channel.
24
27
  #
25
28
  # Non-interactive env overrides (all optional) — consumed by the Node installer:
26
29
  # OURS_ASSUME_YES=1 accept every default; never prompt (no tty needed)
package/lib/logic.mjs CHANGED
@@ -28,96 +28,42 @@ 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 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.
31
+ // OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install the NIGHTLY tag
32
+ // for the packages that HAVE a nightly (mcp, tg-connector, and the harness-plugin
33
+ // launchers claude-code/codex/hermes — all lockstep-published to the `nightly` tag),
34
+ // but keep @ours.network/fleet at @latest ALWAYS: ours-fleet lives in its own repo
35
+ // and publishes NO nightly tag, so `@nightly` there would 404 the whole install.
72
36
  export const DEFAULT_CHANNEL = 'latest';
73
37
 
74
- // Per-package dist-tag by channel. A package absent from this map, or missing a
75
- // key for the selected channel, installs @latestthis 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
- };
38
+ // Packages that follow the selected channel (nightly @nightly). Short keys map to
39
+ // the @ours.network/<key> npm name. NOTE fleet is deliberately ABSENT it is pinned.
40
+ const CHANNEL_TRACKING_PKGS = new Set(['mcp', 'tg-connector', 'claude-code', 'codex', 'hermes']);
41
+ // Packages ALWAYS pinned to @latest regardless of channel (no nightly tag exists).
42
+ const STABLE_ONLY_PKGS = new Set(['fleet']);
86
43
 
87
44
  // Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
88
- // (incl. undefined/'') falls back to the installer's OWN channel — never guesses a tag.
89
- //
90
- // WHY THE INSTALLER'S OWN VERSION IS A CHANNEL SIGNAL. `@ours.network/install` is
91
- // published on both dist-tags from the same lockstep bump (.github/workflows/scripts/
92
- // bump-versions.sh), so a nightly build carries a `-nightly.N` version and a stable
93
- // build does not. Without this, `npm i -g @ours.network/install@nightly && ours-install`
94
- // installed the nightly INSTALLER but @latest for everything it installs — which since
95
- // tg-connector 0.3.3-nightly.1 is not merely older but a DIFFERENT ARCHITECTURE (0.3.2
96
- // hosts its own ADAPT wrapper; the nightly attaches to the shared daemon over /api/v1,
97
- // which only the SDK-based daemon serves). Mixing the two tags across that boundary is
98
- // exactly the split-brain deployment this must not produce. A stable installer stays on
99
- // @latest and can never consume a nightly; an explicit OURS_CHANNEL always wins over both.
45
+ // (incl. undefined/'') falls back to the safe default 'latest' — never guesses a tag.
100
46
  export function resolveChannel(raw, selfVersion = '') {
101
47
  const v = String(raw || '').trim().toLowerCase();
102
48
  if (v === 'nightly' || v === 'prerelease' || v === 'next') return 'nightly';
103
49
  if (v === 'latest' || v === 'stable') return DEFAULT_CHANNEL;
104
- if (v) return DEFAULT_CHANNEL; // unrecognized: never guess, never inherit
50
+ if (v) return DEFAULT_CHANNEL; // unknown explicit values never inherit a prerelease
105
51
  return isNightlyVersion(selfVersion) ? 'nightly' : DEFAULT_CHANNEL;
106
52
  }
107
53
 
108
- // A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
54
+ // Nightly packages are stamped by the release workflow as X.Y.Z-nightly.N.
109
55
  export function isNightlyVersion(version) {
110
- return /-nightly\.\d+/.test(String(version || ''));
56
+ return /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-nightly\.(?:0|[1-9]\d*)$/.test(String(version || ''));
111
57
  }
112
58
 
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.
59
+ // The npm dist-tag to install for one package key under a channel. fleet is ALWAYS
60
+ // 'latest'; channel-tracking packages take the channel; anything else defaults to 'latest'.
116
61
  export function pkgTag(pkgKey, channel = DEFAULT_CHANNEL) {
117
62
  const key = String(pkgKey || '').replace(/^@ours\.network\//, '');
63
+ if (STABLE_ONLY_PKGS.has(key)) return 'latest';
118
64
  const ch = resolveChannel(channel);
119
- if (ch === DEFAULT_CHANNEL) return 'latest';
120
- return PKG_CHANNEL_TAGS[key]?.[ch] ?? 'latest';
65
+ if (ch === 'nightly' && CHANNEL_TRACKING_PKGS.has(key)) return 'nightly';
66
+ return 'latest';
121
67
  }
122
68
 
123
69
  // Full `@ours.network/<key>@<tag>` spec for `npm i -g`, honoring the channel.
@@ -126,341 +72,11 @@ export function pkgSpec(pkgKey, channel = DEFAULT_CHANNEL) {
126
72
  return `@ours.network/${key}@${pkgTag(key, channel)}`;
127
73
  }
128
74
 
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];
75
+ // The Telegram connector owns 3051 the installer must never hand a daemon that port.
76
+ export const RESERVED_PORTS = [3051];
133
77
  export const DEFAULT_PORT = 3050;
134
78
  export const DEFAULT_BROKER = 'wss://broker1.ours.network';
135
79
 
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
-
211
- // ── Handing the Telegram connector the ONE shared daemon ───────────────────────
212
- // The connector has its OWN config file and never inherits the daemon's. Two
213
- // generations of it are in the wild and the installer must satisfy BOTH, because
214
- // which one gets installed is a channel decision (see pkgTag):
215
- //
216
- // @nightly (>=0.3.3-nightly.1) — SDK-based. It ATTACHES to an already-running
217
- // ours daemon over /api/v1 and has NO broker at all. It selects the daemon
218
- // through @ours.network/sdk's resolveDaemonConfig, whose behaviour we verified
219
- // against the published SDK 0.1.2 (the version the nightly pins):
220
- // · with no overrides it reports configPath: null — the daemon's
221
- // ~/.ours/config.json is NEVER read implicitly, so a daemon on any port
222
- // other than the built-in 3050 is simply MISSED.
223
- // · pointing OURS_CONFIG at that file, or passing an endpoint alone, throws
224
- // INCOHERENT_SELECTION: "the endpoint was selected … but the state
225
- // directory is the built-in default" — a deliberate credential-disclosure
226
- // guard that refuses BEFORE any token is read.
227
- // · endpoint AND state dir together resolve cleanly.
228
- // So the ONLY correct contract is to give it BOTH daemonUrl and daemonStateDir.
229
- //
230
- // @latest (<=0.3.2) — the older self-hosting ADAPT wrapper. It ignores
231
- // daemonUrl/daemonStateDir and meets the daemon at a BROKER instead, so its
232
- // brokerUrl must match the daemon's or the two can never see each other.
233
- //
234
- // Writing all three keys satisfies whichever generation is installed: each reads
235
- // only the keys it understands and ignores the rest. This matters at INSTALL time
236
- // specifically, because `ours-tg-connector install-service` BAKES its resolved
237
- // values into the service unit as environment variables, and env outranks the
238
- // config file forever after — a divergence created here can never be repaired by
239
- // editing that file.
240
-
241
- // The daemon's loopback endpoint. The daemon always binds 127.0.0.1 (never a
242
- // hostname), so this is the address the connector must be given.
243
- export function daemonEndpoint(port) {
244
- return `http://127.0.0.1:${port}`;
245
- }
246
-
247
- // The broker the whole deployment shares, for a <=0.3.2 connector.
248
- // Precedence: what the user chose in THIS run > what the running daemon actually
249
- // resolved (`ours-mcp status`, which already accounts for OURS_BROKER_URL) > what
250
- // the daemon's config file says > the built-in default (identical to the daemon's
251
- // DEFAULT_CONFIG.brokerUrl, so "no answer anywhere" still agrees).
252
- export function resolveSharedBroker({ chosenBroker, statusBroker, configBroker } = {}) {
253
- for (const candidate of [chosenBroker, statusBroker, configBroker]) {
254
- const v = validateBroker(candidate ?? '');
255
- if (!v.empty && v.ok) return v.value;
256
- }
257
- return DEFAULT_BROKER;
258
- }
259
-
260
- // The Telegram connector's own config file (mirrors its src/config.ts: OURS_TG_CONFIG,
261
- // else <home>/.ours-telegram/config.json — a FIXED location, independent of its stateDir).
262
- export function tgConfigPath(env = {}, home = '') {
263
- return env.OURS_TG_CONFIG || `${home}/.ours-telegram/config.json`;
264
- }
265
-
266
- // Decide whether the connector's config needs a write, and what to write. Returns
267
- // { changed, text, previous } — `changed` is false when the file already names this
268
- // exact daemon and broker, so an idempotent re-run touches nothing. Unrelated keys
269
- // the user or the connector added (bot tokens, STT settings) are preserved.
270
- export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, brokerUrl } = {}) {
271
- const base = existing && typeof existing === 'object' ? existing : {};
272
- const str = (v) => (typeof v === 'string' ? v : '');
273
- const previous = {
274
- daemonUrl: str(base.daemonUrl),
275
- daemonStateDir: str(base.daemonStateDir),
276
- brokerUrl: str(base.brokerUrl),
277
- };
278
- const next = { daemonUrl, daemonStateDir, brokerUrl };
279
- const changed = Object.entries(next).some(([k, v]) => v !== undefined && previous[k] !== v);
280
- if (!changed) return { changed: false, text: '', previous };
281
- return { changed: true, text: mergeConfig(base, next), previous };
282
- }
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
-
464
80
  // suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
465
81
  // scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
466
82
  // port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
@@ -585,10 +201,10 @@ export function redactSensitive(text, secrets = []) {
585
201
  .replace(/((?:api[_ -]?key|token)\s*[=:]\s*)\S+/gi, '$1[redacted]');
586
202
  }
587
203
 
588
- // parseVersion: pull the first x.y.z out of a version string (e.g. `ours-mcp v0.9.9`), matching
589
- // install.sh's `grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1`. Returns '' when none is present.
204
+ // parseVersion: pull the first stable or release-workflow nightly version from a CLI line.
205
+ // Keeping -nightly.N makes a stable↔nightly channel switch visible to restart detection.
590
206
  export function parseVersion(text) {
591
- const m = String(text || '').match(/[0-9]+\.[0-9]+\.[0-9]+/);
207
+ const m = String(text || '').match(/[0-9]+\.[0-9]+\.[0-9]+(?:-nightly\.[0-9]+)?/);
592
208
  return m ? m[0] : '';
593
209
  }
594
210
 
@@ -665,7 +281,7 @@ export function harnessAvailable(status) { return status === 'ok'; }
665
281
  // instruction for a piece they don't have. The human identity is normally created DURING install,
666
282
  // so its step is included ONLY as a fallback (identity: true) when in-install creation was skipped
667
283
  // or failed. Returns { text, empty } — empty is true when there is nothing left to finish.
668
- export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false, rooms = false } = {}) {
284
+ export function buildHandoffPrompt({ identity = false, fleet = false, telegram = false } = {}) {
669
285
  const steps = [];
670
286
  if (identity) {
671
287
  steps.push(
@@ -687,13 +303,6 @@ export function buildHandoffPrompt({ identity = false, fleet = false, telegram =
687
303
  ' give me the invite link to send.',
688
304
  );
689
305
  }
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
- }
697
306
  if (steps.length === 0) return { text: '', empty: true };
698
307
  const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n');
699
308
  const text =
@@ -0,0 +1,104 @@
1
+ import { join } from 'node:path';
2
+
3
+ const NUM = '(?:0|[1-9]\\d*)';
4
+ const STABLE_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}$`);
5
+ const NIGHTLY_VERSION = new RegExp(`^${NUM}\\.${NUM}\\.${NUM}-nightly\\.${NUM}$`);
6
+
7
+ // npm view --json normally returns a JSON string, while older/custom npm wrappers may
8
+ // return the plain version. Accept exactly one scalar either way; arrays/objects are not
9
+ // a deliberate dist-tag resolution and therefore fail closed.
10
+ export function parseNpmVersion(text) {
11
+ const raw = String(text ?? '').trim();
12
+ if (!raw) return '';
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ return typeof parsed === 'string' ? parsed.trim() : '';
16
+ } catch {
17
+ return /^\S+$/.test(raw) ? raw : '';
18
+ }
19
+ }
20
+
21
+ export function validateChannelVersion(version, channel) {
22
+ const value = String(version ?? '').trim();
23
+ const selected = channel === 'nightly' ? 'nightly' : 'latest';
24
+ const valid = selected === 'nightly'
25
+ ? NIGHTLY_VERSION.test(value)
26
+ : STABLE_VERSION.test(value);
27
+ return valid
28
+ ? { ok: true, version: value, channel: selected }
29
+ : {
30
+ ok: false,
31
+ version: value,
32
+ channel: selected,
33
+ reason: selected === 'nightly'
34
+ ? `expected an exact X.Y.Z-nightly.N version, got ${value || '<empty>'}`
35
+ : `expected an exact stable X.Y.Z version, got ${value || '<empty>'}`,
36
+ };
37
+ }
38
+
39
+ function exactVersion(version, channel) {
40
+ const checked = validateChannelVersion(version, channel);
41
+ if (!checked.ok) throw new Error(checked.reason);
42
+ return checked.version;
43
+ }
44
+
45
+ export function buildClaudeMarketplace(version, channel) {
46
+ const pinned = exactVersion(version, channel);
47
+ return {
48
+ $schema: 'https://json.schemastore.org/claude-code-marketplace.json',
49
+ name: 'ours.network',
50
+ owner: {
51
+ name: 'Adapt Toolkit',
52
+ url: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
53
+ },
54
+ plugins: [{
55
+ name: 'ours',
56
+ displayName: 'ours',
57
+ description: 'Secure agent-to-agent communication channel over ADAPT: self-sovereign pubkey identity, end-to-end encryption.',
58
+ author: { name: 'Adapt Toolkit' },
59
+ homepage: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
60
+ repository: 'https://github.com/adapt-toolkit/ours-claude-marketplace',
61
+ keywords: ['mcp', 'a2a', 'adapt', 'e2e', 'messaging'],
62
+ source: {
63
+ source: 'npm',
64
+ package: '@ours.network/claude-code',
65
+ version: pinned,
66
+ },
67
+ }],
68
+ };
69
+ }
70
+
71
+ export function buildCodexMarketplace(version, channel) {
72
+ const pinned = exactVersion(version, channel);
73
+ return {
74
+ name: 'ours-codex-marketplace',
75
+ interface: { displayName: 'ours.network for Codex' },
76
+ plugins: [{
77
+ name: 'ours',
78
+ source: {
79
+ source: 'npm',
80
+ package: '@ours.network/codex',
81
+ version: pinned,
82
+ registry: 'https://registry.npmjs.org',
83
+ },
84
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
85
+ category: 'Productivity',
86
+ }],
87
+ };
88
+ }
89
+
90
+ export function marketplacePaths(home) {
91
+ const root = join(home, '.ours', 'install', 'marketplaces');
92
+ const claudeRoot = join(root, 'claude-code');
93
+ const codexRoot = join(root, 'codex');
94
+ return {
95
+ claudeRoot,
96
+ claudeManifest: join(claudeRoot, '.claude-plugin', 'marketplace.json'),
97
+ codexRoot,
98
+ codexManifest: join(codexRoot, '.agents', 'plugins', 'marketplace.json'),
99
+ };
100
+ }
101
+
102
+ export function marketplaceJson(value) {
103
+ return JSON.stringify(value, null, 2) + '\n';
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.9",
3
+ "version": "0.17.1",
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",