@pellux/goodvibes-daemon 1.28.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * The `goodvibes-daemon.service` detect/migrate engine, used by the daemon
3
+ * CLI (`src/daemon/service-commands.ts`, the `migrate-service` subcommand)
4
+ * and by this daemon's own boot-time reconcile (`legacy-daemon-reconcile.ts`).
5
+ * The terminal app ships an independent implementation of the same
6
+ * detect/migrate contract for its own onboarding guided UX — not shared
7
+ * code, but the two are meant to agree on what "legacy" means and how a
8
+ * migration is carried out.
9
+ *
10
+ * NAMING, load-bearing: this module's identifiers say "legacy" because the
11
+ * engine migrates AWAY from the `goodvibes-daemon.service` unit name toward
12
+ * the runtime-managed unit — that name was used by an older install script/
13
+ * release (scripts/install.sh now creates `goodvibes.service` and treats
14
+ * `goodvibes-daemon.service` as the retired name it migrates existing hosts
15
+ * away from; see migrate_legacy_installer_unit there). An already-installed
16
+ * `goodvibes-daemon.service` unit is still real and may still be running,
17
+ * though, so user-facing copy describes it as "the install-script unit" and
18
+ * never labels it legacy or implies it should be removed unless the user is
19
+ * explicitly migrating.
20
+ *
21
+ * This lives under `src/runtime/` — not `src/daemon/` — because both
22
+ * `src/daemon/service-commands.ts` (the CLI subcommand) and
23
+ * `src/runtime/legacy-daemon-reconcile.ts` (the boot-time reconcile) need it,
24
+ * and `src/runtime/**` is this repository's shared layer both can import.
25
+ *
26
+ * An earlier release shipped DETECT + DISCLOSE only: a read-only check for
27
+ * the prior generation's systemd unit name plus a manual-removal hint, never
28
+ * touching it. This module adds the guided, CONSENTED migration itself.
29
+ * Design constraints, all load-bearing (see also the per-function docs):
30
+ * - NEVER auto-migrate. Without explicit consent nothing runs except a
31
+ * dry-run plan.
32
+ * - NEW-UP-THEN-OLD-DOWN. The new unit is installed, started, and verified
33
+ * healthy (a fresh, honest systemd `is-active` read through the injected
34
+ * actionRunner) BEFORE the legacy unit is stopped, disabled, or removed.
35
+ * A new unit that fails or doesn't come up healthy rolls itself back
36
+ * (uninstalled) and never touches the legacy one.
37
+ * - ADOPT-OR-WARN, NEVER KILL. If the legacy unit file is simply absent but
38
+ * something is already listening on the configured host:port (this dev
39
+ * host's real case: a manually `nohup`'d daemon with no unit at all),
40
+ * that is an unidentified process, not a managed unit — nothing to stop
41
+ * or disable, and this module never attempts to kill it.
42
+ * - Every action (legacy stop/disable, unit-file removal, daemon-reload)
43
+ * goes through the injectable `actionRunner`/`legacyUnitFileRemove` seams
44
+ * tests use — no code path here bypasses them, so the migration is
45
+ * exercised deterministically via fakes and never touches a real running
46
+ * service in tests.
47
+ */
48
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
49
+ import { spawnSync } from 'node:child_process';
50
+ import net from 'node:net';
51
+ import { basename, dirname, join } from 'node:path';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
54
+ import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
55
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
56
+ import { runDaemonConfigMigration } from '../config/run-daemon-config-migration.ts';
57
+ import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../config/surface.ts';
58
+
59
+ /** Structurally derived from `PlatformServiceManager`'s own constructor — the
60
+ * SDK's public `platform/daemon` entry point only re-exports the class and
61
+ * `ManagedServiceStatus`, not the options/definition/action-runner interfaces
62
+ * by name, so we pull their shapes off the class itself rather than reaching
63
+ * past the package's declared export map. */
64
+ type ManagedServiceManagerOptions = ConstructorParameters<typeof PlatformServiceManager>[1];
65
+ type ManagedServiceDefinition = NonNullable<ManagedServiceManagerOptions['definitionOverride']>;
66
+ export type ManagedServiceActionRunner = NonNullable<ManagedServiceManagerOptions['actionRunner']>;
67
+ type ManagedServiceActionResult = ReturnType<ManagedServiceActionRunner>;
68
+
69
+ // The one unit name/description this tool manages — used by the daemon CLI
70
+ // (`goodvibes-daemon install-service|uninstall-service|service-status|migrate-service`).
71
+ // The terminal app's own onboarding UX builds the exact same service
72
+ // definition independently, so a migration triggered from either surface
73
+ // installs an identical unit.
74
+ // `service.serviceName`/nothing-set config default is 'goodvibes'
75
+ // (schema-domain-runtime.ts), which is what PlatformServiceManager actually
76
+ // resolves to in the common case via `resolveServiceName()`'s `config.get(...)
77
+ // ?? defaultServiceName`.
78
+ export const MANAGED_SERVICE_NAME = 'goodvibes';
79
+ export const MANAGED_SERVICE_DESCRIPTION = 'GoodVibes daemon (shared session broker + companion host)';
80
+
81
+ /**
82
+ * Follow-up: resolve the unit name the SDK's `PlatformServiceManager`
83
+ * would actually manage, from config alone — for callers that need the
84
+ * honest display name BEFORE any manager/status exists (the onboarding
85
+ * wizard's detection banner resolves this at snapshot-collection time and
86
+ * carries it on `OnboardingLegacyDaemonSnapshot.trackedServiceName`).
87
+ * Mirrors the SDK's own internal `resolveServiceName()` precedence exactly:
88
+ * the `service.serviceName` config key first, trimmed, falling back to the
89
+ * default (`MANAGED_SERVICE_NAME`, which `buildManagedDaemonServiceManager`
90
+ * passes as `defaultServiceName`) when the key is unset or blank. Takes a
91
+ * minimal `{ get }` shape rather than the full ConfigManager class so
92
+ * snapshot code and tests can pass whatever config accessor they already
93
+ * hold.
94
+ */
95
+ export function resolveConfiguredServiceName(config: { get(key: string): unknown }): string {
96
+ const raw = config.get('service.serviceName');
97
+ const configured = raw === undefined || raw === null ? '' : String(raw).trim();
98
+ return configured || MANAGED_SERVICE_NAME;
99
+ }
100
+
101
+ export interface BuildManagedDaemonServiceManagerParams {
102
+ readonly binaryPath: string;
103
+ /**
104
+ * The GoodVibes tree home — GOODVIBES_HOME-overridable, used to root the
105
+ * ConfigManager and the daemon's own `--daemon-home` state directory. NEVER
106
+ * used for unit-file path resolution: see `unitHomeDir` for that. A unit
107
+ * path search rooted here would look for `~/.config/systemd/user/` under
108
+ * whatever GOODVIBES_HOME points at instead of the real login home systemd
109
+ * actually reads — the same class of bug the boot-time reconcile in
110
+ * `src/daemon/cli.ts` already guards against with the identical split.
111
+ */
112
+ readonly homeDir: string;
113
+ /**
114
+ * The LOGIN user's home — where `~/.config/systemd/user/` (or the launchd/
115
+ * Windows equivalent) actually lives, regardless of any GOODVIBES_HOME/
116
+ * GOODVIBES_DAEMON_HOME override in effect. Threaded through to
117
+ * `PlatformServiceManager`'s own `homeDirectory` option, which resolves the
118
+ * unit file PATH directly from it. Required (no default to `homeDir`) so a
119
+ * caller cannot silently reintroduce the GOODVIBES_HOME-rooted bug by
120
+ * omission.
121
+ */
122
+ readonly unitHomeDir: string;
123
+ readonly host: string;
124
+ readonly port: number;
125
+ /** Defaults to `homeDir` — overridable so tests can scope both to one tempdir. */
126
+ readonly workingDirectory?: string | undefined;
127
+ /** Injected in tests; a real `ConfigManager` rooted at `homeDir` otherwise. */
128
+ readonly configManager?: ConfigManager | undefined;
129
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
130
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
131
+ }
132
+
133
+ /**
134
+ * Build the ONE `PlatformServiceManager` this tool manages — the single
135
+ * source of truth, in this repository, for the unit's definition
136
+ * (`ExecStart` command/args, name, description). Both
137
+ * `src/daemon/service-commands.ts` (the CLI) and this daemon's own
138
+ * boot-time reconcile call this so every path in this repository installs
139
+ * the identical unit — no risk of consumers drifting apart.
140
+ */
141
+ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServiceManagerParams): PlatformServiceManager {
142
+ const workingDirectory = params.workingDirectory ?? params.homeDir;
143
+ if (!params.configManager) runDaemonConfigMigration(params.homeDir);
144
+ const configManager = params.configManager ?? new ConfigManager({
145
+ workingDir: workingDirectory,
146
+ homeDir: params.homeDir,
147
+ surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
148
+ });
149
+ // The unit's ExecStart deliberately carries NO endpoint flags
150
+ // (--hostname/--port): the daemon resolves controlPlane.hostMode/host/port
151
+ // from the user's settings at boot, so a later config change (or a host
152
+ // already configured for hostMode=network / a non-default port) keeps its
153
+ // endpoint without a unit rewrite. Baking endpoint values here is what
154
+ // silently re-pinned custom-configured hosts back to the values current at
155
+ // install time. scripts/install.sh writes the same shape — the two paths
156
+ // must produce the identical running daemon (see the installer parity test).
157
+ // `params.host`/`params.port` remain inputs because the migration engine
158
+ // still needs them for its read-only port-liveness probe.
159
+ const definition: ManagedServiceDefinition = {
160
+ name: MANAGED_SERVICE_NAME,
161
+ description: MANAGED_SERVICE_DESCRIPTION,
162
+ workingDirectory,
163
+ command: params.binaryPath,
164
+ // `--daemon-home` names the daemon's own STATE directory — the one holding
165
+ // operator-tokens.json, auth-users.json and daemon-settings.json — which is
166
+ // `<home>/.goodvibes/daemon`. This baked the USER HOME, so a serviced
167
+ // daemon filed its identity a level above where every reader in this
168
+ // repository looks: the SDK's platform/config goodvibes-home resolves the flag AS the state
169
+ // directory, cli/service-posture.ts already writes the state directory into
170
+ // GOODVIBES_DAEMON_HOME for the unit it installs, and runtime/bootstrap.ts
171
+ // reads the companion token from the state directory. On a normal machine
172
+ // the mismatch is invisible from the outside — the daemon simply mints a
173
+ // second operator-tokens.json in the home directory and the client keeps
174
+ // reading the empty one under .goodvibes/daemon.
175
+ args: ['--daemon-home', join(params.homeDir, '.goodvibes', 'daemon')],
176
+ env: {},
177
+ restartOnFailure: true,
178
+ };
179
+ return new PlatformServiceManager(configManager, {
180
+ workingDirectory,
181
+ // Unit paths resolve from the LOGIN home, never the (possibly
182
+ // GOODVIBES_HOME-relocated) tree home above — see `unitHomeDir`'s doc.
183
+ homeDirectory: params.unitHomeDir,
184
+ definitionOverride: definition,
185
+ defaultServiceName: MANAGED_SERVICE_NAME,
186
+ defaultServiceDescription: MANAGED_SERVICE_DESCRIPTION,
187
+ actionRunner: params.actionRunner,
188
+ // No `featureFlags` passed: `isFeatureGateEnabled` treats a missing reader
189
+ // as always-open. Both consumers here are the user's explicit request to
190
+ // manage the service, unlike the daemon's own HTTP /api/service/* routes
191
+ // (which gate on the real, config-backed 'service-management' flag).
192
+ });
193
+ }
194
+
195
+ export interface ResolveDaemonBinaryOptions {
196
+ readonly env?: NodeJS.ProcessEnv | undefined;
197
+ /** `import.meta.url` of the caller so the packaged `bin/goodvibes-daemon` can be located. */
198
+ readonly moduleUrl?: string | undefined;
199
+ readonly execPath?: string | undefined;
200
+ readonly fileExists?: ((path: string) => boolean) | undefined;
201
+ }
202
+
203
+ /**
204
+ * Resolve the absolute path to the installed daemon binary used for the unit's
205
+ * `ExecStart`. Preference order:
206
+ * 1. `GOODVIBES_DAEMON_BINARY` env override.
207
+ * 2. The packaged `bin/goodvibes-daemon` launcher next to this checkout.
208
+ * 3. `process.execPath` when this IS the compiled daemon binary.
209
+ * 4. Bare `goodvibes-daemon` (resolved on PATH by systemd's service environment).
210
+ *
211
+ * Lives here (not in `src/daemon/service-commands.ts`) so it carries no
212
+ * CLI-specific dependency: it only needs the CALLER's own `import.meta.url`
213
+ * to locate the packaged `bin/` directory two levels up from ANY
214
+ * `src/<layer>/*.ts` file in this repository.
215
+ */
216
+ export function resolveInstalledDaemonBinary(options: ResolveDaemonBinaryOptions = {}): string {
217
+ const env = options.env ?? process.env;
218
+ const override = env.GOODVIBES_DAEMON_BINARY?.trim();
219
+ if (override) return override;
220
+
221
+ const fileExists = options.fileExists ?? existsSync;
222
+ if (options.moduleUrl) {
223
+ try {
224
+ // e.g. src/daemon/service-commands.ts -> package root is two directories up.
225
+ const here = dirname(fileURLToPath(options.moduleUrl));
226
+ const launcher = join(here, '..', '..', 'bin', 'goodvibes-daemon');
227
+ if (fileExists(launcher)) return launcher;
228
+ } catch {
229
+ // fall through to execPath / PATH resolution
230
+ }
231
+ }
232
+
233
+ const execPath = options.execPath ?? process.execPath;
234
+ if (execPath && /goodvibes-daemon/.test(execPath)) return execPath;
235
+
236
+ return 'goodvibes-daemon';
237
+ }
238
+
239
+ export const LEGACY_SERVICE_UNIT_NAME = 'goodvibes-daemon';
240
+
241
+ export interface LegacyUnitInfo {
242
+ readonly present: boolean;
243
+ readonly active: boolean;
244
+ readonly path: string;
245
+ }
246
+
247
+ export function legacyUnitPath(homeDir: string): string {
248
+ return join(homeDir, '.config', 'systemd', 'user', `${LEGACY_SERVICE_UNIT_NAME}.service`);
249
+ }
250
+
251
+ export interface DetectLegacyUnitInput {
252
+ /**
253
+ * The LOGIN user's home — where the legacy unit file would actually live
254
+ * (`~/.config/systemd/user/goodvibes-daemon.service`), never the
255
+ * GOODVIBES_HOME-overridable tree home. See
256
+ * `BuildManagedDaemonServiceManagerParams.unitHomeDir` for the identical
257
+ * split and why it matters.
258
+ */
259
+ readonly unitHomeDir: string;
260
+ /** Injectable existsSync so tests never touch the host filesystem. */
261
+ readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
262
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
263
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
264
+ }
265
+
266
+ /**
267
+ * Read-only detection: does a legacy `goodvibes-daemon.service` unit file
268
+ * exist, and if so, is it currently active? Never stops, disables, or
269
+ * modifies anything — a file-existence check plus a read-only
270
+ * `systemctl --user is-active` query through the injected actionRunner.
271
+ */
272
+ export function detectLegacyUnit(input: DetectLegacyUnitInput): LegacyUnitInfo {
273
+ const path = legacyUnitPath(input.unitHomeDir);
274
+ const fileExists = input.legacyUnitFileExists ?? existsSync;
275
+ if (!fileExists(path)) return { present: false, active: false, path };
276
+ const run: ManagedServiceActionRunner = input.actionRunner ?? defaultActionRunner(SYSTEMCTL_TIMEOUT_MS);
277
+ const result = run('systemctl', ['--user', 'is-active', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
278
+ const state = (result.stdout ?? '').trim();
279
+ const active = (result.status ?? 1) === 0 && state === 'active';
280
+ return { present: true, active, path };
281
+ }
282
+
283
+ /**
284
+ * The unit name `PlatformServiceManager` is ACTUALLY about to mutate can
285
+ * differ from `MANAGED_SERVICE_NAME` / `definitionOverride.name`. The SDK's
286
+ * internal `resolveServiceName()` — used by `install()`, `uninstall()`, and
287
+ * `status()` alike to compute the unit file PATH — resolves from the
288
+ * `service.serviceName` CONFIG key first, falling back to the
289
+ * `defaultServiceName` this module passes only when that key is unset. It
290
+ * never consults `definitionOverride.name` for the path. So if a host's
291
+ * config sets `service.serviceName` to the legacy unit's own name
292
+ * (`goodvibes-daemon`), `install()` writes over the legacy unit file,
293
+ * `uninstall()` (used for this engine's failed-health rollback) removes it,
294
+ * and a "successful" migration would immediately retire the very unit it
295
+ * just installed.
296
+ *
297
+ * This resolves the name actually in play so callers can detect that
298
+ * collision before mutating anything: it prefers `status.serviceName` when
299
+ * the linked SDK build carries it (a parallel SDK change adds this field to
300
+ * `ManagedServiceStatus` precisely so callers never have to guess), and
301
+ * falls back to the basename of `status.path` with its unit-file extension
302
+ * stripped against an SDK build that predates that field. The fallback only
303
+ * has to handle the systemd `<name>.service` (and launchd `<name>.plist`)
304
+ * shapes: every call site in this module reaches this after already
305
+ * confirming the platform is 'systemd' (a non-systemd host is refused
306
+ * earlier, before any mutation), so the basename fallback is never
307
+ * exercised against the windows/manual path shapes that don't embed the
308
+ * name in their basename.
309
+ */
310
+ export function resolveManagedUnitName(status: ManagedServiceStatus): string {
311
+ const carried = (status as { readonly serviceName?: unknown }).serviceName;
312
+ if (typeof carried === 'string' && carried.trim()) return carried.trim();
313
+ return basename(status.path).replace(/\.(service|plist)$/, '');
314
+ }
315
+
316
+ /** Honest one-line disclosure of the install-script unit's presence/state plus a manual migration hint — never auto-acted-on. */
317
+ export function legacyUnitNote(legacy: LegacyUnitInfo, trackedServiceName: string): string {
318
+ const stateWord = legacy.active ? 'installed and RUNNING' : 'installed (not currently active)';
319
+ return (
320
+ `note: a separate service named ${LEGACY_SERVICE_UNIT_NAME}.service is ${stateWord} at ${legacy.path} — ` +
321
+ `that unit name was used by an older install script/release, while this tool manages ` +
322
+ `${trackedServiceName}.service and will not touch the other unit automatically. Keep whichever one you use; running ` +
323
+ `both would start two daemons competing for the same port. To retire the install-script unit in favor of this ` +
324
+ `tool's: systemctl --user disable --now ${LEGACY_SERVICE_UNIT_NAME}.service && rm ${legacy.path} && systemctl --user daemon-reload`
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Hard ceiling on every systemctl invocation made through a DEFAULT action
330
+ * runner in this module. The reconcile below runs on the daemon's own startup
331
+ * path, and `spawnSync` without a timeout blocks the single JS event loop for
332
+ * as long as the child runs — a wedged user D-Bus (a real incident class on
333
+ * this host) would freeze an already-listening daemon indefinitely. A timed-out
334
+ * call reports `status: null`, which every status check in this module treats
335
+ * as failure, so a wedge degrades to an honest refusal instead of a hang.
336
+ */
337
+ export const SYSTEMCTL_TIMEOUT_MS = 5_000;
338
+
339
+ export function defaultActionRunner(timeoutMs: number): ManagedServiceActionRunner {
340
+ return (command, args) =>
341
+ spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', timeout: timeoutMs }) as ManagedServiceActionResult;
342
+ }
343
+
344
+ /** Parse a `systemctl show -p MainPID --value` reply: a positive integer pid, or undefined when absent/unparseable/0. */
345
+ export function parseMainPid(result: { status?: number | null; stdout?: string | null | undefined }): number | undefined {
346
+ if ((result.status ?? 1) !== 0) return undefined;
347
+ const parsed = Number.parseInt((result.stdout ?? '').trim(), 10);
348
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
349
+ }
350
+
351
+
352
+ /**
353
+ * Read-only, best-effort TCP connect probe used ONLY by the legacy-absent
354
+ * branch to tell "nothing is listening on this port" apart from "an
355
+ * unmanaged process (e.g. a manual `nohup`) already owns it." Never used to
356
+ * identify or act on that process — a positive result only produces a
357
+ * warning, never a kill. Tests always inject a fake `portProbe`; this default
358
+ * is never exercised against a real host in this repo's test suite.
359
+ */
360
+ export function defaultPortProbe(host: string, port: number, timeoutMs = 750): Promise<boolean> {
361
+ return new Promise<boolean>((resolve) => {
362
+ const connectHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : (host || '127.0.0.1');
363
+ const socket = net.createConnection({ host: connectHost, port });
364
+ const finish = (value: boolean): void => {
365
+ socket.removeAllListeners();
366
+ socket.destroy();
367
+ resolve(value);
368
+ };
369
+ socket.setTimeout(timeoutMs);
370
+ socket.once('connect', () => finish(true));
371
+ socket.once('timeout', () => finish(false));
372
+ socket.once('error', () => finish(false));
373
+ });
374
+ }
375
+
376
+ export interface RunLegacyDaemonMigrationParams {
377
+ readonly host: string;
378
+ readonly port: number;
379
+ /** The unit name this tool manages (e.g. 'goodvibes') — distinct from LEGACY_SERVICE_UNIT_NAME. */
380
+ readonly trackedServiceName: string;
381
+ /**
382
+ * Explicit consent to actually execute the migration. Without it, the
383
+ * result is a printed plan only — never auto-migrate.
384
+ */
385
+ readonly confirmMigration?: boolean | undefined;
386
+ /** Injectable port-liveness check for the legacy-absent branch. Defaults to `defaultPortProbe`. */
387
+ readonly portProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
388
+ /** Injectable removal of the legacy unit file. Defaults to a real `rmSync`. */
389
+ readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
390
+ /** Injectable systemctl runner for the legacy stop/disable/daemon-reload steps. */
391
+ readonly actionRunner?: ManagedServiceActionRunner | undefined;
392
+ }
393
+
394
+ /**
395
+ * Belt-and-braces guard: throws if the resolved unit `status` is the
396
+ * legacy unit. Called immediately before the two mutation calls
397
+ * (`manager.install()`, and `manager.uninstall()` on the failed-health
398
+ * rollback path) that would otherwise write to or remove that path. This is
399
+ * an internal invariant check, not a normal user-facing error path — the
400
+ * pre-flight collision check in `runLegacyDaemonMigration` already returns
401
+ * before either call site is reached whenever this would trip, so tripping
402
+ * here means that earlier check regressed, not that the user did anything
403
+ * wrong.
404
+ */
405
+ function assertUnitIsNotLegacy(status: ManagedServiceStatus, legacy: LegacyUnitInfo, action: string): void {
406
+ if (status.path === legacy.path || resolveManagedUnitName(status) === LEGACY_SERVICE_UNIT_NAME) {
407
+ throw new Error(
408
+ `refusing to ${action}: the resolved managed unit (${resolveManagedUnitName(status)} at ${status.path}) is the ` +
409
+ `install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit — this should already have been caught by the pre-flight ` +
410
+ 'collision check in runLegacyDaemonMigration',
411
+ );
412
+ }
413
+ }
414
+
415
+ export interface LegacyDaemonMigrationResult {
416
+ readonly ok: boolean;
417
+ readonly exitCode: number;
418
+ readonly lines: readonly string[];
419
+ readonly status: ManagedServiceStatus;
420
+ }
421
+
422
+ /**
423
+ * The guided, consented takeover itself. See the file banner for the design
424
+ * constraints (never auto-migrate, new-up-then-old-down, adopt-or-warn/never
425
+ * kill an unrecognized process, every action through an injectable seam).
426
+ */
427
+ export async function runLegacyDaemonMigration(
428
+ params: RunLegacyDaemonMigrationParams,
429
+ manager: PlatformServiceManager,
430
+ legacy: LegacyUnitInfo,
431
+ ): Promise<LegacyDaemonMigrationResult> {
432
+ const { trackedServiceName } = params;
433
+ // Computed once, up front, and reused for every branch below (this is the
434
+ // exact same single call each branch made individually before — see the
435
+ // fix note on `resolveManagedUnitName` for why the name/path it reports
436
+ // can differ from `trackedServiceName`).
437
+ const currentStatus = manager.status();
438
+ const resolvedUnitName = resolveManagedUnitName(currentStatus);
439
+
440
+ if (!legacy.present) {
441
+ const probe = params.portProbe ?? defaultPortProbe;
442
+ const occupied = await probe(params.host, params.port);
443
+ if (occupied) {
444
+ return {
445
+ ok: false,
446
+ exitCode: 1,
447
+ lines: [
448
+ `migrate-service: no install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit was found, but something is already ` +
449
+ `listening on ${params.host}:${params.port}.`,
450
+ "That looks like a process this tool doesn't manage (for example, a manually-started `nohup` daemon) rather " +
451
+ 'than a systemd unit — there is nothing here to stop or disable, and this tool will not attempt to kill an ' +
452
+ 'unrecognized process.',
453
+ 'Stop that process yourself, then re-run migrate-service or install-service once the port is free — or, if ' +
454
+ "it's already the daemon you want running, leave it alone: a client surface can still reach it at this " +
455
+ "host:port directly, with no service unit required for this tool to manage.",
456
+ ],
457
+ status: currentStatus,
458
+ };
459
+ }
460
+ return {
461
+ ok: true,
462
+ exitCode: 0,
463
+ lines: [
464
+ `migrate-service: no install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit was found and ${params.host}:${params.port} ` +
465
+ 'is free — there is nothing to migrate.',
466
+ `Run install-service to set up the managed ${resolvedUnitName}.service directly.`,
467
+ ],
468
+ status: currentStatus,
469
+ };
470
+ }
471
+
472
+ if (currentStatus.platform !== 'systemd') {
473
+ return {
474
+ ok: false,
475
+ exitCode: 1,
476
+ lines: [
477
+ `migrate-service: this host's detected service platform is '${currentStatus.platform}', not systemd, but a ` +
478
+ `unit file with the install-script name exists at ${legacy.path}.`,
479
+ 'That unit is systemd-specific and this tool only knows how to migrate a systemd unit today — ' +
480
+ 'nothing was changed.',
481
+ ],
482
+ status: currentStatus,
483
+ };
484
+ }
485
+
486
+ // Before any mutation, confirm the unit PlatformServiceManager is
487
+ // actually about to install/uninstall isn't the legacy unit itself. This
488
+ // happens when the host's `service.serviceName` config key is set to the
489
+ // legacy unit's own name — see `resolveManagedUnitName`'s doc comment for
490
+ // why the SDK resolves mutation paths from that config key rather than
491
+ // from the definition this engine passes. Without this check, `install()`
492
+ // below would overwrite the legacy unit file, a failed-health rollback
493
+ // (`uninstall()`) would DELETE it while still claiming it was "never
494
+ // touched," and a successful migration would immediately retire the unit
495
+ // it just installed.
496
+ if (resolvedUnitName === LEGACY_SERVICE_UNIT_NAME || currentStatus.path === legacy.path) {
497
+ return {
498
+ ok: false,
499
+ exitCode: 1,
500
+ lines: [
501
+ `migrate-service aborted: this host's 'service.serviceName' config key resolves to '${resolvedUnitName}', which ` +
502
+ `is the exact install-script unit name (${LEGACY_SERVICE_UNIT_NAME}.service at ${legacy.path}) this migration is ` +
503
+ 'supposed to retire.',
504
+ 'Installing or rolling back a unit under that name would overwrite or delete the install-script unit instead of ' +
505
+ 'managing a separate one, so nothing has been changed.',
506
+ `Fix: set the 'service.serviceName' config key to something other than '${LEGACY_SERVICE_UNIT_NAME}' (for ` +
507
+ `example, the default '${trackedServiceName}') and re-run migrate-service.`,
508
+ ],
509
+ status: currentStatus,
510
+ };
511
+ }
512
+
513
+ if (!params.confirmMigration) {
514
+ return {
515
+ ok: true,
516
+ exitCode: 0,
517
+ lines: [
518
+ legacyUnitNote(legacy, resolvedUnitName),
519
+ 'migrate-service (dry run — re-run with confirmation to execute): this would',
520
+ ` 1. install and start the new ${resolvedUnitName}.service unit`,
521
+ ' 2. verify it comes up healthy (a fresh, honest systemd is-active check)',
522
+ ` 3. only if that succeeds, stop, disable, and remove the install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit ` +
523
+ 'and run `systemctl --user daemon-reload`',
524
+ 'Nothing has been changed. Nothing is migrated automatically — re-run with explicit confirmation ' +
525
+ "(the CLI's -y/--yes flag) to execute this plan.",
526
+ ],
527
+ status: currentStatus,
528
+ };
529
+ }
530
+
531
+ // Consented: new-up-then-old-down. The legacy unit is not touched until the
532
+ // new unit is verified healthy.
533
+ // Belt-and-braces: the collision check above already returns before
534
+ // reaching here whenever the resolved unit is the legacy one — this
535
+ // re-asserts the same invariant right at the mutation site so a future
536
+ // change to the check above can never silently reopen the hole.
537
+ assertUnitIsNotLegacy(currentStatus, legacy, 'install the new unit');
538
+ const installed = manager.install();
539
+ if (installed.actionError) {
540
+ return {
541
+ ok: false,
542
+ exitCode: 1,
543
+ lines: [
544
+ `migrate-service aborted: could not write the new ${resolvedUnitName}.service unit (${installed.actionError}).`,
545
+ `The install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit was never touched.`,
546
+ ],
547
+ status: installed,
548
+ };
549
+ }
550
+ const started = manager.start();
551
+ const healthCheck = manager.status();
552
+ const healthy = !started.actionError && healthCheck.running;
553
+ if (!healthy) {
554
+ assertUnitIsNotLegacy(installed, legacy, 'roll back (uninstall) the new unit');
555
+ const rollback = manager.uninstall();
556
+ const rollbackNote = rollback.actionError
557
+ ? `rolling back the new unit ALSO hit an error (${rollback.actionError}) — remove ${installed.path} by hand.`
558
+ : 'the newly-written unit has been rolled back (removed).';
559
+ return {
560
+ ok: false,
561
+ exitCode: 1,
562
+ lines: [
563
+ `migrate-service aborted: the new ${resolvedUnitName}.service unit did not come up healthy` +
564
+ (started.actionError ? ` (${started.actionError}).` : '.'),
565
+ rollbackNote,
566
+ `The install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit was never touched and should still be running as before.`,
567
+ ],
568
+ status: healthCheck,
569
+ };
570
+ }
571
+
572
+ // New unit verified healthy — now, and only now, retire the legacy unit.
573
+ const run: ManagedServiceActionRunner = params.actionRunner ?? defaultActionRunner(SYSTEMCTL_TIMEOUT_MS);
574
+ const stopResult = run('systemctl', ['--user', 'stop', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
575
+ const disableResult = run('systemctl', ['--user', 'disable', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
576
+ const removeFile = params.legacyUnitFileRemove ?? ((path: string) => rmSync(path, { force: true }));
577
+ let removeError: string | undefined;
578
+ try {
579
+ removeFile(legacy.path);
580
+ } catch (error) {
581
+ removeError = summarizeError(error);
582
+ }
583
+ run('systemctl', ['--user', 'daemon-reload']);
584
+
585
+ const lines = [`migrated: the new ${resolvedUnitName}.service unit is installed, enabled, and running.`];
586
+ if ((stopResult.status ?? 1) !== 0) {
587
+ lines.push(
588
+ `note: stopping the install-script unit reported a non-zero exit (${stopResult.stderr ?? stopResult.stdout ?? 'no output'}); ` +
589
+ 'it may already have been stopped.',
590
+ );
591
+ }
592
+ if ((disableResult.status ?? 1) !== 0) {
593
+ lines.push(
594
+ `note: disabling the install-script unit reported a non-zero exit (${disableResult.stderr ?? disableResult.stdout ?? 'no output'}); ` +
595
+ 'it may already have been disabled.',
596
+ );
597
+ }
598
+ if (removeError) {
599
+ lines.push(`note: could not remove the install-script unit file at ${legacy.path}: ${removeError} — remove it by hand.`);
600
+ } else {
601
+ lines.push(`the install-script ${LEGACY_SERVICE_UNIT_NAME}.service unit has been stopped, disabled, and removed.`);
602
+ }
603
+ lines.push('ran `systemctl --user daemon-reload`.');
604
+ return { ok: true, exitCode: 0, lines, status: healthCheck };
605
+ }