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

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.mjs CHANGED
@@ -40,7 +40,8 @@ import {
40
40
  coworkSupportsExternalDaemon, COWORK_EXTERNAL_MIN_VERSION,
41
41
  } from './lib/logic.mjs';
42
42
  import { atomicWriteConfig } from './lib/config.mjs';
43
- import { runNightlyInstaller } from './lib/nightly-install.mjs';
43
+ import { realEffects } from './lib/effects.mjs';
44
+ import { runInstall as runInstallV3 } from './lib/orchestrate.mjs';
44
45
 
45
46
  const NPM = process.env.OURS_NPM || 'npm';
46
47
  // Release channel: OURS_CHANNEL=nightly installs each package's PRERELEASE dist-tag —
@@ -239,6 +240,28 @@ Env: OURS_ASSUME_YES=1 (accept defaults, no prompts) · OURS_INSTALL_DRY_RUN=1
239
240
  // ===============================================================================================
240
241
  async function main() {
241
242
  const argv = process.argv.slice(2);
243
+
244
+ // ─── THE CHANNEL FORK ───────────────────────────────────────────────────────
245
+ // Nightly is the v3 installer, end to end. Owner ruling 2026-08-17: v3 SUBSUMES
246
+ // the nightly flow rather than being hosted by it, so this hands the whole run
247
+ // over — arguments, screens, refusals and exit code — and never returns.
248
+ //
249
+ // FIRST IN main(), BEFORE --help/--version, ON PURPOSE. Those used to be
250
+ // answered by the v2 body above, which would have printed v2's usage describing
251
+ // v2's flags for a run that is about to be v3's. Whatever prints the help must
252
+ // be whatever runs.
253
+ //
254
+ // The latest/stable body below is untouched and still serves that channel byte
255
+ // for byte; nothing a stable user does changes.
256
+ if (CHANNEL === 'nightly') {
257
+ const ttyFd = openTty();
258
+ const code = await runInstallV3(argv, realEffects({
259
+ write: makeWriter(ttyFd), ttyFd, env: process.env, version: pkgVersion(),
260
+ }));
261
+ finish(ttyFd);
262
+ process.exit(code);
263
+ }
264
+
242
265
  if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(USAGE + '\n'); return; }
243
266
  if (argv.includes('--version') || argv.includes('-V')) { process.stdout.write(`ours-install v${pkgVersion()}\n`); return; }
244
267
  if (argv.includes('--dry-run')) DRY = true;
@@ -317,16 +340,6 @@ async function main() {
317
340
  finish(ttyFd); return;
318
341
  }
319
342
 
320
- // HARD RELEASE BOUNDARY. Nightly owns the topology-first profile flow. The
321
- // latest/stable consumer-first implementation below remains untouched and
322
- // never reads or writes installer-profiles.json or emits --application.
323
- if (CHANNEL === 'nightly') {
324
- return runNightlyInstaller({
325
- harnesses, ttyFd, interactive, write, yes, ask, cont, dry: DRY,
326
- npm: NPM, run, runAsync, act, actSpin, finish,
327
- });
328
- }
329
-
330
343
  // Daemon state up front (decides first-install vs update, and whether Step 0 runs at all).
331
344
  const versionBefore = daemonVersionLine();
332
345
  const daemonInstalled = !!versionBefore;
