@ours.network/install 0.17.0-nightly.8 → 0.17.0

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 DELETED
@@ -1,238 +0,0 @@
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 by the owner's decision: no prompt, no
98
- * question, so an upgrading user has no manual step. The one line of output
99
- * exists so the replacement is not literally invisible; it does not block and it
100
- * is not a warning.
101
- *
102
- * BECAUSE THERE IS NO PROMPT, `classifyUnit`'s `legacy` match is now the ENTIRE
103
- * safety boundary between a stranger's file and a silent rewrite. It must stay
104
- * strict — a positive identification of ours-mcp's own unit, never "probably
105
- * ours". A `foreign` unit is still a hard stop with no command and no prompt.
106
- *
107
- * `adopt` still carries no command: the --force comes from
108
- * serviceInstallCommand({ adoptLegacyUnit: true }), which the orchestrator opts
109
- * into explicitly. The boundary is worth keeping in the shape of the API even
110
- * without a question in front of it — it is what keeps --force from becoming a
111
- * default that spreads to the other cases.
112
- */
113
- export function planServiceInstall({ stateDir, home, readText }) {
114
- const derived = unitPathForStateDir(stateDir, home);
115
- if (!derived.ok) {
116
- return { action: 'refuse', exitCode: 2, reason: 'unusable-state-dir', message: derived.reason };
117
- }
118
- const existing = classifyUnit(readText(derived.path));
119
- if (existing.kind === 'absent' || existing.kind === 'cli-managed') {
120
- return { action: 'install', unit: derived.unit, unitPath: derived.path, instance: derived.instance };
121
- }
122
- if (existing.kind === 'foreign') {
123
- return {
124
- action: 'refuse',
125
- exitCode: 2,
126
- reason: 'unknown-unit',
127
- unit: derived.unit,
128
- unitPath: derived.path,
129
- 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.`,
130
- };
131
- }
132
- // The legacy case: a unit we POSITIVELY identify as the one published ours-mcp
133
- // wrote. It is adopted and rewritten SILENTLY — no prompt, no question — so an
134
- // upgrading user has no manual step at all. Safe because nothing under the
135
- // state directory changes when a unit file is replaced: identities, keys,
136
- // contacts and message history are untouched, which the 0.16.0 -> ours-sdk
137
- // migration run established rather than assumed.
138
- //
139
- // There is no assumeYes parameter any more, and that is the point: with no
140
- // consent to withhold, an unattended run must behave EXACTLY like an
141
- // interactive one. A dead flag here would be an invitation to reintroduce a
142
- // difference between the two.
143
- return {
144
- action: 'adopt',
145
- unit: derived.unit,
146
- unitPath: derived.path,
147
- instance: derived.instance,
148
- stateDir: resolve(stateDir),
149
- notice: legacyReplacedNotice(derived.path, resolve(stateDir)),
150
- };
151
- }
152
-
153
- /**
154
- * The single informational line printed when a legacy unit is replaced.
155
- *
156
- * Not a warning and not a question — it exists so the replacement is not
157
- * literally invisible. It names the exact file, and says the state directory is
158
- * untouched.
159
- *
160
- * That second clause is accuracy, not reassurance: replacing a systemd unit does
161
- * not change a byte under the state directory. DO NOT "improve" this line by
162
- * adding a data-loss warning. It would be false, and it would push people into
163
- * reinstalling — the one action that really would cost them their identities. A
164
- * test asserts this text contains no lost/delete/erase/wipe/destroy wording, and
165
- * that assertion is here to stop exactly that edit.
166
- */
167
- export function legacyReplacedNotice(unitPath, stateDir) {
168
- return `replaced ${unitPath} — the boot unit an older ours-mcp installed (your state directory ${stateDir} is untouched)`;
169
- }
170
-
171
- /**
172
- * The CLI invocation that installs the boot service. The unit NAME is not passed:
173
- * ours-sdk #20 made the CLI derive it from --state-dir itself, which is why spec
174
- * §4's "the installer must either pass a per-instance unit name or write the unit
175
- * itself" no longer applies — neither, it selects the daemon and the CLI names
176
- * the unit. One derivation, in one place.
177
- */
178
- export function serviceInstallCommand({ stateDir, adoptLegacyUnit = false }) {
179
- const dir = resolve(stateDir);
180
- // --json so the caller can read back whether the unit actually CHANGED. The
181
- // CLI owns that byte-comparison, and an installer that guessed at it would
182
- // report "nothing changed" on a run that rewrote a unit.
183
- const cmd = ['ours', 'daemon', 'install-service', '--yes', '--json', '--state-dir', dir, '--config', join(dir, 'config.json')];
184
- // --force is reachable ONLY through this explicit argument, which the
185
- // orchestrator passes only after the user answered yes to legacyReplacePrompt.
186
- // It is never a default and never appears in a plan.
187
- if (adoptLegacyUnit) cmd.push('--force');
188
- return cmd;
189
- }
190
-
191
- // -----------------------------------------------------------------------------
192
- // §3(a) step 2 / §4 step 2 — the daemon config file
193
- // -----------------------------------------------------------------------------
194
-
195
- /**
196
- * Merge, never rewrite: only `port`, `stateDir` and `brokerUrl` are set, every
197
- * other key in the file is preserved, and a merge that would change nothing
198
- * reports `changed: false` so the caller can leave the file untouched.
199
- *
200
- * `stateDir` is written absolute and always alongside `port`, so the pair that
201
- * identifies a daemon never travels half-formed.
202
- */
203
- export function planDaemonConfig(existing, { port, stateDir, brokerUrl }) {
204
- const base = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
205
- const patch = { port, stateDir: resolve(stateDir), brokerUrl };
206
- const merged = { ...base };
207
- const changes = [];
208
- for (const [key, value] of Object.entries(patch)) {
209
- if (value === undefined || value === null) continue;
210
- if (merged[key] === value) continue;
211
- changes.push(key);
212
- merged[key] = value;
213
- }
214
- return { changed: changes.length > 0, changes, config: merged, text: `${JSON.stringify(merged, null, 2)}\n` };
215
- }
216
-
217
- /**
218
- * The ordered, announced steps for the daemon half of a run (spec §4). Each is
219
- * idempotent, and an `update` skips creation entirely: it never moves a port and
220
- * never creates a second daemon.
221
- */
222
- export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedIt = true } = {}) {
223
- const dir = target.stateDir;
224
- const steps = [{ id: 'cli', label: 'install the ours-sdk CLI', command: ['npm', 'i', '-g', '@ours.network/cli'] }];
225
- steps.push({ id: 'config', label: `write ${join(dir, 'config.json')}`, port: target.port });
226
- if (target.action === 'create') {
227
- steps.push({ id: 'start', label: `start the daemon on port ${target.port}`, command: ['ours', 'daemon', 'start', '--config', join(dir, 'config.json')] });
228
- } else if (cliVersionChanged) {
229
- // `ours daemon stop` refuses to signal a daemon it did not start, so a
230
- // daemon under another launcher is left running and the caller says which
231
- // launcher must be restarted instead.
232
- steps.push(cliStartedIt
233
- ? { id: 'restart', label: 'restart the daemon (package version changed)', command: ['ours', 'daemon', 'restart', '--config', join(dir, 'config.json')] }
234
- : { id: 'restart-external', label: 'daemon was not started by the CLI — restart it with its own launcher', command: null });
235
- }
236
- steps.push({ id: 'service', label: 'install the boot service', command: serviceInstallCommand({ stateDir: dir }) });
237
- return steps;
238
- }