@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
package/lib/components.mjs
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The three components are the MCP server, the Telegram connector and cowork.
|
|
9
9
|
// None of them IS the daemon; all three attach to one. The messenger is out of
|
|
10
|
-
// scope
|
|
11
|
-
// installed here.
|
|
10
|
+
// scope here, and ours-fleet sits on top and is not installed here.
|
|
12
11
|
|
|
13
12
|
import { join, resolve } from 'node:path';
|
|
14
13
|
import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
@@ -23,8 +22,13 @@ import { pkgSpec, resolveChannel } from './logic.mjs';
|
|
|
23
22
|
// effects.installedVersion('@ours.network/mcp@nightly') return null forever,
|
|
24
23
|
// which fails the cowork version floor CLOSED and blanks the version column —
|
|
25
24
|
// a silent regression that looks like "cowork is too old".
|
|
25
|
+
// `required` is not a stronger default — it is the absence of a choice. The
|
|
26
|
+
// daemon is the ours-sdk CLI and this package is the MCP server every harness
|
|
27
|
+
// speaks to it through, so an operator who declined it would have a daemon no
|
|
28
|
+
// harness can reach — which is not a decision anyone means to make. Declining
|
|
29
|
+
// has to be impossible rather than discouraged.
|
|
26
30
|
export const COMPONENTS = [
|
|
27
|
-
{ key: 'mcp', label: 'MCP server', pkg: '@ours.network/mcp', specKey: 'mcp', default: true },
|
|
31
|
+
{ key: 'mcp', label: 'MCP server for your harness', pkg: '@ours.network/mcp', specKey: 'mcp', default: true, required: true },
|
|
28
32
|
{ key: 'tg', label: 'Telegram connector', pkg: '@ours.network/tg-connector', specKey: 'tg-connector', default: false },
|
|
29
33
|
{ key: 'cowork', label: 'cowork', pkg: '@ours.network/cowork', specKey: 'cowork', default: false },
|
|
30
34
|
];
|
|
@@ -41,7 +45,7 @@ export const COMPONENTS = [
|
|
|
41
45
|
* over. Every install path for these three now goes through here.
|
|
42
46
|
*
|
|
43
47
|
* All three publish a real `nightly` dist-tag (verified against the registry,
|
|
44
|
-
*
|
|
48
|
+
* so none of these pins can 404. `pkgSpec` falls back to `latest`
|
|
45
49
|
* for anything unmapped rather than inventing a tag, so an unknown component
|
|
46
50
|
* degrades to today's behaviour instead of failing.
|
|
47
51
|
*
|
|
@@ -84,10 +88,10 @@ export const COWORK_DAEMON_FLOOR = '0.4.1-nightly.20260816.4aaf940';
|
|
|
84
88
|
* daemon side, and anything the installer clears here is gone. The installer's
|
|
85
89
|
* entire business with the connector is three keys in one config file.
|
|
86
90
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
+
* It also keeps a future route migration possible — moving each route's packet
|
|
92
|
+
* into the shared daemon has to read this registry to know which daemon identity
|
|
93
|
+
* corresponds to which route, and an installer that trampled it would have
|
|
94
|
+
* destroyed the mapping.
|
|
91
95
|
*/
|
|
92
96
|
export const TG_STATE_DIR_NAME = '.ours-telegram';
|
|
93
97
|
export const TG_REGISTRY_FILES = ['bots.json'];
|
|
@@ -114,7 +118,12 @@ export function planComponentSelection({ answers = {}, installed = {}, assumeYes
|
|
|
114
118
|
return COMPONENTS.map((component) => {
|
|
115
119
|
const already = installed[component.key] === true;
|
|
116
120
|
const answer = assumeYes ? component.default : answers[component.key];
|
|
117
|
-
|
|
121
|
+
// A required component ignores the answer entirely, including an explicit no.
|
|
122
|
+
// The daemon phase has already installed and started it by the time this runs;
|
|
123
|
+
// "skip" here would only produce a screen that contradicts the machine.
|
|
124
|
+
const wanted = component.required
|
|
125
|
+
? true
|
|
126
|
+
: (answer === undefined ? (already || component.default) : answer === true);
|
|
118
127
|
return {
|
|
119
128
|
...component,
|
|
120
129
|
already,
|
package/lib/detect.mjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
// ours-install v3 — which daemons are already on this machine, and which one this
|
|
2
2
|
// run is for.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the user pick. Selection from what was found is not prompting for a path.
|
|
4
|
+
// Never ask for a state directory or a port to be TYPED (spec §2). When several
|
|
5
|
+
// daemons are DETECTED, show them and let one be picked: selecting from what was
|
|
6
|
+
// found is not prompting for a path.
|
|
8
7
|
//
|
|
9
8
|
// Pure, like target.mjs and plan.mjs: the caller injects the directory listing and
|
|
10
9
|
// the file reads, so the whole decision is testable without a filesystem.
|
|
11
10
|
//
|
|
12
|
-
// DETECTION, NOT A REGISTRY.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// confidently offering a daemon that is gone.
|
|
11
|
+
// DETECTION, NOT A REGISTRY. This is built from what is on disk rather than from a
|
|
12
|
+
// persisted list: a stored list is a second source of truth that goes stale against
|
|
13
|
+
// the daemons that really exist, which is how an installer ends up confidently
|
|
14
|
+
// offering a daemon that is gone.
|
|
17
15
|
|
|
18
16
|
import { basename, join, resolve } from 'node:path';
|
|
19
17
|
|
|
@@ -93,8 +91,7 @@ export function detectDaemons({ candidates = [], exists, readJson }) {
|
|
|
93
91
|
/**
|
|
94
92
|
* A state directory for a daemon this run would CREATE, derived and never typed.
|
|
95
93
|
*
|
|
96
|
-
* Spec §2 forbids asking for a path,
|
|
97
|
-
* added "pick from what was found". So "create a new one" has to derive somewhere
|
|
94
|
+
* Spec §2 forbids asking for a path, so "create a new one" has to derive somewhere
|
|
98
95
|
* to put it: `~/.ours` when free, else the first free `~/.ours-2`, `~/.ours-3`…
|
|
99
96
|
* An operator who wants a specific path still has `--state-dir`, which bypasses
|
|
100
97
|
* this screen entirely.
|
|
@@ -112,16 +109,6 @@ export function deriveNewStateDir(home, taken = [], { limit = 64 } = {}) {
|
|
|
112
109
|
|
|
113
110
|
export const SELECT_CREATE = '__create__';
|
|
114
111
|
|
|
115
|
-
// The nightly flow kept a registry of daemon profiles here. v3 does not read it —
|
|
116
|
-
// detection replaced it (coordinator ruling) — so anyone who used that flow has a
|
|
117
|
-
// file describing daemons that nothing consults any more.
|
|
118
|
-
//
|
|
119
|
-
// It is NOT deleted. Quietly removing a file that describes someone's daemons is
|
|
120
|
-
// not an installer's business, and the file is harmless. But leaving it looking
|
|
121
|
-
// live is worse than saying it is not, so the run says so once.
|
|
122
|
-
export const LEGACY_PROFILE_REGISTRY = 'installer-profiles.json';
|
|
123
|
-
export const legacyRegistryPath = (home) => join(resolve(home), '.ours', LEGACY_PROFILE_REGISTRY);
|
|
124
|
-
|
|
125
112
|
/**
|
|
126
113
|
* What this run should do about choosing a daemon (C1's five rules, in one place
|
|
127
114
|
* so they can be read together and tested without a terminal):
|
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
|
package/lib/extras.mjs
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// ours-install v3 — the four retained extras, re-pointed at the v3 arrangement.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// was an oversight, not a decision.
|
|
3
|
+
// The v3 installer keeps harness plugins, ours-fleet, voice setup and the
|
|
4
|
+
// copy-paste hand-off prompt; spec v3's silence about them was an oversight.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// gets the unit; in v3 the daemon is the ours-sdk CLI and ours-mcp is a
|
|
10
|
-
// per-session stdio proxy with no unit. Each of the four had assumed the v2
|
|
11
|
-
// arrangement somewhere, and this module is where the new assumption lives.
|
|
6
|
+
// The shared daemon belongs to the operator CLI. ours-mcp is only a per-session
|
|
7
|
+
// stdio adapter with no unit, and these extra phases preserve that boundary.
|
|
12
8
|
//
|
|
13
9
|
// Pure, like target.mjs / plan.mjs / components.mjs: no I/O, no subprocess, no
|
|
14
10
|
// terminal. Every function takes what was observed and returns a plan; the
|
|
@@ -52,7 +48,7 @@ export const HARNESSES = [
|
|
|
52
48
|
*
|
|
53
49
|
* So §5's guarantee is ALREADY unmet today for every non-default state
|
|
54
50
|
* directory, silently: the harness attaches to ~/.ours while the operator was
|
|
55
|
-
* told the run targeted somewhere else.
|
|
51
|
+
* told the run targeted somewhere else. The shape is:
|
|
56
52
|
*
|
|
57
53
|
* default state directory today's behaviour, byte for byte.
|
|
58
54
|
* Hermes, non-default real: the pair is handed to ours-hermes-install's
|
|
@@ -265,82 +261,6 @@ export function planFleet({ stateDir, isDefaultStateDir, wanted = true, channel
|
|
|
265
261
|
return wanted ? { ...plan, action: 'install' } : { ...plan, action: 'skip', offerOnRerun: true };
|
|
266
262
|
}
|
|
267
263
|
|
|
268
|
-
// -----------------------------------------------------------------------------
|
|
269
|
-
// voice setup — the installer takes the restart beat
|
|
270
|
-
// -----------------------------------------------------------------------------
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* WHY THE INSTALLER OWNS THE RESTART NOW (owner ruling Q2).
|
|
274
|
-
*
|
|
275
|
-
* cmdVoiceSetup computes `managed = runningPid() !== null` from ours-mcp's OWN
|
|
276
|
-
* pid record. A v3 daemon is started by `ours daemon start`, which writes
|
|
277
|
-
* <state-dir>/ours-cli-daemon.json — not that record. So runningPid() is null
|
|
278
|
-
* and EVERY v3 daemon classifies as `external`. transactVoiceConfig then returns
|
|
279
|
-
* at its `if (daemonState !== 'managed') return { ok: true, stage: 'write' }`:
|
|
280
|
-
* config written, no apply, no restart, no readiness check, exit 0.
|
|
281
|
-
*
|
|
282
|
-
* Nothing goes wrong — voice-setup will not run `ours-mcp restart` against a
|
|
283
|
-
* CLI-started daemon, which is right. But v2's entire restart protocol
|
|
284
|
-
* (restartHandled, the exit-2 branch, "voice setup performed the required
|
|
285
|
-
* restart") can only fire for a `managed` daemon, so carrying it across would
|
|
286
|
-
* ship a branch that can never be taken. The beat it owned now has no owner, and
|
|
287
|
-
* the installer is the only process that knows the daemon is CLI-managed.
|
|
288
|
-
*
|
|
289
|
-
* One phase, AFTER the component phase — voice-setup is an `ours-mcp`
|
|
290
|
-
* subcommand and v3 installs @ours.network/mcp as a COMPONENT, so it is not on
|
|
291
|
-
* PATH any earlier. The create path needs no special case: in v3 the first
|
|
292
|
-
* `ours daemon start` already happened back in the daemon phase.
|
|
293
|
-
*
|
|
294
|
-
* FILED, NOT FIXED HERE: voice-setup's own hints still say `ours-mcp start` and
|
|
295
|
-
* "restart its external launcher" (packages/core/src/cli.ts:542,547). Under v3
|
|
296
|
-
* the answer is `ours daemon restart --config <cfg>`. That is a packages/core
|
|
297
|
-
* change with its own blast radius and does not belong in an installer PR.
|
|
298
|
-
*/
|
|
299
|
-
export function planVoice({
|
|
300
|
-
mcpInstalled = false,
|
|
301
|
-
ready = false,
|
|
302
|
-
assumeYes = false,
|
|
303
|
-
accepted = null,
|
|
304
|
-
configChanged = false,
|
|
305
|
-
stateDir,
|
|
306
|
-
port,
|
|
307
|
-
} = {}) {
|
|
308
|
-
const config = stateDir ? cfgPath(stateDir) : null;
|
|
309
|
-
const skip = (reason, message, extra = {}) => ({
|
|
310
|
-
key: 'voice', action: 'skip', reason, message, offerOnRerun: true, restartOwed: false, ...extra,
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
if (!mcpInstalled) {
|
|
314
|
-
return skip('no-mcp', 'voice setup needs the MCP server, which this run did not install — re-run ours-install to add both.');
|
|
315
|
-
}
|
|
316
|
-
if (ready) {
|
|
317
|
-
return { key: 'voice', action: 'skip', reason: 'already-configured', message: 'voice transcription is already configured', offerOnRerun: false, restartOwed: false };
|
|
318
|
-
}
|
|
319
|
-
if (assumeYes) {
|
|
320
|
-
return skip('non-interactive', 'voice setup is interactive and this run is not — re-run ours-install to configure it.');
|
|
321
|
-
}
|
|
322
|
-
if (accepted === false) {
|
|
323
|
-
return skip('declined', 'skipped cleanly — re-run ours-install any time to configure voice.');
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const env = config ? { OURS_CONFIG: config } : {};
|
|
327
|
-
return {
|
|
328
|
-
key: 'voice',
|
|
329
|
-
action: 'setup',
|
|
330
|
-
env,
|
|
331
|
-
setup: ['ours-mcp', 'voice-setup'],
|
|
332
|
-
statusCheck: ['ours-mcp', 'voice-status', '--json'],
|
|
333
|
-
// The beat. Only owed when the config actually changed: an unchanged config
|
|
334
|
-
// is not a reason to bounce a daemon somebody else may be using.
|
|
335
|
-
restartOwed: Boolean(configChanged),
|
|
336
|
-
restart: configChanged && config ? ['ours', 'daemon', 'restart', '--config', config] : null,
|
|
337
|
-
// Never rolls the daemon back: voice-setup leaves the prior config intact on
|
|
338
|
-
// its own failure path, so the recovery is a retry, not an undo.
|
|
339
|
-
retryHint: config ? `ours daemon restart --config ${config}` : null,
|
|
340
|
-
port: Number.isInteger(port) ? port : null,
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
264
|
// -----------------------------------------------------------------------------
|
|
345
265
|
// the copy-paste hand-off prompt
|
|
346
266
|
// -----------------------------------------------------------------------------
|
package/lib/journal.mjs
CHANGED
|
@@ -71,7 +71,10 @@ export function configJournal(effects, { dryRun = false } = {}) {
|
|
|
71
71
|
for (const entry of entries.slice().reverse()) {
|
|
72
72
|
try {
|
|
73
73
|
effects.restore(entry.path, entry.snapshot);
|
|
74
|
-
|
|
74
|
+
// A write that RETURNED is not a file that HOLDS. Read the bytes back.
|
|
75
|
+
const mismatch = verifyRestored(effects, entry);
|
|
76
|
+
if (mismatch) failed.push({ path: entry.path, reason: mismatch });
|
|
77
|
+
else restored.push({ path: entry.path, existed: entry.snapshot?.exists !== false });
|
|
75
78
|
} catch (error) {
|
|
76
79
|
failed.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
|
|
77
80
|
}
|
|
@@ -82,6 +85,48 @@ export function configJournal(effects, { dryRun = false } = {}) {
|
|
|
82
85
|
};
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Did the restore actually take? Returns null when it did, or the reason it did not.
|
|
90
|
+
*
|
|
91
|
+
* WHY THIS EXISTS. `restore` returning is evidence that a call completed, not that
|
|
92
|
+
* a file holds the bytes it was given. A full disk, a read-only mount, a rename
|
|
93
|
+
* that lands somewhere else, an editor holding the inode — each of those can let
|
|
94
|
+
* the write return and leave the old contents in place. Reporting on the call
|
|
95
|
+
* rather than on the state is how a rollback comes to LIE, and a rollback that
|
|
96
|
+
* lies is worse than one that admits it failed: the operator is told the machine
|
|
97
|
+
* is back where it was and stops looking.
|
|
98
|
+
*
|
|
99
|
+
* The read-back seam is `effects.snapshot`, the same one used to record the bytes
|
|
100
|
+
* in the first place — it already returns exactly the shape being compared, so
|
|
101
|
+
* this needs no second seam and no second notion of what a file's state is.
|
|
102
|
+
*
|
|
103
|
+
* Its own failure is a mismatch, not an exception: if the file cannot be read
|
|
104
|
+
* after being written, that is precisely the case this check exists to catch.
|
|
105
|
+
*/
|
|
106
|
+
function verifyRestored(effects, entry) {
|
|
107
|
+
const expected = entry.snapshot;
|
|
108
|
+
let actual;
|
|
109
|
+
try {
|
|
110
|
+
actual = effects.snapshot(entry.path);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return `restored, but the file could not be read back to confirm it (${error instanceof Error ? error.message : String(error)})`;
|
|
113
|
+
}
|
|
114
|
+
const shouldExist = expected?.exists !== false;
|
|
115
|
+
if (!shouldExist) {
|
|
116
|
+
return actual?.exists ? 'this run created it and the removal did not take — the file is still there' : null;
|
|
117
|
+
}
|
|
118
|
+
if (!actual?.exists) return 'the restore reported success but the file is not there';
|
|
119
|
+
if (actual.text !== expected.text) return 'the restore reported success but the bytes on disk are not the previous ones';
|
|
120
|
+
// Bytes first, permissions second: the contents are back either way, so this
|
|
121
|
+
// must not be reported as "could not restore the config".
|
|
122
|
+
if (expected.mode !== undefined && actual.mode !== undefined && actual.mode !== expected.mode) {
|
|
123
|
+
return `contents restored, but the permissions are ${fmtMode(actual.mode)} and were ${fmtMode(expected.mode)} — check them before re-running`;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fmtMode = (mode) => `0${(mode & 0o777).toString(8).padStart(3, '0')}`;
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* The report, and it is half the feature.
|
|
87
132
|
*
|
package/lib/logic.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function canonHarnesses(raw) {
|
|
|
26
26
|
return { names, unknown };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// ── Release CHANNEL / npm dist-tag selection
|
|
29
|
+
// ── Release CHANNEL / npm dist-tag selection ───────────────────────────────────
|
|
30
30
|
// The installer normally installs everything at @latest (stable). Setting
|
|
31
31
|
// OURS_CHANNEL=nightly (or OURS_INSTALL_CHANNEL) makes it install each package's
|
|
32
32
|
// PRERELEASE dist-tag instead — for the packages that publish one.
|
|
@@ -37,8 +37,8 @@ export function canonHarnesses(raw) {
|
|
|
37
37
|
// from this repo by .github/workflows/scripts/bump-versions.sh)
|
|
38
38
|
// · fleet → `nightly` (its own repo,
|
|
39
39
|
// adapt-toolkit/ours-fleet, publishes a nightly dist-tag of its own)
|
|
40
|
-
// · cowork (rooms) → `nightly` (
|
|
41
|
-
//
|
|
40
|
+
// · cowork (rooms) → `nightly` (cowork aligns
|
|
41
|
+
// with every other service rather than keeping its
|
|
42
42
|
// historical `next` tag)
|
|
43
43
|
//
|
|
44
44
|
// WHY FLEET FOLLOWS THE CHANNEL NOW. It used to be pinned to @latest with the note
|
|
@@ -49,7 +49,7 @@ export function canonHarnesses(raw) {
|
|
|
49
49
|
// channel exists to prevent.
|
|
50
50
|
//
|
|
51
51
|
// WHY COWORK STILL NEEDS ITS OWN ENTRY. It historically published its prerelease
|
|
52
|
-
// line as `next`; the
|
|
52
|
+
// line as `next`; the decision is that it aligns with every
|
|
53
53
|
// other service and publishes `nightly` instead. The entry stays because the map,
|
|
54
54
|
// not a hardcoded string, is what makes such a change one line — and because an
|
|
55
55
|
// UNMAPPED package deliberately falls back to `latest` rather than a guessed tag.
|
|
@@ -60,7 +60,7 @@ export function canonHarnesses(raw) {
|
|
|
60
60
|
// build that predates it — the same architecture-boundary mismatch this whole
|
|
61
61
|
// mechanism exists to prevent, pointed at Rooms instead of Telegram.
|
|
62
62
|
//
|
|
63
|
-
// SEQUENCING — load-bearing, and not yet satisfied.
|
|
63
|
+
// SEQUENCING — load-bearing, and not yet satisfied. cowork
|
|
64
64
|
// publishes NO `nightly` dist-tag at all (its tags are latest=0.4.0 and
|
|
65
65
|
// next=0.3.7-nightly.20260815.80ea770). `npm i -g @ours.network/cowork@nightly`
|
|
66
66
|
// therefore 404s today, and a 404 fails the WHOLE install. So the nightly
|
|
@@ -81,7 +81,7 @@ const PKG_CHANNEL_TAGS = {
|
|
|
81
81
|
codex: { nightly: 'nightly' },
|
|
82
82
|
hermes: { nightly: 'nightly' },
|
|
83
83
|
fleet: { nightly: 'nightly' },
|
|
84
|
-
cowork: { nightly: 'nightly' }, // aligned with every other service
|
|
84
|
+
cowork: { nightly: 'nightly' }, // aligned with every other service
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
// Normalize a raw channel selection to 'latest' | 'nightly'. Anything unrecognized
|
|
@@ -133,81 +133,6 @@ export const RESERVED_PORTS = [3051, 3052];
|
|
|
133
133
|
export const DEFAULT_PORT = 3050;
|
|
134
134
|
export const DEFAULT_BROKER = 'wss://broker1.ours.network';
|
|
135
135
|
|
|
136
|
-
// ── Daemon topology: one COMMON daemon, plus optional DEDICATED ones ───────────
|
|
137
|
-
// Every consumer defaults to the common daemon on the common port — that is the
|
|
138
|
-
// backward-compatible answer and what Enter / non-interactive mode picks. A
|
|
139
|
-
// consumer may instead be given its OWN daemon, which needs three things to be
|
|
140
|
-
// genuinely isolated and not merely differently-addressed:
|
|
141
|
-
// · its own PORT (nothing else may bind it)
|
|
142
|
-
// · its own STATE DIRECTORY (the daemon's API token lives there; sharing one
|
|
143
|
-
// state dir between two daemons corrupts both)
|
|
144
|
-
// · its own SERVICE NAME (ours-mcp's boot unit — without a distinct name,
|
|
145
|
-
// `install-service` overwrites the common daemon's
|
|
146
|
-
// unit; see packages/core/src/service-instance.ts)
|
|
147
|
-
// The config file is separate too, since ours-mcp resolves port + stateDir from
|
|
148
|
-
// whatever OURS_CONFIG points at.
|
|
149
|
-
export const DAEMON_MODES = ['common', 'dedicated'];
|
|
150
|
-
|
|
151
|
-
// Fixed instance names, one per consumer that can own a daemon. These are what
|
|
152
|
-
// core validates and turns into `ours-<name>.service`, so they must satisfy its
|
|
153
|
-
// rules (alphanumeric, no separators at the ends).
|
|
154
|
-
export const DEDICATED_INSTANCES = { telegram: 'tg', rooms: 'rooms' };
|
|
155
|
-
|
|
156
|
-
// Where a dedicated daemon's private config + state live. Derived from the
|
|
157
|
-
// instance name so two consumers can never be handed the same directory.
|
|
158
|
-
export function dedicatedDaemonPaths(home, instance) {
|
|
159
|
-
const name = String(instance || '').trim();
|
|
160
|
-
const stateDir = `${home}/.ours-${name}`;
|
|
161
|
-
return { stateDir, configPath: `${stateDir}/config.json`, serviceName: name };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Normalize a daemon-mode answer. Anything unrecognized (including empty and
|
|
165
|
-
// non-interactive) is 'common' — the backward-compatible default.
|
|
166
|
-
export function resolveDaemonMode(raw) {
|
|
167
|
-
const v = String(raw || '').trim().toLowerCase();
|
|
168
|
-
return v === 'dedicated' || v === 'own' || v === 'separate' ? 'dedicated' : 'common';
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Validate a port the user picked for a daemon, against the reserved list AND
|
|
172
|
-
// every port this install has already committed to. `isTaken(port)` probes a real
|
|
173
|
-
// bind; `taken` is the set of ports already chosen in THIS run, which a live probe
|
|
174
|
-
// cannot see (nothing is listening on them yet). Returns
|
|
175
|
-
// { ok, port, reason } — ok=false means "ask again", never "silently substitute".
|
|
176
|
-
export function validateDaemonPort(input, { fallback = DEFAULT_PORT, isTaken = () => false, taken = [], reserved = RESERVED_PORTS } = {}) {
|
|
177
|
-
// Stricter than parsePort on purpose: this answer becomes a persisted listen port,
|
|
178
|
-
// so "3.5.1" must be a question repeated, not silently accepted as port 3.
|
|
179
|
-
if (!/^\d+$/.test(String(input ?? '').trim())) {
|
|
180
|
-
return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
|
|
181
|
-
}
|
|
182
|
-
const parsed = parsePort(input, fallback);
|
|
183
|
-
if (!parsed.ok) return { ok: false, port: fallback, reason: 'that is not a port number between 1 and 65535' };
|
|
184
|
-
const port = parsed.port;
|
|
185
|
-
if (reserved.includes(port)) {
|
|
186
|
-
return { ok: false, port, reason: `port ${port} is reserved by another part of the stack` };
|
|
187
|
-
}
|
|
188
|
-
if (taken.includes(port)) {
|
|
189
|
-
return { ok: false, port, reason: `port ${port} is already being used by another daemon in this install` };
|
|
190
|
-
}
|
|
191
|
-
if (isTaken(port)) {
|
|
192
|
-
return { ok: false, port, reason: `port ${port} is already in use on this machine` };
|
|
193
|
-
}
|
|
194
|
-
return { ok: true, port, reason: '' };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// The whole install's port plan, checked as a set. Returns { ok, duplicates } so
|
|
198
|
-
// the caller can refuse a topology where two daemons would fight over one port
|
|
199
|
-
// even though each looked fine on its own.
|
|
200
|
-
export function planPorts(entries = []) {
|
|
201
|
-
const seen = new Map();
|
|
202
|
-
const duplicates = [];
|
|
203
|
-
for (const { label, port } of entries) {
|
|
204
|
-
if (!Number.isInteger(port)) continue;
|
|
205
|
-
if (seen.has(port)) duplicates.push({ port, labels: [seen.get(port), label] });
|
|
206
|
-
else seen.set(port, label);
|
|
207
|
-
}
|
|
208
|
-
return { ok: duplicates.length === 0, duplicates };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
136
|
// ── Handing the Telegram connector the ONE shared daemon ───────────────────────
|
|
212
137
|
// The connector has its OWN config file and never inherits the daemon's. Two
|
|
213
138
|
// generations of it are in the wild and the installer must satisfy BOTH, because
|
|
@@ -246,7 +171,7 @@ export function daemonEndpoint(port) {
|
|
|
246
171
|
|
|
247
172
|
// The broker the whole deployment shares, for a <=0.3.2 connector.
|
|
248
173
|
// Precedence: what the user chose in THIS run > what the running daemon actually
|
|
249
|
-
// resolved (`ours
|
|
174
|
+
// resolved (`ours daemon status`, which accounts for the selected config) > what
|
|
250
175
|
// the daemon's config file says > the built-in default (identical to the daemon's
|
|
251
176
|
// DEFAULT_CONFIG.brokerUrl, so "no answer anywhere" still agrees).
|
|
252
177
|
export function resolveSharedBroker({ chosenBroker, statusBroker, configBroker } = {}) {
|
|
@@ -592,7 +517,7 @@ export function parseVersion(text) {
|
|
|
592
517
|
return m ? m[0] : '';
|
|
593
518
|
}
|
|
594
519
|
|
|
595
|
-
// parseStatus: read the daemon's RESOLVED broker + port out of `ours
|
|
520
|
+
// parseStatus: read the daemon's RESOLVED broker + port out of `ours daemon status` output, so we
|
|
596
521
|
// prompt with what the daemon is actually using rather than a hardcoded guess. Returns
|
|
597
522
|
// { broker, port } with either field null when the line isn't present (daemon stopped / older
|
|
598
523
|
// build). Lines look like: " broker: wss://broker1.ours.network" and
|
|
@@ -600,7 +525,10 @@ export function parseVersion(text) {
|
|
|
600
525
|
export function parseStatus(text) {
|
|
601
526
|
const s = String(text || '');
|
|
602
527
|
const bm = s.match(/^\s*broker:\s*(\S+)/m);
|
|
603
|
-
|
|
528
|
+
// `api:` is the current line and `url:` the historical one. Both are matched
|
|
529
|
+
// because a newly installed CLI can be asked to report on an older running
|
|
530
|
+
// daemon, and a parser that knows only today's wording reads that as "no port".
|
|
531
|
+
const pm = s.match(/(?:api|url):\s*https?:\/\/[^:\s/]+:(\d+)/i);
|
|
604
532
|
return {
|
|
605
533
|
broker: bm ? bm[1] : null,
|
|
606
534
|
port: pm ? Number.parseInt(pm[1], 10) : null,
|
|
@@ -644,7 +572,7 @@ export function detectPlatform({ platform, release = '', env = {} } = {}) {
|
|
|
644
572
|
// tell the user plainly + how to fix, and ALWAYS still offer a manual path
|
|
645
573
|
// 'unsafe' — on PATH but the probe failed/looked wrong → don't auto-drive; offer manual path
|
|
646
574
|
// 'absent' — genuinely not installed → this harness is skipped (with a note)
|
|
647
|
-
// The golden rule
|
|
575
|
+
// The golden rule: 'alias'/'unsafe'/'absent' NEVER dead-end — the caller always
|
|
648
576
|
// prints a manual-install path so the component still gets installed.
|
|
649
577
|
export function classifyHarnessProbe({ onPath, versionOk, timedOut, shellType = '' } = {}) {
|
|
650
578
|
if (versionOk) return { status: 'ok', detail: 'real program' };
|