@agent-link/agent 0.1.287 → 0.1.289

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/service.js CHANGED
@@ -1,20 +1,55 @@
1
- import { execSync } from 'child_process';
2
- import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
3
- import { join, dirname, resolve } from 'path';
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'fs';
3
+ import { dirname, join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { fileURLToPath } from 'url';
6
- import { CONFIG_DIR, getLogDir, loadRuntimeState, clearRuntimeState, killProcess, isProcessAlive, } from './config.js';
7
- import { getCachedPathEnvVars, warmExecPaths } from './sdk.js';
6
+ import { CONFIG_DIR, clearRuntimeState, getLogDir, isProcessAlive, killProcess, loadRuntimeState, saveConfig, saveRuntimeState, } from './config.js';
7
+ import { computeLaunchSpecDigest, createLaunchSpec, createServiceLaunchSpec, getServiceLaunchSpecPath, saveLaunchSpec, saveLaunchSpecSnapshot, loadLaunchSpec, } from './launch-spec.js';
8
+ import { getCachedPathEnvVars, getResolvedExecPaths, warmExecPaths } from './sdk.js';
8
9
  const SERVICE_NAME = 'agentlink';
9
10
  const LAUNCHD_LABEL = 'com.agentlink.agent';
10
- export function getServiceEnvironment() {
11
+ function getCliPath() {
12
+ return resolve(dirname(fileURLToPath(import.meta.url)), 'cli.js');
13
+ }
14
+ function getServiceRoot() {
15
+ return process.env.AGENTLINK_SERVICE_ROOT
16
+ ? resolve(process.env.AGENTLINK_SERVICE_ROOT)
17
+ : null;
18
+ }
19
+ function isSimulation() {
20
+ return getServiceRoot() !== null;
21
+ }
22
+ export function getServiceArtifactPath(platform = process.platform) {
23
+ const root = getServiceRoot();
24
+ if (root) {
25
+ if (platform === 'linux')
26
+ return join(root, `${SERVICE_NAME}.service`);
27
+ if (platform === 'darwin')
28
+ return join(root, `${LAUNCHD_LABEL}.plist`);
29
+ if (platform === 'win32')
30
+ return join(root, `${SERVICE_NAME}.bat`);
31
+ }
32
+ if (platform === 'linux') {
33
+ return join(homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
34
+ }
35
+ if (platform === 'darwin') {
36
+ return join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
37
+ }
38
+ if (platform === 'win32') {
39
+ const startupDir = join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
40
+ return join(startupDir, `${SERVICE_NAME}.bat`);
41
+ }
42
+ throw new Error(`Unsupported platform: ${platform}`);
43
+ }
44
+ export function getServiceEnvironment(hostType) {
11
45
  return {
12
46
  AGENTLINK_SERVICE: '1',
13
47
  AGENTLINK_HOME: CONFIG_DIR,
14
48
  ...getCachedPathEnvVars(),
49
+ ...(hostType ? { AGENTLINK_HOST_TYPE: hostType } : {}),
15
50
  };
16
51
  }
17
- function escapeSystemdEnvironment(value) {
52
+ function escapeSystemd(value) {
18
53
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
19
54
  }
20
55
  function escapeXml(value) {
@@ -25,154 +60,54 @@ function escapeXml(value) {
25
60
  .replace(/"/g, '"')
26
61
  .replace(/'/g, ''');
27
62
  }
28
- function getCliPath() {
29
- const __filename = fileURLToPath(import.meta.url);
30
- return resolve(dirname(__filename), 'cli.js');
31
- }
32
- function getNodePath() {
33
- return process.execPath;
63
+ function quoteSystemd(value) {
64
+ return `"${escapeSystemd(value)}"`;
34
65
  }
35
- /** Build extra CLI args string for password (used in systemd/bat templates). */
36
- function buildExtraArgs(config) {
37
- let args = '';
38
- if (config.password)
39
- args += ` --password "${config.password}"`;
40
- if (config.msentra)
41
- args += ' --msentra';
42
- else if (config.entra)
43
- args += ' --entra';
44
- return args;
45
- }
46
- /** Build extra plist <string> entries for password (used in launchd template). */
47
- function buildPlistExtraArgs(config) {
48
- let entries = '';
49
- if (config.password) {
50
- entries += ` <string>--password</string>\n <string>${config.password}</string>\n`;
51
- }
52
- if (config.msentra) {
53
- entries += ` <string>--msentra</string>\n`;
54
- }
55
- else if (config.entra) {
56
- entries += ` <string>--entra</string>\n`;
66
+ function renderEnvironment(environment, format) {
67
+ if (format === 'systemd') {
68
+ return Object.entries(environment)
69
+ .map(([name, value]) => `Environment="${name}=${escapeSystemd(value)}"`)
70
+ .join('\n');
57
71
  }
58
- return entries;
59
- }
60
- // ── Linux (systemd) ──
61
- function isRoot() {
62
- return process.getuid?.() === 0;
63
- }
64
- function getSystemdUnitPath() {
65
- if (isRoot()) {
66
- return join('/etc/systemd/system', `${SERVICE_NAME}.service`);
72
+ if (format === 'xml') {
73
+ return Object.entries(environment)
74
+ .map(([name, value]) => ` <key>${escapeXml(name)}</key>\n <string>${escapeXml(value)}</string>`)
75
+ .join('\n');
67
76
  }
68
- return join(homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
69
- }
70
- function systemctl(...args) {
71
- const cmd = isRoot()
72
- ? `systemctl ${args.join(' ')}`
73
- : `systemctl --user ${args.join(' ')}`;
74
- execSync(cmd);
77
+ return Object.entries(environment)
78
+ .map(([name, value]) => `set "${name}=${value.replace(/%/g, '%%')}"`)
79
+ .join('\r\n');
75
80
  }
76
- function generateSystemdUnit(config) {
77
- const nodePath = getNodePath();
81
+ export function renderServiceArtifact(spec, specPath, platform = process.platform) {
82
+ const nodePath = process.execPath;
78
83
  const cliPath = getCliPath();
79
84
  const logDir = getLogDir();
80
- const nodeBinDir = dirname(nodePath);
81
- const extraArgs = buildExtraArgs(config);
82
- const userSection = isRoot()
83
- ? ''
84
- : `Environment=PATH=${nodeBinDir}:${homedir()}/.local/bin:${homedir()}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin\n`;
85
- const environment = {
86
- ...getServiceEnvironment(),
87
- AGENTLINK_HOST_TYPE: 'systemd',
88
- };
89
- const environmentSection = Object.entries(environment)
90
- .map(([name, value]) => `Environment="${name}=${escapeSystemdEnvironment(value)}"`)
91
- .join('\n');
92
- return `[Unit]
85
+ const environment = getServiceEnvironment(spec.host.type);
86
+ if (platform === 'linux') {
87
+ return `[Unit]
93
88
  Description=AgentLink Agent
94
89
  After=network-online.target
95
90
  Wants=network-online.target
96
91
 
97
92
  [Service]
98
93
  Type=simple
99
- ExecStart=${nodePath} ${cliPath} start --server "${config.server}" --dir "${config.dir}" --name "${config.name}"${extraArgs}
100
- WorkingDirectory=${config.dir}
94
+ ExecStart=${quoteSystemd(nodePath)} ${quoteSystemd(cliPath)} start --launch-spec ${quoteSystemd(specPath)}
95
+ WorkingDirectory=${quoteSystemd(spec.config.dir)}
101
96
  Restart=on-failure
102
97
  RestartSec=10
103
- ${environmentSection}
104
- ${userSection}StandardOutput=append:${logDir}/agent.log
98
+ ${renderEnvironment(environment, 'systemd')}
99
+ StandardOutput=append:${logDir}/agent.log
105
100
  StandardError=append:${logDir}/agent.err
106
101
 
107
102
  [Install]
108
- WantedBy=${isRoot() ? 'multi-user.target' : 'default.target'}
103
+ WantedBy=default.target
109
104
  `;
110
- }
111
- function linuxInstall(config) {
112
- const unitPath = getSystemdUnitPath();
113
- mkdirSync(dirname(unitPath), { recursive: true });
114
- mkdirSync(getLogDir(), { recursive: true });
115
- writeFileSync(unitPath, generateSystemdUnit(config));
116
- systemctl('daemon-reload');
117
- systemctl('enable', SERVICE_NAME);
118
- // Only start via systemd if no agent is already running
119
- const state = loadRuntimeState();
120
- if (state?.pid && isProcessAlive(state.pid)) {
121
- console.log(`Agent is already running (PID ${state.pid}). Skipping start.`);
122
- console.log(`Service will manage the agent on next boot.`);
123
105
  }
124
- else {
125
- systemctl('start', SERVICE_NAME);
126
- }
127
- const scope = isRoot() ? 'system' : 'user';
128
- const ctl = isRoot() ? 'systemctl' : 'systemctl --user';
129
- const jctl = isRoot() ? 'journalctl' : 'journalctl --user';
130
- console.log(`Service installed and started (${scope}-level).`);
131
- console.log(`\nUnit file: ${unitPath}`);
132
- console.log('\nUseful commands:');
133
- console.log(` ${ctl} status ${SERVICE_NAME}`);
134
- console.log(` ${jctl} -u ${SERVICE_NAME} -f`);
135
- if (!isRoot()) {
136
- console.log('\nTo run when not logged in:');
137
- console.log(' sudo loginctl enable-linger $(whoami)');
138
- }
139
- }
140
- function linuxUninstall() {
141
- try {
142
- systemctl('stop', `${SERVICE_NAME} 2>/dev/null`);
143
- }
144
- catch { }
145
- try {
146
- systemctl('disable', `${SERVICE_NAME} 2>/dev/null`);
147
- }
148
- catch { }
149
- const unitPath = getSystemdUnitPath();
150
- if (existsSync(unitPath)) {
151
- unlinkSync(unitPath);
152
- }
153
- try {
154
- systemctl('daemon-reload');
155
- }
156
- catch { }
157
- console.log('Service uninstalled.');
158
- }
159
- // ── macOS (launchd plist) ──
160
- function getLaunchdPlistPath() {
161
- return join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
162
- }
163
- function generateLaunchdPlist(config) {
164
- const nodePath = getNodePath();
165
- const cliPath = getCliPath();
166
- const logDir = getLogDir();
167
- const extraEntries = buildPlistExtraArgs(config);
168
- const environment = {
169
- ...getServiceEnvironment(),
170
- AGENTLINK_HOST_TYPE: 'launchd',
171
- };
172
- const environmentEntries = Object.entries(environment)
173
- .map(([name, value]) => ` <key>${name}</key>\n <string>${escapeXml(value)}</string>`)
174
- .join('\n');
175
- return `<?xml version="1.0" encoding="UTF-8"?>
106
+ if (platform === 'darwin') {
107
+ const args = [nodePath, cliPath, 'start', '--launch-spec', specPath]
108
+ .map(value => ` <string>${escapeXml(value)}</string>`)
109
+ .join('\n');
110
+ return `<?xml version="1.0" encoding="UTF-8"?>
176
111
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
177
112
  <plist version="1.0">
178
113
  <dict>
@@ -180,21 +115,13 @@ function generateLaunchdPlist(config) {
180
115
  <string>${LAUNCHD_LABEL}</string>
181
116
  <key>ProgramArguments</key>
182
117
  <array>
183
- <string>${nodePath}</string>
184
- <string>${cliPath}</string>
185
- <string>start</string>
186
- <string>--server</string>
187
- <string>${config.server}</string>
188
- <string>--dir</string>
189
- <string>${config.dir}</string>
190
- <string>--name</string>
191
- <string>${config.name}</string>
192
- ${extraEntries} </array>
118
+ ${args}
119
+ </array>
193
120
  <key>WorkingDirectory</key>
194
- <string>${config.dir}</string>
121
+ <string>${escapeXml(spec.config.dir)}</string>
195
122
  <key>EnvironmentVariables</key>
196
123
  <dict>
197
- ${environmentEntries}
124
+ ${renderEnvironment(environment, 'xml')}
198
125
  </dict>
199
126
  <key>RunAtLoad</key>
200
127
  <true/>
@@ -206,151 +133,324 @@ ${environmentEntries}
206
133
  <key>ThrottleInterval</key>
207
134
  <integer>10</integer>
208
135
  <key>StandardOutPath</key>
209
- <string>${logDir}/agent.log</string>
136
+ <string>${escapeXml(join(logDir, 'agent.log'))}</string>
210
137
  <key>StandardErrorPath</key>
211
- <string>${logDir}/agent.err</string>
138
+ <string>${escapeXml(join(logDir, 'agent.err'))}</string>
212
139
  </dict>
213
140
  </plist>
214
141
  `;
142
+ }
143
+ if (platform === 'win32') {
144
+ return `@echo off\r\n${renderEnvironment(environment, 'bat')}\r\n"${nodePath}" "${cliPath}" start --launch-spec "${specPath}"\r\n`;
145
+ }
146
+ throw new Error(`Unsupported platform: ${platform}`);
215
147
  }
216
- function macInstall(config) {
217
- const plistPath = getLaunchdPlistPath();
218
- mkdirSync(dirname(plistPath), { recursive: true });
219
- mkdirSync(getLogDir(), { recursive: true });
220
- if (existsSync(plistPath)) {
221
- try {
222
- execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
148
+ function writeTextAtomic(filePath, content) {
149
+ const previous = existsSync(filePath) ? readFileSync(filePath, 'utf8') : null;
150
+ if (previous === content)
151
+ return false;
152
+ mkdirSync(dirname(filePath), { recursive: true });
153
+ const tempPath = `${filePath}.${process.pid}.tmp`;
154
+ writeFileSync(tempPath, content, { encoding: 'utf8', mode: 0o600 });
155
+ try {
156
+ renameSync(tempPath, filePath);
157
+ }
158
+ catch (error) {
159
+ rmSync(tempPath, { force: true });
160
+ throw error;
161
+ }
162
+ return true;
163
+ }
164
+ function runSystemctl(args) {
165
+ execFileSync('systemctl', ['--user', ...args], { stdio: 'ignore' });
166
+ }
167
+ function getLaunchdTarget() {
168
+ const uid = process.getuid?.();
169
+ if (uid === undefined)
170
+ throw new Error('launchd user id is unavailable');
171
+ return `gui/${uid}/${LAUNCHD_LABEL}`;
172
+ }
173
+ function getLegacySystemdUnitPath() {
174
+ return process.env.AGENTLINK_LEGACY_SYSTEMD_UNIT
175
+ ? resolve(process.env.AGENTLINK_LEGACY_SYSTEMD_UNIT)
176
+ : join('/etc', 'systemd', 'system', `${SERVICE_NAME}.service`);
177
+ }
178
+ function removeLegacySystemdService(stopAgent) {
179
+ const legacyPath = getLegacySystemdUnitPath();
180
+ if (!existsSync(legacyPath))
181
+ return;
182
+ try {
183
+ if (stopAgent) {
184
+ execFileSync('systemctl', ['stop', SERVICE_NAME], { stdio: 'ignore' });
223
185
  }
224
- catch { }
186
+ execFileSync('systemctl', ['disable', SERVICE_NAME], { stdio: 'ignore' });
187
+ unlinkSync(legacyPath);
188
+ execFileSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
225
189
  }
226
- writeFileSync(plistPath, generateLaunchdPlist(config));
227
- // Only load/start via launchd if no agent is already running
228
- const state = loadRuntimeState();
229
- if (state?.pid && isProcessAlive(state.pid)) {
230
- console.log('Service installed. Agent is already running — skipping start.');
231
- console.log(`Service will manage the agent on next boot.`);
190
+ catch {
191
+ throw new Error(`Cannot disable legacy system-level service at ${legacyPath}; `
192
+ + 'run the service uninstall command with administrator privileges');
232
193
  }
233
- else {
234
- execSync(`launchctl load "${plistPath}"`);
235
- console.log('Service installed and started.');
194
+ }
195
+ function startFromServiceLaunchSpec(specPath) {
196
+ execFileSync(process.execPath, [getCliPath(), 'start', '--launch-spec', specPath], {
197
+ stdio: 'inherit',
198
+ windowsHide: true,
199
+ });
200
+ }
201
+ function preserveRuntimeServiceSpecBeforeReplace(nextSpec) {
202
+ const state = loadRuntimeState();
203
+ const serviceSpecPath = getServiceLaunchSpecPath();
204
+ if (!state?.launchSpecPath
205
+ || resolve(state.launchSpecPath) !== resolve(serviceSpecPath)
206
+ || !existsSync(serviceSpecPath)) {
207
+ return;
236
208
  }
237
- console.log(`\nPlist file: ${plistPath}`);
238
- console.log('\nUseful commands:');
239
- console.log(` launchctl list | grep ${LAUNCHD_LABEL}`);
209
+ const currentSpec = loadLaunchSpec(serviceSpecPath);
210
+ if (JSON.stringify(currentSpec) === JSON.stringify(nextSpec))
211
+ return;
212
+ const launchSpecPath = saveLaunchSpecSnapshot(currentSpec);
213
+ saveRuntimeState({
214
+ ...state,
215
+ launchSpecPath,
216
+ launchSpecDigest: computeLaunchSpecDigest(currentSpec),
217
+ });
240
218
  }
241
- function macUninstall() {
242
- const plistPath = getLaunchdPlistPath();
243
- if (existsSync(plistPath)) {
244
- try {
245
- execSync(`launchctl unload "${plistPath}"`);
219
+ export function installServiceLaunchSpec(source, options = {}) {
220
+ const platform = options.platform ?? process.platform;
221
+ if (platform === 'linux' && !isSimulation() && existsSync(getLegacySystemdUnitPath())) {
222
+ throw new Error(`Legacy system-level service detected at ${getLegacySystemdUnitPath()}; `
223
+ + 'remove it explicitly before enabling user-level auto-start');
224
+ }
225
+ const specPath = getServiceLaunchSpecPath();
226
+ const spec = createServiceLaunchSpec(source, platform);
227
+ const artifactPath = getServiceArtifactPath(platform);
228
+ const previousContent = existsSync(artifactPath) ? readFileSync(artifactPath, 'utf8') : null;
229
+ const existed = previousContent !== null;
230
+ const content = renderServiceArtifact(spec, specPath, platform);
231
+ preserveRuntimeServiceSpecBeforeReplace(spec);
232
+ const changed = writeTextAtomic(artifactPath, content);
233
+ // Write the referenced LaunchSpec only after the host entry is safely staged.
234
+ // If this fails, the host entry still resolves to the previous valid spec.
235
+ saveLaunchSpec(spec, specPath);
236
+ const state = loadRuntimeState();
237
+ const running = !!state?.pid && isProcessAlive(state.pid);
238
+ let pendingReload = platform === 'darwin'
239
+ && running
240
+ && existed
241
+ && (changed || !previousContent.includes('--launch-spec'));
242
+ if (!isSimulation()) {
243
+ if (platform === 'linux') {
244
+ try {
245
+ runSystemctl(['unmask', SERVICE_NAME]);
246
+ }
247
+ catch { /* not masked */ }
248
+ runSystemctl(['daemon-reload']);
249
+ runSystemctl(['enable', SERVICE_NAME]);
250
+ if (options.startIfStopped !== false && !running)
251
+ runSystemctl(['start', SERVICE_NAME]);
252
+ }
253
+ else if (platform === 'darwin') {
254
+ execFileSync('launchctl', ['enable', getLaunchdTarget()], { stdio: 'ignore' });
255
+ if (!running) {
256
+ if (existed) {
257
+ try {
258
+ execFileSync('launchctl', ['unload', artifactPath], { stdio: 'ignore' });
259
+ }
260
+ catch { /* stale/unloaded plist */ }
261
+ }
262
+ if (options.startIfStopped !== false) {
263
+ execFileSync('launchctl', ['load', artifactPath], { stdio: 'ignore' });
264
+ }
265
+ }
266
+ else if (pendingReload) {
267
+ // launchd keeps the loaded job definition independently from the file.
268
+ // Replacing it live would terminate this agent before it could reload
269
+ // the job, so defer the one-time legacy migration to the next login.
270
+ }
271
+ }
272
+ else if (platform === 'win32') {
273
+ if (options.startIfStopped !== false && !running)
274
+ startFromServiceLaunchSpec(specPath);
275
+ }
276
+ else {
277
+ throw new Error(`Unsupported platform: ${platform}`);
246
278
  }
247
- catch { }
248
- unlinkSync(plistPath);
249
279
  }
250
- console.log('Service uninstalled.');
280
+ if (options.persistPolicy !== false)
281
+ saveConfig({ autoStart: true });
282
+ return { artifactPath, specPath, changed, spec, pendingReload };
251
283
  }
252
- // ── Windows (Startup folder .bat) ──
253
- function getStartupBatPath() {
254
- const startupDir = join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
255
- return join(startupDir, `${SERVICE_NAME}.bat`);
284
+ export function isServiceInstalled(platform = process.platform) {
285
+ try {
286
+ if (platform === 'linux' && !isSimulation() && existsSync(getLegacySystemdUnitPath())) {
287
+ return true;
288
+ }
289
+ return existsSync(getServiceArtifactPath(platform));
290
+ }
291
+ catch {
292
+ return false;
293
+ }
256
294
  }
257
- function generateStartupBat(config) {
258
- const nodePath = getNodePath();
259
- const cliPath = getCliPath();
260
- const extraArgs = buildExtraArgs(config);
261
- const environment = {
262
- ...getServiceEnvironment(),
263
- AGENTLINK_HOST_TYPE: 'windows-startup',
264
- };
265
- const environmentLines = Object.entries(environment)
266
- .map(([name, value]) => `set "${name}=${value.replace(/%/g, '%%')}"`)
267
- .join('\r\n');
268
- return `@echo off\r\n${environmentLines}\r\n"${nodePath}" "${cliPath}" start --daemon --server "${config.server}" --dir "${config.dir}" --name "${config.name}"${extraArgs}\r\n`;
295
+ export function serviceInstall(config) {
296
+ warmExecPaths();
297
+ const source = createLaunchSpec(config, {
298
+ mode: 'daemon',
299
+ hostType: 'standalone',
300
+ resolvedExecPaths: getResolvedExecPaths(),
301
+ });
302
+ const result = installServiceLaunchSpec(source, {
303
+ startIfStopped: true,
304
+ persistPolicy: true,
305
+ });
306
+ console.log('AgentLink auto-start installed.');
307
+ console.log(` LaunchSpec: ${result.specPath}`);
308
+ console.log(` Host entry: ${result.artifactPath}`);
269
309
  }
270
- function winInstall(config) {
271
- mkdirSync(getLogDir(), { recursive: true });
272
- const batPath = getStartupBatPath();
273
- writeFileSync(batPath, generateStartupBat(config));
274
- console.log('Startup script installed.');
275
- console.log(`\nStartup file: ${batPath}`);
276
- // Only start the agent if one isn't already running
310
+ export function serviceUninstall(options = {}) {
311
+ const platform = options.platform ?? process.platform;
312
+ const artifactPath = getServiceArtifactPath(platform);
313
+ const stopAgent = options.stopAgent !== false;
277
314
  const state = loadRuntimeState();
278
- if (state?.pid && isProcessAlive(state.pid)) {
279
- console.log(`\nAgent is already running (PID ${state.pid}). Skipping start.`);
280
- console.log(` URL: ${state.sessionUrl}`);
315
+ const running = !!state?.pid && isProcessAlive(state.pid);
316
+ const serviceSpecPath = getServiceLaunchSpecPath();
317
+ const runtimeUsesServiceSpec = !!state?.launchSpecPath
318
+ && resolve(state.launchSpecPath) === resolve(serviceSpecPath)
319
+ && existsSync(serviceSpecPath);
320
+ let pendingReload = platform === 'darwin'
321
+ && !stopAgent
322
+ && running
323
+ && runtimeUsesServiceSpec
324
+ && existsSync(artifactPath);
325
+ const preserveRunningHostEntry = !stopAgent
326
+ && running
327
+ && runtimeUsesServiceSpec
328
+ && (platform === 'linux' || platform === 'darwin');
329
+ if (!isSimulation()) {
330
+ if (platform === 'linux') {
331
+ removeLegacySystemdService(stopAgent);
332
+ if (stopAgent) {
333
+ try {
334
+ runSystemctl(['stop', SERVICE_NAME]);
335
+ }
336
+ catch { /* not active */ }
337
+ }
338
+ if (!stopAgent && running) {
339
+ // The unit file is intentionally retained for the current process and
340
+ // future upgrade diagnostics, so both controls must succeed before the
341
+ // opt-out can be recorded.
342
+ runSystemctl(['disable', SERVICE_NAME]);
343
+ runSystemctl(['mask', '--runtime', SERVICE_NAME]);
344
+ }
345
+ else {
346
+ try {
347
+ runSystemctl(['disable', SERVICE_NAME]);
348
+ }
349
+ catch { /* not enabled */ }
350
+ }
351
+ }
352
+ else if (platform === 'darwin') {
353
+ if (stopAgent && existsSync(artifactPath)) {
354
+ try {
355
+ execFileSync('launchctl', ['unload', artifactPath], { stdio: 'ignore' });
356
+ }
357
+ catch { /* not loaded */ }
358
+ }
359
+ else if (pendingReload) {
360
+ // The loaded job may continue until logout; removing the plist prevents
361
+ // it from loading in the next login session without killing this agent.
362
+ }
363
+ if (!stopAgent && running) {
364
+ execFileSync('launchctl', ['disable', getLaunchdTarget()], { stdio: 'ignore' });
365
+ }
366
+ }
367
+ else if (platform !== 'win32') {
368
+ throw new Error(`Unsupported platform: ${platform}`);
369
+ }
281
370
  }
282
- else {
283
- console.log('\nStarting agent now...');
371
+ if (runtimeUsesServiceSpec) {
372
+ const runningSpec = loadLaunchSpec(serviceSpecPath);
373
+ const standaloneSpec = {
374
+ ...runningSpec,
375
+ mode: 'daemon',
376
+ host: { type: 'standalone' },
377
+ config: { ...runningSpec.config },
378
+ runtime: {
379
+ ...runningSpec.runtime,
380
+ nodeArgs: [...runningSpec.runtime.nodeArgs],
381
+ resolvedExecPaths: { ...runningSpec.runtime.resolvedExecPaths },
382
+ },
383
+ };
384
+ const launchSpecPath = saveLaunchSpecSnapshot(standaloneSpec);
385
+ saveLaunchSpec(standaloneSpec);
386
+ saveRuntimeState({
387
+ ...state,
388
+ launchSpecPath,
389
+ launchSpecDigest: computeLaunchSpecDigest(standaloneSpec),
390
+ });
391
+ }
392
+ if (!preserveRunningHostEntry) {
393
+ if (existsSync(artifactPath))
394
+ unlinkSync(artifactPath);
395
+ if (existsSync(serviceSpecPath))
396
+ unlinkSync(serviceSpecPath);
397
+ }
398
+ if (!isSimulation() && platform === 'linux') {
284
399
  try {
285
- execSync(`"${getNodePath()}" "${getCliPath()}" start --daemon --server "${config.server}" --dir "${config.dir}" --name "${config.name}"${buildExtraArgs(config)}`, { stdio: 'inherit', windowsHide: true });
400
+ runSystemctl(['daemon-reload']);
286
401
  }
287
- catch {
288
- console.error('Failed to start agent. Check logs in ~/.agentlink/logs/');
402
+ catch { /* unit already gone */ }
403
+ }
404
+ if (stopAgent) {
405
+ if (state?.pid && isProcessAlive(state.pid)) {
406
+ killProcess(state.pid);
407
+ clearRuntimeState();
289
408
  }
290
409
  }
291
- }
292
- function winUninstall() {
293
- const batPath = getStartupBatPath();
294
- if (existsSync(batPath)) {
295
- unlinkSync(batPath);
296
- console.log(`Removed startup file: ${batPath}`);
410
+ if (options.persistPolicy !== false) {
411
+ saveConfig({ autoStart: false, autoStartPendingReload: pendingReload || undefined });
297
412
  }
298
- // Stop running agent if any
299
- const state = loadRuntimeState();
300
- if (state?.pid && isProcessAlive(state.pid)) {
301
- console.log(`Stopping agent (PID ${state.pid})...`);
302
- killProcess(state.pid);
303
- clearRuntimeState();
304
- }
305
- console.log('Service uninstalled.');
413
+ console.log(pendingReload
414
+ ? 'AgentLink auto-start disabled for the next login.'
415
+ : 'AgentLink auto-start uninstalled.');
416
+ return { pendingReload };
306
417
  }
307
- // ── Platform dispatch ──
308
- /** Check whether the auto-start service is already installed on this platform. */
309
- export function isServiceInstalled() {
310
- const p = process.platform;
311
- try {
312
- if (p === 'linux')
313
- return existsSync(getSystemdUnitPath());
314
- if (p === 'darwin')
315
- return existsSync(getLaunchdPlistPath());
316
- if (p === 'win32')
317
- return existsSync(getStartupBatPath());
418
+ export function stopServiceForUpgrade(spec) {
419
+ if (isSimulation())
420
+ return spec.host.type === 'systemd' || spec.host.type === 'launchd';
421
+ if (spec.host.type === 'systemd') {
422
+ if (existsSync(getLegacySystemdUnitPath())) {
423
+ throw new Error(`Cannot upgrade legacy system-level service at ${getLegacySystemdUnitPath()}; `
424
+ + 'migrate it to user-level auto-start first');
425
+ }
426
+ runSystemctl(['stop', SERVICE_NAME]);
427
+ return true;
428
+ }
429
+ if (spec.host.type === 'launchd') {
430
+ const artifactPath = getServiceArtifactPath('darwin');
431
+ if (!existsSync(artifactPath)) {
432
+ throw new Error(`launchd plist not found: ${artifactPath}`);
433
+ }
434
+ execFileSync('launchctl', ['unload', artifactPath], { stdio: 'ignore' });
435
+ return true;
318
436
  }
319
- catch { /* swallow */ }
320
437
  return false;
321
438
  }
322
- export function serviceInstall(config) {
323
- const p = process.platform;
324
- warmExecPaths();
325
- console.log('Installing AgentLink service...');
326
- console.log(` Server: ${config.server}`);
327
- console.log(` Directory: ${config.dir}`);
328
- console.log(` Name: ${config.name}`);
329
- console.log('');
330
- if (p === 'linux')
331
- linuxInstall(config);
332
- else if (p === 'darwin')
333
- macInstall(config);
334
- else if (p === 'win32')
335
- winInstall(config);
336
- else {
337
- console.error(`Unsupported platform: ${p}`);
338
- console.error('Run the agent directly: agentlink-client start --daemon');
339
- process.exit(1);
439
+ export function restartServiceAfterUpgrade(spec) {
440
+ if (isSimulation())
441
+ return spec.host.type === 'systemd' || spec.host.type === 'launchd';
442
+ if (spec.host.type === 'systemd') {
443
+ if (existsSync(getLegacySystemdUnitPath())) {
444
+ throw new Error(`Cannot restart legacy system-level service at ${getLegacySystemdUnitPath()}; `
445
+ + 'migrate it to user-level auto-start first');
446
+ }
447
+ runSystemctl(['start', SERVICE_NAME]);
448
+ return true;
340
449
  }
341
- }
342
- export function serviceUninstall() {
343
- const p = process.platform;
344
- console.log('Uninstalling AgentLink service...');
345
- if (p === 'linux')
346
- linuxUninstall();
347
- else if (p === 'darwin')
348
- macUninstall();
349
- else if (p === 'win32')
350
- winUninstall();
351
- else {
352
- console.error(`Unsupported platform: ${p}`);
353
- process.exit(1);
450
+ if (spec.host.type === 'launchd') {
451
+ execFileSync('launchctl', ['load', getServiceArtifactPath('darwin')], { stdio: 'ignore' });
452
+ return true;
354
453
  }
454
+ return false;
355
455
  }
356
456
  //# sourceMappingURL=service.js.map