@@ -248,7 +248,48 @@ export function atLeastVersion(actual, floor) {
248
248
  * alone; only `daemon.stateDir` is the ours daemon's. Confusing the two fails
249
249
  * closed at boot, which is why they are never touched in the same write.
250
250
  */
251
- export function planCoworkAttachment({ existing, endpoint, stateDir, installedVersion, channel = 'latest' }) {
251
+ /**
252
+ * cowork's own defaults, seeded ONLY into a config that does not have them.
253
+ *
254
+ * WHY THIS IS NOT "CONFIGURING SOMEBODY ELSE'S TOOL". On a first install cowork's
255
+ * config file does not exist, so v3 wrote it with a `daemon` block and nothing
256
+ * else — no version, no broker, no REST port. The nightly installer seeds all
257
+ * three (`planCoworkConfig`, lib/logic.mjs), and the broker in particular is a
258
+ * value the INSTALLER knows and cowork cannot guess: it is the one the operator
259
+ * chose in this run, and a cowork pointed at a different broker cannot reach the
260
+ * agents it was installed to talk to.
261
+ *
262
+ * SEEDED ONLY INTO A FILE THAT DOES NOT EXIST YET. An existing cowork config is
263
+ * copied through untouched — every key, including ones we would have defaulted.
264
+ * That line is deliberate and narrower than "write any key that is absent": a
265
+ * config the operator already has is theirs, and adding keys to it on a re-run
266
+ * would rewrite a file for no reason the operator asked for. A file this run is
267
+ * CREATING is a different thing, and it is the only case seeded here.
268
+ *
269
+ * NOT VERIFIED, and stated rather than assumed: whether cowork boots happily on a
270
+ * daemon block alone. Its own defaults may well cover all three. Seeding what the
271
+ * installer already knows is the safe direction either way, and it is what the
272
+ * flow being replaced does.
273
+ */
274
+ export const COWORK_DEFAULT_REST_PORT = 3052;
275
+
276
+ export function seedCoworkDefaults(base, { brokerUrl, home }) {
277
+ const seeded = { ...base };
278
+ const added = [];
279
+ if (seeded.version === undefined) { seeded.version = 1; added.push('version'); }
280
+ if (typeof seeded.brokerUrl !== 'string' && brokerUrl) { seeded.brokerUrl = brokerUrl; added.push('brokerUrl'); }
281
+ // cowork's OWN state directory, never the daemon's. Confusing the two fails
282
+ // closed at boot, which is why they are never written in the same expression.
283
+ if (typeof seeded.stateDir !== 'string' && home) { seeded.stateDir = join(home, '.ours-cowork'); added.push('stateDir'); }
284
+ const rest = seeded.rest && typeof seeded.rest === 'object' && !Array.isArray(seeded.rest) ? seeded.rest : null;
285
+ if (!rest || !Number.isInteger(rest.port)) {
286
+ seeded.rest = { enabled: rest?.enabled ?? true, ...(rest ?? {}), port: rest?.port ?? COWORK_DEFAULT_REST_PORT };
287
+ added.push('rest.port');
288
+ }
289
+ return { config: seeded, added };
290
+ }
291
+
292
+ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVersion, channel = 'latest', brokerUrl, home }) {
252
293
  const dir = resolve(stateDir);
253
294
  if (!endpoint || !stateDir) {
254
295
  return { key: 'cowork', action: 'refuse', reason: 'half-formed-block', message: 'a cowork daemon block needs both an endpoint and a state directory; refusing to write half of one' };
@@ -264,18 +305,28 @@ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVe
264
305
  }
265
306
  const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
266
307
  const daemon = { mode: 'external', endpoint, stateDir: dir };
267
- const unchanged = base.daemon
308
+ const daemonUnchanged = base.daemon
268
309
  && base.daemon.mode === 'external'
269
310
  && base.daemon.endpoint === endpoint
270
311
  && typeof base.daemon.stateDir === 'string'
271
312
  && resolve(base.daemon.stateDir) === dir;
313
+ // Seeded only where the key is ABSENT, so an existing config is still copied
314
+ // through untouched — which was already the right behaviour and is not changed.
315
+ const creating = existing === null || existing === undefined;
316
+ const { config: seeded, added } = creating
317
+ ? seedCoworkDefaults(base, { brokerUrl, home })
318
+ : { config: base, added: [] };
319
+ const unchanged = daemonUnchanged && added.length === 0;
272
320
  return {
273
321
  key: 'cowork',
274
322
  action: unchanged ? 'unchanged' : 'attach',
275
323
  install: ['npm', 'i', '-g', componentSpec(componentByKey('cowork'), channel)],
276
324
  changed: !unchanged,
277
- // The top-level stateDir is cowork's own; it is copied through untouched.
278
- config: { ...base, daemon },
325
+ seeded: added,
326
+ // The top-level stateDir is cowork's own; an existing one is copied through
327
+ // untouched and only an ABSENT one is seeded. Confusing it with the daemon's
328
+ // fails closed at boot, which is why they are never written together.
329
+ config: { ...seeded, daemon },
279
330
  service: ['ours-cowork', 'install-service'],
280
331
  };
281
332
  }
