@ours.network/install 0.17.0 → 0.18.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -122
- package/install.mjs +23 -790
- package/lib/components.mjs +360 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +320 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +1051 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +47 -0
- package/package.json +1 -1
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
|
@@ -0,0 +1,360 @@
|
|
|
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 here, and ours-fleet sits on top and is not installed here.
|
|
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: false },
|
|
33
|
+
{ key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
|
|
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. Defaults are MCP server yes, connector no,
|
|
109
|
+
* cowork no — the same answers a non-interactive run takes, so
|
|
110
|
+
* `OURS_ASSUME_YES=1 ours-install` produces a daemon plus the MCP server and
|
|
111
|
+
* nothing else (spec §9).
|
|
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
|
+
// Written BEFORE the service: `ours-tg-connector install-service` bakes the
|
|
207
|
+
// resolved values into the unit as environment, and environment outranks the
|
|
208
|
+
// config file afterwards.
|
|
209
|
+
service: ['ours-tg-connector', 'install-service'],
|
|
210
|
+
untouched: [...TG_REGISTRY_FILES, ...TG_ROUTE_FILES],
|
|
211
|
+
};
|
|
212
|
+
if (!pointsElsewhere) return { ...plan, action: plan.changed ? 'attach' : 'unchanged' };
|
|
213
|
+
const repoint = {
|
|
214
|
+
...plan,
|
|
215
|
+
action: 'confirm-repoint',
|
|
216
|
+
from: current,
|
|
217
|
+
to: { daemonUrl: endpoint, daemonStateDir: dir },
|
|
218
|
+
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.`,
|
|
219
|
+
};
|
|
220
|
+
// Never repointed without a human, in any mode (spec §9).
|
|
221
|
+
return assumeYes ? { ...repoint, action: 'skip-repoint', reason: 'never repointed non-interactively' } : repoint;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -----------------------------------------------------------------------------
|
|
225
|
+
// §5 — cowork
|
|
226
|
+
// -----------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Compare an installed version against a floor. Prerelease-aware and CONSERVATIVE
|
|
230
|
+
* by design: anything unparseable is "too old", because the failure it guards is
|
|
231
|
+
* handing a daemon block to a build whose config is strict and whose boot is
|
|
232
|
+
* fail-closed. Being wrong in the cautious direction leaves cowork embedded;
|
|
233
|
+
* being wrong the other way stops it starting.
|
|
234
|
+
*/
|
|
235
|
+
export function atLeastVersion(actual, floor) {
|
|
236
|
+
const parse = (v) => {
|
|
237
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v ?? '').trim());
|
|
238
|
+
return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null;
|
|
239
|
+
};
|
|
240
|
+
const a = parse(actual);
|
|
241
|
+
const b = parse(floor);
|
|
242
|
+
if (!a || !b) return false;
|
|
243
|
+
for (let i = 0; i < 3; i += 1) {
|
|
244
|
+
if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
|
|
245
|
+
}
|
|
246
|
+
if (a.pre === b.pre) return true;
|
|
247
|
+
if (a.pre === null) return true; // a release outranks any prerelease of the same version
|
|
248
|
+
if (b.pre === null) return false;
|
|
249
|
+
return a.pre >= b.pre;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* cowork's external-daemon block. Both halves are required: a half-formed block
|
|
254
|
+
* is REFUSED rather than written, because cowork's boot is fail-closed.
|
|
255
|
+
*
|
|
256
|
+
* The top-level `stateDir` in that file is cowork's OWN private state and is left
|
|
257
|
+
* alone; only `daemon.stateDir` is the ours daemon's. Confusing the two fails
|
|
258
|
+
* closed at boot, which is why they are never touched in the same write.
|
|
259
|
+
*/
|
|
260
|
+
/**
|
|
261
|
+
* cowork's own defaults, seeded ONLY into a config that does not have them.
|
|
262
|
+
*
|
|
263
|
+
* WHY THIS IS NOT "CONFIGURING SOMEBODY ELSE'S TOOL". On a first install cowork's
|
|
264
|
+
* config file does not exist, so v3 wrote it with a `daemon` block and nothing
|
|
265
|
+
* else — no version, no broker, no REST port. The nightly installer seeds all
|
|
266
|
+
* three (`planCoworkConfig`, lib/logic.mjs), and the broker in particular is a
|
|
267
|
+
* value the INSTALLER knows and cowork cannot guess: it is the one the operator
|
|
268
|
+
* chose in this run, and a cowork pointed at a different broker cannot reach the
|
|
269
|
+
* agents it was installed to talk to.
|
|
270
|
+
*
|
|
271
|
+
* SEEDED ONLY INTO A FILE THAT DOES NOT EXIST YET. An existing cowork config is
|
|
272
|
+
* copied through untouched — every key, including ones we would have defaulted.
|
|
273
|
+
* That line is deliberate and narrower than "write any key that is absent": a
|
|
274
|
+
* config the operator already has is theirs, and adding keys to it on a re-run
|
|
275
|
+
* would rewrite a file for no reason the operator asked for. A file this run is
|
|
276
|
+
* CREATING is a different thing, and it is the only case seeded here.
|
|
277
|
+
*
|
|
278
|
+
* NOT VERIFIED, and stated rather than assumed: whether cowork boots happily on a
|
|
279
|
+
* daemon block alone. Its own defaults may well cover all three. Seeding what the
|
|
280
|
+
* installer already knows is the safe direction either way, and it is what the
|
|
281
|
+
* flow being replaced does.
|
|
282
|
+
*/
|
|
283
|
+
export const COWORK_DEFAULT_REST_PORT = 3052;
|
|
284
|
+
|
|
285
|
+
export function seedCoworkDefaults(base, { brokerUrl, home }) {
|
|
286
|
+
const seeded = { ...base };
|
|
287
|
+
const added = [];
|
|
288
|
+
if (seeded.version === undefined) { seeded.version = 1; added.push('version'); }
|
|
289
|
+
if (typeof seeded.brokerUrl !== 'string' && brokerUrl) { seeded.brokerUrl = brokerUrl; added.push('brokerUrl'); }
|
|
290
|
+
// cowork's OWN state directory, never the daemon's. Confusing the two fails
|
|
291
|
+
// closed at boot, which is why they are never written in the same expression.
|
|
292
|
+
if (typeof seeded.stateDir !== 'string' && home) { seeded.stateDir = join(home, '.ours-cowork'); added.push('stateDir'); }
|
|
293
|
+
const rest = seeded.rest && typeof seeded.rest === 'object' && !Array.isArray(seeded.rest) ? seeded.rest : null;
|
|
294
|
+
if (!rest || !Number.isInteger(rest.port)) {
|
|
295
|
+
seeded.rest = { enabled: rest?.enabled ?? true, ...(rest ?? {}), port: rest?.port ?? COWORK_DEFAULT_REST_PORT };
|
|
296
|
+
added.push('rest.port');
|
|
297
|
+
}
|
|
298
|
+
return { config: seeded, added };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function planCoworkAttachment({ existing, endpoint, stateDir, installedVersion, channel = 'latest', brokerUrl, home }) {
|
|
302
|
+
const dir = resolve(stateDir);
|
|
303
|
+
if (!endpoint || !stateDir) {
|
|
304
|
+
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' };
|
|
305
|
+
}
|
|
306
|
+
if (!atLeastVersion(installedVersion, COWORK_DAEMON_FLOOR)) {
|
|
307
|
+
return {
|
|
308
|
+
key: 'cowork',
|
|
309
|
+
action: 'leave-embedded',
|
|
310
|
+
reason: 'version-floor',
|
|
311
|
+
installedVersion: installedVersion ?? null,
|
|
312
|
+
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`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
|
316
|
+
const daemon = { mode: 'external', endpoint, stateDir: dir };
|
|
317
|
+
const daemonUnchanged = base.daemon
|
|
318
|
+
&& base.daemon.mode === 'external'
|
|
319
|
+
&& base.daemon.endpoint === endpoint
|
|
320
|
+
&& typeof base.daemon.stateDir === 'string'
|
|
321
|
+
&& resolve(base.daemon.stateDir) === dir;
|
|
322
|
+
// Seeded only where the key is ABSENT, so an existing config is still copied
|
|
323
|
+
// through untouched — which was already the right behaviour and is not changed.
|
|
324
|
+
const creating = existing === null || existing === undefined;
|
|
325
|
+
const { config: seeded, added } = creating
|
|
326
|
+
? seedCoworkDefaults(base, { brokerUrl, home })
|
|
327
|
+
: { config: base, added: [] };
|
|
328
|
+
const unchanged = daemonUnchanged && added.length === 0;
|
|
329
|
+
return {
|
|
330
|
+
key: 'cowork',
|
|
331
|
+
action: unchanged ? 'unchanged' : 'attach',
|
|
332
|
+
install: ['npm', 'i', '-g', componentSpec(componentByKey('cowork'), channel)],
|
|
333
|
+
changed: !unchanged,
|
|
334
|
+
seeded: added,
|
|
335
|
+
// The top-level stateDir is cowork's own; an existing one is copied through
|
|
336
|
+
// untouched and only an ABSENT one is seeded. Confusing it with the daemon's
|
|
337
|
+
// fails closed at boot, which is why they are never written together.
|
|
338
|
+
config: { ...seeded, daemon },
|
|
339
|
+
service: ['ours-cowork', 'install-service'],
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// -----------------------------------------------------------------------------
|
|
344
|
+
// §5 — one component failing does not stop the others
|
|
345
|
+
// -----------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A component that fails is reported with its reason and the exact manual
|
|
349
|
+
* command, and the run continues to the next. The daemon and the
|
|
350
|
+
* already-installed components stay as they are — a failed component is never a
|
|
351
|
+
* reason to undo a successful one.
|
|
352
|
+
*/
|
|
353
|
+
export function summarizeComponentRun(results) {
|
|
354
|
+
return {
|
|
355
|
+
installed: results.filter((r) => r.state === 'installed').map((r) => r.key),
|
|
356
|
+
failed: results.filter((r) => r.state === 'failed').map((r) => ({ key: r.key, reason: r.reason, retry: r.retry ?? null })),
|
|
357
|
+
skipped: results.filter((r) => r.state === 'skipped').map((r) => r.key),
|
|
358
|
+
continued: true,
|
|
359
|
+
};
|
|
360
|
+
}
|
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
|
+
}
|