@ours.network/install 0.17.0-nightly.10 → 0.17.0-nightly.12
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/lib/effects.mjs +32 -14
- package/lib/orchestrate-uninstall.mjs +19 -0
- package/lib/orchestrate.mjs +34 -2
- package/lib/plan.mjs +33 -1
- package/lib/uninstall.mjs +25 -6
- package/package.json +1 -1
package/lib/effects.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
18
18
|
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
19
19
|
import { askYesNo, askLine as askLineOnTty } from './prompt.mjs';
|
|
20
20
|
import { classifyHarnessProbe } from './logic.mjs';
|
|
21
|
+
import { classifyStateDir } from './detect.mjs';
|
|
21
22
|
|
|
22
23
|
/** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
|
|
23
24
|
async function probePort(port, { timeoutMs = 1500 } = {}) {
|
|
@@ -156,24 +157,41 @@ function copyToClipboard(text) {
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
/**
|
|
159
|
-
* Every state directory on this machine
|
|
160
|
+
* Every DAEMON state directory on this machine.
|
|
160
161
|
*
|
|
161
|
-
*
|
|
162
|
-
* GLOBAL packages still needed by somebody else? Getting it wrong the optimistic
|
|
163
|
-
* way (reporting none) uninstalls the CLI out from under a second daemon that is
|
|
164
|
-
* still running, so the search is deliberately conservative — it looks only where
|
|
165
|
-
* a state directory can actually be, and an unreadable home means "there might be
|
|
166
|
-
* others", not "there are none".
|
|
162
|
+
* ONE DEFINITION OF WHAT A DAEMON IS, and this function is why that matters.
|
|
167
163
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
164
|
+
* It used to count any `~/.ours*` directory containing a config.json. Two of those
|
|
165
|
+
* are not daemons on a perfectly normal machine: `~/.ours-telegram/config.json` is
|
|
166
|
+
* the Telegram connector's and `~/.ours-cowork/config.json` is cowork's. So the
|
|
167
|
+
* uninstaller reported "@ours.network/cli kept — still used by the daemon at
|
|
168
|
+
* ~/.ours-telegram", and two things followed silently:
|
|
169
|
+
*
|
|
170
|
+
* · planGlobalPackages kept cli, mcp and the plugin packages FOREVER on any
|
|
171
|
+
* machine with the connector installed, naming a connector's config directory
|
|
172
|
+
* as a daemon;
|
|
173
|
+
* · worse, planPluginRemoval's `lastDaemon` went false, so the whole harness
|
|
174
|
+
* plugin phase was skipped — with a reason that was not true. A plain
|
|
175
|
+
* interactive `ours-uninstall` on a machine with the connector removed no
|
|
176
|
+
* plugins at all.
|
|
177
|
+
*
|
|
178
|
+
* The selection screen had already closed exactly this: config.json is the one
|
|
179
|
+
* piece of evidence that is AMBIGUOUS, so it cannot be the test. That predicate
|
|
180
|
+
* lives in lib/detect.mjs and this now calls it rather than keeping a second,
|
|
181
|
+
* naive copy that drifted. A daemon is identified by an artefact only a daemon
|
|
182
|
+
* writes, or by a config whose SHAPE is a daemon's.
|
|
183
|
+
*
|
|
184
|
+
* Still deliberately conservative about WHERE it looks: only `~/.ours` and its
|
|
185
|
+
* `~/.ours*` siblings. A state directory somewhere else is not found, and an
|
|
186
|
+
* unreadable home means "there might be others", not "there are none" — because
|
|
187
|
+
* the caller uses this to decide whether a GLOBAL package is still needed, and
|
|
188
|
+
* being wrong the optimistic way uninstalls the CLI out from under a running
|
|
189
|
+
* daemon.
|
|
173
190
|
*/
|
|
174
191
|
function knownStateDirsIn(home) {
|
|
175
192
|
const found = [];
|
|
176
|
-
const
|
|
193
|
+
const io = { exists: existsSync, readJson: readJsonFile };
|
|
194
|
+
const consider = (dir) => { if (classifyStateDir(dir, io).isDaemon) found.push(dir); };
|
|
177
195
|
consider(join(home, '.ours'));
|
|
178
196
|
try {
|
|
179
197
|
for (const entry of readdirSync(home, { withFileTypes: true })) {
|
|
@@ -278,7 +296,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
|
|
|
278
296
|
};
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf };
|
|
299
|
+
export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, installedVersionOf, knownStateDirsIn };
|
|
282
300
|
|
|
283
301
|
// -----------------------------------------------------------------------------
|
|
284
302
|
// THE PAIR
|
|
@@ -141,6 +141,7 @@ export async function runUninstall(argv, effects) {
|
|
|
141
141
|
exists: effects.exists,
|
|
142
142
|
cliStartedIt: effects.readJson(join(dir, 'ours-cli-daemon.json')) !== null,
|
|
143
143
|
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
144
|
+
platform: effects.platform?.platform,
|
|
144
145
|
typedConfirmation: null,
|
|
145
146
|
explicitHarnessSelection: contract.engaged,
|
|
146
147
|
});
|
|
@@ -159,6 +160,7 @@ export async function runUninstall(argv, effects) {
|
|
|
159
160
|
// removal makes that true. If step 3 or 4 fails, the operator is left with a
|
|
160
161
|
// stopped, detached connector NEXT TO A DAEMON THAT IS STILL THERE — a world the
|
|
161
162
|
// bytes no longer describe.
|
|
163
|
+
let unsupportedService = null;
|
|
162
164
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
163
165
|
const detached = [];
|
|
164
166
|
for (const component of plan.detach) {
|
|
@@ -178,6 +180,13 @@ export async function runUninstall(argv, effects) {
|
|
|
178
180
|
// 3-4. The boot service, then the daemon. Both delegate their refusals.
|
|
179
181
|
try {
|
|
180
182
|
for (const step of plan.daemon) {
|
|
183
|
+
if (step.id === 'service-unsupported') {
|
|
184
|
+
// Said, not skipped silently: the operator is entitled to know that the
|
|
185
|
+
// thing they may have expected to be removed was never installed.
|
|
186
|
+
effects.out(warn(`ours: ${step.note}`));
|
|
187
|
+
unsupportedService = step;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
181
190
|
if (step.command === null) {
|
|
182
191
|
effects.out(info(`${step.note} — nothing signalled`));
|
|
183
192
|
continue;
|
|
@@ -204,6 +213,7 @@ export async function runUninstall(argv, effects) {
|
|
|
204
213
|
const packages = planGlobalPackages({
|
|
205
214
|
stateDir: dir,
|
|
206
215
|
otherStateDirsWithConfig: effects.knownStateDirs(),
|
|
216
|
+
platform: effects.platform?.platform,
|
|
207
217
|
pluginPackages: pluginOutcome.packages,
|
|
208
218
|
// The plugin packages are recomputed; the CONNECTOR packages are not. They
|
|
209
219
|
// are decided by what the operator confirmed detaching, which the plugin
|
|
@@ -219,6 +229,15 @@ export async function runUninstall(argv, effects) {
|
|
|
219
229
|
await perform(effects, args.dryRun, `npm rm -g ${pkg}`, () => effects.run('npm', ['rm', '-g', pkg]));
|
|
220
230
|
}
|
|
221
231
|
}
|
|
232
|
+
// SAID AT THE END, where a closing screen is actually read, and not only in the
|
|
233
|
+
// line it scrolled past during the run. The same rule as the install side: an
|
|
234
|
+
// uninstall that quietly did less than the operator believes is the thing to
|
|
235
|
+
// avoid, and "nothing of ours was there to remove" is only reassuring if it is
|
|
236
|
+
// stated.
|
|
237
|
+
if (unsupportedService) {
|
|
238
|
+
effects.out(warn(`Boot service: NOT removed — ${unsupportedService.note}.`));
|
|
239
|
+
effects.out(info('If you start the daemon yourself at login, remove that arrangement by hand.'));
|
|
240
|
+
}
|
|
222
241
|
return EXIT_OK;
|
|
223
242
|
}
|
|
224
243
|
|
package/lib/orchestrate.mjs
CHANGED
|
@@ -239,6 +239,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
239
239
|
// lookup exists to prevent; this is what stops the installer from setting up the
|
|
240
240
|
// conditions for it.
|
|
241
241
|
const journal = configJournal(effects, { dryRun: args.dryRun });
|
|
242
|
+
let serviceUnsupported = null;
|
|
242
243
|
if (merged.changed) {
|
|
243
244
|
journal.snapshot(configPath);
|
|
244
245
|
await perform(effects, args.dryRun, `write ${configPath} (port ${target.port})`, () => effects.writeJson(configPath, merged.text));
|
|
@@ -254,6 +255,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
const service = await runServicePhase(args, effects, dir);
|
|
258
|
+
if (service.unsupported) serviceUnsupported = service.unsupported;
|
|
257
259
|
if (service.refused) {
|
|
258
260
|
// A REFUSAL IS A FAILURE TO REACH THE STATE, not a special case. An unknown
|
|
259
261
|
// unit file stops the run just as a failed start does, and it stops it with
|
|
@@ -287,7 +289,7 @@ export async function runDaemonPhase(args, effects) {
|
|
|
287
289
|
throw error;
|
|
288
290
|
}
|
|
289
291
|
|
|
290
|
-
return { target, steps };
|
|
292
|
+
return { target, steps, serviceUnsupported };
|
|
291
293
|
}
|
|
292
294
|
|
|
293
295
|
/**
|
|
@@ -349,7 +351,19 @@ function rollBack(effects, journal, args, why, { packagesInstalled = true, repla
|
|
|
349
351
|
* positively identified as ours-mcp's, and never for one we cannot identify.
|
|
350
352
|
*/
|
|
351
353
|
export async function runServicePhase(args, effects, dir) {
|
|
352
|
-
const plan = planServiceInstall({
|
|
354
|
+
const plan = planServiceInstall({
|
|
355
|
+
stateDir: dir, home: effects.home, readText: effects.readText, platform: effects.platform?.platform,
|
|
356
|
+
});
|
|
357
|
+
// NOT a refusal and NOT a failure: the daemon is running and correct, and only
|
|
358
|
+
// the boot service could not be installed. So the run CONTINUES — but it says
|
|
359
|
+
// so, and the summary marks it, because the person this hurts is the one who
|
|
360
|
+
// reboots in a fortnight and finds nothing 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 };
|
|
@@ -594,6 +608,12 @@ export function runPreflight(effects) {
|
|
|
594
608
|
return { ok: false, platform: plat };
|
|
595
609
|
}
|
|
596
610
|
effects.out(ok(`Platform: ${plat.label} (supported)`));
|
|
611
|
+
// Supported is still true — the daemon runs here. What is NOT available is the
|
|
612
|
+
// boot service, and saying "supported" without that qualification is what let a
|
|
613
|
+
// Mac user walk into a run that could not finish.
|
|
614
|
+
if (effects.platform?.platform && effects.platform.platform !== 'linux') {
|
|
615
|
+
effects.out(info(`On ${plat.label} the daemon runs, but installing a BOOT SERVICE is not available yet — you will start it yourself after a reboot.`));
|
|
616
|
+
}
|
|
597
617
|
const version = String(effects.nodeVersion ?? '0');
|
|
598
618
|
if (Number.parseInt(version.split('.')[0], 10) < 20) {
|
|
599
619
|
effects.out(warn(`Node.js ${version} — ours needs v20 or newer. Update Node and re-run.`));
|
|
@@ -1003,6 +1023,18 @@ export async function runInstall(argv, effects) {
|
|
|
1003
1023
|
state: target.action === 'create' ? 'installed' : 'current',
|
|
1004
1024
|
note: `port ${target.port}`,
|
|
1005
1025
|
}];
|
|
1026
|
+
// THE SKIP MUST NOT READ AS SUCCESS. A line that scrolls past is not a warning
|
|
1027
|
+
// — the summary is where someone looks, so an uninstallable boot service gets
|
|
1028
|
+
// the same "needs attention" mark a failed component gets. If this screen read
|
|
1029
|
+
// clean on macOS we would have replaced a failed install with a quieter lie.
|
|
1030
|
+
if (daemon.serviceUnsupported) {
|
|
1031
|
+
summary.push({
|
|
1032
|
+
key: 'service',
|
|
1033
|
+
label: 'Boot service',
|
|
1034
|
+
state: 'failed',
|
|
1035
|
+
note: `not available on ${daemon.serviceUnsupported.platform === 'darwin' ? 'macOS' : daemon.serviceUnsupported.platform} yet — start the daemon yourself after a reboot`,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1006
1038
|
|
|
1007
1039
|
const components = await runComponentPhase(args, effects, target);
|
|
1008
1040
|
for (const component of COMPONENTS) {
|
package/lib/plan.mjs
CHANGED
|
@@ -110,7 +110,39 @@ export function classifyUnit(text) {
|
|
|
110
110
|
* without a question in front of it — it is what keeps --force from becoming a
|
|
111
111
|
* default that spreads to the other cases.
|
|
112
112
|
*/
|
|
113
|
-
export function planServiceInstall({ stateDir, home, readText }) {
|
|
113
|
+
export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
|
|
114
|
+
// THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
|
|
115
|
+
//
|
|
116
|
+
// `ours daemon install-service` in @ours.network/cli builds its adapter with
|
|
117
|
+
// `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
|
|
118
|
+
// factory's FIRST line is
|
|
119
|
+
// if (deps.platform !== 'linux') throw new Error('service management is not
|
|
120
|
+
// supported on <platform>; use an external launcher for `ours daemon serve`')
|
|
121
|
+
// — verified by reading the published 0.4.1 tarball, which contains zero
|
|
122
|
+
// occurrences of launchd, LaunchAgents or plist.
|
|
123
|
+
//
|
|
124
|
+
// So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
|
|
125
|
+
// was told their platform was supported, watched the CLI install, the config
|
|
126
|
+
// write and the daemon start, and then got an exception and a rolled-back
|
|
127
|
+
// config. Skipping the step leaves them a working daemon and one true sentence
|
|
128
|
+
// instead — which is the whole of this change.
|
|
129
|
+
//
|
|
130
|
+
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
131
|
+
// package can install a launchd agent, and pretending otherwise by writing a
|
|
132
|
+
// plist ourselves would put a second service implementation in a second repo.
|
|
133
|
+
if (platform && platform !== 'linux') {
|
|
134
|
+
return {
|
|
135
|
+
action: 'unsupported',
|
|
136
|
+
platform,
|
|
137
|
+
reason: 'no-service-manager',
|
|
138
|
+
message: platform === 'darwin'
|
|
139
|
+
? 'installing a boot service is not available on macOS yet — the ours CLI can only manage a Linux user systemd service'
|
|
140
|
+
: `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
|
|
141
|
+
// What the operator can do INSTEAD, which is the difference between a gap
|
|
142
|
+
// and a dead end.
|
|
143
|
+
manual: ['ours', 'daemon', 'serve', '--config'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
114
146
|
const derived = unitPathForStateDir(stateDir, home);
|
|
115
147
|
if (!derived.ok) {
|
|
116
148
|
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
package/lib/uninstall.mjs
CHANGED
|
@@ -168,16 +168,35 @@ export function planComponentDetach(key, existing) {
|
|
|
168
168
|
* case the screen names the external launcher and the run CONTINUES — a daemon
|
|
169
169
|
* someone else supervises is not a failure of this uninstall.
|
|
170
170
|
*/
|
|
171
|
-
export function planDaemonRemoval({ stateDir, cliStartedIt }) {
|
|
171
|
+
export function planDaemonRemoval({ stateDir, cliStartedIt, platform = 'linux' }) {
|
|
172
172
|
const dir = resolve(stateDir);
|
|
173
173
|
const unit = unitNameForStateDir(dir);
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
// THE MIRROR OF THE INSTALL SIDE, and half a fix here would be worse than none:
|
|
175
|
+
// a Mac user who installed successfully and then cannot UNINSTALL is stuck with
|
|
176
|
+
// something they were told worked.
|
|
177
|
+
//
|
|
178
|
+
// `ours daemon uninstall-service` goes through the same
|
|
179
|
+
// createLinuxUserSystemdAdapter() that throws on the first line for any
|
|
180
|
+
// non-linux platform, so calling it on darwin fails the run rather than
|
|
181
|
+
// degrading it. There is also nothing there to remove: this package could never
|
|
182
|
+
// have installed a launchd agent in the first place.
|
|
183
|
+
const service = platform && platform !== 'linux'
|
|
184
|
+
? {
|
|
185
|
+
id: 'service-unsupported',
|
|
186
|
+
unit: null,
|
|
187
|
+
command: null,
|
|
188
|
+
platform,
|
|
189
|
+
continues: true,
|
|
190
|
+
note: `no boot service was installed on ${platform === 'darwin' ? 'macOS' : platform} — the ours CLI can only manage a Linux user systemd service, so there is nothing of ours to remove here`,
|
|
191
|
+
}
|
|
192
|
+
: {
|
|
176
193
|
id: 'service',
|
|
177
194
|
unit: unit.ok ? unit.unit : null,
|
|
178
195
|
command: ['ours', 'daemon', 'uninstall-service', '--yes', '--state-dir', dir],
|
|
179
196
|
note: 'refuses a unit not marked as CLI-managed',
|
|
180
|
-
}
|
|
197
|
+
};
|
|
198
|
+
return [
|
|
199
|
+
service,
|
|
181
200
|
cliStartedIt
|
|
182
201
|
? { id: 'stop', command: ['ours', 'daemon', 'stop', '--config', join(dir, 'config.json')] }
|
|
183
202
|
: { id: 'stop-external', command: null, continues: true, note: 'this daemon was not started by the CLI; naming its launcher and continuing' },
|
|
@@ -474,7 +493,7 @@ export function selectHarnesses(plugins, chosen) {
|
|
|
474
493
|
* The whole §8 order, refusing at step 1 rather than starting and stopping
|
|
475
494
|
* half-way.
|
|
476
495
|
*/
|
|
477
|
-
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false }) {
|
|
496
|
+
export function planUninstall({ home, env = {}, endpoint, stateDir, purge = false, assumeYes = false, confirmedComponents = [], readJson, readText, exists = () => true, cliStartedIt = true, otherStateDirsWithConfig = [], typedConfirmation = null, explicitHarnessSelection = false, platform = 'linux' }) {
|
|
478
497
|
const dir = resolve(stateDir);
|
|
479
498
|
const lastDaemon = otherStateDirsWithConfig.map((d) => resolve(d)).filter((d) => d !== dir).length === 0;
|
|
480
499
|
const plugins = planPluginRemoval({ home, env, exists, lastDaemon, explicitSelection: explicitHarnessSelection });
|
|
@@ -512,7 +531,7 @@ export function planUninstall({ home, env = {}, endpoint, stateDir, purge = fals
|
|
|
512
531
|
action: 'uninstall',
|
|
513
532
|
stateDir: dir,
|
|
514
533
|
detach: pointing.map((p) => ({ key: p.key, service: [`ours-${p.key === 'tg' ? 'tg-connector' : 'cowork'}`, 'uninstall-service'] })),
|
|
515
|
-
daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt }),
|
|
534
|
+
daemon: planDaemonRemoval({ stateDir: dir, cliStartedIt, platform }),
|
|
516
535
|
state: planStatePurge({ stateDir: dir, purge, assumeYes, exists, typedConfirmation }),
|
|
517
536
|
plugins,
|
|
518
537
|
packages: planGlobalPackages({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/install",
|
|
3
|
-
"version": "0.17.0-nightly.
|
|
3
|
+
"version": "0.17.0-nightly.12",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The unified ours.network stack installer (ours-install): one guided ~3-minute flow for ours core (the daemon) + the harness plugins (Claude Code / Codex) + ours-fleet + the Telegram connector, then a single copy-paste hand-off prompt. Self-contained (Node built-ins only); run as `ours-install` or via curl|bash (install.sh).",
|
|
6
6
|
"type": "module",
|