@ours.network/install 0.17.0 → 0.18.0-nightly.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -120
- package/install.mjs +23 -790
- package/lib/components.mjs +361 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +351 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +984 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/ui.mjs +15 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +48 -0
- package/package.json +2 -2
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
|
@@ -0,0 +1,984 @@
|
|
|
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, componentByKey,
|
|
33
|
+
} from './components.mjs';
|
|
34
|
+
import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } from './extras.mjs';
|
|
35
|
+
import { summarizeRun } from './rerun.mjs';
|
|
36
|
+
import { configJournal, reportRollback } from './journal.mjs';
|
|
37
|
+
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
38
|
+
import { detectPlatform, resolveChannel } from './logic.mjs';
|
|
39
|
+
import { daemonEnv } from './effects.mjs';
|
|
40
|
+
import { USAGE } from './usage.mjs';
|
|
41
|
+
import { ok, info, warn, heading, banner, box, c, progress } 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?
|
|
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
|
+
const detected = detectDaemons({
|
|
115
|
+
candidates: effects.knownStateDirs(),
|
|
116
|
+
exists: effects.exists,
|
|
117
|
+
readJson: effects.readJson,
|
|
118
|
+
});
|
|
119
|
+
const plan = planDaemonSelection({
|
|
120
|
+
candidates: detected,
|
|
121
|
+
stateDirExplicit: args.stateDirExplicit,
|
|
122
|
+
portExplicit: args.portExplicit,
|
|
123
|
+
assumeYes: args.assumeYes,
|
|
124
|
+
home: effects.home,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (plan.action === 'flags' || plan.action === 'create') return { ...plan, detected };
|
|
128
|
+
if (plan.action === 'use') {
|
|
129
|
+
// A question with one answer is not a choice, it is a keystroke tax — but the
|
|
130
|
+
// operator still has to be TOLD which daemon this run is about.
|
|
131
|
+
effects.out(info(`using the ours daemon at ${plan.stateDir}${plan.only.port ? ` (port ${plan.only.port})` : ''} — the only one found`));
|
|
132
|
+
args.stateDir = plan.stateDir;
|
|
133
|
+
return { ...plan, detected };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
effects.out(heading('Which ours daemon is this for?'));
|
|
137
|
+
plan.candidates.forEach((candidate, index) => {
|
|
138
|
+
effects.out(` ${index + 1}) ${candidate.stateDir}${candidate.port ? ` port ${candidate.port}` : ''}`);
|
|
139
|
+
});
|
|
140
|
+
if (plan.createOption.stateDir) {
|
|
141
|
+
effects.out(` ${plan.candidates.length + 1}) create a new one at ${plan.createOption.stateDir}`);
|
|
142
|
+
}
|
|
143
|
+
const answer = await effects.askLine(`Choose 1-${plan.candidates.length + (plan.createOption.stateDir ? 1 : 0)}: `, '1');
|
|
144
|
+
const chosen = resolveSelection(answer, plan);
|
|
145
|
+
if (chosen.action === 'invalid') {
|
|
146
|
+
// Refused rather than guessed. Interpreting an unrecognised answer as a path
|
|
147
|
+
// would be the "type a state directory" prompt spec §2 forbids, arriving
|
|
148
|
+
// through the back door.
|
|
149
|
+
effects.out(warn(`ours: ${chosen.reason}. Nothing was changed.`));
|
|
150
|
+
effects.out(info('Re-run and pick one of the numbers, or name a daemon directly with --state-dir.'));
|
|
151
|
+
return { action: 'refuse', exitCode: EXIT_REFUSED, detected };
|
|
152
|
+
}
|
|
153
|
+
args.stateDir = chosen.stateDir;
|
|
154
|
+
effects.out(ok(chosen.action === 'create'
|
|
155
|
+
? `creating a new daemon at ${chosen.stateDir}`
|
|
156
|
+
: `using the ours daemon at ${chosen.stateDir}`));
|
|
157
|
+
return { ...chosen, detected };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The daemon half of a run: §§2-4. Returns the target decision plus the step
|
|
162
|
+
* outcomes, or a refusal.
|
|
163
|
+
*/
|
|
164
|
+
export async function runDaemonPhase(args, effects) {
|
|
165
|
+
const target = await resolveTarget({
|
|
166
|
+
stateDir: args.stateDir,
|
|
167
|
+
port: args.port,
|
|
168
|
+
portExplicit: args.portExplicit,
|
|
169
|
+
probe: effects.probe,
|
|
170
|
+
readJson: effects.readJson,
|
|
171
|
+
readText: effects.readText,
|
|
172
|
+
isTaken: effects.isTaken,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (target.action === 'refuse') {
|
|
176
|
+
effects.out(warn(`ours: refusing to continue — ${target.message}`));
|
|
177
|
+
if (target.reason === 'foreign-daemon') {
|
|
178
|
+
effects.out(info('Fix: re-run with --port for a free port, or with --state-dir naming the state directory that daemon actually owns.'));
|
|
179
|
+
}
|
|
180
|
+
if (target.reason === 'port-mismatch') {
|
|
181
|
+
effects.out(info('Re-run without --port to use the recorded port. Nothing was written.'));
|
|
182
|
+
}
|
|
183
|
+
return { refused: target };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const dir = target.stateDir;
|
|
187
|
+
const creating = target.action === 'create';
|
|
188
|
+
effects.out(heading(creating ? `target ${dir} — creating a daemon here` : `target ${dir} — daemon found on port ${target.port}`));
|
|
189
|
+
if (target.stalePidRecord) {
|
|
190
|
+
effects.out(info(`a PID record names port ${target.stalePidRecord} but nothing answers there; treating it as stale`));
|
|
191
|
+
}
|
|
192
|
+
if (target.defaultPortHeldBy) {
|
|
193
|
+
// Reported, never silent: the operator should know why this daemon did not
|
|
194
|
+
// get the port they might have expected, and whose daemon has it.
|
|
195
|
+
const held = target.defaultPortHeldBy;
|
|
196
|
+
effects.out(info(`port ${held.port} is held by another ours daemon${held.stateDir ? ` (state directory ${held.stateDir})` : ''}; this one uses ${target.port}`));
|
|
197
|
+
}
|
|
198
|
+
if (target.reservedNotice) {
|
|
199
|
+
effects.out(info(`port ${target.reservedNotice} is the Telegram connector's default port`));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Asked ONCE, and only when this run is creating the daemon — an existing
|
|
203
|
+
// daemon's broker is its own record, and re-asking would invite an operator to
|
|
204
|
+
// change it from a screen that is not about changing it. The broker question
|
|
205
|
+
// stays in v3: it is orthogonal to --state-dir/--port, so it
|
|
206
|
+
// does not violate spec §2's "nothing about a state directory or a port
|
|
207
|
+
// appears in any prompt".
|
|
208
|
+
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
209
|
+
|
|
210
|
+
const steps = [];
|
|
211
|
+
|
|
212
|
+
// The operator CLI owns the shared daemon; ours-mcp is the per-session stdio
|
|
213
|
+
// adapter each harness spawns. Both are required, but only `ours daemon`
|
|
214
|
+
// participates in lifecycle or service management.
|
|
215
|
+
const mcpPkg = componentSpec(componentByKey('mcp'), args.channel);
|
|
216
|
+
await perform(effects, args.dryRun, `MCP server installed (npm i -g ${mcpPkg})`, () => effects.run('npm', ['i', '-g', mcpPkg]));
|
|
217
|
+
steps.push({ id: 'mcp-package', changed: true, packageRefresh: true });
|
|
218
|
+
await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
|
|
219
|
+
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
220
|
+
|
|
221
|
+
// The config file — merged, never rewritten, and untouched when it already
|
|
222
|
+
// matches. No provenance marker is written: --purge works on any state
|
|
223
|
+
// directory, so a `createdBy` key would have no consumer, and
|
|
224
|
+
// an unread key in a user's config file is future confusion for nothing.
|
|
225
|
+
const configPath = join(dir, 'config.json');
|
|
226
|
+
const merged = planDaemonConfig(
|
|
227
|
+
effects.readJson(configPath),
|
|
228
|
+
{ port: target.port, stateDir: dir, brokerUrl: args.brokerUrl },
|
|
229
|
+
);
|
|
230
|
+
// THE BYTES AND THE DAEMON THEY DESCRIBE ARE ONE UNIT OF WORK.
|
|
231
|
+
//
|
|
232
|
+
// config.json is written here, and only the two steps AFTER it make what it says
|
|
233
|
+
// true. Without the journal, a start or a service install that failed left a
|
|
234
|
+
// file naming a port nothing listens on — and the next run reads that file FIRST
|
|
235
|
+
// (lib/target.mjs findDaemon), probes the wrong port, and has only the
|
|
236
|
+
// ours-cli-daemon.json lookup between it and creating a SECOND daemon on this
|
|
237
|
+
// state directory. Two writers on one state_data.bin is the corruption case that
|
|
238
|
+
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
239
|
+
// conditions for it.
|
|
240
|
+
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
241
|
+
let serviceUnsupported = null;
|
|
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, target.port);
|
|
257
|
+
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
258
|
+
if (service.refused) {
|
|
259
|
+
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
260
|
+
// unit file stops the run just as a failed start does, and it stops it with
|
|
261
|
+
// config.json already rewritten — indistinguishable to the operator. Same
|
|
262
|
+
// rollback, same report. Every exit path that leaves written bytes
|
|
263
|
+
// describing a state we did not reach gets this treatment.
|
|
264
|
+
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back');
|
|
265
|
+
return { target, refused: service.refused, steps };
|
|
266
|
+
}
|
|
267
|
+
steps.push(service.step);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// THE DAEMON MAY BE DOWN, AND NOT BECAUSE ANYTHING ASKED IT TO BE.
|
|
270
|
+
//
|
|
271
|
+
// `install-service` can STOP a running daemon before it fails — it installs a
|
|
272
|
+
// unit that will own the process, and a failure after that point leaves nothing
|
|
273
|
+
// running. v3 simply ended the run there, so a person who typed ours-install
|
|
274
|
+
// and got an error was also, silently, left without the daemon they had before.
|
|
275
|
+
// The nightly flow re-runs `start` and, crucially, tells the two outcomes
|
|
276
|
+
// apart: "the service failed but the daemon is back" is a bad evening, and "the
|
|
277
|
+
// service failed AND it will not come back" is the one that needs a human now.
|
|
278
|
+
const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
|
|
279
|
+
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
|
|
280
|
+
replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
|
|
281
|
+
});
|
|
282
|
+
if (recovery) {
|
|
283
|
+
effects.out(recovery.recovered
|
|
284
|
+
? ok('your daemon is running again — nothing was committed, and the service is unchanged')
|
|
285
|
+
: warn('and the daemon did NOT come back up — start it yourself before anything else: '
|
|
286
|
+
+ `ours daemon start --config ${configPath}`));
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return { target, steps, serviceUnsupported };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Put the daemon back after a failed boot-service install.
|
|
296
|
+
*
|
|
297
|
+
* Only attempted when the failure came from the SERVICE step (`error.servicePlan`
|
|
298
|
+
* is what says so) — a daemon that never started has nothing to recover, and
|
|
299
|
+
* running `start` after a failed `start` would just fail again with a second, less
|
|
300
|
+
* useful error on top of the first.
|
|
301
|
+
*
|
|
302
|
+
* A dry run recovers nothing because it stopped nothing. The recovery's own failure
|
|
303
|
+
* is REPORTED, never thrown: the caller is already carrying the real error, and
|
|
304
|
+
* losing it to a second one would hide what actually went wrong.
|
|
305
|
+
*/
|
|
306
|
+
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
307
|
+
if (args.dryRun) return null;
|
|
308
|
+
try {
|
|
309
|
+
await effects.run('ours', ['daemon', 'start', '--config', configPath]);
|
|
310
|
+
return { recovered: true };
|
|
311
|
+
} catch (recoveryError) {
|
|
312
|
+
return { recovered: false, reason: reason(recoveryError) };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One rollback, one report, one function — because a rollback whose report is
|
|
318
|
+
* missing reads to the operator exactly like a run that quietly did nothing, and
|
|
319
|
+
* with four call sites the way to guarantee the report is to make it impossible to
|
|
320
|
+
* skip.
|
|
321
|
+
*
|
|
322
|
+
* Package installs are deliberately named as not-rolled-back: by every call site
|
|
323
|
+
* at least one has run, and saying so is the honest boundary rather than an
|
|
324
|
+
* apology.
|
|
325
|
+
*
|
|
326
|
+
* `replacedUnit` is the substitute for something this package must NOT do. A
|
|
327
|
+
* legacy ours-mcp unit adopted under --force cannot be put back: writing unit
|
|
328
|
+
* bytes into ~/.config/systemd/user would break the invariant that systemd is
|
|
329
|
+
* reached only through `ours daemon install-service`, and without a daemon-reload
|
|
330
|
+
* it would not even mean anything. So the unit is NAMED instead — the one
|
|
331
|
+
* informational line the operator already scrolled past, repeated at the moment it
|
|
332
|
+
* matters. This is a report, not a fix, and it is recorded as still-open in the
|
|
333
|
+
* behaviour inventory rather than allowed to look covered.
|
|
334
|
+
*/
|
|
335
|
+
function rollBack(effects, journal, args, why, { packagesInstalled = true, replacedUnit = null } = {}) {
|
|
336
|
+
if (args.dryRun) return;
|
|
337
|
+
effects.out(warn(why));
|
|
338
|
+
const reported = reportRollback(effects, journal.restoreAll(), { packagesInstalled });
|
|
339
|
+
if (replacedUnit) {
|
|
340
|
+
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.`));
|
|
341
|
+
}
|
|
342
|
+
return reported;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The boot service: §4 step 4, including the legacy-unit case.
|
|
347
|
+
*
|
|
348
|
+
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
349
|
+
* the file. `--force` is passed ONLY here, only for a unit
|
|
350
|
+
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
351
|
+
*/
|
|
352
|
+
export async function runServicePhase(args, effects, dir, port) {
|
|
353
|
+
const plan = planServiceInstall({
|
|
354
|
+
stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
|
|
355
|
+
});
|
|
356
|
+
// NOT a refusal and NOT a failure: the daemon is running and correct, and only
|
|
357
|
+
// the boot service could not be installed — `ours daemon install-service` throws
|
|
358
|
+
// on any non-linux platform. The run CONTINUES, says so, and the summary marks
|
|
359
|
+
// it, because the person this hurts reboots in a fortnight and finds nothing
|
|
360
|
+
// listening.
|
|
361
|
+
if (plan.action === 'unsupported') {
|
|
362
|
+
effects.out(warn(`ours: ${plan.message}`));
|
|
363
|
+
effects.out(info(`Your daemon is installed and running now. To start it after a reboot, run: ${plan.manual.join(' ')} ${join(dir, 'config.json')}`));
|
|
364
|
+
effects.out(info('Nothing else in this run depends on the boot service.'));
|
|
365
|
+
return { step: { id: 'service', changed: false, reason: 'not available on this platform' }, plan, unsupported: plan };
|
|
366
|
+
}
|
|
367
|
+
if (plan.action === 'refuse') {
|
|
368
|
+
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
369
|
+
return { refused: plan };
|
|
370
|
+
}
|
|
371
|
+
const adopting = plan.action === 'adopt';
|
|
372
|
+
if (adopting) effects.out(info(plan.notice));
|
|
373
|
+
const command = serviceInstallCommand({ stateDir: dir, adoptLegacyUnit: adopting });
|
|
374
|
+
// The unit's bytes BEFORE, so "did it change?" survives the loss of --json.
|
|
375
|
+
// `ours daemon install-service --json` used to answer that itself; ours-mcp's
|
|
376
|
+
// takes no flags and reports nothing machine-readable. Assuming `changed: true`
|
|
377
|
+
// would make every re-run claim it rewrote the unit, which turns the honest
|
|
378
|
+
// "everything already correct" line into one that never appears — a screen that
|
|
379
|
+
// is wrong in the reassuring direction. So the installer does the comparison it
|
|
380
|
+
// used to delegate: it already reads this file to classify it.
|
|
381
|
+
const unitBefore = plan.unitPath ? effects.readText(plan.unitPath) : null;
|
|
382
|
+
let outcome;
|
|
383
|
+
try {
|
|
384
|
+
outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
// The plan travels with the failure so the caller's rollback can say WHICH
|
|
387
|
+
// unit was replaced. It cannot re-derive that afterwards: once --force has
|
|
388
|
+
// rewritten the file, classifying it again reports a cli-managed unit and the
|
|
389
|
+
// fact that a legacy one was adopted is gone.
|
|
390
|
+
error.servicePlan = plan;
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
// Ours now, by the same byte comparison the CLI used to do. A dry run changed
|
|
394
|
+
// nothing by definition; an unreadable file either side is treated as "changed",
|
|
395
|
+
// which is the safe direction for a summary line.
|
|
396
|
+
const changed = args.dryRun
|
|
397
|
+
? false
|
|
398
|
+
: (() => {
|
|
399
|
+
const after = plan.unitPath ? effects.readText(plan.unitPath) : null;
|
|
400
|
+
if (unitBefore === null || after === null) return true;
|
|
401
|
+
return unitBefore !== after;
|
|
402
|
+
})();
|
|
403
|
+
return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The CLI's --json plan, when we can read it. Deliberately lenient: this is a
|
|
407
|
+
// summary line, not a decision, and it must never throw on unexpected output.
|
|
408
|
+
function readChanged(stdout) {
|
|
409
|
+
if (typeof stdout !== 'string' || !stdout.trim()) return true;
|
|
410
|
+
try {
|
|
411
|
+
const parsed = JSON.parse(stdout);
|
|
412
|
+
return typeof parsed?.changed === 'boolean' ? parsed.changed : true;
|
|
413
|
+
} catch {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* THE QUESTION NOBODY WAS ASKING.
|
|
420
|
+
*
|
|
421
|
+
* The public installer has one product, not a package-selection questionnaire:
|
|
422
|
+
* MCP, Telegram, and cowork are all installed. Explicit injected answers remain
|
|
423
|
+
* only as a compatibility/test seam for callers that deliberately omit a piece.
|
|
424
|
+
*/
|
|
425
|
+
export async function askComponents(args, effects) {
|
|
426
|
+
// The product is the complete stack. Asking the operator to reconstruct that
|
|
427
|
+
// product from package names was choice theatre and made unattended installs
|
|
428
|
+
// incomplete. Explicit injected answers remain a test/compatibility seam.
|
|
429
|
+
return { ...(args.answers ?? {}) };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Components: §5. A component that fails is reported with its retry command and
|
|
434
|
+
* the run CONTINUES — a failed component is never a reason to undo a successful
|
|
435
|
+
* one, or to undo the daemon.
|
|
436
|
+
*/
|
|
437
|
+
export async function runComponentPhase(args, effects, target) {
|
|
438
|
+
const dir = target.stateDir;
|
|
439
|
+
const endpoint = `http://127.0.0.1:${target.port}`;
|
|
440
|
+
const isDefaultStateDir = dir === join(effects.home, '.ours');
|
|
441
|
+
const answers = await askComponents(args, effects);
|
|
442
|
+
const chosen = planComponentSelection({
|
|
443
|
+
answers,
|
|
444
|
+
installed: args.installed ?? {},
|
|
445
|
+
assumeYes: args.assumeYes,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const results = [];
|
|
449
|
+
for (const component of chosen) {
|
|
450
|
+
if (component.action === 'skip' || component.action === 'leave-alone') {
|
|
451
|
+
effects.out(info(`${component.label} — ${component.action === 'skip' ? 'not installed' : 'left as it is'}`));
|
|
452
|
+
results.push({ key: component.key, state: 'skipped' });
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
results.push(await attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }));
|
|
457
|
+
} catch (error) {
|
|
458
|
+
// Reported with its reason and the exact manual command; the run continues.
|
|
459
|
+
// The retry carries the CHANNEL — a nightly run that hands the operator a
|
|
460
|
+
// stable retry command sends them straight into the split-brain install
|
|
461
|
+
// this phase exists to avoid.
|
|
462
|
+
const retry = `npm i -g ${componentSpec(component, args.channel)}`;
|
|
463
|
+
effects.out(warn(`${component.label} failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
464
|
+
effects.out(info(`retry manually: ${retry}`));
|
|
465
|
+
results.push({ key: component.key, state: 'failed', reason: String(error?.message ?? error), retry });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return summarizeComponentRun(results);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function attachComponent(component, { args, effects, dir, endpoint, isDefaultStateDir }) {
|
|
472
|
+
if (component.key === 'mcp') {
|
|
473
|
+
const plan = planMcpAttachment({ stateDir: dir, isDefaultStateDir, channel: args.channel });
|
|
474
|
+
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
475
|
+
if (Object.keys(plan.harnessEnv).length > 0) {
|
|
476
|
+
effects.out(info(`harness registration carries OURS_CONFIG=${plan.harnessEnv.OURS_CONFIG}`));
|
|
477
|
+
}
|
|
478
|
+
return { key: 'mcp', state: 'installed' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (component.key === 'tg') {
|
|
482
|
+
const path = tgConfigPath(effects.home, effects.env);
|
|
483
|
+
const plan = planTgAttachment({
|
|
484
|
+
existing: effects.readJson(path),
|
|
485
|
+
endpoint,
|
|
486
|
+
stateDir: dir,
|
|
487
|
+
brokerUrl: args.brokerUrl,
|
|
488
|
+
assumeYes: args.assumeYes,
|
|
489
|
+
channel: args.channel,
|
|
490
|
+
});
|
|
491
|
+
if (plan.action === 'skip-repoint') {
|
|
492
|
+
effects.out(info('the Telegram connector points at another daemon; never repointed non-interactively'));
|
|
493
|
+
return { key: 'tg', state: 'skipped' };
|
|
494
|
+
}
|
|
495
|
+
if (plan.action === 'confirm-repoint') {
|
|
496
|
+
// Moving a connector is not recoverable by re-running the way a replaced
|
|
497
|
+
// unit file is: the operator's routes would be talking to a daemon they
|
|
498
|
+
// never chose. So this one asks.
|
|
499
|
+
if (!(await effects.ask(plan.prompt, false))) {
|
|
500
|
+
effects.out(info('left where it is'));
|
|
501
|
+
return { key: 'tg', state: 'skipped' };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
await perform(effects, args.dryRun, `install ${plan.install[3]}`, () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
505
|
+
if (plan.changed) {
|
|
506
|
+
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
507
|
+
} else {
|
|
508
|
+
effects.out(ok(`${path} already points here — not touched`));
|
|
509
|
+
}
|
|
510
|
+
effects.out(ok('Telegram connector installed and configured, but not started.'));
|
|
511
|
+
effects.out(info('After adding a bot, start it explicitly with: ours-tg-connector install-service'));
|
|
512
|
+
return { key: 'tg', state: 'installed', note: 'configured; stopped' };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const path = coworkConfigPath(effects.home, effects.env);
|
|
516
|
+
// cowork is the ONE component installed before its plan exists, because the
|
|
517
|
+
// version floor applies to what is now on disk rather than what was requested
|
|
518
|
+
// — so the spec is built here rather than read off the plan. It still goes
|
|
519
|
+
// through componentSpec, so the channel reaches it like every other package.
|
|
520
|
+
const spec = componentSpec(component, args.channel);
|
|
521
|
+
await perform(effects, args.dryRun, `install ${spec}`, () => effects.run('npm', ['i', '-g', spec]));
|
|
522
|
+
// The installed version is read AFTER installing, because the floor applies to
|
|
523
|
+
// what is now on disk rather than what was requested. Read by the BARE package
|
|
524
|
+
// name: `npm ls -g` knows nothing about the dist-tag it was installed from.
|
|
525
|
+
const plan = planCoworkAttachment({
|
|
526
|
+
existing: effects.readJson(path),
|
|
527
|
+
endpoint,
|
|
528
|
+
stateDir: dir,
|
|
529
|
+
installedVersion: effects.installedVersion(component.pkg),
|
|
530
|
+
channel: args.channel,
|
|
531
|
+
// The broker is a value the INSTALLER knows and cowork cannot guess — the one
|
|
532
|
+
// the operator chose in this run. `home` is for cowork's OWN state directory,
|
|
533
|
+
// never the daemon's.
|
|
534
|
+
brokerUrl: args.brokerUrl,
|
|
535
|
+
home: effects.home,
|
|
536
|
+
});
|
|
537
|
+
if (plan.action === 'refuse' || plan.action === 'leave-embedded') {
|
|
538
|
+
effects.out(warn(`cowork: ${plan.message}`));
|
|
539
|
+
return { key: 'cowork', state: 'skipped', reason: plan.reason };
|
|
540
|
+
}
|
|
541
|
+
// Same unit of work, and cowork's version is the worse failure: its boot is
|
|
542
|
+
// fail-closed on this block, so a written block with a service that never came up
|
|
543
|
+
// does not fall back to embedded mode — it does not start at all.
|
|
544
|
+
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
545
|
+
if (plan.changed) {
|
|
546
|
+
journal.snapshot(path);
|
|
547
|
+
await perform(effects, args.dryRun, `write ${path}`, () => effects.writeJson(path, `${JSON.stringify(plan.config, null, 2)}\n`));
|
|
548
|
+
} else {
|
|
549
|
+
effects.out(ok(`${path} already points here — not touched`));
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
await perform(effects, args.dryRun, 'cowork service installed', () => effects.run(plan.service[0], plan.service.slice(1)));
|
|
553
|
+
} catch (error) {
|
|
554
|
+
rollBack(effects, journal, args, 'the cowork service did not come up — putting its config back, which leaves cowork embedded as it was');
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
return { key: 'cowork', state: 'installed' };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The broker question (v2's Step 0a, deliberately kept).
|
|
562
|
+
*
|
|
563
|
+
* Consent-first, with the undo built in: a mistaken custom address is one
|
|
564
|
+
* keystroke back to the standard broker, because the alternative is an operator
|
|
565
|
+
* whose agents cannot find each other and no obvious way back.
|
|
566
|
+
*/
|
|
567
|
+
export async function askBroker(args, effects) {
|
|
568
|
+
const standard = args.brokerUrl;
|
|
569
|
+
// Custom deployments remain available through OURS_BROKER_URL. The ordinary
|
|
570
|
+
// install is intentionally linear and explains the standard safe default.
|
|
571
|
+
effects.out(info(effects.env.OURS_BROKER_URL
|
|
572
|
+
? 'All services use the end-to-end encrypted broker configured in OURS_BROKER_URL.'
|
|
573
|
+
: 'All services use the standard end-to-end encrypted ours broker.'));
|
|
574
|
+
return standard;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Pre-flight: the two disasters worth catching before anything is touched.
|
|
579
|
+
*
|
|
580
|
+
* Carried over from the v2 body deliberately. Native Windows and a Node older
|
|
581
|
+
* than 20 are not "the install went badly", they are "this cannot work here",
|
|
582
|
+
* and finding that out after the daemon package is on disk helps nobody.
|
|
583
|
+
*
|
|
584
|
+
* NOT carried over: v2 also exited when no harness was found. Under v3 the
|
|
585
|
+
* daemon is the product and the harness plugins are one extra among several, so
|
|
586
|
+
* a machine with no harness still gets a working daemon and is told what is
|
|
587
|
+
* missing. That is a deliberate divergence from v2, recorded here rather than
|
|
588
|
+
* discovered.
|
|
589
|
+
*/
|
|
590
|
+
export function runPreflight(effects) {
|
|
591
|
+
effects.out(heading('Checking your machine'));
|
|
592
|
+
const plat = detectPlatform({
|
|
593
|
+
platform: effects.platform?.platform,
|
|
594
|
+
release: effects.platform?.release ?? '',
|
|
595
|
+
env: effects.env,
|
|
596
|
+
});
|
|
597
|
+
if (!plat.supported) {
|
|
598
|
+
if (plat.os === 'windows') {
|
|
599
|
+
effects.out(warn(`${plat.label} isn't supported directly yet.`));
|
|
600
|
+
effects.out(info('Install this inside WSL (Windows Subsystem for Linux), then re-run there:'));
|
|
601
|
+
effects.out(info('https://learn.microsoft.com/windows/wsl/install'));
|
|
602
|
+
} else {
|
|
603
|
+
effects.out(warn(`Platform "${plat.label}" isn't supported. ours runs on Linux, macOS, or WSL.`));
|
|
604
|
+
}
|
|
605
|
+
return { ok: false, platform: plat };
|
|
606
|
+
}
|
|
607
|
+
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
608
|
+
if (effects.platform?.platform && effects.platform.platform !== 'linux') {
|
|
609
|
+
effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available — you will start it yourself after a reboot.`));
|
|
610
|
+
}
|
|
611
|
+
const version = String(effects.nodeVersion ?? '0');
|
|
612
|
+
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
613
|
+
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
614
|
+
return { ok: false, platform: plat, node: version };
|
|
615
|
+
}
|
|
616
|
+
effects.out(ok(`Node.js ${version}`));
|
|
617
|
+
return { ok: true, platform: plat, node: version };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The human identity: `ours identity create-root`.
|
|
622
|
+
*
|
|
623
|
+
* It needs the operator CLI and a reachable daemon. Already-exists is a friendly keep, never an error, and
|
|
624
|
+
* an unreachable daemon gets the exact retry command rather than "ask your agent
|
|
625
|
+
* later"; the hand-off's identity step is the fallback for both failures.
|
|
626
|
+
*/
|
|
627
|
+
export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
628
|
+
effects.out(heading('Your human identity'));
|
|
629
|
+
if (!mcpReady) {
|
|
630
|
+
effects.out(info('this needs the MCP server, which this run did not install — re-run ours-install to add both.'));
|
|
631
|
+
return { key: 'identity', label: 'Human identity', state: 'skipped', note: 'no MCP server' };
|
|
632
|
+
}
|
|
633
|
+
effects.out(info('This is you — the human. Your agents act on your behalf, and it lets you message'));
|
|
634
|
+
effects.out(info('people. (Internally this is your ours root; you just give it a name.)'));
|
|
635
|
+
|
|
636
|
+
const fallback = effects.username();
|
|
637
|
+
const name = (args.assumeYes
|
|
638
|
+
? fallback
|
|
639
|
+
: String(await effects.askLine(`What name should others see? [${fallback}]: `, fallback) || fallback)).trim() || fallback;
|
|
640
|
+
|
|
641
|
+
const env = daemonEnv(target.stateDir, target.port);
|
|
642
|
+
if (args.dryRun) {
|
|
643
|
+
effects.out(info(wouldPrefix(`ours identity create-root --name "${name}"`)));
|
|
644
|
+
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
await effects.run('ours', ['identity', 'create-root', '--name', name, '--json'], { env });
|
|
648
|
+
effects.out(ok(`Your human identity "${name}" is created.`));
|
|
649
|
+
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
650
|
+
} catch (error) {
|
|
651
|
+
const text = reason(error);
|
|
652
|
+
if (/already exists/.test(text)) {
|
|
653
|
+
effects.out(ok('You already have a human identity — keeping it.'));
|
|
654
|
+
return { key: 'identity', label: 'Human identity', state: 'current', note: 'existing identity kept' };
|
|
655
|
+
}
|
|
656
|
+
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
657
|
+
effects.out(warn("The daemon isn't reachable yet — couldn't create your human identity."));
|
|
658
|
+
effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
|
|
659
|
+
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
660
|
+
}
|
|
661
|
+
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
662
|
+
effects.out(info(`Retry any time: 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
|
|
663
|
+
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The harness plugins (spec §5's other half).
|
|
669
|
+
*
|
|
670
|
+
* Two things this phase must never do, both inherited rules rather than new
|
|
671
|
+
* ones. It never DRIVES a command it could not identify — an alias or a wrapper
|
|
672
|
+
* that would not answer `--version` is printed as manual steps instead. And it
|
|
673
|
+
* never CLAIMS the pair travelled when it did not: Claude Code's and Codex's
|
|
674
|
+
* registrations cannot carry a value, so for a non-default state directory they
|
|
675
|
+
* get the exact export line and no promise. Hermes' writer is ours, so its
|
|
676
|
+
* invocation carries the whole pair and the claim is true.
|
|
677
|
+
*/
|
|
678
|
+
export async function runHarnessPhase(args, effects, { target, isDefaultStateDir }) {
|
|
679
|
+
effects.out(heading('Harness plugins'));
|
|
680
|
+
const detected = await effects.detectHarnesses();
|
|
681
|
+
for (const h of detected) {
|
|
682
|
+
if (h.status === 'ok') effects.out(ok(`'${h.command ?? h.name}' → ${h.detail ?? 'real program'} (its plugin can be installed)`));
|
|
683
|
+
else if (h.status === 'alias') effects.out(warn(`'${h.command ?? h.name}' → ${h.detail} (I won't call it — manual steps below)`));
|
|
684
|
+
else if (h.status === 'unsafe') effects.out(warn(`'${h.command ?? h.name}' → on your PATH but didn't answer safely (manual steps below)`));
|
|
685
|
+
else effects.out(info(`'${h.command ?? h.name}' → not installed (skipped)`));
|
|
686
|
+
}
|
|
687
|
+
if (detected.every((h) => h.status === 'absent')) {
|
|
688
|
+
effects.out(info('No Claude Code, Codex or Hermes found — install one and re-run to wire it up.'));
|
|
689
|
+
effects.out(info('Your daemon is unaffected; nothing else in this run depends on a harness.'));
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const plans = planHarnessPlugins({
|
|
694
|
+
harnesses: detected.map((h) => ({ name: h.name, status: h.status })),
|
|
695
|
+
stateDir: target.stateDir,
|
|
696
|
+
isDefaultStateDir,
|
|
697
|
+
channel: args.channel,
|
|
698
|
+
assumeYes: true,
|
|
699
|
+
answers: {},
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
const rows = [];
|
|
703
|
+
for (const plan of plans) {
|
|
704
|
+
const row = { key: plan.name, label: `${plan.label} plugin` };
|
|
705
|
+
if (plan.action === 'skip') {
|
|
706
|
+
if (plan.reason !== 'not installed') effects.out(info(`${plan.label} — ${plan.reason}`));
|
|
707
|
+
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (plan.action === 'manual') {
|
|
711
|
+
// NEVER a dead end: the plugin is still installable, by hand, and the run
|
|
712
|
+
// says so instead of pretending the harness does not exist.
|
|
713
|
+
effects.out(warn(`${plan.label} — ${plan.reason}; install it yourself with:`));
|
|
714
|
+
for (const step of plan.manual) effects.out(info(` ${step}`));
|
|
715
|
+
rows.push({ ...row, state: 'skipped', note: plan.reason });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const env = pairFor(plan, target);
|
|
720
|
+
let failed = null;
|
|
721
|
+
for (const step of plan.steps) {
|
|
722
|
+
const outcome = await attempt(effects, args.dryRun, step.join(' '), () => effects.run(step[0], step.slice(1), env ? { env } : {}));
|
|
723
|
+
if (!outcome.ok) { failed = outcome; break; }
|
|
724
|
+
}
|
|
725
|
+
if (failed) {
|
|
726
|
+
effects.out(info(`${plan.label} can still be installed by hand:`));
|
|
727
|
+
for (const step of plan.manual) effects.out(info(` ${step}`));
|
|
728
|
+
rows.push({ ...row, state: 'failed', note: 'install step failed' });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (plan.envLine) {
|
|
732
|
+
// The honest line. §5's promise does NOT hold for this harness, and the
|
|
733
|
+
// screen says exactly what is true and exactly what to do about it.
|
|
734
|
+
effects.out(warn(`${plan.label}'s registration cannot carry a value, so it will attach to the DEFAULT daemon.`));
|
|
735
|
+
effects.out(info(`Add this to your shell profile so it uses this one instead: ${plan.envLine}`));
|
|
736
|
+
}
|
|
737
|
+
rows.push({ ...row, state: 'installed', note: plan.envLine ? 'needs the env line above' : 'ready' });
|
|
738
|
+
}
|
|
739
|
+
return rows;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Install Fleet, initialize its host support, and stage a stopped starter config. */
|
|
743
|
+
export async function runFleetPhase(args, effects, { target, isDefaultStateDir }) {
|
|
744
|
+
effects.out(heading('ours-fleet (your always-online agent team)'));
|
|
745
|
+
effects.out(info('This makes your harnesses PERSISTENT: they stop being just a terminal session and'));
|
|
746
|
+
effects.out(info('become always-online agents that survive a reboot.'));
|
|
747
|
+
const plan = planFleet({
|
|
748
|
+
home: effects.home, stateDir: target.stateDir, isDefaultStateDir, wanted: true, channel: args.channel,
|
|
749
|
+
});
|
|
750
|
+
if (plan.action === 'skip') {
|
|
751
|
+
effects.out(info('skipped cleanly — re-run ours-install any time to add it.'));
|
|
752
|
+
return { key: 'fleet', label: plan.label, state: 'skipped' };
|
|
753
|
+
}
|
|
754
|
+
const install = await attempt(effects, args.dryRun, plan.install.join(' '), () => effects.run(plan.install[0], plan.install.slice(1)));
|
|
755
|
+
// Pass the complete daemon tuple to host initialization. The generated role
|
|
756
|
+
// also carries OURS_CONFIG when the chosen daemon is non-default.
|
|
757
|
+
const initEnv = daemonEnv(target.stateDir, target.port);
|
|
758
|
+
const init = install.ok
|
|
759
|
+
? 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 }))
|
|
760
|
+
: install;
|
|
761
|
+
if (!init.ok) {
|
|
762
|
+
effects.out(info(`retry manually: ${plan.init.join(' ')}`));
|
|
763
|
+
return { key: 'fleet', label: plan.label, state: 'failed', note: 'ours-fleet init failed' };
|
|
764
|
+
}
|
|
765
|
+
if (effects.readText(plan.configPath) === null) {
|
|
766
|
+
await perform(effects, args.dryRun, `write starter fleet config ${plan.configPath}`, () => effects.writeText(plan.configPath, plan.config));
|
|
767
|
+
} else {
|
|
768
|
+
effects.out(ok(`${plan.configPath} already exists — not touched`));
|
|
769
|
+
}
|
|
770
|
+
effects.out(ok('ours-fleet installed and initialized; no fleet roles were started.'));
|
|
771
|
+
if (plan.instruction) effects.out(info(plan.instruction));
|
|
772
|
+
return { key: 'fleet', label: plan.label, state: 'installed', note: `configured at ${plan.configPath}; stopped` };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Voice configuration belongs to the shared daemon, not the MCP adapter. */
|
|
776
|
+
export async function runVoicePhase(args, effects, { target, mcpReady }) {
|
|
777
|
+
effects.out(heading('Voice messages'));
|
|
778
|
+
effects.out(info('Voice transcription setup is not managed by ours-mcp. Existing daemon configuration is left unchanged.'));
|
|
779
|
+
return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'configure on the shared daemon' };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The final screen and the copy-paste hand-off.
|
|
784
|
+
*
|
|
785
|
+
* The hand-off is the installer's actual product: everything it could not do
|
|
786
|
+
* conversationally is handed to an agent that can. Steps for pieces this run did
|
|
787
|
+
* not install drop out and the rest renumber, so nobody is told to configure
|
|
788
|
+
* something they do not have.
|
|
789
|
+
*/
|
|
790
|
+
export async function endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl }) {
|
|
791
|
+
const rule = '═'.repeat(64);
|
|
792
|
+
effects.out('');
|
|
793
|
+
effects.out(` ${c.cyan(rule)}`);
|
|
794
|
+
effects.out(` ${c.bold('ours.network — install complete')}`);
|
|
795
|
+
effects.out(` ${c.gray(`State directory: ${target.stateDir} • Port: ${target.port}`)}`);
|
|
796
|
+
effects.out(` ${c.gray(`Broker: ${brokerUrl === effects.brokerUrl ? 'standard' : 'custom'}`)}`);
|
|
797
|
+
effects.out(` ${c.cyan(rule)}`);
|
|
798
|
+
for (const row of summary) {
|
|
799
|
+
const mark = row.state === 'failed' ? c.red('✗') : row.state === 'skipped' ? c.gray('·') : c.green('✓');
|
|
800
|
+
const state = (row.state === 'installed' || row.state === 'current')
|
|
801
|
+
? (row.note || 'ready')
|
|
802
|
+
: row.state === 'skipped'
|
|
803
|
+
? c.gray(`skipped${row.note ? ` (${row.note})` : ''}`)
|
|
804
|
+
: c.red(`needs attention${row.note ? ` — ${row.note}` : ''}`);
|
|
805
|
+
effects.out(` ${mark} ${String(row.label).padEnd(26)}${(row.version ? `v${row.version}` : '').padEnd(9)}${state}`);
|
|
806
|
+
}
|
|
807
|
+
effects.out('');
|
|
808
|
+
effects.out(summary.some((r) => r.state === 'failed')
|
|
809
|
+
? ` ${c.yellow('Some pieces need a hand — see the notes above; re-run ours-install after fixing.')}`
|
|
810
|
+
: ` ${c.green('Everything installed cleanly. No problems.')}`);
|
|
811
|
+
|
|
812
|
+
// Said BEFORE the hand-off prompt, because it is the only thing here the
|
|
813
|
+
// operator must do himself for any of the rest to work. A harness that was
|
|
814
|
+
// running when its plugin landed spawns no ours MCP server until it restarts,
|
|
815
|
+
// and someone who goes back to that harness, finds no ours tools and reads a
|
|
816
|
+
// successful install as a failed one is the exact outcome this prevents.
|
|
817
|
+
const restarts = restartHints(summary);
|
|
818
|
+
if (restarts.length > 0) {
|
|
819
|
+
effects.out('');
|
|
820
|
+
effects.out(` ${c.bold('Before this works:')} your harness spawns the ours MCP server when it starts, so`);
|
|
821
|
+
effects.out(' a harness that was already open has not picked it up yet.');
|
|
822
|
+
for (const hint of restarts) effects.out(` ${c.green('→')} ${hint.action}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const has = (key) => summary.some((r) => r.key === key && (r.state === 'installed' || r.state === 'current'));
|
|
826
|
+
if (has('tg') || has('fleet')) {
|
|
827
|
+
effects.out('');
|
|
828
|
+
effects.out(` ${c.bold('Installed but intentionally stopped')}`);
|
|
829
|
+
if (has('tg')) {
|
|
830
|
+
effects.out(` ${c.gray('• Telegram: add a bot/route first, then run')}`);
|
|
831
|
+
effects.out(` ${c.cyan('ours-tg-connector install-service')}`);
|
|
832
|
+
}
|
|
833
|
+
if (has('fleet')) {
|
|
834
|
+
effects.out(` ${c.gray('• Fleet: review the generated coordinator/watchdog config, then run')}`);
|
|
835
|
+
effects.out(` ${c.cyan('ours-fleet doctor && ours-fleet config && ours-fleet up')}`);
|
|
836
|
+
effects.out(` ${c.cyan('ours-fleet ls')}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const { text, empty } = buildHandoffPromptV3({
|
|
841
|
+
identity: !has('identity'),
|
|
842
|
+
fleet: has('fleet'),
|
|
843
|
+
telegram: has('tg'),
|
|
844
|
+
stateDir: target.stateDir,
|
|
845
|
+
isDefaultStateDir,
|
|
846
|
+
});
|
|
847
|
+
if (empty) {
|
|
848
|
+
effects.out('');
|
|
849
|
+
effects.out(` ${c.green("You're all set — open your harness and just start talking to your agent.")}`);
|
|
850
|
+
} else {
|
|
851
|
+
effects.out('');
|
|
852
|
+
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
853
|
+
effects.out(` ${c.bold('ONE LAST STEP')} — paste this prompt into Claude Code, Codex, or Hermes.`);
|
|
854
|
+
effects.out(` ${c.gray('─'.repeat(64))}`);
|
|
855
|
+
effects.out('');
|
|
856
|
+
effects.out(box(text.split('\n'), 'paste this into your agent'));
|
|
857
|
+
if (!args.dryRun && effects.clipboard(text)) effects.out(` ${c.gray('(copied to your clipboard.)')}`);
|
|
858
|
+
}
|
|
859
|
+
effects.out('');
|
|
860
|
+
effects.out(` ${c.gray('Re-run ')}${c.cyan('ours-install')}${c.gray(' any time to add a skipped piece or update.')}`);
|
|
861
|
+
effects.out(` ${c.cyan(rule)}`);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The whole run. Returns an exit code: 0, or 2 for any refusal.
|
|
866
|
+
*
|
|
867
|
+
* The order is not arbitrary and is the one thing here worth reading twice:
|
|
868
|
+
*
|
|
869
|
+
* daemon → components → identity → harness plugins → ours-fleet → voice
|
|
870
|
+
*
|
|
871
|
+
* The daemon comes first because everything else attaches to one. The COMPONENTS
|
|
872
|
+
* come second because `ours-mcp` is what the identity step and the voice step
|
|
873
|
+
* both invoke, and under v3 it is a component rather than the daemon — so
|
|
874
|
+
* anything that shells out to it has to wait for this phase, which is exactly
|
|
875
|
+
* why v2's placement of those two steps could not simply be carried across.
|
|
876
|
+
*
|
|
877
|
+
* Every refusal in this specification applies unchanged in non-interactive mode
|
|
878
|
+
* and exits 2 without writing anything — OURS_ASSUME_YES suppresses questions,
|
|
879
|
+
* never a refusal.
|
|
880
|
+
*/
|
|
881
|
+
export async function runInstall(argv, effects) {
|
|
882
|
+
let args;
|
|
883
|
+
try {
|
|
884
|
+
args = parseInstallArgs(argv, effects.env, { home: effects.home });
|
|
885
|
+
} catch (error) {
|
|
886
|
+
if (error instanceof InstallUsageError) {
|
|
887
|
+
effects.out(warn(`ours: ${error.message}`));
|
|
888
|
+
return EXIT_REFUSED;
|
|
889
|
+
}
|
|
890
|
+
throw error;
|
|
891
|
+
}
|
|
892
|
+
if (args.help) { effects.out(USAGE); return EXIT_OK; }
|
|
893
|
+
if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
894
|
+
|
|
895
|
+
args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
|
|
896
|
+
// THE INSTALLER'S OWN VERSION IS THE CHANNEL SIGNAL WHEN NOTHING SAYS OTHERWISE.
|
|
897
|
+
//
|
|
898
|
+
// resolveChannel falls back to `selfVersion` only when the environment is
|
|
899
|
+
// silent, and this call passed no selfVersion — so a NIGHTLY installer with no
|
|
900
|
+
// OURS_CHANNEL set resolved to `latest` and installed the whole stack at latest.
|
|
901
|
+
// That is what put fleet@latest and a stable ours-mcp on a nightly machine.
|
|
902
|
+
//
|
|
903
|
+
// The v2 bin has always done this correctly (install.mjs:53 passes pkgVersion());
|
|
904
|
+
// the v3 orchestrator dropped the argument. @ours.network/install is published on
|
|
905
|
+
// BOTH dist-tags from one lockstep bump, so its own version is the only thing
|
|
906
|
+
// that distinguishes a nightly installer from a stable one when the operator has
|
|
907
|
+
// said nothing.
|
|
908
|
+
args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL, effects.version);
|
|
909
|
+
|
|
910
|
+
effects.out(banner());
|
|
911
|
+
effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
|
|
912
|
+
if (args.dryRun) effects.out(info('dry-run: nothing will be installed or changed'));
|
|
913
|
+
effects.out(progress(1, 8, 'Check the host', 'Verify the platform and Node.js before changing anything.'));
|
|
914
|
+
|
|
915
|
+
// An unsupported platform is not a refusal of an incoherent selection, it is a
|
|
916
|
+
// machine this cannot run on. v2 exited 0 there and so does this, so a script
|
|
917
|
+
// that wrapped the old installer keeps its meaning.
|
|
918
|
+
if (!runPreflight(effects).ok) return EXIT_OK;
|
|
919
|
+
|
|
920
|
+
// Which daemon, before anything is decided about it. Only args.stateDir can
|
|
921
|
+
// change here; every refusal downstream is unaffected.
|
|
922
|
+
effects.out(progress(2, 8, 'Choose one daemon', 'Reuse the only detected daemon or create one coherent shared target.'));
|
|
923
|
+
const selection = await runSelectionPhase(args, effects);
|
|
924
|
+
if (selection.action === 'refuse') return EXIT_REFUSED;
|
|
925
|
+
|
|
926
|
+
effects.out(progress(3, 8, 'Prepare the shared daemon', 'Install the CLI, write config, start it, and enable boot persistence.'));
|
|
927
|
+
const daemon = await runDaemonPhase(args, effects);
|
|
928
|
+
if (daemon.refused) return EXIT_REFUSED;
|
|
929
|
+
const target = daemon.target;
|
|
930
|
+
const isDefaultStateDir = target.stateDir === join(effects.home, '.ours');
|
|
931
|
+
|
|
932
|
+
const summary = [{
|
|
933
|
+
key: 'core',
|
|
934
|
+
label: 'ours core (daemon)',
|
|
935
|
+
state: target.action === 'create' ? 'installed' : 'current',
|
|
936
|
+
note: `port ${target.port}`,
|
|
937
|
+
}];
|
|
938
|
+
// The skip must not read as success: same "needs attention" mark a failed
|
|
939
|
+
// component gets, so the closing screen cannot say everything is clean.
|
|
940
|
+
if (daemon.serviceUnsupported) {
|
|
941
|
+
summary.push({
|
|
942
|
+
key: 'service',
|
|
943
|
+
label: 'Boot service',
|
|
944
|
+
state: 'failed',
|
|
945
|
+
note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
effects.out(progress(4, 8, 'Install the complete stack', 'Attach MCP, Telegram, and cowork to the same daemon; Telegram stays stopped.'));
|
|
950
|
+
const components = await runComponentPhase(args, effects, target);
|
|
951
|
+
for (const component of COMPONENTS) {
|
|
952
|
+
const state = components.installed.includes(component.key) ? 'installed'
|
|
953
|
+
: components.failed.some((f) => f.key === component.key) ? 'failed' : 'skipped';
|
|
954
|
+
summary.push({
|
|
955
|
+
key: component.key,
|
|
956
|
+
label: component.label,
|
|
957
|
+
state,
|
|
958
|
+
version: state === 'installed' && !args.dryRun ? (effects.installedVersion(component.pkg) ?? '') : '',
|
|
959
|
+
note: components.failed.find((f) => f.key === component.key)?.reason
|
|
960
|
+
?? (state === 'installed' && component.key === 'tg' ? 'configured; stopped'
|
|
961
|
+
: state === 'installed' && component.key === 'cowork' ? 'configured; service running' : undefined),
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
const mcpReady = components.installed.includes('mcp');
|
|
965
|
+
|
|
966
|
+
effects.out(progress(5, 8, 'Create the Human identity', 'Create the daemon root identity once, or keep the existing one.'));
|
|
967
|
+
summary.push(await runIdentityPhase(args, effects, { target, mcpReady }));
|
|
968
|
+
effects.out(progress(6, 8, 'Wire detected harnesses', 'Install the ours plugin into each safe Claude Code, Codex, or Hermes installation.'));
|
|
969
|
+
summary.push(...await runHarnessPhase(args, effects, { target, isDefaultStateDir }));
|
|
970
|
+
effects.out(progress(7, 8, 'Stage the fleet', 'Install Fleet and write a stopped coordinator + watchdog + health-loop starter config.'));
|
|
971
|
+
summary.push(await runFleetPhase(args, effects, { target, isDefaultStateDir }));
|
|
972
|
+
summary.push(await runVoicePhase(args, effects, { target, mcpReady }));
|
|
973
|
+
|
|
974
|
+
const changes = summarizeRun(daemon.steps);
|
|
975
|
+
if (!changes.changedAnything) {
|
|
976
|
+
effects.out(ok('everything already correct — nothing changed except refreshed packages'));
|
|
977
|
+
}
|
|
978
|
+
for (const failure of components.failed) {
|
|
979
|
+
effects.out(warn(`${failure.key} did not install: ${failure.reason}`));
|
|
980
|
+
}
|
|
981
|
+
effects.out(progress(8, 8, 'Finish', 'Summarize what is running, what is stopped, and the exact next commands.'));
|
|
982
|
+
await endScreen(args, effects, { summary, target, isDefaultStateDir, brokerUrl: args.brokerUrl });
|
|
983
|
+
return EXIT_OK;
|
|
984
|
+
}
|