@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.
- package/README.md +24 -217
- package/install.mjs +80 -472
- package/lib/logic.mjs +21 -419
- package/package.json +1 -1
- package/uninstall.mjs +4 -36
- package/lib/components.mjs +0 -351
- package/lib/detect.mjs +0 -182
- package/lib/effects.mjs +0 -331
- package/lib/extras.mjs +0 -400
- package/lib/journal.mjs +0 -113
- package/lib/nightly-install.mjs +0 -739
- package/lib/nightly-uninstall.mjs +0 -396
- package/lib/orchestrate-uninstall.mjs +0 -293
- package/lib/orchestrate.mjs +0 -1035
- package/lib/plan.mjs +0 -238
- package/lib/profiles.mjs +0 -524
- package/lib/rerun.mjs +0 -119
- package/lib/target.mjs +0 -357
- package/lib/uninstall.mjs +0 -467
- package/lib/usage.mjs +0 -47
package/lib/orchestrate.mjs
DELETED
|
@@ -1,1035 +0,0 @@
|
|
|
1
|
-
// ours-install v3 — the orchestrator.
|
|
2
|
-
//
|
|
3
|
-
// This is the part that cannot be pure: it walks the flow, renders the screens
|
|
4
|
-
// and runs the commands. Everything it DECIDES comes from lib/target.mjs,
|
|
5
|
-
// lib/plan.mjs, lib/components.mjs, lib/rerun.mjs and lib/uninstall.mjs, which
|
|
6
|
-
// stay pure and separately tested.
|
|
7
|
-
//
|
|
8
|
-
// The seam is `effects`: every side effect the run can have arrives through one
|
|
9
|
-
// injected object, so the whole orchestration is testable without a socket, a
|
|
10
|
-
// filesystem, a subprocess or a terminal. That is not a testing convenience — it
|
|
11
|
-
// is what makes `--dry-run` trustworthy, because a dry run is the same walk with
|
|
12
|
-
// the mutating effects replaced by a recorder.
|
|
13
|
-
//
|
|
14
|
-
// Effects contract (all injected; the real ones live in lib/effects.mjs):
|
|
15
|
-
// probe(port) -> { ok: true, stateDir } | { ok: false, reason }
|
|
16
|
-
// isTaken(port) -> boolean
|
|
17
|
-
// readJson(path) -> object | null
|
|
18
|
-
// readText(path) -> string | null
|
|
19
|
-
// writeJson(path, text) -> void (atomic; never called on a dry run)
|
|
20
|
-
// run(cmd, args) -> { ok, code, stdout } (never on a dry run)
|
|
21
|
-
// installedVersion(pkg) -> string | null
|
|
22
|
-
// out(line) -> void
|
|
23
|
-
// ask(prompt, default) -> boolean (never called when assumeYes)
|
|
24
|
-
// now() -> number
|
|
25
|
-
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
import { parseInstallArgs, resolveTarget, InstallUsageError } from './target.mjs';
|
|
28
|
-
import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './plan.mjs';
|
|
29
|
-
import {
|
|
30
|
-
COMPONENTS,
|
|
31
|
-
planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
|
|
32
|
-
tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
|
|
33
|
-
} from './components.mjs';
|
|
34
|
-
import { planHarnessPlugins, planFleet, planVoice, buildHandoffPromptV3, restartHints } from './extras.mjs';
|
|
35
|
-
import { summarizeRun } from './rerun.mjs';
|
|
36
|
-
import { configJournal, reportRollback } from './journal.mjs';
|
|
37
|
-
import { detectDaemons, planDaemonSelection, resolveSelection, legacyRegistryPath } from './detect.mjs';
|
|
38
|
-
import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
|
|
39
|
-
import { daemonEnv } from './effects.mjs';
|
|
40
|
-
import { USAGE } from './usage.mjs';
|
|
41
|
-
import { ok, info, warn, heading, banner, box, c } from './ui.mjs';
|
|
42
|
-
|
|
43
|
-
export const EXIT_OK = 0;
|
|
44
|
-
export const EXIT_REFUSED = 2;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A dry run prints what it WOULD do and performs no mutation. The prefix is the
|
|
48
|
-
* existing installer's, kept so the two flows read the same.
|
|
49
|
-
*/
|
|
50
|
-
const wouldPrefix = (label) => `[dry-run] would: ${label}`;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* One mutating step. `dryRun` is checked HERE, once, rather than at each call
|
|
54
|
-
* site — a side effect that forgets the check is the way a dry run stops being
|
|
55
|
-
* one, and there is exactly one place to get it wrong.
|
|
56
|
-
*/
|
|
57
|
-
async function perform(effects, dryRun, label, thunk) {
|
|
58
|
-
if (dryRun) {
|
|
59
|
-
effects.out(info(wouldPrefix(label)));
|
|
60
|
-
return { performed: false, dryRun: true };
|
|
61
|
-
}
|
|
62
|
-
const result = await thunk();
|
|
63
|
-
effects.out(ok(label));
|
|
64
|
-
return { performed: true, ...(result ?? {}) };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* A step that is allowed to fail without ending the run.
|
|
71
|
-
*
|
|
72
|
-
* Everything after the daemon phase is an EXTRA: a harness plugin, ours-fleet,
|
|
73
|
-
* voice. None of them is a reason to undo a daemon that came up correctly, and
|
|
74
|
-
* v2's golden rule — never dead-end — is the same rule stated for a whole phase
|
|
75
|
-
* rather than one harness. So a failure here is one honest line plus whatever
|
|
76
|
-
* the caller wants to say about retrying, and the walk continues.
|
|
77
|
-
*/
|
|
78
|
-
async function attempt(effects, dryRun, label, thunk) {
|
|
79
|
-
try {
|
|
80
|
-
return { ok: true, ...(await perform(effects, dryRun, label, thunk)) };
|
|
81
|
-
} catch (error) {
|
|
82
|
-
effects.out(warn(`${label} — did not complete: ${reason(error)}`));
|
|
83
|
-
return { ok: false, error };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The pair, for one child invocation, from a plan that says it can carry one.
|
|
89
|
-
*
|
|
90
|
-
* lib/extras.mjs states the INTENT (`env: { OURS_CONFIG }` or `{}`); daemonEnv
|
|
91
|
-
* builds the whole thing. The two are deliberately not the same object: a plan
|
|
92
|
-
* is pure and knows only the state directory, while a pair also needs the port,
|
|
93
|
-
* and it is the half-pair — one name set, the rest defaulted to ~/.ours — that
|
|
94
|
-
* silently attaches a child to a daemon the operator never chose.
|
|
95
|
-
*/
|
|
96
|
-
function pairFor(plan, target) {
|
|
97
|
-
return plan && plan.env && Object.keys(plan.env).length > 0
|
|
98
|
-
? daemonEnv(target.stateDir, target.port)
|
|
99
|
-
: undefined;
|
|
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
|
-
|
|
168
|
-
/**
|
|
169
|
-
* The daemon half of a run: §§2-4. Returns the target decision plus the step
|
|
170
|
-
* outcomes, or a refusal.
|
|
171
|
-
*/
|
|
172
|
-
export async function runDaemonPhase(args, effects) {
|
|
173
|
-
const target = await resolveTarget({
|
|
174
|
-
stateDir: args.stateDir,
|
|
175
|
-
port: args.port,
|
|
176
|
-
portExplicit: args.portExplicit,
|
|
177
|
-
probe: effects.probe,
|
|
178
|
-
readJson: effects.readJson,
|
|
179
|
-
isTaken: effects.isTaken,
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
if (target.action === 'refuse') {
|
|
183
|
-
effects.out(warn(`ours: refusing to continue — ${target.message}`));
|
|
184
|
-
if (target.reason === 'foreign-daemon') {
|
|
185
|
-
effects.out(info('Fix: re-run with --port for a free port, or with --state-dir naming the state directory that daemon actually owns.'));
|
|
186
|
-
}
|
|
187
|
-
if (target.reason === 'port-mismatch') {
|
|
188
|
-
effects.out(info('Re-run without --port to use the recorded port. Nothing was written.'));
|
|
189
|
-
}
|
|
190
|
-
return { refused: target };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const dir = target.stateDir;
|
|
194
|
-
const creating = target.action === 'create';
|
|
195
|
-
effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
|
|
196
|
-
if (target.stalePidRecord) {
|
|
197
|
-
effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
|
|
198
|
-
}
|
|
199
|
-
if (target.defaultPortHeldBy) {
|
|
200
|
-
// Reported, never silent: the operator should know why this daemon did not
|
|
201
|
-
// get the port they might have expected, and whose daemon has it.
|
|
202
|
-
const held = target.defaultPortHeldBy;
|
|
203
|
-
effects.out(info(`port ${held.port} is held by another ours daemon${held.stateDir ? ` (state directory ${held.stateDir})` : ''}; this one uses ${target.port}`));
|
|
204
|
-
}
|
|
205
|
-
if (target.reservedNotice) {
|
|
206
|
-
effects.out(info(`port ${target.reservedNotice} is the Telegram connector's default port`));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Asked ONCE, and only when this run is creating the daemon — an existing
|
|
210
|
-
// daemon's broker is its own record, and re-asking would invite an operator to
|
|
211
|
-
// change it from a screen that is not about changing it. Owner ruling: the
|
|
212
|
-
// broker question stays in v3. It is orthogonal to --state-dir/--port, so it
|
|
213
|
-
// does not violate spec §2's "nothing about a state directory or a port
|
|
214
|
-
// appears in any prompt".
|
|
215
|
-
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
216
|
-
|
|
217
|
-
const steps = [];
|
|
218
|
-
|
|
219
|
-
await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
|
|
220
|
-
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
221
|
-
|
|
222
|
-
// The config file — merged, never rewritten, and untouched when it already
|
|
223
|
-
// matches. No provenance marker is written: the owner ruled that --purge works
|
|
224
|
-
// on any state directory, so a `createdBy` key would have had no consumer, and
|
|
225
|
-
// an unread key in a user's config file is future confusion for nothing.
|
|
226
|
-
const configPath = join(dir, 'config.json');
|
|
227
|
-
const merged = planDaemonConfig(
|
|
228
|
-
effects.readJson(configPath),
|
|
229
|
-
{ port: target.port, stateDir: dir, brokerUrl: args.brokerUrl },
|
|
230
|
-
);
|
|
231
|
-
// THE BYTES AND THE DAEMON THEY DESCRIBE ARE ONE UNIT OF WORK.
|
|
232
|
-
//
|
|
233
|
-
// config.json is written here, and only the two steps AFTER it make what it says
|
|
234
|
-
// true. Without the journal, a start or a service install that failed left a
|
|
235
|
-
// file naming a port nothing listens on — and the next run reads that file FIRST
|
|
236
|
-
// (lib/target.mjs findDaemon), probes the wrong port, and has only the
|
|
237
|
-
// ours-cli-daemon.json lookup between it and creating a SECOND daemon on this
|
|
238
|
-
// state directory. Two writers on one state_data.bin is the corruption case that
|
|
239
|
-
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
240
|
-
// conditions for it.
|
|
241
|
-
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
242
|
-
if (merged.changed) {
|
|
243
|
-
journal.snapshot(configPath);
|
|
244
|
-
await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
|
|
245
|
-
} else {
|
|
246
|
-
effects.out(ok(`${configPath} already correct — not touched`));
|
|
247
|
-
}
|
|
248
|
-
steps.push({ id: 'config', changed: merged.changed, reason: merged.changed ? undefined : 'already correct' });
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
if (creating) {
|
|
252
|
-
await perform(effects, args.dryRun, `start the daemon on port ${target.port}`, () => effects.run('ours', ['daemon', 'start', '--config', configPath]));
|
|
253
|
-
steps.push({ id: 'start', changed: true });
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const service = await runServicePhase(args, effects, dir);
|
|
257
|
-
if (service.refused) {
|
|
258
|
-
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
259
|
-
// unit file stops the run just as a failed start does, and it stops it with
|
|
260
|
-
// config.json already rewritten — indistinguishable to the operator. Same
|
|
261
|
-
// rollback, same report. Every exit path that leaves written bytes
|
|
262
|
-
// describing a state we did not reach gets this treatment.
|
|
263
|
-
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back');
|
|
264
|
-
return { target, refused: service.refused, steps };
|
|
265
|
-
}
|
|
266
|
-
steps.push(service.step);
|
|
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;
|
|
278
|
-
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
|
|
279
|
-
replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
|
|
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
|
-
}
|
|
287
|
-
throw error;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return { target, steps };
|
|
291
|
-
}
|
|
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
|
-
|
|
315
|
-
/**
|
|
316
|
-
* One rollback, one report, one function — because a rollback whose report is
|
|
317
|
-
* missing reads to the operator exactly like a run that quietly did nothing, and
|
|
318
|
-
* with four call sites the way to guarantee the report is to make it impossible to
|
|
319
|
-
* skip.
|
|
320
|
-
*
|
|
321
|
-
* Package installs are deliberately named as not-rolled-back: by every call site
|
|
322
|
-
* at least one has run, and saying so is the honest boundary rather than an
|
|
323
|
-
* apology.
|
|
324
|
-
*
|
|
325
|
-
* `replacedUnit` is the substitute for something this package must NOT do. A
|
|
326
|
-
* legacy ours-mcp unit adopted under --force cannot be put back: writing unit
|
|
327
|
-
* bytes into ~/.config/systemd/user would break the invariant that systemd is
|
|
328
|
-
* reached only through `ours daemon install-service`, and without a daemon-reload
|
|
329
|
-
* it would not even mean anything. So the unit is NAMED instead — the one
|
|
330
|
-
* informational line the operator already scrolled past, repeated at the moment it
|
|
331
|
-
* matters. This is a report, not a fix, and it is recorded as still-open in the
|
|
332
|
-
* behaviour inventory rather than allowed to look covered.
|
|
333
|
-
*/
|
|
334
|
-
function rollBack(effects, journal, args, why, { packagesInstalled = true, replacedUnit = null } = {}) {
|
|
335
|
-
if (args.dryRun) return;
|
|
336
|
-
effects.out(warn(why));
|
|
337
|
-
const reported = reportRollback(effects, journal.restoreAll(), { packagesInstalled });
|
|
338
|
-
if (replacedUnit) {
|
|
339
|
-
effects.out(warn(`${replacedUnit} was already replaced with the CLI-managed unit and is NOT restored — the older ours-mcp unit is gone. Your state directory is untouched; re-run ours-install once the cause above is fixed.`));
|
|
340
|
-
}
|
|
341
|
-
return reported;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* The boot service: §4 step 4, including the legacy-unit case.
|
|
346
|
-
*
|
|
347
|
-
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
348
|
-
* the file — the owner's decision. `--force` is passed ONLY here, only for a unit
|
|
349
|
-
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
350
|
-
*/
|
|
351
|
-
export async function runServicePhase(args, effects, dir) {
|
|
352
|
-
const plan = planServiceInstall({ stateDir: dir, home: effects.home, readText: effects.readText });
|
|
353
|
-
if (plan.action === 'refuse') {
|
|
354
|
-
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
355
|
-
return { refused: plan };
|
|
356
|
-
}
|
|
357
|
-
const adopting = plan.action === 'adopt';
|
|
358
|
-
if (adopting) effects.out(info(plan.notice));
|
|
359
|
-
const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
|
|
360
|
-
let outcome;
|
|
361
|
-
try {
|
|
362
|
-
outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
|
|
363
|
-
} catch (error) {
|
|
364
|
-
// The plan travels with the failure so the caller's rollback can say WHICH
|
|
365
|
-
// unit was replaced. It cannot re-derive that afterwards: once --force has
|
|
366
|
-
// rewritten the file, classifying it again reports a cli-managed unit and the
|
|
367
|
-
// fact that a legacy one was adopted is gone.
|
|
368
|
-
error.servicePlan = plan;
|
|
369
|
-
throw error;
|
|
370
|
-
}
|
|
371
|
-
// Whether the unit actually changed is the CLI's answer, not ours: it does the
|
|
372
|
-
// byte comparison and returns `changed` in its --json plan. Guessing here would
|
|
373
|
-
// let a run that rewrote a unit report "nothing changed". Unreadable output is
|
|
374
|
-
// treated as "changed", which is the safe direction for a summary line.
|
|
375
|
-
const changed = readChanged(outcome.stdout);
|
|
376
|
-
return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// The CLI's --json plan, when we can read it. Deliberately lenient: this is a
|
|
380
|
-
// summary line, not a decision, and it must never throw on unexpected output.
|
|
381
|
-
function readChanged(stdout) {
|
|
382
|
-
if (typeof stdout !== 'string' || !stdout.trim()) return true;
|
|
383
|
-
try {
|
|
384
|
-
const parsed = JSON.parse(stdout);
|
|
385
|
-
return typeof parsed?.changed === 'boolean' ? parsed.changed : true;
|
|
386
|
-
} catch {
|
|
387
|
-
return true;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Components: §5. A component that fails is reported with its retry command and
|
|
393
|
-
* the run CONTINUES — a failed component is never a reason to undo a successful
|
|
394
|
-
* one, or to undo the daemon.
|
|
395
|
-
*/
|
|
396
|
-
export async function runComponentPhase(args, effects, target) {
|
|
397
|
-
const dir = target.stateDir;
|
|
398
|
-
const endpoint = `http://127.0.0.1:${target.port}`;
|
|
399
|
-
const isDefaultStateDir = dir === join(effects.home, '.ours');
|
|
400
|
-
const chosen = planComponentSelection({
|
|
401
|
-
answers: args.answers ?? {},
|
|
402
|
-
installed: args.installed ?? {},
|
|
403
|
-
assumeYes: args.assumeYes,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const results = [];
|
|
407
|
-
for (const component of chosen) {
|
|
408
|
-
if (component.action === 'skip' || component.action === 'leave-alone') {
|
|
409
|
-
effects.out(info(`${component.label} — ${component.action === 'skip' ? 'not installed' : 'left as it is'}`));
|
|
410
|
-
results.push({ key: component.key, state: 'skipped' });
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
try {
|
|
414
|
-
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
|
|
415
|
-
} catch (error) {
|
|
416
|
-
// Reported with its reason and the exact manual command; the run continues.
|
|
417
|
-
// The retry carries the CHANNEL — a nightly run that hands the operator a
|
|
418
|
-
// stable retry command sends them straight into the split-brain install
|
|
419
|
-
// this phase exists to avoid.
|
|
420
|
-
const retry = `npm i -g ${componentSpec(component, args.channel)}`;
|
|
421
|
-
effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
422
|
-
effects.out(info(`retry manually: ${retry}`));
|
|
423
|
-
results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return summarizeComponentRun(results);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
|
|
430
|
-
if (component.key === 'mcp') {
|
|
431
|
-
const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
|
|
432
|
-
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
433
|
-
if (Object.keys(plan.harnessEnv).length > 0) {
|
|
434
|
-
effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
|
|
435
|
-
}
|
|
436
|
-
return { key: 'mcp', state: 'installed' };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (component.key === 'tg') {
|
|
440
|
-
const path = tgConfigPath(effects.home, effects.env);
|
|
441
|
-
const plan = planTgAttachment({
|
|
442
|
-
existing: effects.readJson(path),
|
|
443
|
-
endpoint,
|
|
444
|
-
stateDir: dir,
|
|
445
|
-
brokerUrl: args.brokerUrl,
|
|
446
|
-
assumeYes: args.assumeYes,
|
|
447
|
-
channel: args.channel,
|
|
448
|
-
});
|
|
449
|
-
if (plan.action === 'skip-repoint') {
|
|
450
|
-
effects.out(info('the Telegram connector points at another daemon; never repointed non-interactively'));
|
|
451
|
-
return { key: 'tg', state: 'skipped' };
|
|
452
|
-
}
|
|
453
|
-
if (plan.action === 'confirm-repoint') {
|
|
454
|
-
// Moving a connector is not recoverable by re-running the way a replaced
|
|
455
|
-
// unit file is: the operator's routes would be talking to a daemon they
|
|
456
|
-
// never chose. So this one asks.
|
|
457
|
-
if (!(await effects.ask(plan.prompt, false))) {
|
|
458
|
-
effects.out(info('left where it is'));
|
|
459
|
-
return { key: 'tg', state: 'skipped' };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
463
|
-
// ONE UNIT OF WORK: these bytes and the service that reads them. If the
|
|
464
|
-
// service does not come up, the connector's config names this daemon while its
|
|
465
|
-
// unit still carries the OLD environment — and the phase's own catch would
|
|
466
|
-
// report one failed line and let the run print "install complete".
|
|
467
|
-
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
468
|
-
if (plan.changed) {
|
|
469
|
-
// Written BEFORE the service: install-service bakes these values into the
|
|
470
|
-
// unit as environment, and environment outranks the config file after.
|
|
471
|
-
journal.snapshot(path);
|
|
472
|
-
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
473
|
-
} else {
|
|
474
|
-
effects.out(ok(`${path} already points here — not touched`));
|
|
475
|
-
}
|
|
476
|
-
try {
|
|
477
|
-
await perform(effects, args.dryRun, 'Telegram connector service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
|
|
478
|
-
} catch (error) {
|
|
479
|
-
// Scoped to THIS component: the daemon came up correctly and is not undone
|
|
480
|
-
// by a connector that did not. That is the rule this journal's per-unit scope
|
|
481
|
-
// exists to keep.
|
|
482
|
-
rollBack(effects, journal, args, 'the Telegram connector service did not come up — putting its config back');
|
|
483
|
-
throw error;
|
|
484
|
-
}
|
|
485
|
-
return { key: 'tg', state: 'installed' };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
const path = coworkConfigPath(effects.home, effects.env);
|
|
489
|
-
// cowork is the ONE component installed before its plan exists, because the
|
|
490
|
-
// version floor applies to what is now on disk rather than what was requested
|
|
491
|
-
// — so the spec is built here rather than read off the plan. It still goes
|
|
492
|
-
// through componentSpec, so the channel reaches it like every other package.
|
|
493
|
-
const spec = componentSpec(component, args.channel);
|
|
494
|
-
await perform(effects, args.dryRun, `install ${spec}`, () => effects.run('npm', ['i', '-g', spec]));
|
|
495
|
-
// The installed version is read AFTER installing, because the floor applies to
|
|
496
|
-
// what is now on disk rather than what was requested. Read by the BARE package
|
|
497
|
-
// name: `npm ls -g` knows nothing about the dist-tag it was installed from.
|
|
498
|
-
const plan = planCoworkAttachment({
|
|
499
|
-
existing: effects.readJson(path),
|
|
500
|
-
endpoint,
|
|
501
|
-
stateDir: dir,
|
|
502
|
-
installedVersion: effects.installedVersion(component.pkg),
|
|
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,
|
|
509
|
-
});
|
|
510
|
-
if (plan.action === 'refuse' || plan.action === 'leave-embedded') {
|
|
511
|
-
effects.out(warn(`cowork: ${plan.message}`));
|
|
512
|
-
return { key: 'cowork', state: 'skipped', reason: plan.reason };
|
|
513
|
-
}
|
|
514
|
-
// Same unit of work, and cowork's version is the worse failure: its boot is
|
|
515
|
-
// fail-closed on this block, so a written block with a service that never came up
|
|
516
|
-
// does not fall back to embedded mode — it does not start at all.
|
|
517
|
-
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
518
|
-
if (plan.changed) {
|
|
519
|
-
journal.snapshot(path);
|
|
520
|
-
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
521
|
-
} else {
|
|
522
|
-
effects.out(ok(`${path} already points here — not touched`));
|
|
523
|
-
}
|
|
524
|
-
try {
|
|
525
|
-
await perform(effects, args.dryRun, 'cowork service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
|
|
526
|
-
} catch (error) {
|
|
527
|
-
rollBack(effects, journal, args, 'the cowork service did not come up — putting its config back, which leaves cowork embedded as it was');
|
|
528
|
-
throw error;
|
|
529
|
-
}
|
|
530
|
-
return { key: 'cowork', state: 'installed' };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
/**
|
|
534
|
-
* The broker question (v2's Step 0a, kept by the owner's ruling).
|
|
535
|
-
*
|
|
536
|
-
* Consent-first, with the undo built in: a mistaken custom address is one
|
|
537
|
-
* keystroke back to the standard broker, because the alternative is an operator
|
|
538
|
-
* whose agents cannot find each other and no obvious way back.
|
|
539
|
-
*/
|
|
540
|
-
export async function askBroker(args, effects) {
|
|
541
|
-
const standard = args.brokerUrl;
|
|
542
|
-
if (args.assumeYes) return standard;
|
|
543
|
-
effects.out(heading('Your broker'));
|
|
544
|
-
effects.out(info('Your agents connect through a "broker" — a shared meeting point that lets them find'));
|
|
545
|
-
effects.out(info("each other. It's secure: your messages are end-to-end encrypted, so the broker never"));
|
|
546
|
-
effects.out(info('sees what they say. Almost everyone uses the standard one — just press Enter.'));
|
|
547
|
-
if (!(await effects.ask('Use a custom broker address?', false))) {
|
|
548
|
-
effects.out(ok('using the standard broker.'));
|
|
549
|
-
return standard;
|
|
550
|
-
}
|
|
551
|
-
const entered = String(await effects.askLine('Enter the broker address: ', '')).trim();
|
|
552
|
-
const checked = validateBroker(entered);
|
|
553
|
-
if (!entered || !checked.ok || checked.empty) {
|
|
554
|
-
if (entered) effects.out(warn(`"${entered}" doesn't look like a ws:// address — using the standard broker.`));
|
|
555
|
-
else effects.out(ok('using the standard broker.'));
|
|
556
|
-
return standard;
|
|
557
|
-
}
|
|
558
|
-
if (await effects.ask(`Use "${checked.value}"? (No = go back to the standard broker)`, true)) {
|
|
559
|
-
effects.out(ok(`broker set to ${checked.value}.`));
|
|
560
|
-
return checked.value;
|
|
561
|
-
}
|
|
562
|
-
effects.out(ok('using the standard broker.'));
|
|
563
|
-
return standard;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Pre-flight: the two disasters worth catching before anything is touched.
|
|
568
|
-
*
|
|
569
|
-
* Carried over from the v2 body deliberately. Native Windows and a Node older
|
|
570
|
-
* than 20 are not "the install went badly", they are "this cannot work here",
|
|
571
|
-
* and finding that out after the daemon package is on disk helps nobody.
|
|
572
|
-
*
|
|
573
|
-
* NOT carried over: v2 also exited when no harness was found. Under v3 the
|
|
574
|
-
* daemon is the product and the harness plugins are one extra among several, so
|
|
575
|
-
* a machine with no harness still gets a working daemon and is told what is
|
|
576
|
-
* missing. That is a deliberate divergence from v2, recorded here rather than
|
|
577
|
-
* discovered.
|
|
578
|
-
*/
|
|
579
|
-
export function runPreflight(effects) {
|
|
580
|
-
effects.out(heading('Checking your machine'));
|
|
581
|
-
const plat = detectPlatform({
|
|
582
|
-
platform: effects.platform?.platform,
|
|
583
|
-
release: effects.platform?.release ?? '',
|
|
584
|
-
env: effects.env,
|
|
585
|
-
});
|
|
586
|
-
if (!plat.supported) {
|
|
587
|
-
if (plat.os === 'windows') {
|
|
588
|
-
effects.out(warn(`${plat.label} isn't supported directly yet.`));
|
|
589
|
-
effects.out(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
|
|
590
|
-
effects.out(info('https://learn.microsoft.com/windows/wsl/install'));
|
|
591
|
-
} else {
|
|
592
|
-
effects.out(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
|
|
593
|
-
}
|
|
594
|
-
return { ok: false, platform: plat };
|
|
595
|
-
}
|
|
596
|
-
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
597
|
-
const version = String(effects.nodeVersion ?? '0');
|
|
598
|
-
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
599
|
-
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
600
|
-
return { ok: false, platform: plat, node: version };
|
|
601
|
-
}
|
|
602
|
-
effects.out(ok(`Node.js ${version}`));
|
|
603
|
-
return { ok: true, platform: plat, node: version };
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* The human identity: `ours-mcp create-root`, kept by the owner's ruling.
|
|
608
|
-
*
|
|
609
|
-
* It needs `ours-mcp` on PATH and a reachable daemon, so under v3 it lands here
|
|
610
|
-
* — after the component phase installed the MCP server, not back in the daemon
|
|
611
|
-
* phase where v2 had it. Already-exists is a friendly keep, never an error, and
|
|
612
|
-
* an unreachable daemon gets the exact retry command rather than "ask your agent
|
|
613
|
-
* later"; the hand-off's identity step is the fallback for both failures.
|
|
614
|
-
*/
|
|
615
|
-
export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
616
|
-
effects.out(heading('Your human identity'));
|
|
617
|
-
if (!mcpReady) {
|
|
618
|
-
effects.out(info('this needs the MCP server, which this run did not install — re-run ours-install to add both.'));
|
|
619
|
-
return { key: 'identity', label: 'Human identity', state: 'skipped', note: 'no MCP server' };
|
|
620
|
-
}
|
|
621
|
-
effects.out(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
|
|
622
|
-
effects.out(info('people. (Internally this is your ours root; you just give it a name.)'));
|
|
623
|
-
|
|
624
|
-
const fallback = effects.username();
|
|
625
|
-
const name = (args.assumeYes
|
|
626
|
-
? fallback
|
|
627
|
-
: String(await effects.askLine(`What name should others see? [${fallback}]: `, fallback) || fallback)).trim() || fallback;
|
|
628
|
-
|
|
629
|
-
const env = daemonEnv(target.stateDir, target.port);
|
|
630
|
-
if (args.dryRun) {
|
|
631
|
-
effects.out(info(wouldPrefix(`ours-mcp create-root "${name}"`)));
|
|
632
|
-
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
633
|
-
}
|
|
634
|
-
try {
|
|
635
|
-
const result = await effects.run('ours-mcp', ['create-root', name], { env });
|
|
636
|
-
const existing = String(result?.stdout ?? '').match(/already exists \("([^"]+)"\)/);
|
|
637
|
-
if (existing) {
|
|
638
|
-
effects.out(ok(`You already have a human identity ("${existing[1]}") — keeping it.`));
|
|
639
|
-
return { key: 'identity', label: 'Human identity', state: 'current', note: existing[1] };
|
|
640
|
-
}
|
|
641
|
-
effects.out(ok(`Your human identity "${name}" is created.`));
|
|
642
|
-
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
643
|
-
} catch (error) {
|
|
644
|
-
const text = reason(error);
|
|
645
|
-
if (/already exists/.test(text)) {
|
|
646
|
-
effects.out(ok('You already have a human identity — keeping it.'));
|
|
647
|
-
return { key: 'identity', label: 'Human identity', state: 'current', note: 'existing identity kept' };
|
|
648
|
-
}
|
|
649
|
-
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
650
|
-
effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
651
|
-
effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours-mcp create-root "${name}"'.`));
|
|
652
|
-
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
653
|
-
}
|
|
654
|
-
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
655
|
-
effects.out(info(`Retry any time: 'ours-mcp create-root "${name}"'.`));
|
|
656
|
-
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* The harness plugins (spec §5's other half).
|
|
662
|
-
*
|
|
663
|
-
* Two things this phase must never do, both inherited rules rather than new
|
|
664
|
-
* ones. It never DRIVES a command it could not identify — an alias or a wrapper
|
|
665
|
-
* that would not answer `--version` is printed as manual steps instead. And it
|
|
666
|
-
* never CLAIMS the pair travelled when it did not: Claude Code's and Codex's
|
|
667
|
-
* registrations cannot carry a value, so for a non-default state directory they
|
|
668
|
-
* get the exact export line and no promise. Hermes' writer is ours, so its
|
|
669
|
-
* invocation carries the whole pair and the claim is true.
|
|
670
|
-
*/
|
|
671
|
-
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
|
|
672
|
-
effects.out(heading('Harness plugins'));
|
|
673
|
-
const detected = await effects.detectHarnesses();
|
|
674
|
-
for (const h of detected) {
|
|
675
|
-
if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
|
|
676
|
-
else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
|
|
677
|
-
else if (h.status === 'unsafe') effects.out(warn(`'${h.command ?? h.name}' → on your PATH but didn't answer safely (manual steps below)`));
|
|
678
|
-
else effects.out(info(`'${h.command ?? h.name}' → not installed (skipped)`));
|
|
679
|
-
}
|
|
680
|
-
if (detected.every((h) => h.status === 'absent')) {
|
|
681
|
-
effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
|
|
682
|
-
effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
|
|
683
|
-
return [];
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Asked BEFORE planning, because the plan's `wanted` is the answer. Only a
|
|
687
|
-
// harness we can actually drive is worth a question — the others are going to
|
|
688
|
-
// print manual steps whatever the operator says.
|
|
689
|
-
const answers = {};
|
|
690
|
-
if (!args.assumeYes) {
|
|
691
|
-
for (const h of detected) {
|
|
692
|
-
if (h.status !== 'ok') continue;
|
|
693
|
-
answers[h.name] = await effects.ask(`Install the ours plugin into ${h.label ?? h.name}?`, true);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
const plans = planHarnessPlugins({
|
|
698
|
-
harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
|
|
699
|
-
stateDir: target.stateDir,
|
|
700
|
-
isDefaultStateDir,
|
|
701
|
-
channel: args.channel,
|
|
702
|
-
assumeYes: args.assumeYes,
|
|
703
|
-
answers,
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
const rows = [];
|
|
707
|
-
for (const plan of plans) {
|
|
708
|
-
const row = { key: plan.name, label: `${plan.label} plugin` };
|
|
709
|
-
if (plan.action === 'skip') {
|
|
710
|
-
if (plan.reason !== 'not installed') effects.out(info(`${plan.label} — ${plan.reason}`));
|
|
711
|
-
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
if (plan.action === 'manual') {
|
|
715
|
-
// NEVER a dead end: the plugin is still installable, by hand, and the run
|
|
716
|
-
// says so instead of pretending the harness does not exist.
|
|
717
|
-
effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
|
|
718
|
-
for (const step of plan.manual) effects.out(info(` ${step}`));
|
|
719
|
-
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const env = pairFor(plan, target);
|
|
724
|
-
let failed = null;
|
|
725
|
-
for (const step of plan.steps) {
|
|
726
|
-
const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
|
|
727
|
-
if (!outcome.ok) { failed = outcome; break; }
|
|
728
|
-
}
|
|
729
|
-
if (failed) {
|
|
730
|
-
effects.out(info(`${plan.label} can still be installed by hand:`));
|
|
731
|
-
for (const step of plan.manual) effects.out(info(` ${step}`));
|
|
732
|
-
rows.push({ ...row, state: 'failed', note: 'install step failed' });
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
if (plan.envLine) {
|
|
736
|
-
// The honest line. §5's promise does NOT hold for this harness, and the
|
|
737
|
-
// screen says exactly what is true and exactly what to do about it.
|
|
738
|
-
effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
|
|
739
|
-
effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
|
|
740
|
-
}
|
|
741
|
-
rows.push({ ...row, state: 'installed', note: plan.envLine ? 'needs the env line above' : 'ready' });
|
|
742
|
-
}
|
|
743
|
-
return rows;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* ours-fleet. The feature that needed zero code anywhere.
|
|
748
|
-
*
|
|
749
|
-
* `ours-fleet init` takes no daemon argument and reads no daemon config; fleet
|
|
750
|
-
* resolves a daemon per role, from the role's own env. So the installer installs
|
|
751
|
-
* it, runs its one-time host setup, and — for a non-default state directory —
|
|
752
|
-
* SAYS the one fleet.yaml line. Saying it is the whole feature.
|
|
753
|
-
*/
|
|
754
|
-
export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
|
|
755
|
-
effects.out(heading('ours-fleet (your always-online agent team)'));
|
|
756
|
-
effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
|
|
757
|
-
effects.out(info('become always-online agents that survive a reboot.'));
|
|
758
|
-
const wanted = args.assumeYes ? true : await effects.ask('Install it?', true);
|
|
759
|
-
const plan = planFleet({
|
|
760
|
-
stateDir: target.stateDir, isDefaultStateDir, wanted, channel: args.channel,
|
|
761
|
-
});
|
|
762
|
-
if (plan.action === 'skip') {
|
|
763
|
-
effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
764
|
-
return { key: 'fleet', label: plan.label, state: 'skipped' };
|
|
765
|
-
}
|
|
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);
|
|
790
|
-
const init = install.ok
|
|
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 }))
|
|
792
|
-
: install;
|
|
793
|
-
if (!init.ok) {
|
|
794
|
-
effects.out(info(`retry manually: ${plan.init.join(' ')}`));
|
|
795
|
-
return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
|
|
796
|
-
}
|
|
797
|
-
effects.out(ok('ours-fleet ready — the core ours plugin discovers every option through `ours-fleet docs`.'));
|
|
798
|
-
if (plan.instruction) effects.out(info(plan.instruction));
|
|
799
|
-
return { key: 'fleet', label: plan.label, state: 'installed', note: 'CLI + core-plugin discovery' };
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Voice transcription — and the restart beat the installer now owns.
|
|
804
|
-
*
|
|
805
|
-
* Under v3 `ours-mcp voice-setup` classifies every daemon as `external`, because
|
|
806
|
-
* a v3 daemon is started by `ours daemon start` and leaves no ours-mcp pid
|
|
807
|
-
* record. So voice-setup writes the config and returns, correctly declining to
|
|
808
|
-
* restart a daemon it does not manage — and the restart nobody now owns is
|
|
809
|
-
* this phase's, because the installer is the only process that knows the daemon
|
|
810
|
-
* is CLI-managed. Failure never rolls the daemon back: voice-setup leaves the
|
|
811
|
-
* prior config intact on its own failure path, so the recovery is a retry.
|
|
812
|
-
*/
|
|
813
|
-
export async function runVoicePhase(args, effects, { target, mcpReady }) {
|
|
814
|
-
effects.out(heading('Voice messages'));
|
|
815
|
-
const env = daemonEnv(target.stateDir, target.port);
|
|
816
|
-
const ready = mcpReady && !args.dryRun ? await voiceReady(effects, env) : false;
|
|
817
|
-
|
|
818
|
-
const offer = planVoice({
|
|
819
|
-
mcpInstalled: mcpReady, ready, assumeYes: args.assumeYes, stateDir: target.stateDir, port: target.port,
|
|
820
|
-
});
|
|
821
|
-
if (offer.action === 'skip') {
|
|
822
|
-
effects.out(offer.reason === 'already-configured' ? ok(offer.message) : info(offer.message));
|
|
823
|
-
return {
|
|
824
|
-
key: 'voice',
|
|
825
|
-
label: 'Voice transcription',
|
|
826
|
-
state: offer.reason === 'already-configured' ? 'current' : 'skipped',
|
|
827
|
-
note: offer.reason === 'already-configured' ? 'configured' : offer.reason,
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
effects.out(info('Voice notes can be transcribed by a provider you choose. Audio is sent to that'));
|
|
832
|
-
effects.out(info('provider; use a self-hosted endpoint if it must stay local. The API key is hidden.'));
|
|
833
|
-
if (!(await effects.ask('Set up voice transcription now?', true))) {
|
|
834
|
-
const declined = planVoice({ mcpInstalled: mcpReady, ready, accepted: false, stateDir: target.stateDir, port: target.port });
|
|
835
|
-
effects.out(info(declined.message));
|
|
836
|
-
return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'declined; offered again on re-run' };
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
if (args.dryRun) {
|
|
840
|
-
effects.out(info(wouldPrefix(offer.setup.join(' '))));
|
|
841
|
-
return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'dry run' };
|
|
842
|
-
}
|
|
843
|
-
// Interactive on purpose: voice-setup owns the provider choice and the masked
|
|
844
|
-
// key prompt, and the installer keeps no second implementation of either.
|
|
845
|
-
const setup = await effects.runInteractive(offer.setup[0], offer.setup.slice(1), { env });
|
|
846
|
-
if (!setup.ok) {
|
|
847
|
-
effects.out(warn('Voice setup did not complete; no installer-side credential fallback was used.'));
|
|
848
|
-
effects.out(info(`Run '${offer.setup.join(' ')}' directly to try again.`));
|
|
849
|
-
return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'voice-setup did not complete' };
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const applied = planVoice({
|
|
853
|
-
mcpInstalled: mcpReady, ready, accepted: true, configChanged: true, stateDir: target.stateDir, port: target.port,
|
|
854
|
-
});
|
|
855
|
-
const restart = await attempt(effects, args.dryRun, applied.restart.join(' '), () => effects.run(applied.restart[0], applied.restart.slice(1)));
|
|
856
|
-
if (!restart.ok) {
|
|
857
|
-
effects.out(info(`the configuration is saved; apply it with: ${applied.retryHint}`));
|
|
858
|
-
return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'saved, but the daemon did not restart' };
|
|
859
|
-
}
|
|
860
|
-
if (await voiceReady(effects, env)) {
|
|
861
|
-
effects.out(ok('Voice transcription is ready; the API key remains hidden.'));
|
|
862
|
-
return { key: 'voice', label: 'Voice transcription', state: 'installed', note: 'ready' };
|
|
863
|
-
}
|
|
864
|
-
effects.out(warn(`Voice configuration was saved, but readiness was not confirmed — check '${offer.statusCheck.join(' ')}'.`));
|
|
865
|
-
return { key: 'voice', label: 'Voice transcription', state: 'failed', note: 'readiness not confirmed' };
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// Read-only, and deliberately lenient: an unreadable answer is "not ready",
|
|
869
|
-
// which offers voice setup again rather than skipping it on a bad parse.
|
|
870
|
-
async function voiceReady(effects, env) {
|
|
871
|
-
try {
|
|
872
|
-
const result = await effects.run('ours-mcp', ['voice-status', '--json'], { env });
|
|
873
|
-
return JSON.parse(String(result?.stdout ?? '').trim())?.ready === true;
|
|
874
|
-
} catch {
|
|
875
|
-
return false;
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* The final screen and the copy-paste hand-off.
|
|
881
|
-
*
|
|
882
|
-
* The hand-off is the installer's actual product: everything it could not do
|
|
883
|
-
* conversationally is handed to an agent that can. Steps for pieces this run did
|
|
884
|
-
* not install drop out and the rest renumber, so nobody is told to configure
|
|
885
|
-
* something they do not have.
|
|
886
|
-
*/
|
|
887
|
-
export async function endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl }) {
|
|
888
|
-
const rule = '═'.repeat(64);
|
|
889
|
-
effects.out('');
|
|
890
|
-
effects.out(` ${c.cyan(rule)}`);
|
|
891
|
-
effects.out(` ${c.bold('ours.network — install complete')}`);
|
|
892
|
-
effects.out(` ${c.gray(`State directory: ${target.stateDir} • Port: ${target.port}`)}`);
|
|
893
|
-
effects.out(` ${c.gray(`Broker: ${brokerUrl === effects.brokerUrl ? 'standard' : 'custom'}`)}`);
|
|
894
|
-
effects.out(` ${c.cyan(rule)}`);
|
|
895
|
-
for (const row of summary) {
|
|
896
|
-
const mark = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
|
|
897
|
-
const state = (row.state === 'installed' || row.state === 'current')
|
|
898
|
-
? (row.note || 'ready')
|
|
899
|
-
: row.state === 'skipped'
|
|
900
|
-
? c.gray(`skipped${row.note ? ` (${row.note})` : ''}`)
|
|
901
|
-
: c.red(`needs attention${row.note ? ` — ${row.note}` : ''}`);
|
|
902
|
-
effects.out(` ${mark} ${String(row.label).padEnd(26)}${(row.version ? `v${row.version}` : '').padEnd(9)}${state}`);
|
|
903
|
-
}
|
|
904
|
-
effects.out('');
|
|
905
|
-
effects.out(summary.some((r) => r.state === 'failed')
|
|
906
|
-
? ` ${c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')}`
|
|
907
|
-
: ` ${c.green('Everything installed cleanly. No problems.')}`);
|
|
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
|
-
|
|
922
|
-
const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
|
|
923
|
-
const { text, empty } = buildHandoffPromptV3({
|
|
924
|
-
identity: !has('identity'),
|
|
925
|
-
fleet: has('fleet'),
|
|
926
|
-
telegram: has('tg'),
|
|
927
|
-
stateDir: target.stateDir,
|
|
928
|
-
isDefaultStateDir,
|
|
929
|
-
});
|
|
930
|
-
if (empty) {
|
|
931
|
-
effects.out('');
|
|
932
|
-
effects.out(` ${c.green("You're all set — open your harness and just start talking to your agent.")}`);
|
|
933
|
-
} else {
|
|
934
|
-
effects.out('');
|
|
935
|
-
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
936
|
-
effects.out(` ${c.bold('ONE LAST STEP')} — copy the prompt below and paste it into your agent.`);
|
|
937
|
-
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
938
|
-
effects.out('');
|
|
939
|
-
effects.out(box(text.split('\n'), 'paste this into your agent'));
|
|
940
|
-
if (!args.dryRun && effects.clipboard(text)) effects.out(` ${c.gray('(copied to your clipboard.)')}`);
|
|
941
|
-
}
|
|
942
|
-
effects.out('');
|
|
943
|
-
effects.out(` ${c.gray('Re-run ')}${c.cyan('ours-install')}${c.gray(' any time to add a skipped piece or update.')}`);
|
|
944
|
-
effects.out(` ${c.cyan(rule)}`);
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
/**
|
|
948
|
-
* The whole run. Returns an exit code: 0, or 2 for any refusal.
|
|
949
|
-
*
|
|
950
|
-
* The order is not arbitrary and is the one thing here worth reading twice:
|
|
951
|
-
*
|
|
952
|
-
* daemon → components → identity → harness plugins → ours-fleet → voice
|
|
953
|
-
*
|
|
954
|
-
* The daemon comes first because everything else attaches to one. The COMPONENTS
|
|
955
|
-
* come second because `ours-mcp` is what the identity step and the voice step
|
|
956
|
-
* both invoke, and under v3 it is a component rather than the daemon — so
|
|
957
|
-
* anything that shells out to it has to wait for this phase, which is exactly
|
|
958
|
-
* why v2's placement of those two steps could not simply be carried across.
|
|
959
|
-
*
|
|
960
|
-
* Every refusal in this specification applies unchanged in non-interactive mode
|
|
961
|
-
* and exits 2 without writing anything — OURS_ASSUME_YES suppresses questions,
|
|
962
|
-
* never a refusal.
|
|
963
|
-
*/
|
|
964
|
-
export async function runInstall(argv, effects) {
|
|
965
|
-
let args;
|
|
966
|
-
try {
|
|
967
|
-
args = parseInstallArgs(argv, effects.env, { home: effects.home });
|
|
968
|
-
} catch (error) {
|
|
969
|
-
if (error instanceof InstallUsageError) {
|
|
970
|
-
effects.out(warn(`ours: ${error.message}`));
|
|
971
|
-
return EXIT_REFUSED;
|
|
972
|
-
}
|
|
973
|
-
throw error;
|
|
974
|
-
}
|
|
975
|
-
if (args.help) { effects.out(USAGE); return EXIT_OK; }
|
|
976
|
-
if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
977
|
-
|
|
978
|
-
args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
|
|
979
|
-
args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL);
|
|
980
|
-
|
|
981
|
-
effects.out(banner());
|
|
982
|
-
effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
|
|
983
|
-
if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
|
|
984
|
-
|
|
985
|
-
// An unsupported platform is not a refusal of an incoherent selection, it is a
|
|
986
|
-
// machine this cannot run on. v2 exited 0 there and so does this, so a script
|
|
987
|
-
// that wrapped the old installer keeps its meaning.
|
|
988
|
-
if (!runPreflight(effects).ok) return EXIT_OK;
|
|
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
|
-
|
|
995
|
-
const daemon = await runDaemonPhase(args, effects);
|
|
996
|
-
if (daemon.refused) return EXIT_REFUSED;
|
|
997
|
-
const target = daemon.target;
|
|
998
|
-
const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
|
|
999
|
-
|
|
1000
|
-
const summary = [{
|
|
1001
|
-
key: 'core',
|
|
1002
|
-
label: 'ours core (daemon)',
|
|
1003
|
-
state: target.action === 'create' ? 'installed' : 'current',
|
|
1004
|
-
note: `port ${target.port}`,
|
|
1005
|
-
}];
|
|
1006
|
-
|
|
1007
|
-
const components = await runComponentPhase(args, effects, target);
|
|
1008
|
-
for (const component of COMPONENTS) {
|
|
1009
|
-
const state = components.installed.includes(component.key) ? 'installed'
|
|
1010
|
-
: components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
|
|
1011
|
-
summary.push({
|
|
1012
|
-
key: component.key,
|
|
1013
|
-
label: component.label,
|
|
1014
|
-
state,
|
|
1015
|
-
version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
|
|
1016
|
-
note: components.failed.find((f) => f.key === component.key)?.reason,
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
const mcpReady = components.installed.includes('mcp');
|
|
1020
|
-
|
|
1021
|
-
summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
|
|
1022
|
-
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
|
|
1023
|
-
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
|
|
1024
|
-
summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
|
|
1025
|
-
|
|
1026
|
-
const changes = summarizeRun(daemon.steps);
|
|
1027
|
-
if (!changes.changedAnything) {
|
|
1028
|
-
effects.out(ok('everything already correct — nothing changed except refreshed packages'));
|
|
1029
|
-
}
|
|
1030
|
-
for (const failure of components.failed) {
|
|
1031
|
-
effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
|
|
1032
|
-
}
|
|
1033
|
-
await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
|
|
1034
|
-
return EXIT_OK;
|
|
1035
|
-
}
|