package/lib/detect.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // ours-install v3 — which daemons are already on this machine, and which one this
2
+ // run is for.
3
+ //
4
+ // OWNER RULING (C1, 2026-08-17), and the whole shape of this file follows from it:
5
+ // never ask the user to TYPE a state directory or a port — that is what spec §2
6
+ // forbids and it stands — but when several daemons are DETECTED, show them and let
7
+ // the user pick. Selection from what was found is not prompting for a path.
8
+ //
9
+ // Pure, like target.mjs and plan.mjs: the caller injects the directory listing and
10
+ // the file reads, so the whole decision is testable without a filesystem.
11
+ //
12
+ // DETECTION, NOT A REGISTRY. Coordinator ruling: build this from what is actually
13
+ // on disk rather than from a persisted ~/.ours/installer-profiles.json. A stored
14
+ // list is a second source of truth that goes stale against the daemons that really
15
+ // exist, and staleness in exactly that file is how an installer ends up
16
+ // confidently offering a daemon that is gone.
17
+
18
+ import { basename, join, resolve } from 'node:path';
19
+
20
+ // Artefacts ONLY a daemon writes. A directory carrying any of these is a daemon's
21
+ // state directory, whatever else is in it.
22
+ export const DAEMON_ARTEFACTS = ['daemon-token', 'ours-cli-daemon.json', 'root.json'];
23
+
24
+ /**
25
+ * CONFIG.JSON IS THE ONE PIECE OF EVIDENCE THAT IS AMBIGUOUS, SO IT CANNOT BE THE
26
+ * TEST. This is the trap this function exists to avoid, and it is worth stating
27
+ * plainly because the obvious implementation walks straight into it:
28
+ *
29
+ * `~/.ours-telegram/config.json` and `~/.ours-cowork/config.json` both exist on a
30
+ * normal machine, both match a `~/.ours*` scan, and NEITHER is a daemon — they are
31
+ * the connectors' own configs. A selection screen built on "has a config.json"
32
+ * shows three daemons on a machine with one, and choosing the Telegram connector's
33
+ * directory would have the installer create a daemon inside it.
34
+ *
35
+ * So a config.json counts only when its SHAPE is a daemon's: it records a `port`
36
+ * or a `stateDir`, and it carries none of the keys that identify it as somebody
37
+ * else's. `daemonUrl`/`daemonStateDir` are the Telegram connector's; a `daemon`
38
+ * block is cowork's; `botToken` is the connector's too.
39
+ */
40
+ export function looksLikeDaemonConfig(config) {
41
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
42
+ if (typeof config.daemonUrl === 'string' || typeof config.daemonStateDir === 'string') return false;
43
+ if (config.daemon !== undefined) return false;
44
+ if (typeof config.botToken === 'string') return false;
45
+ return typeof config.port === 'number' || typeof config.stateDir === 'string';
46
+ }
47
+
48
+ /**
49
+ * Is this directory a daemon's state directory? Returns the evidence, so a screen
50
+ * or a test can say WHY rather than just yes.
51
+ */
52
+ export function classifyStateDir(dir, { exists, readJson }) {
53
+ const target = resolve(dir);
54
+ const artefact = DAEMON_ARTEFACTS.find((name) => exists(join(target, name)));
55
+ if (artefact) return { isDaemon: true, evidence: artefact, config: readJson(join(target, 'config.json')) };
56
+ const config = readJson(join(target, 'config.json'));
57
+ if (looksLikeDaemonConfig(config)) return { isDaemon: true, evidence: 'config.json', config };
58
+ return { isDaemon: false, evidence: null, config: null };
59
+ }
60
+
61
+ /**
62
+ * Every daemon state directory this machine can be seen to have.
63
+ *
64
+ * KNOWN LIMIT, inherited from effects.knownStateDirs and stated rather than
65
+ * implied: only `~/.ours` and its `~/.ours*` siblings are looked at. A state
66
+ * directory somewhere else entirely is not found, and the failure is that it is not
67
+ * offered — never that the wrong one is chosen, because `--state-dir` still names
68
+ * anything and overrides all of this.
69
+ */
70
+ export function detectDaemons({ candidates = [], exists, readJson }) {
71
+ const found = [];
72
+ for (const dir of candidates) {
73
+ const target = resolve(dir);
74
+ if (found.some((d) => d.stateDir === target)) continue;
75
+ const verdict = classifyStateDir(target, { exists, readJson });
76
+ if (!verdict.isDaemon) continue;
77
+ found.push({
78
+ stateDir: target,
79
+ port: typeof verdict.config?.port === 'number' ? verdict.config.port : null,
80
+ evidence: verdict.evidence,
81
+ label: basename(target),
82
+ });
83
+ }
84
+ // Deterministic, and the default daemon first when it is one of them — it is the
85
+ // one an operator means by "my daemon".
86
+ return found.sort((a, b) => {
87
+ if (basename(a.stateDir) === '.ours') return -1;
88
+ if (basename(b.stateDir) === '.ours') return 1;
89
+ return a.stateDir.localeCompare(b.stateDir);
90
+ });
91
+ }
92
+
93
+ /**
94
+ * A state directory for a daemon this run would CREATE, derived and never typed.
95
+ *
96
+ * Spec §2 forbids asking for a path, and the C1 ruling did not change that — it
97
+ * added "pick from what was found". So "create a new one" has to derive somewhere
98
+ * to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
99
+ * An operator who wants a specific path still has `--state-dir`, which bypasses
100
+ * this screen entirely.
101
+ */
102
+ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
103
+ const used = new Set(taken.map((d) => resolve(d)));
104
+ const first = resolve(join(home, '.ours'));
105
+ if (!used.has(first)) return first;
106
+ for (let n = 2; n < limit; n += 1) {
107
+ const candidate = resolve(join(home, `.ours-${n}`));
108
+ if (!used.has(candidate)) return candidate;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ export const SELECT_CREATE = '__create__';
114
+
115
+ // The nightly flow kept a registry of daemon profiles here. v3 does not read it —
116
+ // detection replaced it (coordinator ruling) — so anyone who used that flow has a
117
+ // file describing daemons that nothing consults any more.
118
+ //
119
+ // It is NOT deleted. Quietly removing a file that describes someone's daemons is
120
+ // not an installer's business, and the file is harmless. But leaving it looking
121
+ // live is worse than saying it is not, so the run says so once.
122
+ export const LEGACY_PROFILE_REGISTRY = 'installer-profiles.json';
123
+ export const legacyRegistryPath = (home) => join(resolve(home), '.ours', LEGACY_PROFILE_REGISTRY);
124
+
125
+ /**
126
+ * What this run should do about choosing a daemon (C1's five rules, in one place
127
+ * so they can be read together and tested without a terminal):
128
+ *
129
+ * flags given → no screen at all. `--state-dir` or `--port` is an
130
+ * explicit target and outranks anything detected.
131
+ * non-interactive → no screen. Flags only, and every existing refusal still
132
+ * applies — OURS_ASSUME_YES suppresses questions, never a
133
+ * refusal.
134
+ * none detected → create, exactly as before.
135
+ * exactly one detected → no prompt, use it, and SAY which one. A question with
136
+ * one answer is not a choice, it is a keystroke tax.
137
+ * several detected → show them and pick, with "create a new one" as the last
138
+ * option.
139
+ */
140
+ export function planDaemonSelection({
141
+ candidates = [],
142
+ stateDirExplicit = false,
143
+ portExplicit = false,
144
+ assumeYes = false,
145
+ home,
146
+ } = {}) {
147
+ if (stateDirExplicit || portExplicit) {
148
+ return { action: 'flags', reason: stateDirExplicit ? '--state-dir names the target' : '--port names the target' };
149
+ }
150
+ if (assumeYes) return { action: 'flags', reason: 'non-interactive: flags only, no screen' };
151
+ if (candidates.length === 0) return { action: 'create', stateDir: deriveNewStateDir(home, []) };
152
+ if (candidates.length === 1) {
153
+ return { action: 'use', stateDir: candidates[0].stateDir, only: candidates[0], announce: true };
154
+ }
155
+ return {
156
+ action: 'choose',
157
+ candidates,
158
+ createOption: { id: SELECT_CREATE, stateDir: deriveNewStateDir(home, candidates.map((c) => c.stateDir)) },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Resolve a typed answer against the offered list.
164
+ *
165
+ * Deliberately strict: an answer that is not a number in range, or the create
166
+ * option, is NOT a state directory to be interpreted. Accepting free text here
167
+ * would be exactly the "type a path" prompt spec §2 forbids, arriving through the
168
+ * back door.
169
+ */
170
+ export function resolveSelection(answer, { candidates = [], createOption = null } = {}) {
171
+ const value = String(answer ?? '').trim().toLowerCase();
172
+ if (!value) return { action: 'invalid', reason: 'no answer' };
173
+ if (value === 'n' || value === 'new' || value === String(candidates.length + 1)) {
174
+ return createOption?.stateDir
175
+ ? { action: 'create', stateDir: createOption.stateDir }
176
+ : { action: 'invalid', reason: 'no free state directory could be derived' };
177
+ }
178
+ if (!/^\d+$/.test(value)) return { action: 'invalid', reason: 'that is not one of the numbers offered' };
179
+ const index = Number(value) - 1;
180
+ if (!Number.isInteger(index) || !candidates[index]) return { action: 'invalid', reason: 'that is not one of the numbers offered' };
181
+ return { action: 'use', stateDir: candidates[index].stateDir };
182
+ }
package/lib/extras.mjs CHANGED
@@ -173,6 +173,49 @@ export function planHarnessPlugins({
173
173
  });
174
174
  }
175
175
 
176
+ // -----------------------------------------------------------------------------
177
+ // §5 — what the operator has to do BEFORE any of this works
178
+ // -----------------------------------------------------------------------------
179
+
180
+ /**
181
+ * The restart each harness needs before its new plugin is live.
182
+ *
183
+ * A CORRECTNESS PROBLEM WEARING A COSMETIC COSTUME. The ours MCP server is spawned
184
+ * BY the harness, once per session (`ours-mcp proxy` over stdio), so a harness that
185
+ * was already running when its plugin was installed has no ours tools and will not
186
+ * get them until it restarts. v3 said nothing at all about this: the screen read
187
+ * "Everything installed cleanly", the user went back to a running Claude Code,
188
+ * found no ours tools, and concluded the install had failed. The nightly installer
189
+ * prints these hints and v3 dropped them.
190
+ *
191
+ * Derived from what THIS RUN installed rather than from a registry — v3 already
192
+ * knows, and its own summary is a better source than a persisted file that can go
193
+ * stale against reality.
194
+ *
195
+ * The connectors are deliberately absent. The installer runs their
196
+ * `install-service` itself, so their new configuration is already applied; telling
197
+ * someone to restart something that was just restarted for them is noise, and noise
198
+ * in this list is what stops the real lines being read.
199
+ */
200
+ export const HARNESS_RESTART = {
201
+ 'claude-code': 'restart Claude Code',
202
+ codex: 'start a new Codex session (or `ours-codex`)',
203
+ hermes: 'run /reload-mcp in Hermes',
204
+ };
205
+
206
+ export function restartHints(summary = []) {
207
+ const live = (row) => row && (row.state === 'installed' || row.state === 'current');
208
+ const hints = [];
209
+ for (const [name, action] of Object.entries(HARNESS_RESTART)) {
210
+ const row = summary.find((r) => r.key === name);
211
+ if (live(row)) hints.push({ key: name, action });
212
+ }
213
+ // Nothing to restart if no harness got a plugin this run. The MCP server on its
214
+ // own changes nothing a running harness can see, so an "install the MCP server
215
+ // and restart everything" line would be advice with no reason behind it.
216
+ return hints;
217
+ }
218
+
176
219
  // -----------------------------------------------------------------------------
177
220
  // ours-fleet — the one that needs zero code
178
221
  // -----------------------------------------------------------------------------
@@ -1,3 +1,26 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // NO LONGER REACHED BY ANY CODE PATH, DELIBERATELY AND TEMPORARILY.
3
+ //
4
+ // The nightly channel now runs the v3 installer end to end (owner ruling
5
+ // 2026-08-17: v3 SUBSUMES the nightly flow), so the two dispatch sites that led
6
+ // here — install.mjs and uninstall.mjs — were removed. This file is retained on
7
+ // purpose rather than deleted in the same commit.
8
+ //
9
+ // WHY IT IS STILL HERE. The behaviour inventory
10
+ // (/home/fleet/work/dev1-installer-notes/NIGHTLY-BEHAVIOUR-INVENTORY.md) lists
11
+ // what this flow does that v3 does not, and that list is not finished being
12
+ // carried across. Deleting the implementation and its tests before the one-for-one
13
+ // replacement exists is how a test that was covering something real gets removed
14
+ // alongside the ones that were not. Its tests still run and still pass, because
15
+ // they exercise this module directly.
16
+ //
17
+ // The retirement — removing this file and retiring each of its tests against a
18
+ // named v3 equivalent — is its own piece of work. Until then this is ORPHANED AND
19
+ // SAID SO, which is the opposite of the failure the staging existed to prevent:
20
+ // seven commits of feature reachable by no code path, with git reporting no
21
+ // conflict and nothing saying it had happened.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
1
24
  import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
25
  import { homedir, userInfo } from 'node:os';
3
26
  import { join, resolve } from 'node:path';
@@ -1,3 +1,26 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // NO LONGER REACHED BY ANY CODE PATH, DELIBERATELY AND TEMPORARILY.
3
+ //
4
+ // The nightly channel now runs the v3 installer end to end (owner ruling
5
+ // 2026-08-17: v3 SUBSUMES the nightly flow), so the two dispatch sites that led
6
+ // here — install.mjs and uninstall.mjs — were removed. This file is retained on
7
+ // purpose rather than deleted in the same commit.
8
+ //
9
+ // WHY IT IS STILL HERE. The behaviour inventory
10
+ // (/home/fleet/work/dev1-installer-notes/NIGHTLY-BEHAVIOUR-INVENTORY.md) lists
11
+ // what this flow does that v3 does not, and that list is not finished being
12
+ // carried across. Deleting the implementation and its tests before the one-for-one
13
+ // replacement exists is how a test that was covering something real gets removed
14
+ // alongside the ones that were not. Its tests still run and still pass, because
15
+ // they exercise this module directly.
16
+ //
17
+ // The retirement — removing this file and retiring each of its tests against a
18
+ // named v3 equivalent — is its own piece of work. Until then this is ORPHANED AND
19
+ // SAID SO, which is the opposite of the failure the staging existed to prevent:
20
+ // seven commits of feature reachable by no code path, with git reporting no
21
+ // conflict and nothing saying it had happened.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
1
24
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
25
  import { homedir } from 'node:os';
3
26
  import { join } from 'node:path';
@@ -31,9 +31,10 @@ import {
31
31
  planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
32
32
  tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
33
33
  } from './components.mjs';
34
- import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3 } from './extras.mjs';
34
+ import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
35
35
  import { summarizeRun } from './rerun.mjs';
36
36
  import { configJournal, reportRollback } from './journal.mjs';
37
+ import { detectDaemons, planDaemonSelection, resolveSelection, legacyRegistryPath } from './detect.mjs';
37
38
  import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
38
39
  import { daemonEnv } from './effects.mjs';
39
40
  import { USAGE } from './usage.mjs';
@@ -98,6 +99,72 @@ function pairFor(plan, target) {
98
99
  : undefined;
99
100
  }
100
101
 
102
+ /**
103
+ * Which daemon is this run for? (C1, owner ruling 2026-08-17.)
104
+ *
105
+ * Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
106
+ * it shows them and lets the operator pick, because choosing from what was found
107
+ * is not prompting for a state directory.
108
+ *
109
+ * Runs BEFORE the daemon phase and only changes `args.stateDir`. Everything
110
+ * downstream — resolveTarget, the refusals, the journal — is untouched and does
111
+ * not know a screen happened, which is what keeps the flags path byte-identical.
112
+ */
113
+ export async function runSelectionPhase(args, effects) {
114
+ // Said once, and only when the file is actually there: a file that looks live is
115
+ // worse than a file that says it is not. Never deleted — quietly removing
116
+ // something that describes an operator's daemons is not this installer's
117
+ // business.
118
+ const legacy = legacyRegistryPath(effects.home);
119
+ if (effects.exists(legacy)) {
120
+ effects.out(info(`${legacy} is left over from the older nightly installer and is no longer read — this run detects daemons directly. It is left alone; you can delete it.`));
121
+ }
122
+ const detected = detectDaemons({
123
+ candidates: effects.knownStateDirs(),
124
+ exists: effects.exists,
125
+ readJson: effects.readJson,
126
+ });
127
+ const plan = planDaemonSelection({
128
+ candidates: detected,
129
+ stateDirExplicit: args.stateDirExplicit,
130
+ portExplicit: args.portExplicit,
131
+ assumeYes: args.assumeYes,
132
+ home: effects.home,
133
+ });
134
+
135
+ if (plan.action === 'flags' || plan.action === 'create') return { ...plan, detected };
136
+ if (plan.action === 'use') {
137
+ // A question with one answer is not a choice, it is a keystroke tax — but the
138
+ // operator still has to be TOLD which daemon this run is about.
139
+ effects.out(info(`using the ours daemon at ${plan.stateDir}${plan.only.port ? ` (port ${plan.only.port})` : ''} — the only one found`));
140
+ args.stateDir = plan.stateDir;
141
+ return { ...plan, detected };
142
+ }
143
+
144
+ effects.out(heading('Which ours daemon is this for?'));
145
+ plan.candidates.forEach((candidate, index) => {
146
+ effects.out(` ${index + 1}) ${candidate.stateDir}${candidate.port ? ` port ${candidate.port}` : ''}`);
147
+ });
148
+ if (plan.createOption.stateDir) {
149
+ effects.out(` ${plan.candidates.length + 1}) create a new one at ${plan.createOption.stateDir}`);
150
+ }
151
+ const answer = await effects.askLine(`Choose 1-${plan.candidates.length + (plan.createOption.stateDir ? 1 : 0)}: `, '1');
152
+ const chosen = resolveSelection(answer, plan);
153
+ if (chosen.action === 'invalid') {
154
+ // Refused rather than guessed. Interpreting an unrecognised answer as a path
155
+ // would be the "type a state directory" prompt spec §2 forbids, arriving
156
+ // through the back door.
157
+ effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
158
+ effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
159
+ return { action: 'refuse', exitCode: EXIT_REFUSED, detected };
160
+ }
161
+ args.stateDir = chosen.stateDir;
162
+ effects.out(ok(chosen.action === 'create'
163
+ ? `creating a new daemon at ${chosen.stateDir}`
164
+ : `using the ours daemon at ${chosen.stateDir}`));
165
+ return { ...chosen, detected };
166
+ }
167
+
101
168
  /**
102
169
  * The daemon half of a run: §§2-4. Returns the target decision plus the step
103
170
  * outcomes, or a refusal.
@@ -198,15 +265,53 @@ export async function runDaemonPhase(args, effects) {
198
265
  }
199
266
  steps.push(service.step);
200
267
  } catch (error) {
268
+ // THE DAEMON MAY BE DOWN, AND NOT BECAUSE ANYTHING ASKED IT TO BE.
269
+ //
270
+ // `install-service` can STOP a running daemon before it fails — it installs a
271
+ // unit that will own the process, and a failure after that point leaves nothing
272
+ // running. v3 simply ended the run there, so a person who typed ours-install
273
+ // and got an error was also, silently, left without the daemon they had before.
274
+ // The nightly flow re-runs `start` and, crucially, tells the two outcomes
275
+ // apart: "the service failed but the daemon is back" is a bad evening, and "the
276
+ // service failed AND it will not come back" is the one that needs a human now.
277
+ const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath) : null;
201
278
  rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
202
279
  replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
203
280
  });
281
+ if (recovery) {
282
+ effects.out(recovery.recovered
283
+ ? ok('your daemon is running again — nothing was committed, and the service is unchanged')
284
+ : warn('and the daemon did NOT come back up — start it yourself before anything else: '
285
+ + `ours daemon start --config ${configPath}`));
286
+ }
204
287
  throw error;
205
288
  }
206
289
 
207
290
  return { target, steps };
208
291
  }
209
292
 
293
+ /**
294
+ * Put the daemon back after a failed boot-service install.
295
+ *
296
+ * Only attempted when the failure came from the SERVICE step (`error.servicePlan`
297
+ * is what says so) — a daemon that never started has nothing to recover, and
298
+ * running `start` after a failed `start` would just fail again with a second, less
299
+ * useful error on top of the first.
300
+ *
301
+ * A dry run recovers nothing because it stopped nothing. The recovery's own failure
302
+ * is REPORTED, never thrown: the caller is already carrying the real error, and
303
+ * losing it to a second one would hide what actually went wrong.
304
+ */
305
+ async function recoverDaemon(args, effects, dir, configPath) {
306
+ if (args.dryRun) return null;
307
+ try {
308
+ await effects.run('ours', ['daemon', 'start', '--config', configPath]);
309
+ return { recovered: true };
310
+ } catch (recoveryError) {
311
+ return { recovered: false, reason: reason(recoveryError) };
312
+ }
313
+ }
314
+
210
315
  /**
211
316
  * One rollback, one report, one function — because a rollback whose report is
212
317
  * missing reads to the operator exactly like a run that quietly did nothing, and
@@ -396,6 +501,11 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
396
501
  stateDir: dir,
397
502
  installedVersion: effects.installedVersion(component.pkg),
398
503
  channel: args.channel,
504
+ // The broker is a value the INSTALLER knows and cowork cannot guess — the one
505
+ // the operator chose in this run. `home` is for cowork's OWN state directory,
506
+ // never the daemon's.
507
+ brokerUrl: args.brokerUrl,
508
+ home: effects.home,
399
509
  });
400
510
  if (plan.action === 'refuse' || plan.action === 'leave-embedded') {
401
511
  effects.out(warn(`cowork: ${plan.message}`));
@@ -654,8 +764,31 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
654
764
  return { key: 'fleet', label: plan.label, state: 'skipped' };
655
765
  }
656
766
  const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
767
+ // THE PAIR IS PASSED AS DELIBERATE INSURANCE AGAINST AN UNRESOLVED
768
+ // CONTRADICTION, not because the question was settled.
769
+ //
770
+ // Two written analyses disagree, and NEITHER was verified — ours-fleet is not in
771
+ // this repo:
772
+ // lib/nightly-install.mjs:611 says fleet resolves its daemon from
773
+ // OURS_CONFIG / OURS_PORT / OURS_STATE_DIR and has no concept of a registry,
774
+ // so an `init` run without the pair points every role at the historical
775
+ // default daemon — which, when the selected daemon is not the default, may
776
+ // be one the user does not even have.
777
+ // lib/extras.mjs:180 says `init` takes no daemon argument of any kind, reads no
778
+ // daemon config, and resolves per role through
779
+ // resolveEndpoint({ ...process.env, ...role.env }) — so the pair is
780
+ // unnecessary here.
781
+ // Passing it is harmless if extras.mjs is right and load-bearing if
782
+ // nightly-install.mjs is. When the cheap action is safe under both readings and
783
+ // the expensive one is only safe under one, take the cheap one. (Coordinator
784
+ // ruling, 2026-08-17.)
785
+ //
786
+ // Passed for EVERY state directory, not only a non-default one, exactly as the
787
+ // nightly flow does: `init` is a one-time host setup and the pair is what names
788
+ // the daemon it was set up beside.
789
+ const initEnv = daemonEnv(target.stateDir, target.port);
657
790
  const init = install.ok
658
- ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1)))
791
+ ? await attempt(effects, args.dryRun, `${plan.init.join(' ')} (one-time host setup: units, dirs, linger)`, () => effects.run(plan.init[0], plan.init.slice(1), { env: initEnv }))
659
792
  : install;
660
793
  if (!init.ok) {
661
794
  effects.out(info(`retry manually: ${plan.init.join(' ')}`));
@@ -773,6 +906,19 @@ export async function endScreen(args, effects, { summary, target, isDefaultState
773
906
  ? ` ${c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')}`
774
907
  : ` ${c.green('Everything installed cleanly. No problems.')}`);
775
908
 
909
+ // Said BEFORE the hand-off prompt, because it is the only thing here the
910
+ // operator must do himself for any of the rest to work. A harness that was
911
+ // running when its plugin landed spawns no ours MCP server until it restarts,
912
+ // and someone who goes back to that harness, finds no ours tools and reads a
913
+ // successful install as a failed one is the exact outcome this prevents.
914
+ const restarts = restartHints(summary);
915
+ if (restarts.length > 0) {
916
+ effects.out('');
917
+ effects.out(` ${c.bold('Before this works:')} your harness spawns the ours MCP server when it starts, so`);
918
+ effects.out(' a harness that was already open has not picked it up yet.');
919
+ for (const hint of restarts) effects.out(` ${c.green('→')} ${hint.action}`);
920
+ }
921
+
776
922
  const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
777
923
  const { text, empty } = buildHandoffPromptV3({
778
924
  identity: !has('identity'),
@@ -841,6 +987,11 @@ export async function runInstall(argv, effects) {
841
987
  // that wrapped the old installer keeps its meaning.
842
988
  if (!runPreflight(effects).ok) return EXIT_OK;
843
989
 
990
+ // Which daemon, before anything is decided about it. Only args.stateDir can
991
+ // change here; every refusal downstream is unaffected.
992
+ const selection = await runSelectionPhase(args, effects);
993
+ if (selection.action === 'refuse') return EXIT_REFUSED;
994
+
844
995
  const daemon = await runDaemonPhase(args, effects);
845
996
  if (daemon.refused) return EXIT_REFUSED;
846
997
  const target = daemon.target;
package/lib/profiles.mjs CHANGED
@@ -1,3 +1,26 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // NO LONGER REACHED BY ANY CODE PATH, DELIBERATELY AND TEMPORARILY.
3
+ //
4
+ // The nightly channel now runs the v3 installer end to end (owner ruling
5
+ // 2026-08-17: v3 SUBSUMES the nightly flow), so the two dispatch sites that led
6
+ // here — install.mjs and uninstall.mjs — were removed. This file is retained on
7
+ // purpose rather than deleted in the same commit.
8
+ //
9
+ // WHY IT IS STILL HERE. The behaviour inventory
10
+ // (/home/fleet/work/dev1-installer-notes/NIGHTLY-BEHAVIOUR-INVENTORY.md) lists
11
+ // what this flow does that v3 does not, and that list is not finished being
12
+ // carried across. Deleting the implementation and its tests before the one-for-one
13
+ // replacement exists is how a test that was covering something real gets removed
14
+ // alongside the ones that were not. Its tests still run and still pass, because
15
+ // they exercise this module directly.
16
+ //
17
+ // The retirement — removing this file and retiring each of its tests against a
18
+ // named v3 equivalent — is its own piece of work. Until then this is ORPHANED AND
19
+ // SAID SO, which is the opposite of the failure the staging existed to prevent:
20
+ // seven commits of feature reachable by no code path, with git reporting no
21
+ // conflict and nothing saying it had happened.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
1
24
  import {
2
25
  chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync,
3
26
  unlinkSync, writeFileSync,
package/lib/target.mjs CHANGED
@@ -73,6 +73,10 @@ export function parseInstallArgs(argv = [], env = {}, { home = homedir() } = {})
73
73
  const out = {
74
74
  stateDir: null,
75
75
  port: null,
76
+ // `stateDir` is always populated (it defaults to ~/.ours), so "was it given?"
77
+ // needs its own flag — the selection screen has to tell an explicit target from
78
+ // a defaulted one, and a defaulted value looks identical to a chosen one.
79
+ stateDirExplicit: false,
76
80
  portExplicit: false,
77
81
  dryRun: env.OURS_INSTALL_DRY_RUN === '1',
78
82
  assumeYes: env.OURS_ASSUME_YES === '1',
@@ -98,7 +102,7 @@ export function parseInstallArgs(argv = [], env = {}, { home = homedir() } = {})
98
102
  seen.add(name);
99
103
  const value = equal >= 0 ? raw.slice(equal + 1) : argv[++i];
100
104
  if (value === undefined || value === '') throw new InstallUsageError(`${name} requires a value`);
101
- if (name === '--state-dir') out.stateDir = resolve(String(value));
105
+ if (name === '--state-dir') { out.stateDir = resolve(String(value)); out.stateDirExplicit = true; }
102
106
  if (name === '--port') {
103
107
  if (!/^[0-9]+$/.test(String(value).trim())) throw new InstallUsageError('--port must be an integer');
104
108
  const n = Number.parseInt(String(value).trim(), 10);
package/lib/uninstall.mjs CHANGED
@@ -221,7 +221,9 @@ export function planStatePurge({ stateDir, purge = false, assumeYes = false, exi
221
221
  * directory on this machine still has a daemon config; otherwise keep them and
222
222
  * say which daemon still needs them.
223
223
  */
224
- export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [] }) {
224
+ export const CONNECTOR_PACKAGES = { tg: '@ours.network/tg-connector', cowork: '@ours.network/cowork' };
225
+
226
+ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pluginPackages = [], detachedComponents = [] }) {
225
227
  const dir = resolve(stateDir);
226
228
  const others = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir);
