@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.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- 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
|
+
}
|