@ours.network/install 0.17.0 → 0.18.0-nightly.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -120
- package/install.mjs +23 -790
- package/lib/components.mjs +361 -0
- package/lib/detect.mjs +169 -0
- package/lib/effects.mjs +349 -0
- package/lib/extras.mjs +351 -0
- package/lib/journal.mjs +158 -0
- package/lib/logic.mjs +351 -25
- package/lib/orchestrate-uninstall.mjs +379 -0
- package/lib/orchestrate.mjs +984 -0
- package/lib/plan.mjs +270 -0
- package/lib/rerun.mjs +119 -0
- package/lib/target.mjs +390 -0
- package/lib/ui.mjs +15 -0
- package/lib/uninstall.mjs +736 -0
- package/lib/usage.mjs +48 -0
- package/package.json +2 -2
- package/uninstall.mjs +23 -194
- package/uninstall.sh +13 -4
package/lib/plan.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// ours-install v3 — daemon creation and boot-service installation.
|
|
2
|
+
//
|
|
3
|
+
// Spec: installer-spec-v3 §§3-4. Pure, like lib/target.mjs: the orchestrator
|
|
4
|
+
// injects file reads, and every function returns a PLAN the caller renders and
|
|
5
|
+
// executes. Nothing here writes, spawns, or runs systemctl.
|
|
6
|
+
|
|
7
|
+
import { join, resolve, basename } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const CLI_UNIT_MARKER = '# Managed by @ours.network/cli';
|
|
10
|
+
export const SYSTEMD_USER_DIR = ['.config', 'systemd', 'user'];
|
|
11
|
+
export const DEFAULT_SYSTEMD_UNIT = 'ours.service';
|
|
12
|
+
|
|
13
|
+
// -----------------------------------------------------------------------------
|
|
14
|
+
// §4 — which unit file does this state directory own?
|
|
15
|
+
// -----------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
// 1–32 chars, alphanumeric with interior hyphens/underscores, no dots.
|
|
18
|
+
const INSTANCE_RE = /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,30}[A-Za-z0-9])?$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* State directory -> systemd user unit name.
|
|
22
|
+
*
|
|
23
|
+
* SOURCE OF TRUTH IS ours-sdk `packages/cli/src/service-instance.ts` (merged in
|
|
24
|
+
* ours-sdk #20). The CLI performs this derivation itself when it installs, so
|
|
25
|
+
* the installer does NOT pass a unit name — it only needs to know which file to
|
|
26
|
+
* INSPECT before invoking the CLI, because of the unmarked-unit case below.
|
|
27
|
+
* This is a deliberate second copy across two repos; the table test pins it, and
|
|
28
|
+
* if the CLI's rule ever changes this must change with it.
|
|
29
|
+
*
|
|
30
|
+
* `~/.ours` -> '' -> ours.service (the historical unnamed unit, unchanged).
|
|
31
|
+
*/
|
|
32
|
+
export function unitNameForStateDir(stateDir) {
|
|
33
|
+
const segment = basename(resolve(stateDir));
|
|
34
|
+
const undotted = segment.startsWith('.') ? segment.slice(1) : segment;
|
|
35
|
+
if (undotted === 'ours') return { ok: true, unit: DEFAULT_SYSTEMD_UNIT, instance: '' };
|
|
36
|
+
const name = undotted.startsWith('ours-') ? undotted.slice('ours-'.length) : undotted;
|
|
37
|
+
if (!name || name.length > 32 || !INSTANCE_RE.test(name)) {
|
|
38
|
+
return { ok: false, unit: null, instance: null, reason: `state directory ${resolve(stateDir)} does not yield a usable service name (${JSON.stringify(name)})` };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, unit: `ours-${name}.service`, instance: name };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function unitPathForStateDir(stateDir, home) {
|
|
44
|
+
const derived = unitNameForStateDir(stateDir);
|
|
45
|
+
if (!derived.ok) return derived;
|
|
46
|
+
return { ...derived, path: join(home, ...SYSTEMD_USER_DIR, derived.unit) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// -----------------------------------------------------------------------------
|
|
50
|
+
// The unmarked-unit case — the migration blocker
|
|
51
|
+
// -----------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Classify whatever is already at the unit path.
|
|
55
|
+
*
|
|
56
|
+
* absent — nothing there; install proceeds
|
|
57
|
+
* cli-managed — written by @ours.network/cli; the CLI's own idempotence and
|
|
58
|
+
* its baked-state-dir guard handle it from here
|
|
59
|
+
* legacy — the unit published ours-mcp wrote: NO marker, ExecStart running
|
|
60
|
+
* ours-mcp. This is the migration blocker. `ours daemon
|
|
61
|
+
* install-service` refuses to overwrite an unmarked unit without
|
|
62
|
+
* --force, so spec §4 step 4 fails for every existing Linux user.
|
|
63
|
+
* foreign — unmarked and NOT recognisably ours-mcp's. Someone else's file.
|
|
64
|
+
*
|
|
65
|
+
* THE legacy/foreign SPLIT IS NOW THE ENTIRE SAFETY BOUNDARY. A `legacy` unit is
|
|
66
|
+
* rewritten SILENTLY — no prompt stands between this match and someone's file —
|
|
67
|
+
* so this match must stay a POSITIVE IDENTIFICATION of ours-mcp's own unit and
|
|
68
|
+
* must never drift toward "probably ours". A later reader must not collapse the
|
|
69
|
+
* two into one "unmarked unit" case for tidiness, and must not relax the patterns
|
|
70
|
+
* to catch more variants.
|
|
71
|
+
*
|
|
72
|
+
* For `foreign` we do not know what the file is, so the installer stops, offers
|
|
73
|
+
* no command, and does not prompt either: a confirmation dialogue over an
|
|
74
|
+
* unidentified file in someone's systemd directory is how you talk a user into
|
|
75
|
+
* destroying something.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyUnit(text) {
|
|
78
|
+
if (text === null || text === undefined) return { kind: 'absent' };
|
|
79
|
+
const s = String(text);
|
|
80
|
+
if (s.startsWith(CLI_UNIT_MARKER)) return { kind: 'cli-managed' };
|
|
81
|
+
const looksLikeOursMcp = /ExecStart=.*\bours-mcp\b/.test(s)
|
|
82
|
+
|| /^Description=ours MCP daemon\b/m.test(s)
|
|
83
|
+
|| (/^Environment=OURS_STATE_DIR=/m.test(s) && /^Environment=OURS_TRANSPORT=http$/m.test(s));
|
|
84
|
+
return looksLikeOursMcp ? { kind: 'legacy' } : { kind: 'foreign' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What this run should do about the boot service (spec §4 step 4).
|
|
89
|
+
*
|
|
90
|
+
* Returns one of:
|
|
91
|
+
* { action: 'install' } — call the CLI; it does the rest
|
|
92
|
+
* { action: 'adopt', notice, … } — a legacy ours-mcp unit is in the
|
|
93
|
+
* way; rewrite it, and print one
|
|
94
|
+
* informational line naming it
|
|
95
|
+
* { action: 'refuse', exitCode: 2, … } — unknown unit, or unusable state dir
|
|
96
|
+
*
|
|
97
|
+
* Adoption of a legacy unit is SILENT: no prompt, no question, so an upgrading
|
|
98
|
+
* user has no manual step. The one line of output exists so the replacement is not
|
|
99
|
+
* literally invisible; it does not block and it is not a warning.
|
|
100
|
+
*
|
|
101
|
+
* BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
|
|
102
|
+
* safety boundary between a stranger's file and a silent rewrite. It must stay
|
|
103
|
+
* strict — a positive identification of ours-mcp's own unit, never "probably
|
|
104
|
+
* ours". A `foreign` unit is still a hard stop with no command and no prompt.
|
|
105
|
+
*
|
|
106
|
+
* `adopt` still carries no command: the --force comes from
|
|
107
|
+
* serviceInstallCommand({ adoptLegacyUnit: true }), which the orchestrator opts
|
|
108
|
+
* into explicitly. The boundary is worth keeping in the shape of the API even
|
|
109
|
+
* without a question in front of it — it is what keeps --force from becoming a
|
|
110
|
+
* default that spreads to the other cases.
|
|
111
|
+
*/
|
|
112
|
+
export function planServiceInstall({ stateDir, home, readText, platform = 'linux' }) {
|
|
113
|
+
// THE BOOT SERVICE IS LINUX-ONLY, AND NOT BECAUSE THIS FILE SAYS SO.
|
|
114
|
+
//
|
|
115
|
+
// `ours daemon install-service` in @ours.network/cli builds its adapter with
|
|
116
|
+
// `createLinuxUserSystemdAdapter()` and no platform branch at all, and that
|
|
117
|
+
// factory's FIRST line is
|
|
118
|
+
// if (deps.platform !== 'linux') throw new Error('service management is not
|
|
119
|
+
// supported on <platform>; use an external launcher for `ours daemon serve`')
|
|
120
|
+
// — verified by reading the published 0.4.1 tarball, which contains zero
|
|
121
|
+
// occurrences of launchd, LaunchAgents or plist.
|
|
122
|
+
//
|
|
123
|
+
// So calling it on macOS does not degrade, it THROWS. Before this, a Mac user
|
|
124
|
+
// was told their platform was supported, watched the CLI install, the config
|
|
125
|
+
// write and the daemon start, and then got an exception and a rolled-back
|
|
126
|
+
// config. Skipping the step leaves them a working daemon and one true sentence
|
|
127
|
+
// instead — which is the whole of this change.
|
|
128
|
+
//
|
|
129
|
+
// A real launchd adapter belongs in the SDK CLI, not here. Nothing in this
|
|
130
|
+
// package can install a launchd agent, and pretending otherwise by writing a
|
|
131
|
+
// plist ourselves would put a second service implementation in a second repo.
|
|
132
|
+
// `ours daemon install-service` supports Linux/systemd only: its adapter throws
|
|
133
|
+
// for any other platform, so calling it would fail the run rather than degrade.
|
|
134
|
+
// Skip it and say so; the daemon itself is unaffected.
|
|
135
|
+
if (platform && platform !== 'linux') {
|
|
136
|
+
return {
|
|
137
|
+
action: 'unsupported',
|
|
138
|
+
platform,
|
|
139
|
+
reason: 'no-service-manager',
|
|
140
|
+
message: platform === 'darwin'
|
|
141
|
+
? 'installing a boot service is not available on macOS — the ours CLI can only manage a Linux user systemd service'
|
|
142
|
+
: `installing a boot service is not available on ${platform} — the ours CLI can only manage a Linux user systemd service`,
|
|
143
|
+
manual: ['ours', 'daemon', 'serve', '--config'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const derived = unitPathForStateDir(stateDir, home);
|
|
147
|
+
if (!derived.ok) {
|
|
148
|
+
return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
|
|
149
|
+
}
|
|
150
|
+
const existing = classifyUnit(readText(derived.path));
|
|
151
|
+
if (existing.kind === 'absent' || existing.kind === 'cli-managed') {
|
|
152
|
+
return { action: 'install', unit: derived.unit, unitPath: derived.path, instance: derived.instance };
|
|
153
|
+
}
|
|
154
|
+
if (existing.kind === 'foreign') {
|
|
155
|
+
return {
|
|
156
|
+
action: 'refuse',
|
|
157
|
+
exitCode: 2,
|
|
158
|
+
reason: 'unknown-unit',
|
|
159
|
+
unit: derived.unit,
|
|
160
|
+
unitPath: derived.path,
|
|
161
|
+
message: `${derived.path} already exists and was not written by ours. Refusing to touch it. Inspect it, and remove it yourself if it is no longer wanted.`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// The legacy case: a unit we POSITIVELY identify as the one published ours-mcp
|
|
165
|
+
// wrote. It is adopted and rewritten SILENTLY — no prompt, no question — so an
|
|
166
|
+
// upgrading user has no manual step at all. Safe because nothing under the
|
|
167
|
+
// state directory changes when a unit file is replaced: identities, keys,
|
|
168
|
+
// contacts and message history are untouched, which the 0.16.0 -> ours-sdk
|
|
169
|
+
// migration run established rather than assumed.
|
|
170
|
+
//
|
|
171
|
+
// There is no assumeYes parameter any more, and that is the point: with no
|
|
172
|
+
// consent to withhold, an unattended run must behave EXACTLY like an
|
|
173
|
+
// interactive one. A dead flag here would be an invitation to reintroduce a
|
|
174
|
+
// difference between the two.
|
|
175
|
+
return {
|
|
176
|
+
action: 'adopt',
|
|
177
|
+
unit: derived.unit,
|
|
178
|
+
unitPath: derived.path,
|
|
179
|
+
instance: derived.instance,
|
|
180
|
+
stateDir: resolve(stateDir),
|
|
181
|
+
notice: legacyReplacedNotice(derived.path, resolve(stateDir)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The single informational line printed when a legacy unit is replaced.
|
|
187
|
+
*
|
|
188
|
+
* Not a warning and not a question — it exists so the replacement is not
|
|
189
|
+
* literally invisible. It names the exact file, and says the state directory is
|
|
190
|
+
* untouched.
|
|
191
|
+
*
|
|
192
|
+
* That second clause is accuracy, not reassurance: replacing a systemd unit does
|
|
193
|
+
* not change a byte under the state directory. DO NOT "improve" this line by
|
|
194
|
+
* adding a data-loss warning. It would be false, and it would push people into
|
|
195
|
+
* reinstalling — the one action that really would cost them their identities. A
|
|
196
|
+
* test asserts this text contains no lost/delete/erase/wipe/destroy wording, and
|
|
197
|
+
* that assertion is here to stop exactly that edit.
|
|
198
|
+
*/
|
|
199
|
+
export function legacyReplacedNotice(unitPath, stateDir) {
|
|
200
|
+
return `replaced ${unitPath} — the boot unit an older ours-mcp installed (your state directory ${stateDir} is untouched)`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The CLI invocation that installs the boot service. The unit NAME is not passed:
|
|
205
|
+
* ours-sdk #20 made the CLI derive it from --state-dir itself, which is why spec
|
|
206
|
+
* §4's "the installer must either pass a per-instance unit name or write the unit
|
|
207
|
+
* itself" no longer applies — neither, it selects the daemon and the CLI names
|
|
208
|
+
* the unit. One derivation, in one place.
|
|
209
|
+
*/
|
|
210
|
+
export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
|
|
211
|
+
const dir = resolve(stateDir);
|
|
212
|
+
// --json so the caller can read back whether the unit actually CHANGED. The
|
|
213
|
+
// CLI owns that byte-comparison, and an installer that guessed at it would
|
|
214
|
+
// report "nothing changed" on a run that rewrote a unit.
|
|
215
|
+
// --json so the caller can read back whether the unit actually CHANGED rather
|
|
216
|
+
// than assuming it did. --force is reachable only through the explicit argument
|
|
217
|
+
// above, and the CLI refuses to overwrite a unit it did not write.
|
|
218
|
+
const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
|
|
219
|
+
if (adoptLegacyUnit) cmd.push('--force');
|
|
220
|
+
return cmd;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// -----------------------------------------------------------------------------
|
|
224
|
+
// §3(a) step 2 / §4 step 2 — the daemon config file
|
|
225
|
+
// -----------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Merge, never rewrite: only `port`, `stateDir` and `brokerUrl` are set, every
|
|
229
|
+
* other key in the file is preserved, and a merge that would change nothing
|
|
230
|
+
* reports `changed: false` so the caller can leave the file untouched.
|
|
231
|
+
*
|
|
232
|
+
* `stateDir` is written absolute and always alongside `port`, so the pair that
|
|
233
|
+
* identifies a daemon never travels half-formed.
|
|
234
|
+
*/
|
|
235
|
+
export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
|
|
236
|
+
const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
|
237
|
+
const patch = { port, stateDir: resolve(stateDir), brokerUrl };
|
|
238
|
+
const merged = { ...base };
|
|
239
|
+
const changes = [];
|
|
240
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
241
|
+
if (value === undefined || value === null) continue;
|
|
242
|
+
if (merged[key] === value) continue;
|
|
243
|
+
changes.push(key);
|
|
244
|
+
merged[key] = value;
|
|
245
|
+
}
|
|
246
|
+
return { changed: changes.length > 0, changes, config: merged, text: `${JSON.stringify(merged, null, 2)}\n` };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The ordered, announced steps for the daemon half of a run (spec §4). Each is
|
|
251
|
+
* idempotent, and an `update` skips creation entirely: it never moves a port and
|
|
252
|
+
* never creates a second daemon.
|
|
253
|
+
*/
|
|
254
|
+
export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedIt = true } = {}) {
|
|
255
|
+
const dir = target.stateDir;
|
|
256
|
+
const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
|
|
257
|
+
steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
|
|
258
|
+
if (target.action === 'create') {
|
|
259
|
+
steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours', 'daemon', 'start', '--config', join(dir, 'config.json')] });
|
|
260
|
+
} else if (cliVersionChanged) {
|
|
261
|
+
// `ours daemon stop` refuses to signal a daemon it did not start, so a
|
|
262
|
+
// daemon under another launcher is left running and the caller says which
|
|
263
|
+
// launcher must be restarted instead.
|
|
264
|
+
steps.push(cliStartedIt
|
|
265
|
+
? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours', 'daemon', 'restart', '--config', join(dir, 'config.json')] }
|
|
266
|
+
: { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
|
|
267
|
+
}
|
|
268
|
+
steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
|
|
269
|
+
return steps;
|
|
270
|
+
}
|
package/lib/rerun.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ours-install v3 — re-running, and a second daemon alongside the first.
|
|
2
|
+
//
|
|
3
|
+
// Spec: installer-spec-v3 §§6-7. Pure, like the earlier stages.
|
|
4
|
+
//
|
|
5
|
+
// Two properties this file exists to make checkable rather than hoped for:
|
|
6
|
+
//
|
|
7
|
+
// IDEMPOTENCE (§6). Running the installer again with the same answers changes
|
|
8
|
+
// nothing except refreshed npm packages. Not "changes little" — nothing: no
|
|
9
|
+
// config written, no unit rewritten, no systemctl run, no daemon restarted.
|
|
10
|
+
//
|
|
11
|
+
// COEXISTENCE (§7). Two daemons share no per-daemon artefact. Everything keyed
|
|
12
|
+
// to a daemon is derived from its state directory, so two state directories
|
|
13
|
+
// produce two of everything.
|
|
14
|
+
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { unitNameForStateDir } from './plan.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Everything that belongs to ONE daemon, derived from its state directory (spec
|
|
20
|
+
* §7's table). Listing them in one place is what makes "these two daemons share
|
|
21
|
+
* nothing" a property a test can check instead of a claim in a document.
|
|
22
|
+
*
|
|
23
|
+
* `port` is included because it is per-daemon, but note it is NOT what identifies
|
|
24
|
+
* one: it is a fact about a daemon, not its name.
|
|
25
|
+
*/
|
|
26
|
+
export function perDaemonArtefacts(stateDir, port) {
|
|
27
|
+
const dir = resolve(stateDir);
|
|
28
|
+
const unit = unitNameForStateDir(dir);
|
|
29
|
+
return {
|
|
30
|
+
stateDir: dir,
|
|
31
|
+
port,
|
|
32
|
+
config: join(dir, 'config.json'),
|
|
33
|
+
token: join(dir, 'daemon-token'),
|
|
34
|
+
pidRecord: join(dir, 'ours-cli-daemon.json'),
|
|
35
|
+
log: join(dir, 'ours-cli-daemon.log'),
|
|
36
|
+
unit: unit.ok ? unit.unit : null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Do two daemons collide anywhere?
|
|
42
|
+
*
|
|
43
|
+
* Returns the list of colliding fields, empty when they are fully independent.
|
|
44
|
+
* The unit name is checked like everything else, and it is the ONE field that can
|
|
45
|
+
* collide for two legitimately different state directories — `~/.ours-tg` and
|
|
46
|
+
* `/srv/ours-tg` both derive `ours-tg.service`. That is not a bug in the
|
|
47
|
+
* derivation, it is the price of a readable unit name, and it is closed one layer
|
|
48
|
+
* down: the CLI refuses to overwrite a CLI-managed unit whose baked
|
|
49
|
+
* OURS_STATE_DIR is a different daemon's. This function surfaces it so the
|
|
50
|
+
* installer can say so before the CLI has to.
|
|
51
|
+
*/
|
|
52
|
+
export function daemonCollisions(a, b) {
|
|
53
|
+
const fields = ['stateDir', 'port', 'config', 'token', 'pidRecord', 'log', 'unit'];
|
|
54
|
+
return fields.filter((f) => a[f] !== null && a[f] !== undefined && a[f] === b[f]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The per-component coexistence rule (spec §7), stated so the screen can never
|
|
59
|
+
* imply something the design does not do.
|
|
60
|
+
*
|
|
61
|
+
* mcp — coexists naturally. Each harness registration carries its own
|
|
62
|
+
* OURS_CONFIG, so one harness can point at ~/.ours and another at
|
|
63
|
+
* ~/.ours-tg.
|
|
64
|
+
* tg — ONE config file with ONE daemon pair and ONE unit. Pointing it at a
|
|
65
|
+
* second daemon MOVES it. Running two connectors at once needs a
|
|
66
|
+
* second config file and a second unit, which is outside what this
|
|
67
|
+
* installer does — stated here so no screen implies otherwise.
|
|
68
|
+
* cowork — the same, for its single `daemon` block.
|
|
69
|
+
*/
|
|
70
|
+
export function componentCoexistence(key) {
|
|
71
|
+
if (key === 'mcp') {
|
|
72
|
+
return { key, coexists: true, why: 'each harness registration carries its own OURS_CONFIG' };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
key,
|
|
76
|
+
coexists: false,
|
|
77
|
+
why: 'one config file with one daemon pair and one unit — pointing it elsewhere moves it rather than adding a second',
|
|
78
|
+
outOfScope: 'running two at once needs a second config file and a second unit, which this installer does not do',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Summarise a run for the screen, and decide whether it changed anything.
|
|
84
|
+
*
|
|
85
|
+
* `steps` are the outcomes the orchestrator collected, each
|
|
86
|
+
* `{ id, changed: boolean, reason?: string, packageRefresh?: boolean }`.
|
|
87
|
+
*
|
|
88
|
+
* A repeated run must come back `changedAnything: false` with a reason recorded
|
|
89
|
+
* against every step, because "nothing happened" is only trustworthy when the run
|
|
90
|
+
* can say why for each thing it did not do. Package refreshes are counted
|
|
91
|
+
* separately: `npm i -g` is not idempotent in the same sense and re-running it is
|
|
92
|
+
* the one thing a repeat run is allowed to do.
|
|
93
|
+
*/
|
|
94
|
+
export function summarizeRun(steps) {
|
|
95
|
+
const changed = steps.filter((s) => s.changed === true && s.packageRefresh !== true);
|
|
96
|
+
const refreshed = steps.filter((s) => s.packageRefresh === true).map((s) => s.id);
|
|
97
|
+
const noops = steps.filter((s) => s.changed !== true).map((s) => ({ id: s.id, reason: s.reason ?? 'unchanged' }));
|
|
98
|
+
return {
|
|
99
|
+
changedAnything: changed.length > 0,
|
|
100
|
+
changed: changed.map((s) => s.id),
|
|
101
|
+
refreshedPackages: refreshed,
|
|
102
|
+
noops,
|
|
103
|
+
// Every no-op carries a reason: a silent "nothing happened" is
|
|
104
|
+
// indistinguishable from a step that was skipped by accident.
|
|
105
|
+
allNoopsExplained: noops.every((n) => typeof n.reason === 'string' && n.reason.length > 0),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Did a re-run leave the daemon alone? Spec §3(a): update never deletes state,
|
|
111
|
+
* never moves a port, and never creates a second daemon.
|
|
112
|
+
*/
|
|
113
|
+
export function assertUpdateLeftDaemonAlone({ before, after }) {
|
|
114
|
+
const problems = [];
|
|
115
|
+
if (before.stateDir !== after.stateDir) problems.push('state directory changed');
|
|
116
|
+
if (before.port !== after.port) problems.push('port moved');
|
|
117
|
+
if (after.created === true) problems.push('a second daemon was created');
|
|
118
|
+
return { ok: problems.length === 0, problems };
|
|
119
|
+
}
|