@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.
- package/dist/agent-cli.js +1191 -887
- package/package.json +1 -1
- package/src/agent/agent.ts +53 -29
- package/src/agent/banner.ts +82 -0
- package/src/agent/cli-help.test.ts +79 -15
- package/src/agent/cli-reauth.test.ts +205 -0
- package/src/agent/cli.ts +399 -661
- package/src/agent/config.ts +19 -0
- package/src/agent/credential-probe.ts +95 -0
- package/src/agent/credential-store.ts +74 -0
- package/src/agent/device-auth.test.ts +132 -0
- package/src/agent/device-auth.ts +213 -0
- package/src/agent/log-format.ts +24 -0
- package/src/agent/prompts.ts +37 -0
- package/src/agent/service-control.ts +69 -0
- package/src/agent/service-drivers.ts +299 -0
- package/src/agent/service-lifecycle.test.ts +248 -0
- package/src/agent/service-paths.ts +80 -0
- package/src/agent/service-quoting.ts +16 -0
- package/src/agent/service.test.ts +52 -2
- package/src/agent/service.ts +132 -356
- package/src/agent/terminal.ts +53 -0
- package/src/agent/version.ts +52 -0
|
@@ -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
|
|
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
|
-
|
|
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');
|
package/src/agent/service.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
import { existsSync, mkdirSync,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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():
|
|
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:
|
|
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
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
const
|
|
155
|
-
const
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
if (
|
|
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:
|
|
374
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
}
|