@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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-lifecycle subcommands — `goodvibes-daemon install-service |
|
|
3
|
+
* uninstall-service | service-status`.
|
|
4
|
+
*
|
|
5
|
+
* The daemon is a SYSTEM SERVICE. These subcommands install it as a durable host
|
|
6
|
+
* service (systemd user unit on Linux, launchd agent on macOS, a Scheduled Task
|
|
7
|
+
* on Windows) so N surfaces share ONE daemon that survives reboots, instead of
|
|
8
|
+
* every surface spawning a session-scoped daemon.
|
|
9
|
+
*
|
|
10
|
+
* Drift note: the SDK's older `systemd-user-service.ts` (a Linux-only,
|
|
11
|
+
* bespoke systemd shim) was dead code and got deleted from the SDK. The SDK's
|
|
12
|
+
* REAL wired service machinery — reached in production by the daemon's own HTTP
|
|
13
|
+
* `/api/service/*` routes via facade-composition.ts — is
|
|
14
|
+
* `PlatformServiceManager` (`@pellux/goodvibes-sdk/platform/daemon`): a single
|
|
15
|
+
* systemd/launchd/windows-aware manager with install/uninstall/status/start/
|
|
16
|
+
* stop/restart and a `suggestedCommands` hint list. This module now rewires the
|
|
17
|
+
* three CLI subcommands onto that manager instead of the deleted shim.
|
|
18
|
+
*
|
|
19
|
+
* Two real behavioral differences from the old shim, called out honestly:
|
|
20
|
+
* - `install()` only WRITES the unit/plist/task; it does not enable/start it.
|
|
21
|
+
* So `install-service` here calls `install()` then `start()` to preserve the
|
|
22
|
+
* old "install implies enabled + running" behavior.
|
|
23
|
+
* - `uninstall()` only REMOVES the unit file; there is no manager-level
|
|
24
|
+
* `disable` verb (only start/stop/restart exist). So `uninstall-service`
|
|
25
|
+
* calls `stop()` then `uninstall()`, and honestly tells the caller that a
|
|
26
|
+
* stray "enabled" symlink may remain until `systemctl --user daemon-reload`
|
|
27
|
+
* (offered back as a suggested follow-up) or the next login cleans it up.
|
|
28
|
+
*
|
|
29
|
+
* A fourth subcommand, `migrate-service`, closes the earlier "detect and
|
|
30
|
+
* disclose only" inheritance (W3 Finding 4, below) with a GUIDED, CONSENTED
|
|
31
|
+
* takeover of the legacy `goodvibes-daemon.service` unit. Design constraints,
|
|
32
|
+
* all load-bearing:
|
|
33
|
+
* - NEVER auto-migrate. Without explicit consent (`confirmMigration`, wired
|
|
34
|
+
* from the CLI's existing `-y`/`--yes` flag) this prints the exact plan
|
|
35
|
+
* and touches nothing.
|
|
36
|
+
* - NEW-UP-THEN-OLD-DOWN. The new `goodvibes.service` unit is installed,
|
|
37
|
+
* started, and verified healthy (a fresh `status().running` read, which
|
|
38
|
+
* honestly queries systemd via the injected actionRunner) BEFORE the
|
|
39
|
+
* legacy unit is stopped, disabled, or removed. A failed or unhealthy new
|
|
40
|
+
* unit rolls itself back (uninstalled) and never touches the legacy one
|
|
41
|
+
* — a botched takeover must never cost the user their working daemon.
|
|
42
|
+
* - ADOPT-OR-WARN, NEVER KILL. If the legacy unit file is simply absent but
|
|
43
|
+
* something is already listening on the configured host:port (Mike's real
|
|
44
|
+
* dev-host case: a manual `nohup`'d daemon with no unit at all), this is
|
|
45
|
+
* an unidentified process, not a managed unit — there is nothing to stop
|
|
46
|
+
* or disable, and this module will not attempt to kill it. It warns and
|
|
47
|
+
* leaves the decision to the operator.
|
|
48
|
+
* - Every action (legacy stop/disable, unit-file removal, daemon-reload)
|
|
49
|
+
* goes through the SAME injectable `actionRunner`/`legacyUnitFileRemove`
|
|
50
|
+
* seams tests already use — this module never has a code path that bypasses
|
|
51
|
+
* them, so the migration is exercised deterministically via fakes and never
|
|
52
|
+
* touches a real running service in tests.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
56
|
+
import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
57
|
+
import {
|
|
58
|
+
buildManagedDaemonServiceManager,
|
|
59
|
+
detectLegacyUnit,
|
|
60
|
+
legacyUnitNote,
|
|
61
|
+
resolveManagedUnitName,
|
|
62
|
+
runLegacyDaemonMigration,
|
|
63
|
+
LEGACY_SERVICE_UNIT_NAME,
|
|
64
|
+
MANAGED_SERVICE_NAME as SERVICE_NAME,
|
|
65
|
+
MANAGED_SERVICE_DESCRIPTION as SERVICE_DESCRIPTION,
|
|
66
|
+
type LegacyUnitInfo,
|
|
67
|
+
type ManagedServiceActionRunner,
|
|
68
|
+
} from '../runtime/legacy-daemon-migration.ts';
|
|
69
|
+
|
|
70
|
+
// `resolveInstalledDaemonBinary` lives in the runtime module — re-exported
|
|
71
|
+
// here so this module stays the CLI's stable public surface (and so existing
|
|
72
|
+
// test imports keep working).
|
|
73
|
+
export {
|
|
74
|
+
resolveInstalledDaemonBinary,
|
|
75
|
+
type ResolveDaemonBinaryOptions,
|
|
76
|
+
} from '../runtime/legacy-daemon-migration.ts';
|
|
77
|
+
export type { ManagedServiceActionRunner } from '../runtime/legacy-daemon-migration.ts';
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Legacy-unit detection, plus guided migration.
|
|
81
|
+
//
|
|
82
|
+
// The prior command installed the daemon's systemd unit under the
|
|
83
|
+
// literal name `goodvibes-daemon.service`. This module (rewired onto
|
|
84
|
+
// PlatformServiceManager, see the file banner above) manages a DIFFERENT
|
|
85
|
+
// unit name (`goodvibes`, SERVICE_NAME). A host that still has the legacy
|
|
86
|
+
// unit installed — this dev machine included — would otherwise see
|
|
87
|
+
// service-status honestly report installed:false/running:false for the
|
|
88
|
+
// tracked name while the legacy unit keeps running untouched underneath it,
|
|
89
|
+
// uninstall-service would silently leave the legacy unit orphaned with no
|
|
90
|
+
// mention, and install-service could start a SECOND daemon competing for the
|
|
91
|
+
// same port.
|
|
92
|
+
//
|
|
93
|
+
// The detection (`detectLegacyUnit`/`legacyUnitNote`) and the guided
|
|
94
|
+
// migration engine (`runLegacyDaemonMigration`) both live in
|
|
95
|
+
// `../runtime/legacy-daemon-migration.ts` rather than here, because this
|
|
96
|
+
// daemon's own boot-time reconcile (`../runtime/legacy-daemon-reconcile.ts`)
|
|
97
|
+
// needs them too — the entrypoint-agnostic `runtime` layer is what both of
|
|
98
|
+
// this repository's consumers can import, so this CLI module is just one of
|
|
99
|
+
// them. The terminal app's own onboarding guided UX solves the same problem
|
|
100
|
+
// independently, under its own architecture constraints.
|
|
101
|
+
//
|
|
102
|
+
// This detection is entirely independent of PlatformServiceManager's own
|
|
103
|
+
// status() — it does not rely on (or get invalidated by) the parallel SDK
|
|
104
|
+
// fix that makes status().running itself query systemd honestly for the
|
|
105
|
+
// TRACKED unit name.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export const DAEMON_SERVICE_SUBCOMMANDS = [
|
|
109
|
+
'install-service',
|
|
110
|
+
'uninstall-service',
|
|
111
|
+
'service-status',
|
|
112
|
+
'migrate-service',
|
|
113
|
+
'start-service',
|
|
114
|
+
'stop-service',
|
|
115
|
+
'restart-service',
|
|
116
|
+
] as const;
|
|
117
|
+
|
|
118
|
+
export type DaemonServiceSubcommand = (typeof DAEMON_SERVICE_SUBCOMMANDS)[number];
|
|
119
|
+
|
|
120
|
+
export function isDaemonServiceSubcommand(value: string | undefined): value is DaemonServiceSubcommand {
|
|
121
|
+
return typeof value === 'string' && (DAEMON_SERVICE_SUBCOMMANDS as readonly string[]).includes(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Exit codes `service-status` reports, so a script never parses prose.
|
|
126
|
+
*
|
|
127
|
+
* 0/3/4 are the three answers the question actually has; 1 stays what it has
|
|
128
|
+
* always been — the platform refused the query and the error is printed.
|
|
129
|
+
*/
|
|
130
|
+
export const SERVICE_STATUS_EXIT_RUNNING = 0;
|
|
131
|
+
export const SERVICE_STATUS_EXIT_INSTALLED_NOT_RUNNING = 3;
|
|
132
|
+
export const SERVICE_STATUS_EXIT_NOT_INSTALLED = 4;
|
|
133
|
+
|
|
134
|
+
export interface DaemonServiceCliInput {
|
|
135
|
+
readonly subcommand: DaemonServiceSubcommand;
|
|
136
|
+
readonly binaryPath: string;
|
|
137
|
+
/** The GoodVibes tree home (GOODVIBES_HOME-overridable) — config/state root only, never unit-path resolution. See `unitHomeDir`. */
|
|
138
|
+
readonly homeDir: string;
|
|
139
|
+
/**
|
|
140
|
+
* The LOGIN user's home — where the systemd/launchd/Windows unit actually
|
|
141
|
+
* lives, regardless of any GOODVIBES_HOME/GOODVIBES_DAEMON_HOME override.
|
|
142
|
+
* Required (no default to `homeDir`) so a unit-management subcommand can
|
|
143
|
+
* never silently search for its unit under a relocated tree home instead of
|
|
144
|
+
* where the service manager actually looks.
|
|
145
|
+
*/
|
|
146
|
+
readonly unitHomeDir: string;
|
|
147
|
+
readonly host: string;
|
|
148
|
+
readonly port: number;
|
|
149
|
+
/**
|
|
150
|
+
* Whether the invoking CLI line carried an explicit `--hostname`/`--port`
|
|
151
|
+
* flag (not merely whether `host`/`port` above are set — those are always
|
|
152
|
+
* set, resolved from config either way). `install-service`/`migrate-service`
|
|
153
|
+
* refuse when either is true — see `validateServiceEndpointFlags`. Defaults
|
|
154
|
+
* to false (no flag), matching every subcommand these two don't apply to.
|
|
155
|
+
*/
|
|
156
|
+
readonly hostnameFlagProvided?: boolean | undefined;
|
|
157
|
+
readonly portFlagProvided?: boolean | undefined;
|
|
158
|
+
/** Defaults to `homeDir` — overridable so tests can scope both to one tempdir. */
|
|
159
|
+
readonly workingDirectory?: string | undefined;
|
|
160
|
+
/** Injected in tests; a real `ConfigManager` rooted at `homeDir` otherwise. */
|
|
161
|
+
readonly configManager?: ConfigManager | undefined;
|
|
162
|
+
/** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
|
|
163
|
+
readonly actionRunner?: ManagedServiceActionRunner | undefined;
|
|
164
|
+
/** Injectable existsSync for the legacy-unit file check (W3 Finding 4) so tests never touch the host filesystem. */
|
|
165
|
+
readonly legacyUnitFileExists?: ((path: string) => boolean) | undefined;
|
|
166
|
+
/**
|
|
167
|
+
* `migrate-service` only: explicit consent to actually execute the
|
|
168
|
+
* migration (wired from the CLI's `-y`/`--yes` flag). Without it, the
|
|
169
|
+
* subcommand prints the exact plan and changes nothing — never auto-migrate.
|
|
170
|
+
*/
|
|
171
|
+
readonly confirmMigration?: boolean | undefined;
|
|
172
|
+
/**
|
|
173
|
+
* `migrate-service` only: injectable port-liveness check for the
|
|
174
|
+
* legacy-absent branch (never a raw network call in tests). Defaults to a
|
|
175
|
+
* real, read-only TCP connect attempt.
|
|
176
|
+
*/
|
|
177
|
+
readonly portProbe?: ((host: string, port: number) => boolean | Promise<boolean>) | undefined;
|
|
178
|
+
/**
|
|
179
|
+
* `migrate-service` only: injectable removal of the legacy unit file, so
|
|
180
|
+
* tests never call a raw fs op against a real path. Defaults to a real
|
|
181
|
+
* `rmSync`.
|
|
182
|
+
*/
|
|
183
|
+
readonly legacyUnitFileRemove?: ((path: string) => void) | undefined;
|
|
184
|
+
/**
|
|
185
|
+
* `service-status` only: report one JSON document instead of prose. The exit
|
|
186
|
+
* code is the same either way — the codes are the machine-readable answer,
|
|
187
|
+
* and JSON is for when a script wants the fields as well.
|
|
188
|
+
*/
|
|
189
|
+
readonly json?: boolean | undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface DaemonServiceCliResult {
|
|
193
|
+
readonly ok: boolean;
|
|
194
|
+
readonly exitCode: number;
|
|
195
|
+
/** Honest, human-readable stdout lines describing exactly what happened. */
|
|
196
|
+
readonly lines: readonly string[];
|
|
197
|
+
readonly status: ManagedServiceStatus;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The manager's definition (`ExecStart` command/args, name, description) is
|
|
202
|
+
* built once, in `../runtime/legacy-daemon-migration.ts`, and shared with the
|
|
203
|
+
* onboarding guided UX — see that module's doc comment for why.
|
|
204
|
+
*/
|
|
205
|
+
function buildManager(input: DaemonServiceCliInput): PlatformServiceManager {
|
|
206
|
+
return buildManagedDaemonServiceManager({
|
|
207
|
+
binaryPath: input.binaryPath,
|
|
208
|
+
homeDir: input.homeDir,
|
|
209
|
+
unitHomeDir: input.unitHomeDir,
|
|
210
|
+
host: input.host,
|
|
211
|
+
port: input.port,
|
|
212
|
+
workingDirectory: input.workingDirectory,
|
|
213
|
+
configManager: input.configManager,
|
|
214
|
+
actionRunner: input.actionRunner,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function statusLines(status: ManagedServiceStatus): string[] {
|
|
219
|
+
const lines = [
|
|
220
|
+
`platform: ${status.platform}`,
|
|
221
|
+
`installed: ${status.installed}`,
|
|
222
|
+
`running: ${status.running}`,
|
|
223
|
+
`unit path: ${status.path}`,
|
|
224
|
+
];
|
|
225
|
+
if (status.pid !== undefined) lines.push(`pid: ${status.pid}`);
|
|
226
|
+
if (status.platform !== 'manual' && status.installed && !status.running) {
|
|
227
|
+
// W3 Finding 4: this used to assert "'running' here only reflects
|
|
228
|
+
// processes this tool started directly" — true for the pid-file-only
|
|
229
|
+
// check the (currently linked) SDK still uses, but the parallel SDK
|
|
230
|
+
// batch is making status().running query systemd honestly via
|
|
231
|
+
// `is-active`, which would make that specific claim stale. Drop the
|
|
232
|
+
// claim about HOW running was computed and just offer the escape
|
|
233
|
+
// hatch — true and useful under either SDK version.
|
|
234
|
+
lines.push(
|
|
235
|
+
`note: if this looks wrong, verify directly: ${status.suggestedCommands[status.suggestedCommands.length - 1] ?? 'the platform service-status command'}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return lines;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build the install-branch's result lines. Exported for direct unit
|
|
243
|
+
* coverage: the "suggested follow-ups" block is gated on the actual
|
|
244
|
+
* not-started case (W3 Finding 4 friction fix) rather than printed
|
|
245
|
+
* unconditionally, and driving that through the full
|
|
246
|
+
* PlatformServiceManager/systemd integration can't reliably produce
|
|
247
|
+
* `running: true` under the (currently linked) SDK build, whose systemd
|
|
248
|
+
* status check is still pid-file-only (see the honesty note above).
|
|
249
|
+
*/
|
|
250
|
+
export function buildInstallResultLines(status: ManagedServiceStatus): string[] {
|
|
251
|
+
const lines = [`installed the ${status.platform} service at ${status.path}`];
|
|
252
|
+
if (status.running) {
|
|
253
|
+
lines.push('service is enabled and running');
|
|
254
|
+
} else {
|
|
255
|
+
lines.push('suggested follow-ups if it did not start automatically:');
|
|
256
|
+
for (const cmd of status.suggestedCommands) lines.push(` ${cmd}`);
|
|
257
|
+
}
|
|
258
|
+
return lines;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The receipt for `start-service` / `stop-service` / `restart-service`.
|
|
263
|
+
*
|
|
264
|
+
* Every line names the RESOLVED unit (`status.serviceName`, `status.path`) and
|
|
265
|
+
* says what the platform reported afterwards, rather than asserting the verb
|
|
266
|
+
* worked because it was dispatched without throwing. A verb aimed at a service
|
|
267
|
+
* that is not installed says exactly that — installing it silently would be a
|
|
268
|
+
* different command than the one that was run.
|
|
269
|
+
*/
|
|
270
|
+
export function buildLifecycleResultLines(
|
|
271
|
+
action: 'start' | 'stop' | 'restart',
|
|
272
|
+
status: ManagedServiceStatus,
|
|
273
|
+
): string[] {
|
|
274
|
+
if (!status.installed) {
|
|
275
|
+
return [
|
|
276
|
+
`no ${status.platform} service is installed at ${status.path}, so there is nothing to ${action}.`,
|
|
277
|
+
`install it first: goodvibes-daemon install-service`,
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
const past = action === 'stop' ? 'stopped' : `${action}ed`;
|
|
281
|
+
const lines = [`${past} the ${status.platform} service ${status.serviceName} (${status.path})`];
|
|
282
|
+
lines.push(status.running ? 'it is running' : 'it is not running');
|
|
283
|
+
if (action !== 'stop' && !status.running) {
|
|
284
|
+
lines.push('suggested follow-ups:');
|
|
285
|
+
for (const cmd of status.suggestedCommands) lines.push(` ${cmd}`);
|
|
286
|
+
}
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Run one of start / stop / restart against the managed service.
|
|
292
|
+
*
|
|
293
|
+
* The installed check happens BEFORE the platform verb is dispatched, not
|
|
294
|
+
* after: `systemctl --user enable --now goodvibes.service` against a unit that
|
|
295
|
+
* does not exist fails with systemd's own wording, which reads like a broken
|
|
296
|
+
* service rather than an absent one. Exit 4 is the same "not installed" answer
|
|
297
|
+
* `service-status` gives, so a script reading one reads the other.
|
|
298
|
+
*/
|
|
299
|
+
function runLifecycleVerb(
|
|
300
|
+
action: 'start' | 'stop' | 'restart',
|
|
301
|
+
manager: PlatformServiceManager,
|
|
302
|
+
): DaemonServiceCliResult {
|
|
303
|
+
const before = manager.status();
|
|
304
|
+
if (!before.installed) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
exitCode: SERVICE_STATUS_EXIT_NOT_INSTALLED,
|
|
308
|
+
lines: buildLifecycleResultLines(action, before),
|
|
309
|
+
status: before,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const status = action === 'start'
|
|
313
|
+
? manager.start()
|
|
314
|
+
: action === 'stop'
|
|
315
|
+
? manager.stop()
|
|
316
|
+
: manager.restart();
|
|
317
|
+
if (status.actionError) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
exitCode: 1,
|
|
321
|
+
lines: [`service ${action} failed: ${status.actionError}`],
|
|
322
|
+
status,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return { ok: true, exitCode: 0, lines: buildLifecycleResultLines(action, status), status };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The honest 0/3/4 answer to "is the service installed, and is it running?". */
|
|
329
|
+
export function serviceStatusExitCode(status: ManagedServiceStatus): number {
|
|
330
|
+
if (!status.installed) return SERVICE_STATUS_EXIT_NOT_INSTALLED;
|
|
331
|
+
return status.running ? SERVICE_STATUS_EXIT_RUNNING : SERVICE_STATUS_EXIT_INSTALLED_NOT_RUNNING;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The `--json` document for `service-status`: the status fields plus the code. */
|
|
335
|
+
export function serviceStatusJson(status: ManagedServiceStatus, legacy: LegacyUnitInfo): string {
|
|
336
|
+
return JSON.stringify(
|
|
337
|
+
{
|
|
338
|
+
ok: true,
|
|
339
|
+
data: {
|
|
340
|
+
platform: status.platform,
|
|
341
|
+
serviceName: status.serviceName,
|
|
342
|
+
path: status.path,
|
|
343
|
+
installed: status.installed,
|
|
344
|
+
running: status.running,
|
|
345
|
+
autostart: status.autostart,
|
|
346
|
+
...(status.pid === undefined ? {} : { pid: status.pid }),
|
|
347
|
+
...(status.logPath === undefined ? {} : { logPath: status.logPath }),
|
|
348
|
+
...(status.lingerNote === undefined ? {} : { lingerNote: status.lingerNote }),
|
|
349
|
+
suggestedCommands: status.suggestedCommands,
|
|
350
|
+
legacyUnitPresent: legacy.present,
|
|
351
|
+
exitCode: serviceStatusExitCode(status),
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
null,
|
|
355
|
+
2,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function ok(action: 'install' | 'uninstall' | 'status', status: ManagedServiceStatus, extra: string[] = []): DaemonServiceCliResult {
|
|
360
|
+
const lines: string[] = [];
|
|
361
|
+
if (action === 'install') {
|
|
362
|
+
lines.push(...buildInstallResultLines(status));
|
|
363
|
+
} else if (action === 'uninstall') {
|
|
364
|
+
lines.push(`removed the ${status.platform} service at ${status.path}`);
|
|
365
|
+
if (status.platform === 'systemd') {
|
|
366
|
+
lines.push(
|
|
367
|
+
"note: this removes the unit file but does not run `disable` — run " +
|
|
368
|
+
"`systemctl --user daemon-reload` to clear any stale enablement symlink.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
lines.push(...statusLines(status));
|
|
373
|
+
}
|
|
374
|
+
lines.push(...extra);
|
|
375
|
+
return { ok: true, exitCode: 0, lines, status };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function failed(action: 'install' | 'uninstall' | 'status', status: ManagedServiceStatus): DaemonServiceCliResult {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
exitCode: 1,
|
|
382
|
+
lines: [`service ${action} failed: ${status.actionError ?? 'unknown error'}`],
|
|
383
|
+
status,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* `install-service` and `migrate-service` refuse an explicit
|
|
389
|
+
* `--hostname`/`--port` flag rather than silently accepting it. Both flags are
|
|
390
|
+
* RUNTIME-ONLY overrides applied to this one invocation's in-memory config
|
|
391
|
+
* (`applyRuntimeEndpointFlagOverrides` in `src/daemon/cli.ts`) — but the unit
|
|
392
|
+
* this subcommand writes carries no endpoint flags at all (see
|
|
393
|
+
* `buildManagedDaemonServiceManager`'s ExecStart doc): the daemon always
|
|
394
|
+
* re-resolves `controlPlane.host`/`port`/`hostMode` from PERSISTED settings at
|
|
395
|
+
* boot. A flag accepted here would print/probe a binding the installed unit
|
|
396
|
+
* will never actually have — an honest gap, not a cosmetic one, since the
|
|
397
|
+
* printed value is exactly what an operator would expect the running service
|
|
398
|
+
* to bind to. Refusing with a pointer to the persistent config path is
|
|
399
|
+
* strictly better than silently doing nothing with the flag.
|
|
400
|
+
*/
|
|
401
|
+
export function validateServiceEndpointFlags(
|
|
402
|
+
subcommand: DaemonServiceSubcommand,
|
|
403
|
+
flags: { readonly hostnameProvided: boolean; readonly portProvided: boolean },
|
|
404
|
+
): readonly string[] {
|
|
405
|
+
if (subcommand !== 'install-service' && subcommand !== 'migrate-service') return [];
|
|
406
|
+
if (!flags.hostnameProvided && !flags.portProvided) return [];
|
|
407
|
+
const flagNames = [flags.hostnameProvided ? '--hostname' : null, flags.portProvided ? '--port' : null]
|
|
408
|
+
.filter((name): name is string => name !== null)
|
|
409
|
+
.join('/');
|
|
410
|
+
return [
|
|
411
|
+
`${subcommand} refused: ${flagNames} only override this process's runtime config — the installed unit carries ` +
|
|
412
|
+
'no endpoint flags and re-resolves controlPlane.host/port/hostMode from persisted settings at boot, so the ' +
|
|
413
|
+
'override would never take effect there.',
|
|
414
|
+
`Set the persistent binding instead: goodvibes-daemon config set controlPlane.host / controlPlane.port (or ` +
|
|
415
|
+
`controlPlane.hostMode), then re-run ${subcommand} without ${flagNames}.`,
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `migrate-service`: the guided, consented takeover of the legacy
|
|
421
|
+
* `goodvibes-daemon.service` unit. Thin wrapper over
|
|
422
|
+
* `runLegacyDaemonMigration` (`../runtime/legacy-daemon-migration.ts`) — see
|
|
423
|
+
* that module for the design constraints (never auto-migrate,
|
|
424
|
+
* new-up-then-old-down, adopt-or-warn/never kill an unrecognized process,
|
|
425
|
+
* every action through an injectable seam) and the onboarding UX consumer.
|
|
426
|
+
*/
|
|
427
|
+
function runMigrateService(
|
|
428
|
+
input: DaemonServiceCliInput,
|
|
429
|
+
manager: PlatformServiceManager,
|
|
430
|
+
legacy: LegacyUnitInfo,
|
|
431
|
+
): Promise<DaemonServiceCliResult> {
|
|
432
|
+
return runLegacyDaemonMigration(
|
|
433
|
+
{
|
|
434
|
+
host: input.host,
|
|
435
|
+
port: input.port,
|
|
436
|
+
trackedServiceName: SERVICE_NAME,
|
|
437
|
+
confirmMigration: input.confirmMigration,
|
|
438
|
+
portProbe: input.portProbe,
|
|
439
|
+
legacyUnitFileRemove: input.legacyUnitFileRemove,
|
|
440
|
+
actionRunner: input.actionRunner,
|
|
441
|
+
},
|
|
442
|
+
manager,
|
|
443
|
+
legacy,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Dispatch a daemon service subcommand to the SDK's `PlatformServiceManager`. */
|
|
448
|
+
export async function runDaemonServiceCli(input: DaemonServiceCliInput): Promise<DaemonServiceCliResult> {
|
|
449
|
+
const manager = buildManager(input);
|
|
450
|
+
const flagErrors = validateServiceEndpointFlags(input.subcommand, {
|
|
451
|
+
hostnameProvided: input.hostnameFlagProvided ?? false,
|
|
452
|
+
portProvided: input.portFlagProvided ?? false,
|
|
453
|
+
});
|
|
454
|
+
if (flagErrors.length > 0) {
|
|
455
|
+
return { ok: false, exitCode: 1, lines: [...flagErrors], status: manager.status() };
|
|
456
|
+
}
|
|
457
|
+
const legacy = detectLegacyUnit(input);
|
|
458
|
+
switch (input.subcommand) {
|
|
459
|
+
case 'migrate-service':
|
|
460
|
+
return runMigrateService(input, manager, legacy);
|
|
461
|
+
case 'install-service': {
|
|
462
|
+
// W3 Finding 4: refuse rather than risk starting a second daemon
|
|
463
|
+
// alongside an already-installed legacy unit. Refuses whenever the
|
|
464
|
+
// legacy unit is present at all (not just when currently active) —
|
|
465
|
+
// an installed-but-inactive legacy unit can still be enabled and
|
|
466
|
+
// start competing for the same port later, and "never silently
|
|
467
|
+
// start a second daemon" is the bar here, not "never right now."
|
|
468
|
+
if (legacy.present) {
|
|
469
|
+
const status = manager.status();
|
|
470
|
+
const resolvedName = resolveManagedUnitName(status);
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
exitCode: 1,
|
|
474
|
+
lines: [
|
|
475
|
+
`service install refused: a service is already installed as ${LEGACY_SERVICE_UNIT_NAME}.service (the unit name an older install script/release used).`,
|
|
476
|
+
legacyUnitNote(legacy, resolvedName),
|
|
477
|
+
`Installing this tool's ${resolvedName}.service alongside it risks two daemons competing for the same port.`,
|
|
478
|
+
],
|
|
479
|
+
status,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const installed = manager.install();
|
|
483
|
+
if (installed.actionError) return failed('install', installed);
|
|
484
|
+
const started = manager.start();
|
|
485
|
+
// start()'s actionError (e.g. a platform this manager can't dispatch
|
|
486
|
+
// actions for) doesn't undo the write — report install as ok, but surface
|
|
487
|
+
// the follow-up problem honestly instead of claiming it is running.
|
|
488
|
+
return started.actionError
|
|
489
|
+
? ok('install', { ...started, running: false }, [`could not start it automatically: ${started.actionError}`])
|
|
490
|
+
: ok('install', started);
|
|
491
|
+
}
|
|
492
|
+
case 'uninstall-service': {
|
|
493
|
+
const stopped = manager.stop();
|
|
494
|
+
const uninstalled = manager.uninstall();
|
|
495
|
+
if (uninstalled.actionError) return failed('uninstall', uninstalled);
|
|
496
|
+
const extra: string[] = [];
|
|
497
|
+
if (stopped.actionError) extra.push(`(it may not have been running: ${stopped.actionError})`);
|
|
498
|
+
// This command only ever touches the TRACKED unit (whatever name/path
|
|
499
|
+
// actually resolved — not necessarily the SERVICE_NAME constant) above —
|
|
500
|
+
// say so explicitly when an install-script unit also exists, so its
|
|
501
|
+
// continued presence is never a silent surprise.
|
|
502
|
+
if (legacy.present) extra.push(legacyUnitNote(legacy, resolveManagedUnitName(uninstalled)));
|
|
503
|
+
return ok('uninstall', uninstalled, extra);
|
|
504
|
+
}
|
|
505
|
+
case 'start-service':
|
|
506
|
+
return runLifecycleVerb('start', manager);
|
|
507
|
+
case 'stop-service':
|
|
508
|
+
return runLifecycleVerb('stop', manager);
|
|
509
|
+
case 'restart-service':
|
|
510
|
+
return runLifecycleVerb('restart', manager);
|
|
511
|
+
case 'service-status': {
|
|
512
|
+
const status = manager.status();
|
|
513
|
+
if (status.actionError) return failed('status', status);
|
|
514
|
+
if (input.json) {
|
|
515
|
+
return {
|
|
516
|
+
ok: true,
|
|
517
|
+
exitCode: serviceStatusExitCode(status),
|
|
518
|
+
lines: [serviceStatusJson(status, legacy)],
|
|
519
|
+
status,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const result = ok('status', status, legacy.present ? [legacyUnitNote(legacy, resolveManagedUnitName(status))] : []);
|
|
523
|
+
// The status verb reports; it does not fail. `ok` is still true — the
|
|
524
|
+
// question was answered — while the exit code carries the answer itself
|
|
525
|
+
// (0 running / 3 installed-not-running / 4 not installed), so a script
|
|
526
|
+
// never has to read the prose above.
|
|
527
|
+
return { ...result, exitCode: serviceStatusExitCode(status) };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|