227
229
  if (others.length > 0) {
@@ -230,7 +232,26 @@ export function planGlobalPackages({ stateDir, otherStateDirsWithConfig = [], pl
230
232
  // The harness-plugin launchers follow the SAME rule, not a second one: they
231
233
  // speak to a daemon through ours-mcp, so a second daemon still on the machine
232
234
  // still needs them, and the one condition above already decides that.
233
- return { action: 'remove', packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages], stillNeededBy: [] };
235
+ // A connector's package goes only when BOTH conditions hold: the operator
236
+ // confirmed removing its attachment in THIS run, and this was the last daemon —
237
+ // the same condition already decided once above.
238
+ //
239
+ // WHY NOT NIGHTLY'S THREE-WAY CHOICE. The nightly uninstaller asks per connector
240
+ // for detach / uninstall / reassign:<profile> and removes the package only on
241
+ // `uninstall`. v3 has no such lifecycle question and inventing one here would be
242
+ // deciding scope in a patch. What v3 DOES have is the operator's explicit yes to
243
+ // "remove its attachment too", which is a narrower thing than nightly's
244
+ // `uninstall` — so this stays behind the last-daemon condition rather than
245
+ // standing on the confirmation alone. A connector detached while another daemon
246
+ // survives keeps its package, because that daemon may still be using it.
247
+ const connectors = detachedComponents
248
+ .map((key) => CONNECTOR_PACKAGES[key])
249
+ .filter(Boolean);
250
+ return {
251
+ action: 'remove',
252
+ packages: ['@ours.network/cli', '@ours.network/mcp', ...pluginPackages, ...connectors],
253
+ stillNeededBy: [],
254
+ };
234
255
  }
235
256
 
236
257
  // -----------------------------------------------------------------------------
@@ -403,7 +424,14 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
403
424
  daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
404
425
  state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
405
426
  plugins,
406
- packages: planGlobalPackages({ stateDir: dir, otherStateDirsWithConfig, pluginPackages: plugins.packages }),
427
+ packages: planGlobalPackages({
428
+ stateDir: dir,
429
+ otherStateDirsWithConfig,
430
+ pluginPackages: plugins.packages,
431
+ // Only the components the operator explicitly confirmed removing — never
432
+ // every component that merely happens to point here.
433
+ detachedComponents: pointing.map((p) => p.key).filter((key) => confirmedComponents.includes(key)),
434
+ }),
407
435
  };
