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

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.
@@ -1,351 +0,0 @@
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. The messenger is out of
10
- // scope by the owner's instruction, and ours-fleet sits on top and is not
11
- // installed here.
12
-
13
- import { join, resolve } from 'node:path';
14
- import { pkgSpec, resolveChannel } from './logic.mjs';
15
-
16
- // `pkg` is the package's IDENTITY — bare, no dist-tag — because that is what
17
- // `npm ls -g --json <pkg>` needs to read an installed version back, and what a
18
- // component is keyed by in every summary. `specKey` is the same package's name
19
- // in lib/logic.mjs's channel map, and `componentSpec` below is the only thing
20
- // that turns the two into an `npm i -g` argument.
21
- //
22
- // THE TWO MUST STAY SEPARATE. Putting the tag in `pkg` would make
23
- // effects.installedVersion('@ours.network/mcp@nightly') return null forever,
24
- // which fails the cowork version floor CLOSED and blanks the version column —
25
- // a silent regression that looks like "cowork is too old".
26
- export const COMPONENTS = [
27
- { key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
28
- { key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
29
- { key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
30
- ];
31
-
32
- /**
33
- * The `npm i -g` argument for one component on one channel.
34
- *
35
- * WHY THIS EXISTS AT ALL. `args.channel` was resolved in the orchestrator and
36
- * then reached only lib/extras.mjs's two planners, while these three packages
37
- * were installed by their bare names. So `CHANNEL=nightly` installed the NIGHTLY
38
- * Codex/Hermes plugins and NIGHTLY ours-fleet beside a STABLE MCP server — the
39
- * split-brain deployment the channel exists to prevent, and the same class of
40
- * bug the extras.mjs channel-map correction fixed for ours-fleet one package
41
- * over. Every install path for these three now goes through here.
42
- *
43
- * All three publish a real `nightly` dist-tag (verified against the registry,
44
- * 2026-08-17), so none of these pins can 404. `pkgSpec` falls back to `latest`
45
- * for anything unmapped rather than inventing a tag, so an unknown component
46
- * degrades to today's behaviour instead of failing.
47
- *
48
- * ON THE STABLE CHANNEL THIS RETURNS THE BARE NAME, byte for byte what shipped
49
- * before. `npm i -g pkg` and `npm i -g pkg@latest` are the same install, so
50
- * appending the tag would have bought nothing and changed every stable screen
51
- * line and assertion. The nightly channel is the case that was broken; it is the
52
- * only case that changes.
53
- */
54
- export const componentByKey = (key) => COMPONENTS.find((c) => c.key === key);
55
-
56
- export function componentSpec(component, channel = 'latest') {
57
- const key = typeof component === 'string' ? component : component?.specKey ?? component?.key;
58
- const name = typeof component === 'string' ? null : component?.pkg ?? null;
59
- if (resolveChannel(channel) === 'latest') return name ?? `@ours.network/${String(key).replace(/^@ours\.network\//, '')}`;
60
- return pkgSpec(key, channel);
61
- }
62
-
63
- // cowork must be at least this build to understand an external-daemon block.
64
- export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
65
-
66
- // -----------------------------------------------------------------------------
67
- // THE REGISTRY THAT IS NOT OURS TO TOUCH
68
- // -----------------------------------------------------------------------------
69
-
70
- /**
71
- * The Telegram connector keeps its OWN registry of routes, and the installer must
72
- * never write into it.
73
- *
74
- * ~/.ours-telegram/bots.json the bot registry
75
- * ~/.ours-telegram/<route>/ one directory per route, each holding
76
- * identity.key, state_data.bin, connection.json
77
- *
78
- * (tg-connector `src/connector.ts:35-42,170-173`, `src/config.ts:39`.)
79
- *
80
- * WHY THIS IS STATED AS A CONSTANT AND ASSERTED BY A TEST. The connector
81
- * distinguishes its identities by walking its own state directory, NOT by asking
82
- * the daemon: a daemon's identity list is FLAT and carries no attribution to the
83
- * app that created it. So there is no way to reconstruct this registry from the
84
- * daemon side, and anything the installer clears here is gone. The installer's
85
- * entire business with the connector is three keys in one config file.
86
- *
87
- * This also keeps the door open for the route migration the owner is considering
88
- * — moving each route's packet into the shared daemon. That migration has to read
89
- * this registry to know which daemon identity corresponds to which route; an
90
- * installer that had trampled it would have destroyed the mapping.
91
- */
92
- export const TG_STATE_DIR_NAME = '.ours-telegram';
93
- export const TG_REGISTRY_FILES = ['bots.json'];
94
- export const TG_ROUTE_FILES = ['identity.key', 'state_data.bin', 'connection.json'];
95
-
96
- export const tgConfigPath = (home, env = {}) => env.OURS_TG_CONFIG ?? join(home, TG_STATE_DIR_NAME, 'config.json');
97
- export const coworkConfigPath = (home, env = {}) => env.OURS_COWORK_CONFIG ?? join(home, '.ours-cowork', 'config.json');
98
-
99
- // -----------------------------------------------------------------------------
100
- // §5 — selection
101
- // -----------------------------------------------------------------------------
102
-
103
- /**
104
- * Which components this run installs. Defaults are MCP server yes, connector no,
105
- * cowork no — the same answers a non-interactive run takes, so
106
- * `OURS_ASSUME_YES=1 ours-install` produces a daemon plus the MCP server and
107
- * nothing else (spec §9).
108
- *
109
- * `installed` marks a component already present so the question reads "keep it?"
110
- * — and DECLINING AN ALREADY-INSTALLED COMPONENT NEVER UNINSTALLS IT. Removal is
111
- * `ours-uninstall`. A "no" here means "do not add", never "take it away".
112
- */
113
- export function planComponentSelection({ answers = {}, installed = {}, assumeYes = false } = {}) {
114
- return COMPONENTS.map((component) => {
115
- const already = installed[component.key] === true;
116
- const answer = assumeYes ? component.default : answers[component.key];
117
- const wanted = answer === undefined ? (already || component.default) : answer === true;
118
- return {
119
- ...component,
120
- already,
121
- action: already ? (wanted ? 'keep' : 'leave-alone') : (wanted ? 'install' : 'skip'),
122
- };
123
- });
124
- }
125
-
126
- // -----------------------------------------------------------------------------
127
- // §5 — the MCP server
128
- // -----------------------------------------------------------------------------
129
-
130
- /**
131
- * The MCP server runs per session, not as a daemon: the harness spawns
132
- * `ours-mcp proxy` over stdio. No systemd unit is installed for it, and the only
133
- * thing written to disk is the harness's MCP registration.
134
- *
135
- * For a non-default state directory the registration carries
136
- * `OURS_CONFIG=<state-dir>/config.json`, so the endpoint and the state directory
137
- * travel together — which is what keeps "endpoint given, state directory
138
- * defaulted" unreachable. Several harnesses can each point at a different daemon
139
- * precisely because the pair lives in each registration.
140
- */
141
- export function planMcpAttachment({ stateDir, isDefaultStateDir, channel = 'latest' }) {
142
- const dir = resolve(stateDir);
143
- return {
144
- key: 'mcp',
145
- install: ['npm', 'i', '-g', componentSpec(componentByKey('mcp'), channel)],
146
- service: null, // deliberate: per-session stdio proxy, never a unit
147
- harnessEnv: isDefaultStateDir ? {} : { OURS_CONFIG: join(dir, 'config.json') },
148
- writes: ['the harness MCP registration'],
149
- };
150
- }
151
-
152
- // -----------------------------------------------------------------------------
153
- // §5 / §7 — the Telegram connector
154
- // -----------------------------------------------------------------------------
155
-
156
- /**
157
- * Attach the connector to this daemon by setting exactly three keys in its config
158
- * file, preserving every other key in it — the file also holds the operator's bot
159
- * token and STT settings. If all three already match, the file is not touched.
160
- *
161
- * Both daemon keys are ALWAYS written together. A half-formed pair is what makes
162
- * "endpoint selected, state directory defaulted" reachable downstream, and the
163
- * SDK refuses that before it opens a socket.
164
- *
165
- * Returns `action: 'confirm-repoint'` when the connector is currently attached to
166
- * a DIFFERENT daemon. There is exactly one `daemonUrl`/`daemonStateDir` pair and
167
- * one unit, so pointing it elsewhere MOVES the connector; it does not add a
168
- * second one. That is not something to do silently, and unlike the legacy-unit
169
- * case it is not recoverable by re-running: the operator's routes would be
170
- * talking to a daemon they did not choose.
171
- */
172
- export function planTgAttachment({ existing, endpoint, stateDir, brokerUrl, assumeYes = false, channel = 'latest' }) {
173
- const dir = resolve(stateDir);
174
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
175
- const desired = { daemonUrl: endpoint, daemonStateDir: dir, brokerUrl };
176
-
177
- const current = typeof base.daemonUrl === 'string' || typeof base.daemonStateDir === 'string'
178
- ? { daemonUrl: base.daemonUrl ?? null, daemonStateDir: base.daemonStateDir ?? null }
179
- : null;
180
- const pointsElsewhere = current !== null
181
- && (current.daemonStateDir === null || resolve(current.daemonStateDir) !== dir);
182
-
183
- const merged = { ...base };
184
- const changes = [];
185
- for (const [key, value] of Object.entries(desired)) {
186
- if (value === undefined || value === null) continue;
187
- if (merged[key] === value) continue;
188
- changes.push(key);
189
- merged[key] = value;
190
- }
191
- const plan = {
192
- key: 'tg',
193
- install: ['npm', 'i', '-g', componentSpec(componentByKey('tg'), channel)],
194
- changed: changes.length > 0,
195
- changes,
196
- config: merged,
197
- // Written BEFORE the service: `ours-tg-connector install-service` bakes the
198
- // resolved values into the unit as environment, and environment outranks the
199
- // config file afterwards.
200
- service: ['ours-tg-connector', 'install-service'],
201
- untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
202
- };
203
- if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
204
- const repoint = {
205
- ...plan,
206
- action: 'confirm-repoint',
207
- from: current,
208
- to: { daemonUrl: endpoint, daemonStateDir: dir },
209
- 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.`,
210
- };
211
- // Never repointed without a human, in any mode (spec §9).
212
- return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
213
- }
214
-
215
- // -----------------------------------------------------------------------------
216
- // §5 — cowork
217
- // -----------------------------------------------------------------------------
218
-
219
- /**
220
- * Compare an installed version against a floor. Prerelease-aware and CONSERVATIVE
221
- * by design: anything unparseable is "too old", because the failure it guards is
222
- * handing a daemon block to a build whose config is strict and whose boot is
223
- * fail-closed. Being wrong in the cautious direction leaves cowork embedded;
224
- * being wrong the other way stops it starting.
225
- */
226
- export function atLeastVersion(actual, floor) {
227
- const parse = (v) => {
228
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
229
- return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
230
- };
231
- const a = parse(actual);
232
- const b = parse(floor);
233
- if (!a || !b) return false;
234
- for (let i = 0; i < 3; i += 1) {
235
- if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
236
- }
237
- if (a.pre === b.pre) return true;
238
- if (a.pre === null) return true; // a release outranks any prerelease of the same version
239
- if (b.pre === null) return false;
240
- return a.pre >= b.pre;
241
- }
242
-
243
- /**
244
- * cowork's external-daemon block. Both halves are required: a half-formed block
245
- * is REFUSED rather than written, because cowork's boot is fail-closed.
246
- *
247
- * The top-level `stateDir` in that file is cowork's OWN private state and is left
248
- * alone; only `daemon.stateDir` is the ours daemon's. Confusing the two fails
249
- * closed at boot, which is why they are never touched in the same write.
250
- */
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 }) {
293
- const dir = resolve(stateDir);
294
- if (!endpoint || !stateDir) {
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' };
296
- }
297
- if (!atLeastVersion(installedVersion, COWORK_DAEMON_FLOOR)) {
298
- return {
299
- key: 'cowork',
300
- action: 'leave-embedded',
301
- reason: 'version-floor',
302
- installedVersion: installedVersion ?? null,
303
- 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`,
304
- };
305
- }
306
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
307
- const daemon = { mode: 'external', endpoint, stateDir: dir };
308
- const daemonUnchanged = base.daemon
309
- && base.daemon.mode === 'external'
310
- && base.daemon.endpoint === endpoint
311
- && typeof base.daemon.stateDir === 'string'
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;
320
- return {
321
- key: 'cowork',
322
- action: unchanged ? 'unchanged' : 'attach',
323
- install: ['npm', 'i', '-g', componentSpec(componentByKey('cowork'), channel)],
324
- changed: !unchanged,
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 },
330
- service: ['ours-cowork', 'install-service'],
331
- };
332
- }
333
-
334
- // -----------------------------------------------------------------------------
335
- // §5 — one component failing does not stop the others
336
- // -----------------------------------------------------------------------------
337
-
338
- /**
339
- * A component that fails is reported with its reason and the exact manual
340
- * command, and the run continues to the next. The daemon and the
341
- * already-installed components stay as they are — a failed component is never a
342
- * reason to undo a successful one.
343
- */
344
- export function summarizeComponentRun(results) {
345
- return {
346
- installed: results.filter((r) => r.state === 'installed').map((r) => r.key),
347
- failed: results.filter((r) => r.state === 'failed').map((r) => ({ key: r.key, reason: r.reason, retry: r.retry ?? null })),
348
- skipped: results.filter((r) => r.state === 'skipped').map((r) => r.key),
349
- continued: true,
350
- };
351
- }
package/lib/detect.mjs DELETED
@@ -1,182 +0,0 @@
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
- }