@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 +24 -11
- package/lib/components.mjs +55 -4
- package/lib/detect.mjs +182 -0
- package/lib/extras.mjs +43 -0
- package/lib/nightly-install.mjs +23 -0
- package/lib/nightly-uninstall.mjs +23 -0
- package/lib/orchestrate.mjs +153 -2
- package/lib/profiles.mjs +23 -0
- package/lib/target.mjs +5 -1
- package/lib/uninstall.mjs +31 -3
- package/package.json +1 -1
- package/uninstall.mjs +26 -7
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 {
|
|
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;
|
package/lib/components.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
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
|
// -----------------------------------------------------------------------------
|
package/lib/nightly-install.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 { 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';
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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({
|
|
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.
|
|
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 {
|
|
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
|
+
});
|