@ours.network/install 0.16.0 → 0.17.0-nightly.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,8 +64,8 @@ dependency on the things it installs): an ASCII banner, tasteful colour (degrade
64
64
  prints the exact manual commands and continues — it **never dead-ends**.
65
65
  - **3/4 ours-fleet** — makes your harnesses persistent, always-online agent teams that survive
66
66
  a reboot; runs `ours-fleet init`. Default **Yes**.
67
- - **4/4 Telegram connector** — install-only (no bot tokens here), then optionally as a
68
- boot service.
67
+ - **4/4 Telegram connector** — install-only (no bot tokens here), **pointed at the daemon
68
+ step 1 just built**, then optionally as a boot service.
69
69
  4. **Summary + hand-off** — a recap (skipped/failed rows call out the fix), then a **literal
70
70
  copy-paste prompt** (root identity + fleet + Telegram) with the steps for any skipped/failed
71
71
  component dropped out. Copied to the clipboard where supported.
@@ -74,6 +74,48 @@ The human identity is created idempotently after the daemon becomes reachable. B
74
74
  `curl … | bash` gives the script its input over the pipe, every prompt is read from the
75
75
  controlling terminal (`/dev/tty`), so the flow still works piped.
76
76
 
77
+ ## One daemon, and everything pointed at it
78
+
79
+ A clean deployment installs, configures and starts **one** ours daemon first, then wires every
80
+ client to that same daemon. What that takes differs per client:
81
+
82
+ - **The harness plugins** need nothing extra: each is `ours-mcp proxy`, which reads the daemon's
83
+ own config (`OURS_CONFIG`, else `~/.ours/config.json`). `autoStart` is off by default, so a proxy
84
+ whose daemon is down reports it rather than quietly starting a second one.
85
+ - **The Telegram connector keeps its own config file** and never inherits the daemon's, so the
86
+ installer writes `~/.ours-telegram/config.json` (honouring `OURS_TG_CONFIG`) **before** the
87
+ connector is started or installed as a service — `install-service` bakes whatever it resolves
88
+ into the service unit as environment variables, and those outrank the file from then on.
89
+ Three keys are written, so whichever connector generation is installed finds what it reads:
90
+ - `daemonUrl` + `daemonStateDir` — for `>=0.3.3-nightly.1`, which attaches to the running daemon
91
+ over `/api/v1`. **Both** are required: with neither, its SDK never reads `~/.ours/config.json`
92
+ and falls back to the built-in `127.0.0.1:3050`, missing a daemon on any other port; with the
93
+ endpoint alone it refuses outright (`INCOHERENT_SELECTION` — the daemon's API token belongs to
94
+ a state directory, so selecting an endpoint without one would disclose that token).
95
+ - `brokerUrl` — for `<=0.3.2`, which hosts its own ADAPT wrapper and meets the daemon at a broker
96
+ instead. It must match the daemon's or the two can never see each other.
97
+
98
+ A re-run that changes nothing rewrites nothing, and keys the installer does not own are preserved.
99
+
100
+ If `ours-mcp install-service` fails (no systemd user bus, no linger, a container, WSL without
101
+ systemd) it has already **stopped** the daemon it was about to supervise. The installer restarts it
102
+ and says plainly that the boot service is missing — a clean deployment never ends with no daemon
103
+ while the summary claims success.
104
+
105
+ ## Release channel
106
+
107
+ `OURS_CHANNEL=nightly` (or `OURS_INSTALL_CHANNEL`) installs the `nightly` dist-tag for the packages
108
+ that publish one — `mcp`, `tg-connector`, and the `claude-code` / `codex` / `hermes` launchers —
109
+ while `@ours.network/fleet` stays `@latest` always (it publishes no nightly).
110
+
111
+ **With no explicit selection the installer follows its own version.** A published nightly build
112
+ carries the `-nightly.N` suffix the release bump stamps, so `npm i -g @ours.network/install@nightly`
113
+ builds a nightly stack, and a stable installer can never consume a nightly. This is load-bearing
114
+ rather than cosmetic: across `tg-connector` 0.3.2 → 0.3.3-nightly.1 the connector stopped hosting
115
+ its own ADAPT wrapper and became a client of the shared daemon, so mixing tags across that boundary
116
+ pairs a connector that needs `/api/v1` with a daemon that does not serve it. `OURS_CHANNEL` still
117
+ overrides in both directions.
118
+
77
119
  ## Non-interactive / CI / safe dry-run
78
120
 
79
121
  ```sh
@@ -96,6 +138,9 @@ is reported and left unchanged.
96
138
  | `OURS_INSTALL_DRY_RUN` | walk the flow without installing or changing anything |
97
139
  | `OURS_NPM` | npm binary to use (default `npm`) |
98
140
  | `OURS_CONFIG` | daemon config file location (default `~/.ours/config.json`) |
141
+ | `OURS_STATE_DIR` | daemon state directory (default `~/.ours`) — also what the Telegram connector is told to expect |
142
+ | `OURS_TG_CONFIG` | Telegram connector config file location (default `~/.ours-telegram/config.json`) |
143
+ | `OURS_CHANNEL` | `nightly` or `latest`; unset follows the installer's own version |
99
144
 
100
145
  ## Uninstall
101
146
 
@@ -142,5 +187,6 @@ OURS_UNINSTALL_DAEMON=yes \
142
187
  published components.
143
188
  - **Idempotent + safe to re-run.** A re-run adds a skipped piece, re-points the plugins, or (only
144
189
  when you say yes) updates a component; an already-current daemon is left untouched, its running
145
- port and complete voice setup are reused everywhere. Bot tokens and fleet roles remain in the
190
+ port and complete voice setup are reused everywhere and the Telegram connector's daemon
191
+ selection is rewritten only if it actually changed. Bot tokens and fleet roles remain in the
146
192
  copy-paste hand-off; provider keys never enter that prompt or agent chat.
package/install.mjs CHANGED
@@ -20,21 +20,24 @@
20
20
  import { spawn, spawnSync } from 'node:child_process';
21
21
  import { readFileSync, existsSync } from 'node:fs';
22
22
  import { homedir, userInfo, platform as osPlatform, release as osRelease } from 'node:os';
23
- import { join } from 'node:path';
23
+ import { join, resolve } from 'node:path';
24
24
  import { banner, heading, ok, info, warn, c, box, withSpinner, openTty, makeWriter, closeSync } from './lib/ui.mjs';
25
25
  import { askLine, askYesNo, isCancel } from './lib/prompt.mjs';
26
26
  import {
27
27
  suggestPort, parsePort, validateBroker, mergeConfig, parseVersion, parseStatus,
28
28
  detectPlatform, classifyHarnessProbe, buildHandoffPrompt,
29
- voiceSetupStatus,
29
+ voiceSetupStatus, resolveSharedBroker, tgConfigPath, planTgDaemonConfig, daemonEndpoint,
30
30
  DEFAULT_PORT, resolveChannel, pkgSpec,
31
31
  } from './lib/logic.mjs';
32
32
  import { atomicWriteConfig } from './lib/config.mjs';
33
33
 
34
34
  const NPM = process.env.OURS_NPM || 'npm';
35
35
  // Release channel: OURS_CHANNEL=nightly installs @nightly for mcp/tg-connector/plugin
36
- // launchers but keeps @ours.network/fleet at @latest (fleet has no nightly). Default: latest.
37
- const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL);
36
+ // launchers but keeps @ours.network/fleet at @latest (fleet has no nightly). With no
37
+ // explicit selection the installer follows its OWN channel, so a nightly installer
38
+ // builds a nightly stack instead of silently mixing tags across the connector's
39
+ // architecture boundary (see resolveChannel).
40
+ const CHANNEL = resolveChannel(process.env.OURS_CHANNEL || process.env.OURS_INSTALL_CHANNEL, pkgVersion());
38
41
  const spec = (pkgKey) => pkgSpec(pkgKey, CHANNEL); // → "@ours.network/<key>@<tag>"
39
42
  let DRY = !!process.env.OURS_INSTALL_DRY_RUN;
40
43
  const SELFHOST_URL = 'ours.network';
@@ -109,6 +112,18 @@ function writeConfigPatch(patch) {
109
112
  return p;
110
113
  }
111
114
 
115
+ // The daemon's state directory, resolved exactly the way packages/core/src/config.ts
116
+ // resolves it (env > config file > ~/.ours). The Telegram connector needs the ABSOLUTE
117
+ // path: the SDK reads the daemon's API token from it and refuses to send that token to
118
+ // a separately-chosen endpoint unless the state dir was chosen just as deliberately.
119
+ function daemonStateDir() {
120
+ const fromEnv = process.env.OURS_STATE_DIR?.trim();
121
+ if (fromEnv) return resolve(fromEnv);
122
+ const fromFile = readConfigObject().stateDir;
123
+ if (typeof fromFile === 'string' && fromFile.trim()) return resolve(fromFile.trim());
124
+ return join(homedir(), '.ours');
125
+ }
126
+
112
127
  function daemonVoiceCapability() {
113
128
  const r = run('ours-mcp', ['voice-status', '--json'], { capture: true, timeout: 6000 });
114
129
  if (!r.ok) return null;
@@ -439,10 +454,24 @@ async function main() {
439
454
  const voice = offerVoiceSetup({ readinessAfterStart: true });
440
455
  const started = await act(`ours-mcp start (port ${chosenPort})`, async () => run('ours-mcp', ['start']));
441
456
  const svc = await act('ours-mcp install-service (survives reboot)', async () => run('ours-mcp', ['install-service']));
442
- if (started.ok) line(ok(`ours core ready running on port ${chosenPort}. No problems.`));
457
+ // `ours-mcp install-service` STOPS the daemon before it writes the unit (core's
458
+ // cmdInstallService), then exits non-zero if `systemctl --user enable --now` fails —
459
+ // no linger, no user bus, a container, WSL without systemd. Left alone that turns a
460
+ // WORKING daemon into no daemon at all, while the line below still said "ready": the
461
+ // human identity and every MCP client then fail against a port nothing is listening
462
+ // on. Put the one shared daemon back up and report what is actually true.
463
+ let running = started.ok;
464
+ if (!DRY && !svc.ok) {
465
+ running = daemonRunning();
466
+ if (!running) {
467
+ line(info('the boot-service step stopped the daemon before it failed — restarting it.'));
468
+ running = run('ours-mcp', ['start']).ok || daemonRunning();
469
+ }
470
+ }
471
+ if (running) line(ok(`ours core ready — running on port ${chosenPort}. No problems.`));
443
472
  else line(warn(`could not auto-start — run '${c.cyan('ours-mcp start')}' to bring it up.`));
444
473
  if (!svc.ok && !svc.dry) line(warn(`boot-service not installed — retry '${c.cyan('ours-mcp install-service')}' later.`));
445
- if (voice.setupRan && !DRY && started.ok) {
474
+ if (voice.setupRan && !DRY && running) {
446
475
  const verified = daemonVoiceCapability();
447
476
  if (verified?.ready) {
448
477
  line(ok(`Voice transcription readiness confirmed (${verified.provider}) after the first start.`));
@@ -455,7 +484,13 @@ async function main() {
455
484
  }
456
485
  }
457
486
  }
458
- record({ key: 'core', label: 'ours core (daemon)', state: started.ok ? 'installed' : 'failed', version: parseVersion(daemonVersionLine()), note: 'starts on boot' });
487
+ record({
488
+ key: 'core',
489
+ label: 'ours core (daemon)',
490
+ state: running ? 'installed' : 'failed',
491
+ version: parseVersion(daemonVersionLine()),
492
+ note: svc.ok ? 'starts on boot' : 'running; no boot service',
493
+ });
459
494
  } else {
460
495
  // Installed: offer an update; never re-ask config; reuse the running port everywhere.
461
496
  const daemonState = daemonLifecycleState();
@@ -639,6 +674,39 @@ async function main() {
639
674
  }
640
675
  cont(goFleet);
641
676
 
677
+ // Give the Telegram connector the ONE daemon this install just built: its loopback
678
+ // endpoint, the state directory that endpoint's API token belongs to, and — for a
679
+ // pre-0.3.3 connector that still meets the daemon at a broker instead — that broker.
680
+ // Idempotent: an unchanged selection writes nothing. Returns { changed, hadPrevious }
681
+ // so the caller can warn about a service unit that froze an older selection.
682
+ async function writeTgDaemonConfig({ chosenPort, chosenBroker, status0 }) {
683
+ const path = tgConfigPath(process.env, homedir());
684
+ const stateDir = daemonStateDir();
685
+ const desired = {
686
+ daemonUrl: daemonEndpoint(chosenPort),
687
+ daemonStateDir: stateDir,
688
+ brokerUrl: resolveSharedBroker({
689
+ chosenBroker,
690
+ statusBroker: status0.broker,
691
+ configBroker: readConfigObject().brokerUrl,
692
+ }),
693
+ };
694
+ let existing = {};
695
+ try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* absent or unreadable */ }
696
+ const plan = planTgDaemonConfig(existing, desired);
697
+ const hadPrevious = !!(plan.previous.daemonUrl || plan.previous.brokerUrl);
698
+ if (!plan.changed) {
699
+ line(ok(`Telegram connector already points at this daemon (${desired.daemonUrl}) — no change.`));
700
+ return { changed: false, hadPrevious };
701
+ }
702
+ await act(`write ${path} (daemon ${desired.daemonUrl}, state ${stateDir})`, async () => {
703
+ atomicWriteConfig(path, plan.text);
704
+ return { ok: true };
705
+ });
706
+ line(ok(`Telegram connector configured to use this daemon (${desired.daemonUrl}).`));
707
+ return { changed: true, hadPrevious };
708
+ }
709
+
642
710
  // ============================================================================================
643
711
  // STEP 4 / 4 — Telegram connector. Install-only (no bot tokens here). Then: run as a service?
644
712
  // ============================================================================================
@@ -648,15 +716,28 @@ async function main() {
648
716
  const goTg = yes(' Install it?', false);
649
717
  if (goTg) {
650
718
  await actSpin(`installing ${spec('tg-connector')}…`, `npm i -g ${spec('tg-connector')}`, () => runAsync(NPM, ['i', '-g', spec('tg-connector')]));
719
+ // POINT IT AT THE ONE DAEMON — BEFORE it is started or installed as a service.
720
+ // The connector never inherits ~/.ours/config.json (its SDK reports configPath:
721
+ // null unless told otherwise), and `install-service` bakes whatever it resolves
722
+ // into the unit as environment variables that outrank the file from then on. So
723
+ // the daemon's identity has to be in its config BEFORE either happens. See
724
+ // planTgDaemonConfig for why all three keys are written.
725
+ const tgConfigured = await writeTgDaemonConfig({ chosenPort, chosenBroker, status0 });
651
726
  const asService = yes(' Keep it running in the background so it starts automatically on boot?', true);
652
727
  if (asService) {
653
728
  const svc = await act('ours-tg-connector install-service (starts on boot)', async () => run('ours-tg-connector', ['install-service']));
654
- if (svc.ok) line(ok('Telegram connector installed and running as a service (starts on boot). No problems.'));
729
+ if (svc.ok) line(ok(`Telegram connector installed and running as a service (starts on boot), pointed at the daemon on port ${chosenPort}. No problems.`));
655
730
  else line(warn(`connector installed, but the service didn't start — retry '${c.cyan('ours-tg-connector install-service')}'.`));
656
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'service (boot)' });
731
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `service (boot) · daemon ${chosenPort}` });
657
732
  } else {
658
- line(ok(`Telegram connector installed. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
659
- record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: 'start on demand' });
733
+ line(ok(`Telegram connector installed, pointed at the daemon on port ${chosenPort}. Start it any time with '${c.cyan('ours-tg-connector start')}'. No problems.`));
734
+ // A connector already installed as a service froze its OLD daemon selection into
735
+ // the unit's environment, which outranks the file we just wrote. Config alone
736
+ // cannot repair that — say so plainly rather than let it look fixed.
737
+ if (tgConfigured.changed && tgConfigured.hadPrevious) {
738
+ 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
+ }
740
+ record({ key: 'telegram', label: 'Telegram connector', state: 'installed', version: globalVersion('@ours.network/tg-connector'), note: `start on demand · daemon ${chosenPort}` });
660
741
  }
661
742
  } else {
662
743
  line(info('skipped cleanly.'));
package/lib/logic.mjs CHANGED
@@ -42,11 +42,29 @@ const CHANNEL_TRACKING_PKGS = new Set(['mcp', 'tg-connector', 'claude-code', 'co
42
42
  const STABLE_ONLY_PKGS = new Set(['fleet']);
43
43
 
44
44
  // Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
45
- // (incl. undefined/'') falls back to the safe default 'latest' — never guesses a tag.
46
- export function resolveChannel(raw) {
45
+ // (incl. undefined/'') falls back to the installer's OWN channel — never guesses a tag.
46
+ //
47
+ // WHY THE INSTALLER'S OWN VERSION IS A CHANNEL SIGNAL. `@ours.network/install` is
48
+ // published on both dist-tags from the same lockstep bump (.github/workflows/scripts/
49
+ // bump-versions.sh), so a nightly build carries a `-nightly.N` version and a stable
50
+ // build does not. Without this, `npm i -g @ours.network/install@nightly && ours-install`
51
+ // installed the nightly INSTALLER but @latest for everything it installs — which since
52
+ // tg-connector 0.3.3-nightly.1 is not merely older but a DIFFERENT ARCHITECTURE (0.3.2
53
+ // hosts its own ADAPT wrapper; the nightly attaches to the shared daemon over /api/v1,
54
+ // which only the SDK-based daemon serves). Mixing the two tags across that boundary is
55
+ // exactly the split-brain deployment this must not produce. A stable installer stays on
56
+ // @latest and can never consume a nightly; an explicit OURS_CHANNEL always wins over both.
57
+ export function resolveChannel(raw, selfVersion = '') {
47
58
  const v = String(raw || '').trim().toLowerCase();
48
59
  if (v === 'nightly' || v === 'prerelease' || v === 'next') return 'nightly';
49
- return DEFAULT_CHANNEL; // 'latest' and everything else
60
+ if (v === 'latest' || v === 'stable') return DEFAULT_CHANNEL;
61
+ if (v) return DEFAULT_CHANNEL; // unrecognized: never guess, never inherit
62
+ return isNightlyVersion(selfVersion) ? 'nightly' : DEFAULT_CHANNEL;
63
+ }
64
+
65
+ // A published nightly carries the `-nightly.N` prerelease suffix the bump script writes.
66
+ export function isNightlyVersion(version) {
67
+ return /-nightly\.\d+/.test(String(version || ''));
50
68
  }
51
69
 
52
70
  // The npm dist-tag to install for one package key under a channel. fleet is ALWAYS
@@ -70,6 +88,79 @@ export const RESERVED_PORTS = [3051];
70
88
  export const DEFAULT_PORT = 3050;
71
89
  export const DEFAULT_BROKER = 'wss://broker1.ours.network';
72
90
 
91
+ // ── Handing the Telegram connector the ONE shared daemon ───────────────────────
92
+ // The connector has its OWN config file and never inherits the daemon's. Two
93
+ // generations of it are in the wild and the installer must satisfy BOTH, because
94
+ // which one gets installed is a channel decision (see pkgTag):
95
+ //
96
+ // @nightly (>=0.3.3-nightly.1) — SDK-based. It ATTACHES to an already-running
97
+ // ours daemon over /api/v1 and has NO broker at all. It selects the daemon
98
+ // through @ours.network/sdk's resolveDaemonConfig, whose behaviour we verified
99
+ // against the published SDK 0.1.2 (the version the nightly pins):
100
+ // · with no overrides it reports configPath: null — the daemon's
101
+ // ~/.ours/config.json is NEVER read implicitly, so a daemon on any port
102
+ // other than the built-in 3050 is simply MISSED.
103
+ // · pointing OURS_CONFIG at that file, or passing an endpoint alone, throws
104
+ // INCOHERENT_SELECTION: "the endpoint was selected … but the state
105
+ // directory is the built-in default" — a deliberate credential-disclosure
106
+ // guard that refuses BEFORE any token is read.
107
+ // · endpoint AND state dir together resolve cleanly.
108
+ // So the ONLY correct contract is to give it BOTH daemonUrl and daemonStateDir.
109
+ //
110
+ // @latest (<=0.3.2) — the older self-hosting ADAPT wrapper. It ignores
111
+ // daemonUrl/daemonStateDir and meets the daemon at a BROKER instead, so its
112
+ // brokerUrl must match the daemon's or the two can never see each other.
113
+ //
114
+ // Writing all three keys satisfies whichever generation is installed: each reads
115
+ // only the keys it understands and ignores the rest. This matters at INSTALL time
116
+ // specifically, because `ours-tg-connector install-service` BAKES its resolved
117
+ // values into the service unit as environment variables, and env outranks the
118
+ // config file forever after — a divergence created here can never be repaired by
119
+ // editing that file.
120
+
121
+ // The daemon's loopback endpoint. The daemon always binds 127.0.0.1 (never a
122
+ // hostname), so this is the address the connector must be given.
123
+ export function daemonEndpoint(port) {
124
+ return `http://127.0.0.1:${port}`;
125
+ }
126
+
127
+ // The broker the whole deployment shares, for a <=0.3.2 connector.
128
+ // Precedence: what the user chose in THIS run > what the running daemon actually
129
+ // resolved (`ours-mcp status`, which already accounts for OURS_BROKER_URL) > what
130
+ // the daemon's config file says > the built-in default (identical to the daemon's
131
+ // DEFAULT_CONFIG.brokerUrl, so "no answer anywhere" still agrees).
132
+ export function resolveSharedBroker({ chosenBroker, statusBroker, configBroker } = {}) {
133
+ for (const candidate of [chosenBroker, statusBroker, configBroker]) {
134
+ const v = validateBroker(candidate ?? '');
135
+ if (!v.empty && v.ok) return v.value;
136
+ }
137
+ return DEFAULT_BROKER;
138
+ }
139
+
140
+ // The Telegram connector's own config file (mirrors its src/config.ts: OURS_TG_CONFIG,
141
+ // else <home>/.ours-telegram/config.json — a FIXED location, independent of its stateDir).
142
+ export function tgConfigPath(env = {}, home = '') {
143
+ return env.OURS_TG_CONFIG || `${home}/.ours-telegram/config.json`;
144
+ }
145
+
146
+ // Decide whether the connector's config needs a write, and what to write. Returns
147
+ // { changed, text, previous } — `changed` is false when the file already names this
148
+ // exact daemon and broker, so an idempotent re-run touches nothing. Unrelated keys
149
+ // the user or the connector added (bot tokens, STT settings) are preserved.
150
+ export function planTgDaemonConfig(existing, { daemonUrl, daemonStateDir, brokerUrl } = {}) {
151
+ const base = existing && typeof existing === 'object' ? existing : {};
152
+ const str = (v) => (typeof v === 'string' ? v : '');
153
+ const previous = {
154
+ daemonUrl: str(base.daemonUrl),
155
+ daemonStateDir: str(base.daemonStateDir),
156
+ brokerUrl: str(base.brokerUrl),
157
+ };
158
+ const next = { daemonUrl, daemonStateDir, brokerUrl };
159
+ const changed = Object.entries(next).some(([k, v]) => v !== undefined && previous[k] !== v);
160
+ if (!changed) return { changed: false, text: '', previous };
161
+ return { changed: true, text: mergeConfig(base, next), previous };
162
+ }
163
+
73
164
  // suggestPort: pick a usable HTTP port. If `desired` is free and not reserved, keep it. Otherwise
74
165
  // scan upward from 3060 (the brief's suggested alternate band) for the first free, non-reserved
75
166
  // port. `isTaken(port)` is injected so this stays pure and testable (real caller probes a bind).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.16.0",
3
+ "version": "0.17.0-nightly.2",
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",