408
436
  }
409
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "0.17.0-nightly.8",
3
+ "version": "0.17.0-nightly.9",
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",
package/uninstall.mjs CHANGED
@@ -20,7 +20,8 @@ import { join } from 'node:path';
20
20
  import { banner, heading, c, openTty, makeWriter, closeSync } from './lib/ui.mjs';
21
21
  import { askLine, checkboxSelect } from './lib/prompt.mjs';
22
22
  import { canonHarnesses, resolveChannel } from './lib/logic.mjs';
23
- import { runNightlyUninstaller } from './lib/nightly-uninstall.mjs';
23
+ import { realEffects } from './lib/effects.mjs';
24
+ import { runUninstall as runUninstallV3 } from './lib/orchestrate-uninstall.mjs';
24
25
 
25
26
  const NPM = process.env.OURS_NPM || 'npm';
26
27
  const HOME = homedir();
@@ -116,18 +117,30 @@ function confirmDestructive(write, fd, what) {
116
117
  }
117
118
 
118
119
  // ===============================================================================================
119
- function main() {
120
+ async function main() {
120
121
  const ttyFd = openTty();
121
122
  const write = makeWriter(ttyFd);
122
123
 
124
+ // ─── THE CHANNEL FORK ───────────────────────────────────────────────────────
125
+ // The partner to install.mjs's, and it has to move in the SAME commit. v3
126
+ // installing while this body uninstalls would run the old flow against state it
127
+ // no longer understands — the kind of half-state that only surfaces when someone
128
+ // tries to undo something.
129
+ //
130
+ // Before the banner, so the screen the operator sees is the one belonging to the
131
+ // flow that is about to run.
132
+ if (CHANNEL === 'nightly') {
133
+ const code = await runUninstallV3(process.argv.slice(2), realEffects({
134
+ write, ttyFd, env: process.env, version: packageVersion,
135
+ }));
136
+ finish(ttyFd);
137
+ process.exit(code);
138
+ }
139
+
123
140
  line(banner());
124
141
  line(c.bold(' ours.network uninstaller'));
125
142
  say('Removes only what the ours installers created. Nothing is removed until you confirm.');
126
143
 
127
- if (CHANNEL === 'nightly') {
128
- return runNightlyUninstaller({ ttyFd, write, run, npm: NPM, assumeYes: ASSUME_YES, finish });
129
- }
130
-
131
144
  // --- 1) choose what to remove ----------------------------------------------------------------
132
145
  let selected = [];
133
146
  let wantData = false;
@@ -208,4 +221,10 @@ function main() {
208
221
 
209
222
  function finish(ttyFd) { if (ttyFd != null) { try { closeSync(ttyFd); } catch { /* ignore */ } } }
210
223
 
211
- main();
224
+ // `main` became async when the nightly fork moved in: the v3 uninstaller is async
225
+ // all the way down. An unhandled rejection here would exit 0 with no output, so the
226
+ // failure is caught and reported like the installer's.
227
+ main().catch((error) => {
228
+ say(`unexpected error: ${String(error)}`);
229
+ process.exitCode = 1;
230
+ });