@kortix/agent-tunnel 0.12.7 → 0.13.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.
@@ -1,12 +1,17 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import {
3
6
  DEFAULT_INSTALL_BACKGROUND_SERVICE,
4
7
  SERVICE_LABEL,
5
8
  buildServiceShellCommand,
6
9
  getServicePaths,
10
+ isEphemeralRunnerPath,
7
11
  renderLaunchdPlist,
8
12
  renderSystemdUnit,
9
13
  renderWindowsPowerShellScript,
14
+ vendorRunner,
10
15
  } from './service';
11
16
 
12
17
  describe('agent tunnel service definitions', () => {
@@ -31,10 +36,12 @@ describe('agent tunnel service definitions', () => {
31
36
  expect(plist).toContain('agent-tunnel.err.log');
32
37
  });
33
38
 
34
- test('systemd unit restarts forever', () => {
39
+ test('systemd unit restarts on failure but not after a terminal exit', () => {
35
40
  const unit = renderSystemdUnit('exec /bin/echo tunnel');
36
41
  expect(unit).toContain('Description=Kortix Agent Tunnel');
37
- expect(unit).toContain('Restart=always');
42
+ // Restart=always respawned the agent forever when the credential was
43
+ // missing or revoked, which no restart can fix.
44
+ expect(unit).toContain('Restart=on-failure');
38
45
  expect(unit).toContain('UMask=0077');
39
46
  expect(unit).toContain('WantedBy=default.target');
40
47
  expect(unit).toContain('agent-tunnel.out.log');
@@ -52,6 +59,49 @@ describe('agent tunnel service definitions', () => {
52
59
  expect(script).toContain('Start-Sleep -Seconds 5');
53
60
  });
54
61
 
62
+ test('treats package-manager caches as ephemeral runner locations', () => {
63
+ expect(
64
+ isEphemeralRunnerPath(
65
+ '/Users/x/.npm/_npx/d2c324008dde6a9b/node_modules/@kortix/agent-tunnel/dist/agent-cli.js',
66
+ ),
67
+ ).toBe(true);
68
+ expect(isEphemeralRunnerPath('/Users/x/.npm/_cacache/content-v2/sha512/ab/cd')).toBe(true);
69
+ expect(isEphemeralRunnerPath('/usr/local/lib/node_modules/@kortix/agent-tunnel/dist/agent-cli.js')).toBe(false);
70
+ expect(isEphemeralRunnerPath('/opt/homebrew/bin/agent-tunnel')).toBe(false);
71
+ });
72
+
73
+ test('vendors an npx-cached runner into the config directory', () => {
74
+ const home = mkdtempSync(join(tmpdir(), 'agent-tunnel-vendor-'));
75
+ try {
76
+ const paths = {
77
+ ...getServicePaths(),
78
+ binDir: join(home, 'bin'),
79
+ vendoredRunner: join(home, 'bin', 'agent-cli.js'),
80
+ };
81
+
82
+ // A stable install location is used as-is.
83
+ const stable = join(home, 'agent-cli.js');
84
+ writeFileSync(stable, '// bundle\n');
85
+ expect(vendorRunner(stable, paths)).toBe(stable);
86
+
87
+ // An npx cache path is copied out to the stable location instead.
88
+ const cacheDir = join(home, '_npx', 'abc');
89
+ mkdirSync(cacheDir, { recursive: true });
90
+ const cached = join(cacheDir, 'agent-cli.js');
91
+ writeFileSync(cached, '// cached bundle\n');
92
+ const copied = vendorRunner(cached, paths);
93
+ expect(copied).toBe(paths.vendoredRunner);
94
+ expect(readFileSync(copied, 'utf8')).toBe('// cached bundle\n');
95
+ } finally {
96
+ rmSync(home, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test('resolves the interpreter at start instead of pinning one absolute path', () => {
101
+ const command = buildServiceShellCommand();
102
+ expect(command).toContain('command -v node');
103
+ });
104
+
55
105
  test('service paths are under the user home', () => {
56
106
  const paths = getServicePaths();
57
107
  expect(paths.configDir).toContain('.agent-tunnel');
@@ -1,18 +1,34 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
- import { homedir, platform, userInfo } from 'os';
3
- import { dirname, join } from 'path';
4
- import { spawnSync } from 'child_process';
5
-
6
- export const SERVICE_LABEL = 'ai.kortix.agent-tunnel';
7
- export const DEFAULT_INSTALL_BACKGROUND_SERVICE = true;
8
-
9
- export interface ServicePaths {
10
- configDir: string;
11
- logDir: string;
12
- launchdPlist: string;
13
- systemdUnit: string;
14
- windowsScript: string;
15
- }
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'fs';
2
+ import { platform } from 'os';
3
+ import { join } from 'path';
4
+
5
+ import {
6
+ SUPPORTED_PLATFORMS_MESSAGE,
7
+ type RunnerParts,
8
+ posixShellCommand,
9
+ serviceDriver,
10
+ } from './service-drivers';
11
+ import {
12
+ type ServicePaths,
13
+ getServicePaths,
14
+ rotateServiceLogs,
15
+ } from './service-paths';
16
+
17
+ export {
18
+ DEFAULT_INSTALL_BACKGROUND_SERVICE,
19
+ MAX_SERVICE_LOG_BYTES,
20
+ SERVICE_LABEL,
21
+ TERMINAL_SERVICE_EXIT_CODE,
22
+ type ServicePaths,
23
+ getServicePaths,
24
+ rotateServiceLogs,
25
+ serviceLogFiles,
26
+ } from './service-paths';
27
+ export {
28
+ renderLaunchdPlist,
29
+ renderSystemdUnit,
30
+ renderWindowsPowerShellScript,
31
+ } from './service-drivers';
16
32
 
17
33
  export interface ServiceStatus {
18
34
  platform: NodeJS.Platform;
@@ -22,338 +38,129 @@ export interface ServiceStatus {
22
38
  detail?: string;
23
39
  }
24
40
 
25
- export function getServicePaths(): ServicePaths {
26
- const home = homedir();
27
- const configDir = join(home, '.agent-tunnel');
28
- return {
29
- configDir,
30
- logDir: join(configDir, 'logs'),
31
- launchdPlist: join(home, 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`),
32
- systemdUnit: join(home, '.config', 'systemd', 'user', `${SERVICE_LABEL}.service`),
33
- windowsScript: join(configDir, 'agent-tunnel-service.ps1'),
34
- };
35
- }
36
-
37
- function shellQuote(value: string): string {
38
- return `'${value.replace(/'/g, `'\\''`)}'`;
39
- }
40
-
41
- function powershellQuote(value: string): string {
42
- return `'${value.replace(/'/g, "''")}'`;
41
+ /**
42
+ * True for locations a package manager may delete without warning.
43
+ *
44
+ * `npx` extracts the package into a content-addressed cache directory and
45
+ * garbage-collects it. A background service pointed at that path starts fine and
46
+ * then dies permanently the first time the cache is pruned, leaving only a
47
+ * MODULE_NOT_FOUND in a log file nobody reads.
48
+ */
49
+ export function isEphemeralRunnerPath(path: string): boolean {
50
+ const normalized = path.replace(/\\/g, '/');
51
+ return (
52
+ normalized.includes('/_npx/') ||
53
+ normalized.includes('/_cacache/') ||
54
+ normalized.includes('/.pnpm-store/') ||
55
+ normalized.includes('/.yarn/$$virtual/')
56
+ );
43
57
  }
44
58
 
45
- function xmlEscape(value: string): string {
46
- return value
47
- .replace(/&/g, '&')
48
- .replace(/</g, '&lt;')
49
- .replace(/>/g, '&gt;')
50
- .replace(/"/g, '&quot;')
51
- .replace(/'/g, '&apos;');
59
+ /**
60
+ * Copies the running CLI bundle into ~/.agent-tunnel/bin so the installed
61
+ * service owns its executable. The bundle is a single self-contained file that
62
+ * imports only Node builtins, so a plain copy is sufficient.
63
+ */
64
+ export function vendorRunner(scriptPath: string, paths: ServicePaths = getServicePaths()): string {
65
+ if (!isEphemeralRunnerPath(scriptPath)) return scriptPath;
66
+
67
+ mkdirSync(paths.binDir, { recursive: true, mode: 0o700 });
68
+ const source = realpathSync(scriptPath);
69
+ copyFileSync(source, paths.vendoredRunner);
70
+ try { chmodSync(paths.vendoredRunner, 0o700); } catch {}
71
+ writeFileSync(
72
+ join(paths.binDir, 'agent-cli.source.json'),
73
+ JSON.stringify({ source, vendoredFrom: scriptPath }, null, 2),
74
+ { mode: 0o600 },
75
+ );
76
+ return paths.vendoredRunner;
52
77
  }
53
78
 
54
- function currentRunnerParts(): { command: string; args: string[] } {
55
- const exec = process.execPath;
79
+ function currentRunnerParts(): RunnerParts {
56
80
  const script = process.argv[1];
57
81
  if (script && existsSync(script)) {
58
- return { command: exec, args: [script, 'run', '--service'] };
82
+ return { command: process.execPath, args: [vendorRunner(script), 'run', '--service'] };
59
83
  }
60
84
  throw new Error(
61
85
  'Cannot install the background service because the current Agent Tunnel executable was not found',
62
86
  );
63
87
  }
64
88
 
65
- function currentRunnerCommand(): string {
66
- const runner = currentRunnerParts();
67
- return [runner.command, ...runner.args].map(shellQuote).join(' ');
68
- }
69
-
70
89
  export function buildServiceShellCommand(): string {
71
- return `exec ${currentRunnerCommand()}`;
72
- }
73
-
74
- export function renderWindowsPowerShellScript(
75
- runner = currentRunnerParts(),
76
- ): string {
77
- const command = powershellQuote(runner.command);
78
- const args = runner.args.map(powershellQuote).join(' ');
79
-
80
- return `$ErrorActionPreference = 'Continue'
81
- while ($true) {
82
- & ${command}${args ? ` ${args}` : ''}
83
- Start-Sleep -Seconds 5
84
- }
85
- `;
86
- }
87
-
88
- function windowsTaskCommand(paths: ServicePaths): string {
89
- return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${paths.windowsScript}"`;
90
- }
91
-
92
- export function renderLaunchdPlist(command: string, paths: ServicePaths = getServicePaths()): string {
93
- const stdout = join(paths.logDir, 'agent-tunnel.out.log');
94
- const stderr = join(paths.logDir, 'agent-tunnel.err.log');
95
- return `<?xml version="1.0" encoding="UTF-8"?>
96
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
97
- <plist version="1.0">
98
- <dict>
99
- <key>Label</key>
100
- <string>${xmlEscape(SERVICE_LABEL)}</string>
101
- <key>ProgramArguments</key>
102
- <array>
103
- <string>/bin/sh</string>
104
- <string>-lc</string>
105
- <string>${xmlEscape(command)}</string>
106
- </array>
107
- <key>RunAtLoad</key>
108
- <true/>
109
- <key>KeepAlive</key>
110
- <true/>
111
- <key>Umask</key>
112
- <integer>63</integer>
113
- <key>StandardOutPath</key>
114
- <string>${xmlEscape(stdout)}</string>
115
- <key>StandardErrorPath</key>
116
- <string>${xmlEscape(stderr)}</string>
117
- <key>WorkingDirectory</key>
118
- <string>${xmlEscape(homedir())}</string>
119
- <key>EnvironmentVariables</key>
120
- <dict>
121
- <key>PATH</key>
122
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
123
- </dict>
124
- </dict>
125
- </plist>
126
- `;
127
- }
128
-
129
- export function renderSystemdUnit(command: string, paths: ServicePaths = getServicePaths()): string {
130
- const stdout = join(paths.logDir, 'agent-tunnel.out.log');
131
- const stderr = join(paths.logDir, 'agent-tunnel.err.log');
132
- return `[Unit]
133
- Description=Kortix Agent Tunnel
134
- After=network-online.target
135
- Wants=network-online.target
136
-
137
- [Service]
138
- Type=simple
139
- UMask=0077
140
- ExecStart=/bin/sh -lc ${shellQuote(command)}
141
- Restart=always
142
- RestartSec=5
143
- WorkingDirectory=${homedir()}
144
- Environment=PATH=/usr/local/bin:/usr/bin:/bin
145
- StandardOutput=append:${stdout}
146
- StandardError=append:${stderr}
90
+ return posixShellCommand(currentRunnerParts());
91
+ }
92
+
93
+ /**
94
+ * Runs one driver operation and normalises it into a ServiceStatus.
95
+ *
96
+ * Every public verb below shares this shape, which is why the per-platform
97
+ * branching lives in the driver table rather than in six near-identical
98
+ * functions.
99
+ */
100
+ function withDriver(
101
+ operate: (driver: NonNullable<ReturnType<typeof serviceDriver>>, paths: ServicePaths, installed: boolean) => {
102
+ active?: boolean | null;
103
+ installed?: boolean;
104
+ detail?: string;
105
+ },
106
+ fallback: { installed: boolean; active: boolean | null },
107
+ ): ServiceStatus {
108
+ const driver = serviceDriver();
109
+ const paths = getServicePaths();
147
110
 
148
- [Install]
149
- WantedBy=default.target
150
- `;
151
- }
111
+ // Mutating verbs must fail loudly on an unsupported platform. Reporting a
112
+ // successful-looking status would make the CLI claim it installed a service
113
+ // that does not exist.
114
+ if (!driver) throw new Error(SUPPORTED_PLATFORMS_MESSAGE);
152
115
 
153
- function run(command: string, args: string[]): { ok: boolean; detail: string } {
154
- const result = spawnSync(command, args, { encoding: 'utf8' });
155
- const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
156
- return { ok: result.status === 0, detail };
157
- }
116
+ const path = driver.unitPath(paths);
117
+ const installed = existsSync(path);
118
+ const outcome = operate(driver, paths, installed);
158
119
 
159
- function launchdTarget(): string {
160
- const uid = typeof process.getuid === 'function' ? process.getuid() : userInfo().uid;
161
- return `gui/${uid}`;
120
+ return {
121
+ platform: platform(),
122
+ installed: outcome.installed ?? installed,
123
+ active: outcome.active ?? fallback.active,
124
+ path,
125
+ detail: outcome.detail,
126
+ };
162
127
  }
163
128
 
164
129
  export function installService(): ServiceStatus {
165
130
  const paths = getServicePaths();
166
131
  mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
167
132
  mkdirSync(paths.logDir, { recursive: true, mode: 0o700 });
133
+ rotateServiceLogs(paths);
168
134
 
169
- const command = buildServiceShellCommand();
170
-
171
- if (platform() === 'darwin') {
172
- mkdirSync(dirname(paths.launchdPlist), { recursive: true });
173
- writeFileSync(paths.launchdPlist, renderLaunchdPlist(command, paths), { mode: 0o600 });
174
- run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
175
- const boot = run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist]);
176
- const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${SERVICE_LABEL}`]);
177
- return {
178
- platform: platform(),
179
- installed: true,
180
- active: boot.ok || kick.ok ? true : null,
181
- path: paths.launchdPlist,
182
- detail: [boot.detail, kick.detail].filter(Boolean).join('\n'),
183
- };
184
- }
185
-
186
- if (platform() === 'linux') {
187
- mkdirSync(dirname(paths.systemdUnit), { recursive: true });
188
- writeFileSync(paths.systemdUnit, renderSystemdUnit(command, paths), { mode: 0o600 });
189
- const reload = run('systemctl', ['--user', 'daemon-reload']);
190
- const enable = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_LABEL}.service`]);
191
- return {
192
- platform: platform(),
193
- installed: true,
194
- active: enable.ok ? true : null,
195
- path: paths.systemdUnit,
196
- detail: [reload.detail, enable.detail].filter(Boolean).join('\n'),
197
- };
198
- }
199
-
200
- if (platform() === 'win32') {
201
- writeFileSync(paths.windowsScript, renderWindowsPowerShellScript(), { mode: 0o600 });
202
- const create = run('schtasks.exe', [
203
- '/Create',
204
- '/TN',
205
- SERVICE_LABEL,
206
- '/TR',
207
- windowsTaskCommand(paths),
208
- '/SC',
209
- 'ONLOGON',
210
- '/F',
211
- '/RL',
212
- 'LIMITED',
213
- ]);
214
- const start = run('schtasks.exe', ['/Run', '/TN', SERVICE_LABEL]);
215
- return {
216
- platform: platform(),
217
- installed: create.ok,
218
- active: start.ok ? true : null,
219
- path: paths.windowsScript,
220
- detail: [create.detail, start.detail].filter(Boolean).join('\n'),
221
- };
222
- }
223
-
224
- throw new Error('Background service install is currently supported on macOS launchd, Linux systemd user services, and Windows Scheduled Tasks.');
135
+ const runner = currentRunnerParts();
136
+ return withDriver(
137
+ (driver, servicePaths) => driver.install(servicePaths, runner),
138
+ { installed: false, active: null },
139
+ );
225
140
  }
226
141
 
227
142
  export function uninstallService(): ServiceStatus {
228
- const paths = getServicePaths();
229
-
230
- if (platform() === 'darwin') {
231
- const existed = existsSync(paths.launchdPlist);
232
- const stop = run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]);
233
- if (existed) rmSync(paths.launchdPlist, { force: true });
234
- return {
235
- platform: platform(),
236
- installed: false,
237
- active: false,
238
- path: paths.launchdPlist,
239
- detail: stop.detail,
240
- };
241
- }
242
-
243
- if (platform() === 'linux') {
244
- const existed = existsSync(paths.systemdUnit);
245
- const disable = run('systemctl', ['--user', 'disable', '--now', `${SERVICE_LABEL}.service`]);
246
- if (existed) rmSync(paths.systemdUnit, { force: true });
247
- run('systemctl', ['--user', 'daemon-reload']);
248
- return {
249
- platform: platform(),
250
- installed: false,
251
- active: false,
252
- path: paths.systemdUnit,
253
- detail: disable.detail,
254
- };
255
- }
256
-
257
- if (platform() === 'win32') {
258
- const existed = existsSync(paths.windowsScript);
259
- const stop = run('schtasks.exe', ['/End', '/TN', SERVICE_LABEL]);
260
- const del = run('schtasks.exe', ['/Delete', '/TN', SERVICE_LABEL, '/F']);
261
- if (existed) rmSync(paths.windowsScript, { force: true });
262
- return {
263
- platform: platform(),
264
- installed: false,
265
- active: false,
266
- path: paths.windowsScript,
267
- detail: [stop.detail, del.detail].filter(Boolean).join('\n'),
268
- };
269
- }
270
-
271
- throw new Error('Background service uninstall is currently supported on macOS launchd, Linux systemd user services, and Windows Scheduled Tasks.');
143
+ // Remove the vendored executable too, so uninstall leaves no residue a later
144
+ // install would silently reuse.
145
+ rmSync(getServicePaths().binDir, { recursive: true, force: true });
146
+ return withDriver(
147
+ (driver, paths) => ({ ...driver.uninstall(paths), installed: false, active: false }),
148
+ { installed: false, active: false },
149
+ );
272
150
  }
273
151
 
274
152
  export function startService(): ServiceStatus {
275
- const paths = getServicePaths();
276
-
277
- if (platform() === 'darwin') {
278
- const installed = existsSync(paths.launchdPlist);
279
- const boot = installed ? run('launchctl', ['bootstrap', launchdTarget(), paths.launchdPlist]) : { ok: false, detail: 'LaunchAgent is not installed.' };
280
- const kick = run('launchctl', ['kickstart', '-k', `${launchdTarget()}/${SERVICE_LABEL}`]);
281
- return {
282
- platform: platform(),
283
- installed,
284
- active: boot.ok || kick.ok ? true : null,
285
- path: paths.launchdPlist,
286
- detail: [boot.detail, kick.detail].filter(Boolean).join('\n'),
287
- };
288
- }
289
-
290
- if (platform() === 'linux') {
291
- const installed = existsSync(paths.systemdUnit);
292
- const start = installed ? run('systemctl', ['--user', 'start', `${SERVICE_LABEL}.service`]) : { ok: false, detail: 'systemd unit is not installed.' };
293
- return {
294
- platform: platform(),
295
- installed,
296
- active: start.ok ? true : null,
297
- path: paths.systemdUnit,
298
- detail: start.detail,
299
- };
300
- }
301
-
302
- if (platform() === 'win32') {
303
- const installed = existsSync(paths.windowsScript);
304
- const start = installed ? run('schtasks.exe', ['/Run', '/TN', SERVICE_LABEL]) : { ok: false, detail: 'Scheduled Task is not installed.' };
305
- return {
306
- platform: platform(),
307
- installed,
308
- active: start.ok ? true : null,
309
- path: paths.windowsScript,
310
- detail: start.detail,
311
- };
312
- }
313
-
314
- throw new Error('Background service start is currently supported on macOS, Linux, and Windows.');
153
+ return withDriver(
154
+ (driver, paths, installed) => driver.start(paths, installed),
155
+ { installed: false, active: null },
156
+ );
315
157
  }
316
158
 
317
159
  export function stopService(): ServiceStatus {
318
- const paths = getServicePaths();
319
-
320
- if (platform() === 'darwin') {
321
- const installed = existsSync(paths.launchdPlist);
322
- const stop = installed ? run('launchctl', ['bootout', launchdTarget(), paths.launchdPlist]) : { ok: false, detail: 'LaunchAgent is not installed.' };
323
- return {
324
- platform: platform(),
325
- installed,
326
- active: false,
327
- path: paths.launchdPlist,
328
- detail: stop.detail,
329
- };
330
- }
331
-
332
- if (platform() === 'linux') {
333
- const installed = existsSync(paths.systemdUnit);
334
- const stop = installed ? run('systemctl', ['--user', 'stop', `${SERVICE_LABEL}.service`]) : { ok: false, detail: 'systemd unit is not installed.' };
335
- return {
336
- platform: platform(),
337
- installed,
338
- active: false,
339
- path: paths.systemdUnit,
340
- detail: stop.detail,
341
- };
342
- }
343
-
344
- if (platform() === 'win32') {
345
- const installed = existsSync(paths.windowsScript);
346
- const stop = installed ? run('schtasks.exe', ['/End', '/TN', SERVICE_LABEL]) : { ok: false, detail: 'Scheduled Task is not installed.' };
347
- return {
348
- platform: platform(),
349
- installed,
350
- active: false,
351
- path: paths.windowsScript,
352
- detail: stop.detail,
353
- };
354
- }
355
-
356
- throw new Error('Background service stop is currently supported on macOS, Linux, and Windows.');
160
+ return withDriver(
161
+ (driver, paths, installed) => ({ ...driver.stop(paths, installed), active: false }),
162
+ { installed: false, active: false },
163
+ );
357
164
  }
358
165
 
359
166
  export function restartService(): ServiceStatus {
@@ -362,49 +169,18 @@ export function restartService(): ServiceStatus {
362
169
  }
363
170
 
364
171
  export function getServiceStatus(): ServiceStatus {
365
- const paths = getServicePaths();
366
-
367
- if (platform() === 'darwin') {
368
- const installed = existsSync(paths.launchdPlist);
369
- const status = run('launchctl', ['print', `${launchdTarget()}/${SERVICE_LABEL}`]);
172
+ // Status is the one verb that must answer on every platform: callers use it
173
+ // to decide whether a service exists at all.
174
+ if (!serviceDriver()) {
370
175
  return {
371
176
  platform: platform(),
372
- installed,
373
- active: status.ok,
374
- path: paths.launchdPlist,
375
- detail: status.detail || (installed ? readFileSync(paths.launchdPlist, 'utf8') : undefined),
376
- };
377
- }
378
-
379
- if (platform() === 'linux') {
380
- const installed = existsSync(paths.systemdUnit);
381
- const status = run('systemctl', ['--user', 'is-active', `${SERVICE_LABEL}.service`]);
382
- return {
383
- platform: platform(),
384
- installed,
385
- active: status.ok,
386
- path: paths.systemdUnit,
387
- detail: status.detail,
388
- };
389
- }
390
-
391
- if (platform() === 'win32') {
392
- const installed = existsSync(paths.windowsScript);
393
- const status = run('schtasks.exe', ['/Query', '/TN', SERVICE_LABEL, '/FO', 'LIST', '/V']);
394
- const detail = status.detail || (installed ? readFileSync(paths.windowsScript, 'utf8') : undefined);
395
- return {
396
- platform: platform(),
397
- installed,
398
- active: status.ok ? /Status:\s*Running/i.test(detail ?? '') : false,
399
- path: paths.windowsScript,
400
- detail,
177
+ installed: false,
178
+ active: null,
179
+ detail: SUPPORTED_PLATFORMS_MESSAGE,
401
180
  };
402
181
  }
403
-
404
- return {
405
- platform: platform(),
406
- installed: false,
407
- active: null,
408
- detail: 'Background service status is currently supported on macOS launchd, Linux systemd user services, and Windows Scheduled Tasks.',
409
- };
182
+ return withDriver(
183
+ (driver, paths, installed) => driver.status(paths, installed),
184
+ { installed: false, active: null },
185
+ );
410
186
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Terminal rendering primitives.
3
+ *
4
+ * The colour table used to be copy-pasted into agent.ts and cli.ts, which drifted
5
+ * (one carried keys the other did not). One table, one place.
6
+ */
7
+ export const c = {
8
+ reset: '\x1b[0m',
9
+ bold: '\x1b[1m',
10
+ dim: '\x1b[2m',
11
+ cyan: '\x1b[36m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ red: '\x1b[31m',
15
+ white: '\x1b[97m',
16
+ gray: '\x1b[90m',
17
+ } as const;
18
+
19
+ const ANSI = /\x1b\[[0-9;]*m/g;
20
+
21
+ export function stripAnsi(value: string): string {
22
+ return value.replace(ANSI, '');
23
+ }
24
+
25
+ /** Printable width, ignoring colour escapes. Needed to pad boxed layouts. */
26
+ export function visibleLength(value: string): number {
27
+ return stripAnsi(value).length;
28
+ }
29
+
30
+ export function clearScreen(): void {
31
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
32
+ }
33
+
34
+ export const sleep = (ms: number): Promise<void> =>
35
+ new Promise((resolve) => setTimeout(resolve, ms));
36
+
37
+ /** Status glyphs. Kept together so "on/off/warn" reads the same everywhere. */
38
+ export const glyph = {
39
+ on: `${c.green}●${c.reset}`,
40
+ off: `${c.gray}○${c.reset}`,
41
+ warn: `${c.yellow}!${c.reset}`,
42
+ bad: `${c.red}✗${c.reset}`,
43
+ mark: `${c.cyan}◆${c.reset}`,
44
+ } as const;
45
+
46
+ /** ` label value` — the two-column layout shared by status and summaries. */
47
+ export function field(label: string, value: string, width = 14): void {
48
+ console.log(` ${c.dim}${label.padEnd(width)}${c.reset}${value}`);
49
+ }
50
+
51
+ export function blankLine(): void {
52
+ console.log('');
53
+ }