@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/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
+ }