@ours.network/install 0.17.0-nightly.9 → 0.18.0-nightly.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -315
- package/install.mjs +22 -1181
- package/lib/components.mjs +18 -9
- package/lib/detect.mjs +8 -21
- package/lib/effects.mjs +32 -14
- package/lib/extras.mjs +5 -85
- package/lib/journal.mjs +46 -1
- package/lib/logic.mjs +13 -85
- package/lib/orchestrate-uninstall.mjs +105 -19
- package/lib/orchestrate.mjs +134 -118
- package/lib/plan.mjs +40 -8
- package/lib/target.mjs +38 -5
- package/lib/uninstall.mjs +285 -16
- package/package.json +1 -1
- package/uninstall.mjs +22 -225
- package/uninstall.sh +13 -4
- package/lib/nightly-install.mjs +0 -739
- package/lib/nightly-uninstall.mjs +0 -396
- package/lib/profiles.mjs +0 -524
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { parseInstallArgs, InstallUsageError } from './target.mjs';
|
|
22
|
-
import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock } from './uninstall.mjs';
|
|
22
|
+
import { planUninstall, planComponentDetach, planStatePurge, stripManagedBlock, planHarnessSelection, selectHarnesses, planGlobalPackages, planPluginRemoval, parseUninstallEnv } from './uninstall.mjs';
|
|
23
23
|
import { tgConfigPath, coworkConfigPath } from './components.mjs';
|
|
24
24
|
import { configJournal, reportRollback } from './journal.mjs';
|
|
25
25
|
import { UNINSTALL_USAGE } from './usage.mjs';
|
|
@@ -81,6 +81,33 @@ export async function runUninstall(argv, effects) {
|
|
|
81
81
|
effects.out(heading(`ours-uninstall --state-dir ${dir}`));
|
|
82
82
|
if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
|
|
83
83
|
|
|
84
|
+
// §9 — the documented OURS_UNINSTALL_* contract (item 10.9), read BEFORE any
|
|
85
|
+
// file is opened. A variable this uninstaller cannot deliver stops the run
|
|
86
|
+
// here, naming itself and naming the replacement, rather than being silently
|
|
87
|
+
// ignored while the operator's script reports success.
|
|
88
|
+
const contract = parseUninstallEnv(effects.env);
|
|
89
|
+
if (contract.action === 'refuse') {
|
|
90
|
+
effects.out(warn(`ours: ${contract.message}`));
|
|
91
|
+
return EXIT_REFUSED;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// OURS_UNINSTALL_DAEMON decides whether this is an uninstall of the daemon at
|
|
95
|
+
// all. Before this gate, OURS_UNINSTALL="hermes" — a script asking for one
|
|
96
|
+
// harness plugin — tore down the daemon, its service and the global packages,
|
|
97
|
+
// because none of the seven variables was read. A request to detach a plugin
|
|
98
|
+
// stays a request to detach a plugin.
|
|
99
|
+
if (!contract.daemon) {
|
|
100
|
+
effects.out(info(`the daemon at ${dir} is KEPT — OURS_UNINSTALL is set and OURS_UNINSTALL_DAEMON is not "yes"`));
|
|
101
|
+
const plugins = planPluginRemoval({ home: effects.home, env: effects.env, exists: effects.exists, lastDaemon: false, explicitSelection: true });
|
|
102
|
+
const outcome = await runPluginPhase(plugins, { args, effects, selection: contract.harnesses });
|
|
103
|
+
// Only the launchers of the harnesses that actually went. @ours.network/cli
|
|
104
|
+
// and /mcp belong to the daemon, and the daemon is staying.
|
|
105
|
+
for (const pkg of outcome.packages) {
|
|
106
|
+
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
107
|
+
}
|
|
108
|
+
return EXIT_OK;
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
// Ask first, mutate second. The question is about resolving the step-1
|
|
85
112
|
// refusal, so it has to come before the plan that would refuse.
|
|
86
113
|
// `readText` is passed alongside `readJson` so the planner can tell an ABSENT
|
|
@@ -93,7 +120,13 @@ export async function runUninstall(argv, effects) {
|
|
|
93
120
|
return EXIT_REFUSED;
|
|
94
121
|
}
|
|
95
122
|
const pointing = probe.action === 'refuse' ? probe.components : [];
|
|
96
|
-
|
|
123
|
+
// With the contract engaged there is nobody to ask, and OURS_UNINSTALL_TELEGRAM
|
|
124
|
+
// / _ROOMS at `detach` is exactly the answer the question wants. Without it, an
|
|
125
|
+
// unattended run against a daemon a connector points at refuses — which is the
|
|
126
|
+
// right outcome, and the reason honouring `detach` is worth doing.
|
|
127
|
+
const confirmedComponents = contract.engaged
|
|
128
|
+
? contract.confirmedComponents
|
|
129
|
+
: await confirmComponentRemoval(pointing, { assumeYes: args.assumeYes, effects });
|
|
97
130
|
|
|
98
131
|
const plan = planUninstall({
|
|
99
132
|
home: effects.home,
|
|
@@ -106,9 +139,12 @@ export async function runUninstall(argv, effects) {
|
|
|
106
139
|
readJson: effects.readJson,
|
|
107
140
|
readText: effects.readText,
|
|
108
141
|
exists: effects.exists,
|
|
109
|
-
|
|
142
|
+
// Accept the current CLI record and the legacy ours-mcp pid file during migration.
|
|
143
|
+
cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null
|
|
144
|
+
|| effects.readText(join(dir, 'daemon.pid')) !== null,
|
|
110
145
|
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
111
146
|
typedConfirmation: null,
|
|
147
|
+
explicitHarnessSelection: contract.engaged,
|
|
112
148
|
});
|
|
113
149
|
|
|
114
150
|
if (plan.action === 'refuse') {
|
|
@@ -156,16 +192,32 @@ export async function runUninstall(argv, effects) {
|
|
|
156
192
|
}
|
|
157
193
|
|
|
158
194
|
// 5. The harness plugins the installer wrote.
|
|
159
|
-
await runPluginPhase(plan.plugins, { args, effects });
|
|
195
|
+
const pluginOutcome = await runPluginPhase(plan.plugins, { args, effects, selection: contract.harnesses });
|
|
160
196
|
|
|
161
197
|
// 6. State. Last, because it is the only irreversible thing here.
|
|
162
198
|
await runPurgePhase({ dir, purge, args, effects });
|
|
163
199
|
|
|
164
200
|
// 7. Global packages, only when this was the last daemon.
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
//
|
|
202
|
+
// Recomputed from what the plugin phase ACTUALLY removed, not from what it
|
|
203
|
+
// found. A harness the operator kept keeps its launcher: removing the package
|
|
204
|
+
// out from under a plugin that is still registered is the same broken
|
|
205
|
+
// half-state, one layer down.
|
|
206
|
+
const packages = planGlobalPackages({
|
|
207
|
+
stateDir: dir,
|
|
208
|
+
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
209
|
+
pluginPackages: pluginOutcome.packages,
|
|
210
|
+
// The plugin packages are recomputed; the CONNECTOR packages are not. They
|
|
211
|
+
// are decided by what the operator confirmed detaching, which the plugin
|
|
212
|
+
// phase does not touch, so this repeats the plan's own answer rather than
|
|
213
|
+
// inventing a second one — and rather than dropping it, which would quietly
|
|
214
|
+
// undo the connector-package removal one line up the file.
|
|
215
|
+
detachedComponents: plan.detach.map((d) => d.key),
|
|
216
|
+
});
|
|
217
|
+
if (packages.action === 'keep') {
|
|
218
|
+
effects.out(info(`@ours.network/cli kept — ${packages.reason}`));
|
|
167
219
|
} else {
|
|
168
|
-
for (const pkg of
|
|
220
|
+
for (const pkg of packages.packages) {
|
|
169
221
|
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
170
222
|
}
|
|
171
223
|
}
|
|
@@ -224,24 +276,45 @@ export async function rollBackDetach(effects, journal, detached, args) {
|
|
|
224
276
|
* harnesses still need these plugins — the same condition that keeps the global
|
|
225
277
|
* packages, decided once.
|
|
226
278
|
*/
|
|
227
|
-
export async function runPluginPhase(plugins, { args, effects }) {
|
|
279
|
+
export async function runPluginPhase(plugins, { args, effects, selection = null }) {
|
|
228
280
|
effects.out(heading('Harness plugins'));
|
|
229
|
-
for (const step of plugins.manual) {
|
|
230
|
-
// Never a dead end, and never a claim: Claude Code's plugin is not ours to
|
|
231
|
-
// remove, so the run says so and prints the two commands that do it.
|
|
232
|
-
effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
|
|
233
|
-
for (const command of step.steps) effects.out(info(` ${command}`));
|
|
234
|
-
}
|
|
235
281
|
if (plugins.action === 'keep') {
|
|
282
|
+
for (const step of plugins.manual) announceManual(step, effects);
|
|
236
283
|
effects.out(info(`harness plugins kept — ${plugins.reason}`));
|
|
237
|
-
return;
|
|
284
|
+
return { removed: [], packages: [] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// WHICH harnesses (item 9.5). Asked before the first block is stripped, so a
|
|
288
|
+
// "no" costs nothing and a "yes" is the operator's, not this file's.
|
|
289
|
+
const choice = planHarnessSelection(plugins, { selection, assumeYes: args.assumeYes });
|
|
290
|
+
let chosen = choice.chosen;
|
|
291
|
+
if (choice.mode === 'ask') {
|
|
292
|
+
chosen = [];
|
|
293
|
+
for (const harness of choice.offered) {
|
|
294
|
+
if (await effects.ask(`Remove the ${harness.label}?`, true)) chosen.push(harness.key);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const name of choice.ignored) {
|
|
298
|
+
effects.out(info(`${name} was named for removal but no ${name} plugin is installed here — nothing to do for it`));
|
|
238
299
|
}
|
|
239
|
-
if (
|
|
240
|
-
effects.out(info(
|
|
241
|
-
|
|
300
|
+
if (choice.mode === 'keep') {
|
|
301
|
+
effects.out(info(`harness plugins kept — ${choice.reason}`));
|
|
302
|
+
effects.out(info(choice.hint));
|
|
303
|
+
return { removed: [], packages: [] };
|
|
242
304
|
}
|
|
243
305
|
|
|
244
|
-
|
|
306
|
+
const selected = selectHarnesses(plugins, chosen);
|
|
307
|
+
for (const step of selected.manual) announceManual(step, effects);
|
|
308
|
+
if (selected.harnesses.length === 0) {
|
|
309
|
+
effects.out(info(
|
|
310
|
+
chosen.length === 0
|
|
311
|
+
? 'no harness plugin selected — none removed'
|
|
312
|
+
: 'no Hermes or Codex plugin files found — nothing of ours to remove',
|
|
313
|
+
));
|
|
314
|
+
return { removed: chosen, packages: [] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const harness of selected.harnesses) {
|
|
245
318
|
for (const block of harness.blocks) {
|
|
246
319
|
const before = effects.readText(block.path);
|
|
247
320
|
if (before === null) continue;
|
|
@@ -254,6 +327,9 @@ export async function runPluginPhase(plugins, { args, effects }) {
|
|
|
254
327
|
effects.out(warn(`${block.path}: ${stripped.reason}. Remove it by hand.`));
|
|
255
328
|
continue;
|
|
256
329
|
}
|
|
330
|
+
// Deliberately NOT journalled: this write IS the state its bytes describe, so
|
|
331
|
+
// nothing behind it can fail and leave them untrue. Adding one would undo a
|
|
332
|
+
// completed removal.
|
|
257
333
|
await perform(effects, args.dryRun, `remove the ours managed block from ${block.path} (file kept)`, () => effects.writeText(block.path, stripped.text));
|
|
258
334
|
}
|
|
259
335
|
for (const dir of harness.dirs) {
|
|
@@ -265,6 +341,16 @@ export async function runPluginPhase(plugins, { args, effects }) {
|
|
|
265
341
|
await perform(effects, args.dryRun, `remove ${file}`, () => effects.removeFile(file));
|
|
266
342
|
}
|
|
267
343
|
}
|
|
344
|
+
return { removed: chosen, packages: selected.packages };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Never a dead end, and never a claim: Claude Code's plugin is not ours to
|
|
349
|
+
* remove, so the run says so and prints the two commands that do it.
|
|
350
|
+
*/
|
|
351
|
+
function announceManual(step, effects) {
|
|
352
|
+
effects.out(info(`${step.label} — ${step.reason}. Inside Claude Code, run:`));
|
|
353
|
+
for (const command of step.steps) effects.out(info(` ${command}`));
|
|
268
354
|
}
|
|
269
355
|
|
|
270
356
|
/**
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -29,12 +29,12 @@ import { planDaemonConfig, planServiceInstall, serviceInstallCommand } from './p
|
|
|
29
29
|
import {
|
|
30
30
|
COMPONENTS,
|
|
31
31
|
planComponentSelection, planMcpAttachment, planTgAttachment, planCoworkAttachment,
|
|
32
|
-
tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec,
|
|
32
|
+
tgConfigPath, coworkConfigPath, summarizeComponentRun, componentSpec, componentByKey,
|
|
33
33
|
} from './components.mjs';
|
|
34
|
-
import { planHarnessPlugins, planFleet,
|
|
34
|
+
import { planHarnessPlugins, planFleet, buildHandoffPromptV3, restartHints } from './extras.mjs';
|
|
35
35
|
import { summarizeRun } from './rerun.mjs';
|
|
36
36
|
import { configJournal, reportRollback } from './journal.mjs';
|
|
37
|
-
import { detectDaemons, planDaemonSelection, resolveSelection
|
|
37
|
+
import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
|
|
38
38
|
import { detectPlatform, resolveChannel, validateBroker } from './logic.mjs';
|
|
39
39
|
import { daemonEnv } from './effects.mjs';
|
|
40
40
|
import { USAGE } from './usage.mjs';
|
|
@@ -100,7 +100,7 @@ function pairFor(plan, target) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
* Which daemon is this run for?
|
|
103
|
+
* Which daemon is this run for?
|
|
104
104
|
*
|
|
105
105
|
* Never asks for a PATH — spec §2 stands — but when several daemons are DETECTED
|
|
106
106
|
* it shows them and lets the operator pick, because choosing from what was found
|
|
@@ -111,14 +111,6 @@ function pairFor(plan, target) {
|
|
|
111
111
|
* not know a screen happened, which is what keeps the flags path byte-identical.
|
|
112
112
|
*/
|
|
113
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
114
|
const detected = detectDaemons({
|
|
123
115
|
candidates: effects.knownStateDirs(),
|
|
124
116
|
exists: effects.exists,
|
|
@@ -176,6 +168,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
176
168
|
portExplicit: args.portExplicit,
|
|
177
169
|
probe: effects.probe,
|
|
178
170
|
readJson: effects.readJson,
|
|
171
|
+
readText: effects.readText,
|
|
179
172
|
isTaken: effects.isTaken,
|
|
180
173
|
});
|
|
181
174
|
|
|
@@ -208,20 +201,26 @@ export async function runDaemonPhase(args, effects) {
|
|
|
208
201
|
|
|
209
202
|
// Asked ONCE, and only when this run is creating the daemon — an existing
|
|
210
203
|
// 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.
|
|
212
|
-
//
|
|
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
|
|
213
206
|
// does not violate spec §2's "nothing about a state directory or a port
|
|
214
207
|
// appears in any prompt".
|
|
215
208
|
if (creating) args.brokerUrl = await askBroker(args, effects);
|
|
216
209
|
|
|
217
210
|
const steps = [];
|
|
218
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 });
|
|
219
218
|
await perform(effects, args.dryRun, 'ours CLI installed (npm i -g @ours.network/cli)', () => effects.run('npm', ['i', '-g', '@ours.network/cli']));
|
|
220
219
|
steps.push({ id: 'cli', changed: true, packageRefresh: true });
|
|
221
220
|
|
|
222
221
|
// The config file — merged, never rewritten, and untouched when it already
|
|
223
|
-
// matches. No provenance marker is written:
|
|
224
|
-
//
|
|
222
|
+
// matches. No provenance marker is written: --purge works on any state
|
|
223
|
+
// directory, so a `createdBy` key would have no consumer, and
|
|
225
224
|
// an unread key in a user's config file is future confusion for nothing.
|
|
226
225
|
const configPath = join(dir, 'config.json');
|
|
227
226
|
const merged = planDaemonConfig(
|
|
@@ -239,6 +238,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
239
238
|
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
240
239
|
// conditions for it.
|
|
241
240
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
241
|
+
let serviceUnsupported = null;
|
|
242
242
|
if (merged.changed) {
|
|
243
243
|
journal.snapshot(configPath);
|
|
244
244
|
await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
|
|
@@ -253,7 +253,8 @@ export async function runDaemonPhase(args, effects) {
|
|
|
253
253
|
steps.push({ id: 'start', changed: true });
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
const service = await runServicePhase(args, effects, dir);
|
|
256
|
+
const service = await runServicePhase(args, effects, dir, target.port);
|
|
257
|
+
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
257
258
|
if (service.refused) {
|
|
258
259
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
259
260
|
// unit file stops the run just as a failed start does, and it stops it with
|
|
@@ -274,7 +275,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
274
275
|
// The nightly flow re-runs `start` and, crucially, tells the two outcomes
|
|
275
276
|
// apart: "the service failed but the daemon is back" is a bad evening, and "the
|
|
276
277
|
// 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
|
+
const recovery = error?.servicePlan ? await recoverDaemon(args, effects, dir, configPath, target.port) : null;
|
|
278
279
|
rollBack(effects, journal, args, 'the daemon did not reach the state its config describes — putting the config back', {
|
|
279
280
|
replacedUnit: error?.servicePlan?.action === 'adopt' ? error.servicePlan.unitPath : null,
|
|
280
281
|
});
|
|
@@ -287,7 +288,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
287
288
|
throw error;
|
|
288
289
|
}
|
|
289
290
|
|
|
290
|
-
return { target, steps };
|
|
291
|
+
return { target, steps, serviceUnsupported };
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
/**
|
|
@@ -302,7 +303,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
302
303
|
* is REPORTED, never thrown: the caller is already carrying the real error, and
|
|
303
304
|
* losing it to a second one would hide what actually went wrong.
|
|
304
305
|
*/
|
|
305
|
-
async function recoverDaemon(args, effects, dir, configPath) {
|
|
306
|
+
async function recoverDaemon(args, effects, dir, configPath, port) {
|
|
306
307
|
if (args.dryRun) return null;
|
|
307
308
|
try {
|
|
308
309
|
await effects.run('ours', ['daemon', 'start', '--config', configPath]);
|
|
@@ -345,11 +346,24 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
345
346
|
* The boot service: §4 step 4, including the legacy-unit case.
|
|
346
347
|
*
|
|
347
348
|
* A legacy ours-mcp unit is adopted SILENTLY, with one informational line naming
|
|
348
|
-
* the file
|
|
349
|
+
* the file. `--force` is passed ONLY here, only for a unit
|
|
349
350
|
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
350
351
|
*/
|
|
351
|
-
export async function runServicePhase(args, effects, dir) {
|
|
352
|
-
const plan = planServiceInstall({
|
|
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
|
+
}
|
|
353
367
|
if (plan.action === 'refuse') {
|
|
354
368
|
effects.out(warn(`ours: refusing to continue — ${plan.message}`));
|
|
355
369
|
return { refused: plan };
|
|
@@ -357,6 +371,14 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
357
371
|
const adopting = plan.action === 'adopt';
|
|
358
372
|
if (adopting) effects.out(info(plan.notice));
|
|
359
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;
|
|
360
382
|
let outcome;
|
|
361
383
|
try {
|
|
362
384
|
outcome = await perform(effects, args.dryRun, `boot service ${plan.unit} installed and enabled`, () => effects.run(command[0], command.slice(1)));
|
|
@@ -368,11 +390,16 @@ export async function runServicePhase(args, effects, dir) {
|
|
|
368
390
|
error.servicePlan = plan;
|
|
369
391
|
throw error;
|
|
370
392
|
}
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
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
|
+
})();
|
|
376
403
|
return { step: { id: 'service', changed, reason: changed ? undefined : 'unit unchanged' }, plan };
|
|
377
404
|
}
|
|
378
405
|
|
|
@@ -388,6 +415,47 @@ function readChanged(stdout) {
|
|
|
388
415
|
}
|
|
389
416
|
}
|
|
390
417
|
|
|
418
|
+
/**
|
|
419
|
+
* THE QUESTION NOBODY WAS ASKING.
|
|
420
|
+
*
|
|
421
|
+
* `planComponentSelection` has always read an `answers` map — and NOTHING EVER
|
|
422
|
+
* POPULATED IT. `args.answers` is not produced by parseInstallArgs and is not
|
|
423
|
+
* written by any phase, so it was permanently `{}`, every optional component fell
|
|
424
|
+
* to its own default, and tg and cowork (both default false) were skipped in
|
|
425
|
+
* silence. The screen then printed "cowork — not installed" as though that had
|
|
426
|
+
* been a decision.
|
|
427
|
+
*
|
|
428
|
+
* This was the ONE phase with no question. Harness plugins ask, ours-fleet asks,
|
|
429
|
+
* voice asks, the broker asks. Components did not.
|
|
430
|
+
*
|
|
431
|
+
* IT IS NOT A COWORK BUG, and fixing only the component that got reported would
|
|
432
|
+
* have left the identical defect live under a different label: the Telegram
|
|
433
|
+
* connector is unreachable by exactly the same route, and nobody noticed only
|
|
434
|
+
* because tg is usually configured somewhere else.
|
|
435
|
+
*
|
|
436
|
+
* A REQUIRED component is never asked — since #74 the MCP package IS the daemon,
|
|
437
|
+
* the daemon phase has already installed it, and a question whose only honest
|
|
438
|
+
* answer is yes is not a question. Non-interactive runs are unchanged: assumeYes
|
|
439
|
+
* takes each component's own default, which is what NON_INTERACTIVE_ANSWERS
|
|
440
|
+
* documents (mcp yes, tg no, cowork no), so nothing about the unattended contract
|
|
441
|
+
* moves.
|
|
442
|
+
*/
|
|
443
|
+
export async function askComponents(args, effects) {
|
|
444
|
+
const answers = { ...(args.answers ?? {}) };
|
|
445
|
+
if (args.assumeYes) return answers;
|
|
446
|
+
const optional = COMPONENTS.filter((c) => !c.required && answers[c.key] === undefined);
|
|
447
|
+
if (optional.length === 0) return answers;
|
|
448
|
+
effects.out(heading('What else should this daemon carry?'));
|
|
449
|
+
for (const component of optional) {
|
|
450
|
+
const already = (args.installed ?? {})[component.key] === true;
|
|
451
|
+
answers[component.key] = await effects.ask(
|
|
452
|
+
already ? `Keep ${component.label}?` : `Install ${component.label}?`,
|
|
453
|
+
already || component.default,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
return answers;
|
|
457
|
+
}
|
|
458
|
+
|
|
391
459
|
/**
|
|
392
460
|
* Components: §5. A component that fails is reported with its retry command and
|
|
393
461
|
* the run CONTINUES — a failed component is never a reason to undo a successful
|
|
@@ -397,8 +465,9 @@ export async function runComponentPhase(args, effects, target) {
|
|
|
397
465
|
const dir = target.stateDir;
|
|
398
466
|
const endpoint = `http://127.0.0.1:${target.port}`;
|
|
399
467
|
const isDefaultStateDir = dir === join(effects.home, '.ours');
|
|
468
|
+
const answers = await askComponents(args, effects);
|
|
400
469
|
const chosen = planComponentSelection({
|
|
401
|
-
answers
|
|
470
|
+
answers,
|
|
402
471
|
installed: args.installed ?? {},
|
|
403
472
|
assumeYes: args.assumeYes,
|
|
404
473
|
});
|
|
@@ -531,7 +600,7 @@ async function attachComponent(component, { args, effects, dir, endpoint, isDefa
|
|
|
531
600
|
}
|
|
532
601
|
|
|
533
602
|
/**
|
|
534
|
-
* The broker question (v2's Step 0a, kept
|
|
603
|
+
* The broker question (v2's Step 0a, deliberately kept).
|
|
535
604
|
*
|
|
536
605
|
* Consent-first, with the undo built in: a mistaken custom address is one
|
|
537
606
|
* keystroke back to the standard broker, because the alternative is an operator
|
|
@@ -594,6 +663,9 @@ export function runPreflight(effects) {
|
|
|
594
663
|
return { ok: false, platform: plat };
|
|
595
664
|
}
|
|
596
665
|
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
666
|
+
if (effects.platform?.platform && effects.platform.platform !== 'linux') {
|
|
667
|
+
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.`));
|
|
668
|
+
}
|
|
597
669
|
const version = String(effects.nodeVersion ?? '0');
|
|
598
670
|
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
599
671
|
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
@@ -604,11 +676,9 @@ export function runPreflight(effects) {
|
|
|
604
676
|
}
|
|
605
677
|
|
|
606
678
|
/**
|
|
607
|
-
* The human identity: `ours
|
|
679
|
+
* The human identity: `ours identity create-root`.
|
|
608
680
|
*
|
|
609
|
-
* It needs
|
|
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
|
|
681
|
+
* It needs the operator CLI and a reachable daemon. Already-exists is a friendly keep, never an error, and
|
|
612
682
|
* an unreachable daemon gets the exact retry command rather than "ask your agent
|
|
613
683
|
* later"; the hand-off's identity step is the fallback for both failures.
|
|
614
684
|
*/
|
|
@@ -628,16 +698,11 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
628
698
|
|
|
629
699
|
const env = daemonEnv(target.stateDir, target.port);
|
|
630
700
|
if (args.dryRun) {
|
|
631
|
-
effects.out(info(wouldPrefix(`ours
|
|
701
|
+
effects.out(info(wouldPrefix(`ours identity create-root --name "${name}"`)));
|
|
632
702
|
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
633
703
|
}
|
|
634
704
|
try {
|
|
635
|
-
|
|
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
|
-
}
|
|
705
|
+
await effects.run('ours', ['identity', 'create-root', '--name', name, '--json'], { env });
|
|
641
706
|
effects.out(ok(`Your human identity "${name}" is created.`));
|
|
642
707
|
return { key: 'identity', label: 'Human identity', state: 'installed', note: name };
|
|
643
708
|
} catch (error) {
|
|
@@ -648,11 +713,11 @@ export async function runIdentityPhase(args, effects, { target, mcpReady }) {
|
|
|
648
713
|
}
|
|
649
714
|
if (/not running|not reachable|ECONNREFUSED|connect/i.test(text)) {
|
|
650
715
|
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
|
|
716
|
+
effects.out(info(`Fix: run 'ours daemon start --config ${env.OURS_CONFIG}', then 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
|
|
652
717
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'daemon not reachable' };
|
|
653
718
|
}
|
|
654
719
|
effects.out(warn(`Couldn't create your human identity: ${text.split('\n')[0]}`));
|
|
655
|
-
effects.out(info(`Retry any time: 'ours
|
|
720
|
+
effects.out(info(`Retry any time: 'ours identity create-root --config ${env.OURS_CONFIG} --name "${name}"'.`));
|
|
656
721
|
return { key: 'identity', label: 'Human identity', state: 'failed', note: 'create-root failed' };
|
|
657
722
|
}
|
|
658
723
|
}
|
|
@@ -781,7 +846,6 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
781
846
|
// Passing it is harmless if extras.mjs is right and load-bearing if
|
|
782
847
|
// nightly-install.mjs is. When the cheap action is safe under both readings and
|
|
783
848
|
// the expensive one is only safe under one, take the cheap one. (Coordinator
|
|
784
|
-
// ruling, 2026-08-17.)
|
|
785
849
|
//
|
|
786
850
|
// Passed for EVERY state directory, not only a non-default one, exactly as the
|
|
787
851
|
// nightly flow does: `init` is a one-time host setup and the pair is what names
|
|
@@ -799,81 +863,11 @@ export async function runFleetPhase(args, effects, { target, isDefaultStateDir }
|
|
|
799
863
|
return { key: 'fleet', label: plan.label, state: 'installed', note: 'CLI + core-plugin discovery' };
|
|
800
864
|
}
|
|
801
865
|
|
|
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
|
-
*/
|
|
866
|
+
/** Voice configuration belongs to the shared daemon, not the MCP adapter. */
|
|
813
867
|
export async function runVoicePhase(args, effects, { target, mcpReady }) {
|
|
814
868
|
effects.out(heading('Voice messages'));
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
}
|
|
869
|
+
effects.out(info('Voice transcription setup is not managed by ours-mcp. Existing daemon configuration is left unchanged.'));
|
|
870
|
+
return { key: 'voice', label: 'Voice transcription', state: 'skipped', note: 'configure on the shared daemon' };
|
|
877
871
|
}
|
|
878
872
|
|
|
879
873
|
/**
|
|
@@ -976,7 +970,19 @@ export async function runInstall(argv, effects) {
|
|
|
976
970
|
if (args.version) { effects.out(`ours-install v${effects.version ?? '?'}`); return EXIT_OK; }
|
|
977
971
|
|
|
978
972
|
args.brokerUrl = args.brokerUrl ?? effects.brokerUrl;
|
|
979
|
-
|
|
973
|
+
// THE INSTALLER'S OWN VERSION IS THE CHANNEL SIGNAL WHEN NOTHING SAYS OTHERWISE.
|
|
974
|
+
//
|
|
975
|
+
// resolveChannel falls back to `selfVersion` only when the environment is
|
|
976
|
+
// silent, and this call passed no selfVersion — so a NIGHTLY installer with no
|
|
977
|
+
// OURS_CHANNEL set resolved to `latest` and installed the whole stack at latest.
|
|
978
|
+
// That is what put fleet@latest and a stable ours-mcp on a nightly machine.
|
|
979
|
+
//
|
|
980
|
+
// The v2 bin has always done this correctly (install.mjs:53 passes pkgVersion());
|
|
981
|
+
// the v3 orchestrator dropped the argument. @ours.network/install is published on
|
|
982
|
+
// BOTH dist-tags from one lockstep bump, so its own version is the only thing
|
|
983
|
+
// that distinguishes a nightly installer from a stable one when the operator has
|
|
984
|
+
// said nothing.
|
|
985
|
+
args.channel = resolveChannel(effects.env.OURS_CHANNEL ?? effects.env.OURS_INSTALL_CHANNEL, effects.version);
|
|
980
986
|
|
|
981
987
|
effects.out(banner());
|
|
982
988
|
effects.out(heading(`ours: target ${args.stateDir}${args.portExplicit ? `, port ${args.port}` : ''}`));
|
|
@@ -1003,6 +1009,16 @@ export async function runInstall(argv, effects) {
|
|
|
1003
1009
|
state: target.action === 'create' ? 'installed' : 'current',
|
|
1004
1010
|
note: `port ${target.port}`,
|
|
1005
1011
|
}];
|
|
1012
|
+
// The skip must not read as success: same "needs attention" mark a failed
|
|
1013
|
+
// component gets, so the closing screen cannot say everything is clean.
|
|
1014
|
+
if (daemon.serviceUnsupported) {
|
|
1015
|
+
summary.push({
|
|
1016
|
+
key: 'service',
|
|
1017
|
+
label: 'Boot service',
|
|
1018
|
+
state: 'failed',
|
|
1019
|
+
note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} — start the daemon yourself after a reboot`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1006
1022
|
|
|
1007
1023
|
const components = await runComponentPhase(args, effects, target);
|
|
1008
1024
|
for (const component of COMPONENTS) {
|