@ours.network/install 0.17.0 → 0.18.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.
@@ -0,0 +1,361 @@
1
+ // ours-install v3 — component selection and attachment.
2
+ //
3
+ // Spec: installer-spec-v3 §5 (and the repoint half of §7, which cannot be
4
+ // separated from attaching the connector without making a silent move possible).
5
+ // Pure, like target.mjs and plan.mjs: the caller injects the current file
6
+ // contents and the installed versions, and every function returns a plan.
7
+ //
8
+ // The three components are the MCP server, the Telegram connector and cowork.
9
+ // None of them IS the daemon; all three attach to one. A normal install carries
10
+ // the complete stack, while the messenger remains deliberately out of scope.
11
+
12
+ import { join, resolve } from 'node:path';
13
+ import { pkgSpec, resolveChannel } from './logic.mjs';
14
+
15
+ // `pkg` is the package's IDENTITY — bare, no dist-tag — because that is what
16
+ // `npm ls -g --json <pkg>` needs to read an installed version back, and what a
17
+ // component is keyed by in every summary. `specKey` is the same package's name
18
+ // in lib/logic.mjs's channel map, and `componentSpec` below is the only thing
19
+ // that turns the two into an `npm i -g` argument.
20
+ //
21
+ // THE TWO MUST STAY SEPARATE. Putting the tag in `pkg` would make
22
+ // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
23
+ // which fails the cowork version floor CLOSED and blanks the version column —
24
+ // a silent regression that looks like "cowork is too old".
25
+ // `required` is not a stronger default — it is the absence of a choice. The
26
+ // daemon is the ours-sdk CLI and this package is the MCP server every harness
27
+ // speaks to it through, so an operator who declined it would have a daemon no
28
+ // harness can reach — which is not a decision anyone means to make. Declining
29
+ // has to be impossible rather than discouraged.
30
+ export const COMPONENTS = [
31
+ { key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
32
+ { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: true },
33
+ { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: true },
34
+ ];
35
+
36
+ /**
37
+ * The `npm i -g` argument for one component on one channel.
38
+ *
39
+ * WHY THIS EXISTS AT ALL. `args.channel` was resolved in the orchestrator and
40
+ * then reached only lib/extras.mjs's two planners, while these three packages
41
+ * were installed by their bare names. So `CHANNEL=nightly` installed the NIGHTLY
42
+ * Codex/Hermes plugins and NIGHTLY ours-fleet beside a STABLE MCP server — the
43
+ * split-brain deployment the channel exists to prevent, and the same class of
44
+ * bug the extras.mjs channel-map correction fixed for ours-fleet one package
45
+ * over. Every install path for these three now goes through here.
46
+ *
47
+ * All three publish a real `nightly` dist-tag (verified against the registry,
48
+ * so none of these pins can 404. `pkgSpec` falls back to `latest`
49
+ * for anything unmapped rather than inventing a tag, so an unknown component
50
+ * degrades to today's behaviour instead of failing.
51
+ *
52
+ * ON THE STABLE CHANNEL THIS RETURNS THE BARE NAME, byte for byte what shipped
53
+ * before. `npm i -g pkg` and `npm i -g pkg@latest` are the same install, so
54
+ * appending the tag would have bought nothing and changed every stable screen
55
+ * line and assertion. The nightly channel is the case that was broken; it is the
56
+ * only case that changes.
57
+ */
58
+ export const componentByKey = (key) => COMPONENTS.find((c) => c.key === key);
59
+
60
+ export function componentSpec(component, channel = 'latest') {
61
+ const key = typeof component === 'string' ? component : component?.specKey ?? component?.key;
62
+ const name = typeof component === 'string' ? null : component?.pkg ?? null;
63
+ if (resolveChannel(channel) === 'latest') return name ?? `@ours.network/${String(key).replace(/^@ours\.network\//, '')}`;
64
+ return pkgSpec(key, channel);
65
+ }
66
+
67
+ // cowork must be at least this build to understand an external-daemon block.
68
+ export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
69
+
70
+ // -----------------------------------------------------------------------------
71
+ // THE REGISTRY THAT IS NOT OURS TO TOUCH
72
+ // -----------------------------------------------------------------------------
73
+
74
+ /**
75
+ * The Telegram connector keeps its OWN registry of routes, and the installer must
76
+ * never write into it.
77
+ *
78
+ * ~/.ours-telegram/bots.json the bot registry
79
+ * ~/.ours-telegram/<route>/ one directory per route, each holding
80
+ * identity.key, state_data.bin, connection.json
81
+ *
82
+ * (tg-connector `src/connector.ts:35-42,170-173`, `src/config.ts:39`.)
83
+ *
84
+ * WHY THIS IS STATED AS A CONSTANT AND ASSERTED BY A TEST. The connector
85
+ * distinguishes its identities by walking its own state directory, NOT by asking
86
+ * the daemon: a daemon's identity list is FLAT and carries no attribution to the
87
+ * app that created it. So there is no way to reconstruct this registry from the
88
+ * daemon side, and anything the installer clears here is gone. The installer's
89
+ * entire business with the connector is three keys in one config file.
90
+ *
91
+ * It also keeps a future route migration possible — moving each route's packet
92
+ * into the shared daemon has to read this registry to know which daemon identity
93
+ * corresponds to which route, and an installer that trampled it would have
94
+ * destroyed the mapping.
95
+ */
96
+ export const TG_STATE_DIR_NAME = '.ours-telegram';
97
+ export const TG_REGISTRY_FILES = ['bots.json'];
98
+ export const TG_ROUTE_FILES = ['identity.key', 'state_data.bin', 'connection.json'];
99
+
100
+ export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home, TG_STATE_DIR_NAME, 'config.json');
101
+ export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
102
+
103
+ // -----------------------------------------------------------------------------
104
+ // §5 — selection
105
+ // -----------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Which components this run installs. The default is the complete stack: MCP,
109
+ * Telegram and cowork all attach to the same daemon. Explicit programmatic
110
+ * answers are retained for compatibility, but the public installer no longer
111
+ * asks the user to assemble the product one package at a time.
112
+ *
113
+ * `installed` marks a component already present so the question reads "keep it?"
114
+ * — and DECLINING AN ALREADY-INSTALLED COMPONENT NEVER UNINSTALLS IT. Removal is
115
+ * `ours-uninstall`. A "no" here means "do not add", never "take it away".
116
+ */
117
+ export function planComponentSelection({ answers = {}, installed = {}, assumeYes = false } = {}) {
118
+ return COMPONENTS.map((component) => {
119
+ const already = installed[component.key] === true;
120
+ const answer = assumeYes ? component.default : answers[component.key];
121
+ // A required component ignores the answer entirely, including an explicit no.
122
+ // The daemon phase has already installed and started it by the time this runs;
123
+ // "skip" here would only produce a screen that contradicts the machine.
124
+ const wanted = component.required
125
+ ? true
126
+ : (answer === undefined ? (already || component.default) : answer === true);
127
+ return {
128
+ ...component,
129
+ already,
130
+ action: already ? (wanted ? 'keep' : 'leave-alone') : (wanted ? 'install' : 'skip'),
131
+ };
132
+ });
133
+ }
134
+
135
+ // -----------------------------------------------------------------------------
136
+ // §5 — the MCP server
137
+ // -----------------------------------------------------------------------------
138
+
139
+ /**
140
+ * The MCP server runs per session, not as a daemon: the harness spawns
141
+ * `ours-mcp proxy` over stdio. No systemd unit is installed for it, and the only
142
+ * thing written to disk is the harness's MCP registration.
143
+ *
144
+ * For a non-default state directory the registration carries
145
+ * `OURS_CONFIG=<state-dir>/config.json`, so the endpoint and the state directory
146
+ * travel together — which is what keeps "endpoint given, state directory
147
+ * defaulted" unreachable. Several harnesses can each point at a different daemon
148
+ * precisely because the pair lives in each registration.
149
+ */
150
+ export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'latest' }) {
151
+ const dir = resolve(stateDir);
152
+ return {
153
+ key: 'mcp',
154
+ install: ['npm', 'i', '-g', componentSpec(componentByKey('mcp'), channel)],
155
+ service: null, // deliberate: per-session stdio proxy, never a unit
156
+ harnessEnv: isDefaultStateDir ? {} : { OURS_CONFIG: join(dir, 'config.json') },
157
+ writes: ['the harness MCP registration'],
158
+ };
159
+ }
160
+
161
+ // -----------------------------------------------------------------------------
162
+ // §5 / §7 — the Telegram connector
163
+ // -----------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Attach the connector to this daemon by setting exactly three keys in its config
167
+ * file, preserving every other key in it — the file also holds the operator's bot
168
+ * token and STT settings. If all three already match, the file is not touched.
169
+ *
170
+ * Both daemon keys are ALWAYS written together. A half-formed pair is what makes
171
+ * "endpoint selected, state directory defaulted" reachable downstream, and the
172
+ * SDK refuses that before it opens a socket.
173
+ *
174
+ * Returns `action: 'confirm-repoint'` when the connector is currently attached to
175
+ * a DIFFERENT daemon. There is exactly one `daemonUrl`/`daemonStateDir` pair and
176
+ * one unit, so pointing it elsewhere MOVES the connector; it does not add a
177
+ * second one. That is not something to do silently, and unlike the legacy-unit
178
+ * case it is not recoverable by re-running: the operator's routes would be
179
+ * talking to a daemon they did not choose.
180
+ */
181
+ export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assumeYes = false, channel = 'latest' }) {
182
+ const dir = resolve(stateDir);
183
+ const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
184
+ const desired = { daemonUrl: endpoint, daemonStateDir: dir, brokerUrl };
185
+
186
+ const current = typeof base.daemonUrl === 'string' || typeof base.daemonStateDir === 'string'
187
+ ? { daemonUrl: base.daemonUrl ?? null, daemonStateDir: base.daemonStateDir ?? null }
188
+ : null;
189
+ const pointsElsewhere = current !== null
190
+ && (current.daemonStateDir === null || resolve(current.daemonStateDir) !== dir);
191
+
192
+ const merged = { ...base };
193
+ const changes = [];
194
+ for (const [key, value] of Object.entries(desired)) {
195
+ if (value === undefined || value === null) continue;
196
+ if (merged[key] === value) continue;
197
+ changes.push(key);
198
+ merged[key] = value;
199
+ }
200
+ const plan = {
201
+ key: 'tg',
202
+ install: ['npm', 'i', '-g', componentSpec(componentByKey('tg'), channel)],
203
+ changed: changes.length > 0,
204
+ changes,
205
+ config: merged,
206
+ // Telegram is intentionally staged but stopped. Its CLI currently couples
207
+ // service installation with `enable --now`, so calling it here would violate
208
+ // the installer's promise not to launch Telegram before a bot is configured.
209
+ // The end screen gives this exact command as the explicit opt-in start step.
210
+ service: null,
211
+ untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
212
+ };
213
+ if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
214
+ const repoint = {
215
+ ...plan,
216
+ action: 'confirm-repoint',
217
+ from: current,
218
+ to: { daemonUrl: endpoint, daemonStateDir: dir },
219
+ prompt: `The Telegram connector currently uses ${current.daemonUrl ?? 'an unrecorded daemon'} (${current.daemonStateDir ?? 'unrecorded state directory'}).\nPoint it at ${endpoint} (${dir}) instead? This MOVES the connector; it does not add a second one.`,
220
+ };
221
+ // Never repointed without a human, in any mode (spec §9).
222
+ return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
223
+ }
224
+
225
+ // -----------------------------------------------------------------------------
226
+ // §5 — cowork
227
+ // -----------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Compare an installed version against a floor. Prerelease-aware and CONSERVATIVE
231
+ * by design: anything unparseable is "too old", because the failure it guards is
232
+ * handing a daemon block to a build whose config is strict and whose boot is
233
+ * fail-closed. Being wrong in the cautious direction leaves cowork embedded;
234
+ * being wrong the other way stops it starting.
235
+ */
236
+ export function atLeastVersion(actual, floor) {
237
+ const parse = (v) => {
238
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
239
+ return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
240
+ };
241
+ const a = parse(actual);
242
+ const b = parse(floor);
243
+ if (!a || !b) return false;
244
+ for (let i = 0; i < 3; i += 1) {
245
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
246
+ }
247
+ if (a.pre === b.pre) return true;
248
+ if (a.pre === null) return true; // a release outranks any prerelease of the same version
249
+ if (b.pre === null) return false;
250
+ return a.pre >= b.pre;
251
+ }
252
+
253
+ /**
254
+ * cowork's external-daemon block. Both halves are required: a half-formed block
255
+ * is REFUSED rather than written, because cowork's boot is fail-closed.
256
+ *
257
+ * The top-level `stateDir` in that file is cowork's OWN private state and is left
258
+ * alone; only `daemon.stateDir` is the ours daemon's. Confusing the two fails
259
+ * closed at boot, which is why they are never touched in the same write.
260
+ */
261
+ /**
262
+ * cowork's own defaults, seeded ONLY into a config that does not have them.
263
+ *
264
+ * WHY THIS IS NOT "CONFIGURING SOMEBODY ELSE'S TOOL". On a first install cowork's
265
+ * config file does not exist, so v3 wrote it with a `daemon` block and nothing
266
+ * else — no version, no broker, no REST port. The nightly installer seeds all
267
+ * three (`planCoworkConfig`, lib/logic.mjs), and the broker in particular is a
268
+ * value the INSTALLER knows and cowork cannot guess: it is the one the operator
269
+ * chose in this run, and a cowork pointed at a different broker cannot reach the
270
+ * agents it was installed to talk to.
271
+ *
272
+ * SEEDED ONLY INTO A FILE THAT DOES NOT EXIST YET. An existing cowork config is
273
+ * copied through untouched — every key, including ones we would have defaulted.
274
+ * That line is deliberate and narrower than "write any key that is absent": a
275
+ * config the operator already has is theirs, and adding keys to it on a re-run
276
+ * would rewrite a file for no reason the operator asked for. A file this run is
277
+ * CREATING is a different thing, and it is the only case seeded here.
278
+ *
279
+ * NOT VERIFIED, and stated rather than assumed: whether cowork boots happily on a
280
+ * daemon block alone. Its own defaults may well cover all three. Seeding what the
281
+ * installer already knows is the safe direction either way, and it is what the
282
+ * flow being replaced does.
283
+ */
284
+ export const COWORK_DEFAULT_REST_PORT = 3052;
285
+
286
+ export function seedCoworkDefaults(base, { brokerUrl, home }) {
287
+ const seeded = { ...base };
288
+ const added = [];
289
+ if (seeded.version === undefined) { seeded.version = 1; added.push('version'); }
290
+ if (typeof seeded.brokerUrl !== 'string' && brokerUrl) { seeded.brokerUrl = brokerUrl; added.push('brokerUrl'); }
291
+ // cowork's OWN state directory, never the daemon's. Confusing the two fails
292
+ // closed at boot, which is why they are never written in the same expression.
293
+ if (typeof seeded.stateDir !== 'string' && home) { seeded.stateDir = join(home, '.ours-cowork'); added.push('stateDir'); }
294
+ const rest = seeded.rest && typeof seeded.rest === 'object' && !Array.isArray(seeded.rest) ? seeded.rest : null;
295
+ if (!rest || !Number.isInteger(rest.port)) {
296
+ seeded.rest = { enabled: rest?.enabled ?? true, ...(rest ?? {}), port: rest?.port ?? COWORK_DEFAULT_REST_PORT };
297
+ added.push('rest.port');
298
+ }
299
+ return { config: seeded, added };
300
+ }
301
+
302
+ export function planCoworkAttachment({ existing, endpoint, stateDir, installedVersion, channel = 'latest', brokerUrl, home }) {
303
+ const dir = resolve(stateDir);
304
+ if (!endpoint || !stateDir) {
305
+ 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' };
306
+ }
307
+ if (!atLeastVersion(installedVersion, COWORK_DAEMON_FLOOR)) {
308
+ return {
309
+ key: 'cowork',
310
+ action: 'leave-embedded',
311
+ reason: 'version-floor',
312
+ installedVersion: installedVersion ?? null,
313
+ message: `the installed cowork (${installedVersion ?? 'version unreadable'}) predates ${COWORK_DAEMON_FLOOR}, which is the first build that understands an external-daemon block; leaving cowork embedded`,
314
+ };
315
+ }
316
+ const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
317
+ const daemon = { mode: 'external', endpoint, stateDir: dir };
318
+ const daemonUnchanged = base.daemon
319
+ && base.daemon.mode === 'external'
320
+ && base.daemon.endpoint === endpoint
321
+ && typeof base.daemon.stateDir === 'string'
322
+ && resolve(base.daemon.stateDir) === dir;
323
+ // Seeded only where the key is ABSENT, so an existing config is still copied
324
+ // through untouched — which was already the right behaviour and is not changed.
325
+ const creating = existing === null || existing === undefined;
326
+ const { config: seeded, added } = creating
327
+ ? seedCoworkDefaults(base, { brokerUrl, home })
328
+ : { config: base, added: [] };
329
+ const unchanged = daemonUnchanged && added.length === 0;
330
+ return {
331
+ key: 'cowork',
332
+ action: unchanged ? 'unchanged' : 'attach',
333
+ install: ['npm', 'i', '-g', componentSpec(componentByKey('cowork'), channel)],
334
+ changed: !unchanged,
335
+ seeded: added,
336
+ // The top-level stateDir is cowork's own; an existing one is copied through
337
+ // untouched and only an ABSENT one is seeded. Confusing it with the daemon's
338
+ // fails closed at boot, which is why they are never written together.
339
+ config: { ...seeded, daemon },
340
+ service: ['ours-cowork', 'install-service'],
341
+ };
342
+ }
343
+
344
+ // -----------------------------------------------------------------------------
345
+ // §5 — one component failing does not stop the others
346
+ // -----------------------------------------------------------------------------
347
+
348
+ /**
349
+ * A component that fails is reported with its reason and the exact manual
350
+ * command, and the run continues to the next. The daemon and the
351
+ * already-installed components stay as they are — a failed component is never a
352
+ * reason to undo a successful one.
353
+ */
354
+ export function summarizeComponentRun(results) {
355
+ return {
356
+ installed: results.filter((r) => r.state === 'installed').map((r) => r.key),
357
+ failed: results.filter((r) => r.state === 'failed').map((r) => ({ key: r.key, reason: r.reason, retry: r.retry ?? null })),
358
+ skipped: results.filter((r) => r.state === 'skipped').map((r) => r.key),
359
+ continued: true,
360
+ };
361
+ }
package/lib/detect.mjs ADDED
@@ -0,0 +1,169 @@
1
+ // ours-install v3 — which daemons are already on this machine, and which one this
2
+ // run is for.
3
+ //
4
+ // Never ask for a state directory or a port to be TYPED (spec §2). When several
5
+ // daemons are DETECTED, show them and let one be picked: selecting from what was
6
+ // found is not prompting for a path.
7
+ //
8
+ // Pure, like target.mjs and plan.mjs: the caller injects the directory listing and
9
+ // the file reads, so the whole decision is testable without a filesystem.
10
+ //
11
+ // DETECTION, NOT A REGISTRY. This is built from what is on disk rather than from a
12
+ // persisted list: a stored list is a second source of truth that goes stale against
13
+ // the daemons that really exist, which is how an installer ends up confidently
14
+ // offering a daemon that is gone.
15
+
16
+ import { basename, join, resolve } from 'node:path';
17
+
18
+ // Artefacts ONLY a daemon writes. A directory carrying any of these is a daemon's
19
+ // state directory, whatever else is in it.
20
+ export const DAEMON_ARTEFACTS = ['daemon-token', 'ours-cli-daemon.json', 'root.json'];
21
+
22
+ /**
23
+ * CONFIG.JSON IS THE ONE PIECE OF EVIDENCE THAT IS AMBIGUOUS, SO IT CANNOT BE THE
24
+ * TEST. This is the trap this function exists to avoid, and it is worth stating
25
+ * plainly because the obvious implementation walks straight into it:
26
+ *
27
+ * `~/.ours-telegram/config.json` and `~/.ours-cowork/config.json` both exist on a
28
+ * normal machine, both match a `~/.ours*` scan, and NEITHER is a daemon — they are
29
+ * the connectors' own configs. A selection screen built on "has a config.json"
30
+ * shows three daemons on a machine with one, and choosing the Telegram connector's
31
+ * directory would have the installer create a daemon inside it.
32
+ *
33
+ * So a config.json counts only when its SHAPE is a daemon's: it records a `port`
34
+ * or a `stateDir`, and it carries none of the keys that identify it as somebody
35
+ * else's. `daemonUrl`/`daemonStateDir` are the Telegram connector's; a `daemon`
36
+ * block is cowork's; `botToken` is the connector's too.
37
+ */
38
+ export function looksLikeDaemonConfig(config) {
39
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
40
+ if (typeof config.daemonUrl === 'string' || typeof config.daemonStateDir === 'string') return false;
41
+ if (config.daemon !== undefined) return false;
42
+ if (typeof config.botToken === 'string') return false;
43
+ return typeof config.port === 'number' || typeof config.stateDir === 'string';
44
+ }
45
+
46
+ /**
47
+ * Is this directory a daemon's state directory? Returns the evidence, so a screen
48
+ * or a test can say WHY rather than just yes.
49
+ */
50
+ export function classifyStateDir(dir, { exists, readJson }) {
51
+ const target = resolve(dir);
52
+ const artefact = DAEMON_ARTEFACTS.find((name) => exists(join(target, name)));
53
+ if (artefact) return { isDaemon: true, evidence: artefact, config: readJson(join(target, 'config.json')) };
54
+ const config = readJson(join(target, 'config.json'));
55
+ if (looksLikeDaemonConfig(config)) return { isDaemon: true, evidence: 'config.json', config };
56
+ return { isDaemon: false, evidence: null, config: null };
57
+ }
58
+
59
+ /**
60
+ * Every daemon state directory this machine can be seen to have.
61
+ *
62
+ * KNOWN LIMIT, inherited from effects.knownStateDirs and stated rather than
63
+ * implied: only `~/.ours` and its `~/.ours*` siblings are looked at. A state
64
+ * directory somewhere else entirely is not found, and the failure is that it is not
65
+ * offered — never that the wrong one is chosen, because `--state-dir` still names
66
+ * anything and overrides all of this.
67
+ */
68
+ export function detectDaemons({ candidates = [], exists, readJson }) {
69
+ const found = [];
70
+ for (const dir of candidates) {
71
+ const target = resolve(dir);
72
+ if (found.some((d) => d.stateDir === target)) continue;
73
+ const verdict = classifyStateDir(target, { exists, readJson });
74
+ if (!verdict.isDaemon) continue;
75
+ found.push({
76
+ stateDir: target,
77
+ port: typeof verdict.config?.port === 'number' ? verdict.config.port : null,
78
+ evidence: verdict.evidence,
79
+ label: basename(target),
80
+ });
81
+ }
82
+ // Deterministic, and the default daemon first when it is one of them — it is the
83
+ // one an operator means by "my daemon".
84
+ return found.sort((a, b) => {
85
+ if (basename(a.stateDir) === '.ours') return -1;
86
+ if (basename(b.stateDir) === '.ours') return 1;
87
+ return a.stateDir.localeCompare(b.stateDir);
88
+ });
89
+ }
90
+
91
+ /**
92
+ * A state directory for a daemon this run would CREATE, derived and never typed.
93
+ *
94
+ * Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
95
+ * to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
96
+ * An operator who wants a specific path still has `--state-dir`, which bypasses
97
+ * this screen entirely.
98
+ */
99
+ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
100
+ const used = new Set(taken.map((d) => resolve(d)));
101
+ const first = resolve(join(home, '.ours'));
102
+ if (!used.has(first)) return first;
103
+ for (let n = 2; n < limit; n += 1) {
104
+ const candidate = resolve(join(home, `.ours-${n}`));
105
+ if (!used.has(candidate)) return candidate;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ export const SELECT_CREATE = '__create__';
111
+
112
+ /**
113
+ * What this run should do about choosing a daemon (C1's five rules, in one place
114
+ * so they can be read together and tested without a terminal):
115
+ *
116
+ * flags given → no screen at all. `--state-dir` or `--port` is an
117
+ * explicit target and outranks anything detected.
118
+ * non-interactive → no screen. Flags only, and every existing refusal still
119
+ * applies — OURS_ASSUME_YES suppresses questions, never a
120
+ * refusal.
121
+ * none detected → create, exactly as before.
122
+ * exactly one detected → no prompt, use it, and SAY which one. A question with
123
+ * one answer is not a choice, it is a keystroke tax.
124
+ * several detected → show them and pick, with "create a new one" as the last
125
+ * option.
126
+ */
127
+ export function planDaemonSelection({
128
+ candidates = [],
129
+ stateDirExplicit = false,
130
+ portExplicit = false,
131
+ assumeYes = false,
132
+ home,
133
+ } = {}) {
134
+ if (stateDirExplicit || portExplicit) {
135
+ return { action: 'flags', reason: stateDirExplicit ? '--state-dir names the target' : '--port names the target' };
136
+ }
137
+ if (assumeYes) return { action: 'flags', reason: 'non-interactive: flags only, no screen' };
138
+ if (candidates.length === 0) return { action: 'create', stateDir: deriveNewStateDir(home, []) };
139
+ if (candidates.length === 1) {
140
+ return { action: 'use', stateDir: candidates[0].stateDir, only: candidates[0], announce: true };
141
+ }
142
+ return {
143
+ action: 'choose',
144
+ candidates,
145
+ createOption: { id: SELECT_CREATE, stateDir: deriveNewStateDir(home, candidates.map((c) => c.stateDir)) },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Resolve a typed answer against the offered list.
151
+ *
152
+ * Deliberately strict: an answer that is not a number in range, or the create
153
+ * option, is NOT a state directory to be interpreted. Accepting free text here
154
+ * would be exactly the "type a path" prompt spec §2 forbids, arriving through the
155
+ * back door.
156
+ */
157
+ export function resolveSelection(answer, { candidates = [], createOption = null } = {}) {
158
+ const value = String(answer ?? '').trim().toLowerCase();
159
+ if (!value) return { action: 'invalid', reason: 'no answer' };
160
+ if (value === 'n' || value === 'new' || value === String(candidates.length + 1)) {
161
+ return createOption?.stateDir
162
+ ? { action: 'create', stateDir: createOption.stateDir }
163
+ : { action: 'invalid', reason: 'no free state directory could be derived' };
164
+ }
165
+ if (!/^\d+$/.test(value)) return { action: 'invalid', reason: 'that is not one of the numbers offered' };
166
+ const index = Number(value) - 1;
167
+ if (!Number.isInteger(index) || !candidates[index]) return { action: 'invalid', reason: 'that is not one of the numbers offered' };
168
+ return { action: 'use', stateDir: candidates[index].stateDir };
169
+ }