@ai-support-agent/cli 0.1.8-beta.0 → 0.1.9-beta.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.
@@ -33,22 +33,186 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.LinuxServiceStrategy = void 0;
36
+ exports.LinuxServiceStrategy = exports.getNodePath = exports.getCliEntryPoint = void 0;
37
+ exports.assertProjectCodeIsSafe = assertProjectCodeIsSafe;
38
+ exports.validateProjectDirForMount = validateProjectDirForMount;
39
+ exports.getProjectUnitName = getProjectUnitName;
40
+ exports.getProjectUnitFilePath = getProjectUnitFilePath;
41
+ exports.getAllProjectUnits = getAllProjectUnits;
37
42
  exports.generateServiceUnit = generateServiceUnit;
43
+ exports.generateProjectServiceUnit = generateProjectServiceUnit;
44
+ exports.generateWrapperScript = generateWrapperScript;
45
+ exports.generateUpdateScript = generateUpdateScript;
46
+ exports.writeProjectServiceFiles = writeProjectServiceFiles;
47
+ exports.installAndStartProject = installAndStartProject;
38
48
  const child_process_1 = require("child_process");
39
49
  const fs = __importStar(require("fs"));
40
50
  const os = __importStar(require("os"));
41
51
  const path = __importStar(require("path"));
52
+ const config_manager_1 = require("../../config-manager");
53
+ const docker_utils_1 = require("../../docker/docker-utils");
42
54
  const i18n_1 = require("../../i18n");
43
55
  const logger_1 = require("../../logger");
56
+ const security_1 = require("../../security");
44
57
  const node_paths_1 = require("./node-paths");
58
+ Object.defineProperty(exports, "getCliEntryPoint", { enumerable: true, get: function () { return node_paths_1.getCliEntryPoint; } });
59
+ Object.defineProperty(exports, "getNodePath", { enumerable: true, get: function () { return node_paths_1.getNodePath; } });
45
60
  const SERVICE_NAME = 'ai-support-agent.service';
46
- function getServiceFilePath() {
47
- return path.join(os.homedir(), '.config', 'systemd', 'user', SERVICE_NAME);
61
+ const SERVICE_PREFIX = 'ai-support-agent';
62
+ function getSystemdUserDir() {
63
+ return path.join(os.homedir(), '.config', 'systemd', 'user');
48
64
  }
49
65
  function getLogDir() {
50
66
  return path.join(os.homedir(), '.local', 'share', 'ai-support-agent', 'logs');
51
67
  }
68
+ // ---------------------------------------------------------------------------
69
+ // Per-project unit helpers
70
+ // ---------------------------------------------------------------------------
71
+ function sanitize(s) {
72
+ return s.toLowerCase().replace(/[^a-z0-9-]/g, '-');
73
+ }
74
+ /**
75
+ * POSIX shell single-quote a value so it can be safely interpolated into a
76
+ * bash script. Wraps the value in single quotes and escapes any embedded
77
+ * single quote as `'\''`. The result is always exactly one shell argument.
78
+ */
79
+ function shellQuote(value) {
80
+ return `'${value.replace(/'/g, `'\\''`)}'`;
81
+ }
82
+ /**
83
+ * Write a file with content and enforce mode. `fs.writeFileSync({ mode })`
84
+ * only applies the mode when the file is *created*; overwriting an existing
85
+ * file inherits the prior permissions, so a previously world-readable
86
+ * wrapper script (e.g. left by an older install or an out-of-band edit)
87
+ * would retain its lax mode despite containing bearer tokens. Explicit
88
+ * chmod after write guarantees the desired permissions either way.
89
+ */
90
+ function writeFileEnsuringMode(filePath, content, mode) {
91
+ fs.writeFileSync(filePath, content, { mode });
92
+ fs.chmodSync(filePath, mode);
93
+ }
94
+ /**
95
+ * Reject projectCodes whose characters would break
96
+ * `AI_SUPPORT_AGENT_PROJECT_DIR_MAP` parsing.
97
+ *
98
+ * The env value uses `;` as entry separator and `=` as key/value separator;
99
+ * a projectCode containing either would silently truncate the map and let
100
+ * `resolveProjectDir()` fall back to the default template, silently
101
+ * re-introducing the doubly-nested layout this PR is trying to prevent.
102
+ * Allow `[A-Za-z0-9_-]` only — matching the configured naming convention
103
+ * (UPPER_SNAKE_CASE for project, lower_snake_case for tenant).
104
+ */
105
+ function assertProjectCodeIsSafe(projectCode) {
106
+ if (!/^[A-Za-z0-9_-]+$/.test(projectCode)) {
107
+ throw new Error((0, i18n_1.t)('service.invalidProjectCode', { projectCode }));
108
+ }
109
+ }
110
+ /**
111
+ * Validate a user-supplied project.projectDir for use as a bind mount in
112
+ * the generated wrapper script.
113
+ *
114
+ * Returns the original value when the path is acceptable, or `undefined`
115
+ * when it should be dropped (and the wrapper should fall back to the
116
+ * default per-project dir). Emits a warning log so the user is aware that
117
+ * their configured projectDir was ignored.
118
+ *
119
+ * Rejects: empty string, non-string, non-existent paths, paths under
120
+ * BLOCKED_PATH_PREFIXES / getSensitiveHomePaths (e.g. `/etc`, `~/.ssh`).
121
+ */
122
+ function validateProjectDirForMount(projectDir) {
123
+ if (!projectDir)
124
+ return undefined;
125
+ if (!fs.existsSync(projectDir)) {
126
+ logger_1.logger.warn((0, i18n_1.t)('service.projectDirMissing', { path: projectDir }));
127
+ return undefined;
128
+ }
129
+ const blockedError = (0, security_1.validateBindMountPathSync)(projectDir);
130
+ if (blockedError) {
131
+ logger_1.logger.warn((0, i18n_1.t)('service.projectDirBlocked', { path: projectDir, message: blockedError }));
132
+ return undefined;
133
+ }
134
+ return projectDir;
135
+ }
136
+ /**
137
+ * Escape a value for use inside a systemd unit (ExecStart, Environment, etc.).
138
+ * Per `man 5 systemd.service`, `man 5 systemd.exec` and `man 5 systemd.unit`:
139
+ * - Backslash is the escape character; the literal backslash must be written
140
+ * as `\\`.
141
+ * - `$` in command lines triggers Environment variable expansion (`$FOO`); the
142
+ * literal dollar sign must be written as `$$`.
143
+ * - `%` triggers specifier expansion (`%h`, `%u`, etc.); the literal percent
144
+ * sign must be written as `%%`.
145
+ * - Whitespace separates argv tokens and must be escaped as `\x20`.
146
+ * - Double-quote, tab, and newline also need backslash escaping.
147
+ *
148
+ * Ordering matters: backslash MUST be doubled first so that the backslashes
149
+ * we subsequently introduce (e.g. `\"`, `\x20`) are not themselves re-doubled.
150
+ * `$` → `$$` and `%` → `%%` are independent of the backslash pass because
151
+ * they don't introduce a backslash. In JS replace strings, `$$` denotes a
152
+ * literal `$`, so `'$$$$'` produces `$$` and `'%%'` produces `%%`.
153
+ */
154
+ function systemdEscape(value) {
155
+ return value
156
+ .replace(/\\/g, '\\\\')
157
+ .replace(/\$/g, '$$$$')
158
+ .replace(/%/g, '%%')
159
+ .replace(/"/g, '\\"')
160
+ .replace(/\t/g, '\\t')
161
+ .replace(/\n/g, '\\n')
162
+ .replace(/ /g, '\\x20');
163
+ }
164
+ /** Returns the systemd unit name for a given project (without .service suffix) */
165
+ function getProjectUnitName(tenantCode, projectCode) {
166
+ return `${SERVICE_PREFIX}-${sanitize(tenantCode)}-${sanitize(projectCode)}`;
167
+ }
168
+ /** Returns the .service file path for a given project */
169
+ function getProjectUnitFilePath(tenantCode, projectCode) {
170
+ return path.join(getSystemdUserDir(), `${getProjectUnitName(tenantCode, projectCode)}.service`);
171
+ }
172
+ /** Lists all per-project unit files under ~/.config/systemd/user */
173
+ function getAllProjectUnits() {
174
+ const systemdDir = getSystemdUserDir();
175
+ const prefix = `${SERVICE_PREFIX}-`;
176
+ const results = [];
177
+ try {
178
+ const files = fs.readdirSync(systemdDir);
179
+ for (const file of files) {
180
+ if (file.startsWith(prefix) &&
181
+ file.endsWith('.service') &&
182
+ file !== SERVICE_NAME) {
183
+ const unitName = file.slice(0, -'.service'.length);
184
+ results.push({ unitName, unitPath: path.join(systemdDir, file) });
185
+ }
186
+ }
187
+ }
188
+ catch {
189
+ // systemd user dir doesn't exist yet — return empty list
190
+ }
191
+ return results;
192
+ }
193
+ /** Returns the host-side per-project config dir (mirrors what docker-runner uses) */
194
+ function getProjectConfigHostDir(tenantCode, projectCode) {
195
+ const configDir = process.env.AI_SUPPORT_AGENT_CONFIG_DIR
196
+ ? path.resolve(process.env.AI_SUPPORT_AGENT_CONFIG_DIR)
197
+ : path.join(os.homedir(), '.ai-support-agent');
198
+ return path.join(configDir, 'projects', tenantCode, projectCode, '.ai-support-agent');
199
+ }
200
+ /** Returns the services dir where wrapper scripts are stored */
201
+ function getServicesDir() {
202
+ const configDir = process.env.AI_SUPPORT_AGENT_CONFIG_DIR
203
+ ? path.resolve(process.env.AI_SUPPORT_AGENT_CONFIG_DIR)
204
+ : path.join(os.homedir(), '.ai-support-agent');
205
+ return path.join(configDir, 'services');
206
+ }
207
+ function getUpdateScriptPath() {
208
+ const configDir = process.env.AI_SUPPORT_AGENT_CONFIG_DIR
209
+ ? path.resolve(process.env.AI_SUPPORT_AGENT_CONFIG_DIR)
210
+ : path.join(os.homedir(), '.ai-support-agent');
211
+ return path.join(configDir, 'update-and-restart.sh');
212
+ }
213
+ // ---------------------------------------------------------------------------
214
+ // Legacy single-unit generation (kept for backward compatibility / tests)
215
+ // ---------------------------------------------------------------------------
52
216
  function generateServiceUnit(options) {
53
217
  const { nodePath, entryPoint, logDir, verbose, docker } = options;
54
218
  const execArgs = [nodePath, entryPoint, 'start'];
@@ -77,117 +241,630 @@ StandardError=append:${path.join(logDir, 'agent.err.log')}
77
241
  WantedBy=default.target
78
242
  `;
79
243
  }
244
+ // ---------------------------------------------------------------------------
245
+ // Per-project unit generation
246
+ // ---------------------------------------------------------------------------
247
+ /** Generate a systemd unit that runs a wrapper shell script for one project */
248
+ function generateProjectServiceUnit(opts) {
249
+ const escapedWrapper = systemdEscape(opts.wrapperScriptPath);
250
+ const escapedHome = systemdEscape(os.homedir());
251
+ const escapedOutLog = systemdEscape(path.join(opts.logDir, 'agent.out.log'));
252
+ const escapedErrLog = systemdEscape(path.join(opts.logDir, 'agent.err.log'));
253
+ return `[Unit]
254
+ Description=AI Support Agent (${opts.unitName})
255
+ After=network-online.target
256
+ Wants=network-online.target
257
+
258
+ [Service]
259
+ Type=simple
260
+ ExecStart=/bin/bash ${escapedWrapper}
261
+ Restart=always
262
+ RestartSec=10
263
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
264
+ Environment=HOME=${escapedHome}
265
+ StandardOutput=append:${escapedOutLog}
266
+ StandardError=append:${escapedErrLog}
267
+
268
+ [Install]
269
+ WantedBy=default.target
270
+ `;
271
+ }
272
+ /** Convert localhost/127.0.0.1 to host.docker.internal for container use */
273
+ function toContainerApiUrl(apiUrl) {
274
+ // The host portion must be terminated by `:`, `/`, or end-of-string;
275
+ // otherwise URLs like `http://localhost.example.com` would partially match
276
+ // and produce `http://host.docker.internal.example.com` (a different host).
277
+ return apiUrl.replace(/^(https?:\/\/)(localhost|127\.0\.0\.1)(?=$|[:/])/, (_, scheme) => `${scheme}host.docker.internal`);
278
+ }
279
+ /** Generate a bash wrapper script that runs docker for one project */
280
+ function generateWrapperScript(opts) {
281
+ const containerHome = '/home/node';
282
+ const containerConfigDir = `${containerHome}/.ai-support-agent`;
283
+ const homeDir = os.homedir();
284
+ const containerApiUrl = toContainerApiUrl(opts.apiUrl);
285
+ // All user-supplied values (paths, tokens, URLs, API keys) are interpolated
286
+ // into a generated bash script. Shell-quote every value to defend against
287
+ // whitespace, shell metacharacters (`$`, backticks, `"`), and outright
288
+ // injection from a malicious or malformed token / env var.
289
+ const qProjectConfig = shellQuote(opts.projectConfigHostDir);
290
+ const qContainerHome = shellQuote(containerHome);
291
+ const qContainerConfigDir = shellQuote(containerConfigDir);
292
+ const qToken = shellQuote(opts.token);
293
+ const qApiUrl = shellQuote(containerApiUrl);
294
+ const qImageName = shellQuote(opts.imageName);
295
+ const qUpdateScriptPath = shellQuote(opts.updateScriptPath);
296
+ // tenant/project are sanitized to [a-z0-9-] for container name and the
297
+ // --project value, so they're shell-safe; still quote for defense in depth.
298
+ const containerName = `ai-${sanitize(opts.tenantCode)}-${sanitize(opts.projectCode)}`;
299
+ const qContainerName = shellQuote(containerName);
300
+ const qProjectArg = shellQuote(`${opts.tenantCode}/${opts.projectCode}`);
301
+ // Project directory mount strategy:
302
+ // - projectConfigHostDir (~/.ai-support-agent/projects/<t>/<p>/.ai-support-agent)
303
+ // mounts to /home/node/.ai-support-agent inside the container. This is the
304
+ // per-project metadata dir that docker-supervisor writes hash files into.
305
+ // - The PROJECT DIR itself (~/.ai-support-agent/projects/<t>/<p>) — the
306
+ // parent of projectConfigHostDir — is mounted separately so the agent's
307
+ // ensureProjectDirs() writes workspace/, uploads/ to the right place
308
+ // inside the project dir (not double-nested under the metadata dir).
309
+ // - AI_SUPPORT_AGENT_PROJECT_DIR_MAP pins the in-container project dir so
310
+ // resolveProjectDir() does NOT fall back to the default template, which
311
+ // would otherwise be `<configDir>/projects/<t>/<p>` and produce a
312
+ // doubly-nested layout on disk.
313
+ // containerProjectDir is a Linux-style absolute path inside the docker
314
+ // container, so it's safe to hard-code with forward slashes regardless of
315
+ // the host platform that generated the wrapper.
316
+ const containerProjectDir = `/workspace/projects/${opts.projectCode}`;
317
+ // hostProjectDir is a HOST path; the Linux wrapper is only generated on
318
+ // Linux hosts so `path.dirname` (which equals path.posix.dirname there)
319
+ // is correct. `||` (not `??`) — an empty `opts.projectDir` must fall
320
+ // back to the default; an empty hostProjectDir would emit
321
+ // `-v :/workspace/...:rw` which docker rejects.
322
+ const hostProjectDir = opts.projectDir || path.dirname(opts.projectConfigHostDir);
323
+ const mountLines = [
324
+ ` -v ${shellQuote(`${homeDir}/.claude:${containerHome}/.claude:rw`)} \\`,
325
+ ` -v ${shellQuote(`${opts.projectConfigHostDir}:${containerConfigDir}:rw`)} \\`,
326
+ ` -v ${shellQuote(`${homeDir}/.claude.json:${containerHome}/.claude.json:rw`)} \\`,
327
+ ` -v ${shellQuote(`${hostProjectDir}:${containerProjectDir}:rw`)} \\`,
328
+ ];
329
+ const envLines = [
330
+ ` -e AI_SUPPORT_AGENT_IN_DOCKER=1 \\`,
331
+ ` -e HOME=${qContainerHome} \\`,
332
+ ` -e AI_SUPPORT_AGENT_CONFIG_DIR=${qContainerConfigDir} \\`,
333
+ ` -e AI_SUPPORT_AGENT_TOKEN=${qToken} \\`,
334
+ ` -e AI_SUPPORT_AGENT_API_URL=${qApiUrl} \\`,
335
+ // Pin the in-container project dir so the agent does not re-derive it
336
+ // from `${CONFIG_DIR}/projects/<t>/<p>` (which would double-nest).
337
+ ` -e AI_SUPPORT_AGENT_PROJECT_DIR_MAP=${shellQuote(`${opts.projectCode}=${containerProjectDir}`)} \\`,
338
+ ];
339
+ if (opts.anthropicApiKey) {
340
+ envLines.push(` -e ANTHROPIC_API_KEY=${shellQuote(opts.anthropicApiKey)} \\`);
341
+ }
342
+ if (opts.claudeCodeOauthToken) {
343
+ envLines.push(` -e CLAUDE_CODE_OAUTH_TOKEN=${shellQuote(opts.claudeCodeOauthToken)} \\`);
344
+ }
345
+ const containerArgs = [
346
+ 'ai-support-agent', 'start', '--no-docker',
347
+ `--project ${qProjectArg}`,
348
+ ];
349
+ if (opts.verbose)
350
+ containerArgs.push('--verbose');
351
+ return `#!/bin/bash
352
+ set -uo pipefail
353
+
354
+ # Load nvm if available so that node/npm are on PATH when launched as a systemd service
355
+ export NVM_DIR="\${HOME}/.nvm"
356
+ # shellcheck disable=SC1091
357
+ [ -s "\${NVM_DIR}/nvm.sh" ] && source "\${NVM_DIR}/nvm.sh"
358
+ # Also try common system locations as fallback
359
+ export PATH="/usr/local/bin:/usr/bin:/bin:\${PATH}"
360
+
361
+ REBUILD_MARKER=${qProjectConfig}/docker-rebuild-needed
362
+ if [ -f "$REBUILD_MARKER" ]; then
363
+ rm -f "$REBUILD_MARKER"
364
+ ai-support-agent docker-build 2>/dev/null || true
365
+ fi
366
+
367
+ # Resolve the installed version at runtime so the image stays current after npm updates
368
+ _NPM_ROOT=$(npm root -g 2>/dev/null)
369
+ _CLI_PKG_JSON="\${_NPM_ROOT}/@ai-support-agent/cli/package.json"
370
+ _INSTALLED_VERSION=$(node -p "require('\${_CLI_PKG_JSON}').version" 2>/dev/null || echo "")
371
+ if [ -z "$_INSTALLED_VERSION" ]; then
372
+ echo "ERROR: Could not determine installed version of @ai-support-agent/cli" >&2
373
+ exit 1
374
+ fi
375
+ IMAGE_TAG=${qImageName}:\${_INSTALLED_VERSION}
376
+
377
+ # Auto-build Docker image if the required version does not exist locally
378
+ if ! docker image inspect "\$IMAGE_TAG" >/dev/null 2>&1; then
379
+ echo "Docker image \$IMAGE_TAG not found — building..." >&2
380
+ ai-support-agent docker-build || { echo "ERROR: docker-build failed — cannot start container" >&2; exit 1; }
381
+ fi
382
+
383
+ # Remove stale container if it exists (e.g. from a previous crash)
384
+ docker rm -f ${qContainerName} 2>/dev/null || true
385
+
386
+ # Run the container as the invoking user so that bind-mounted host
387
+ # directories (token wrapper, project config, .claude state) remain
388
+ # writable inside the container. The docker image's entrypoint adds
389
+ # the runtime UID to /etc/passwd dynamically so unknown UIDs still
390
+ # get a usable home. Without --user, root inside the container can't
391
+ # write to host paths owned by the unprivileged service user under
392
+ # rootless docker / userns-remap setups (EACCES on mkdir).
393
+ _DOCKER_UID=$(id -u)
394
+ _DOCKER_GID=$(id -g)
395
+
396
+ docker run --rm -i --name ${qContainerName} \\
397
+ --user "\${_DOCKER_UID}:\${_DOCKER_GID}" \\
398
+ ${mountLines.join('\n')}
399
+ ${envLines.join('\n')}
400
+ "\$IMAGE_TAG" \\
401
+ ${containerArgs.join(' ')}
402
+
403
+ EXIT_CODE=$?
404
+
405
+ if [ "$EXIT_CODE" -eq 42 ]; then
406
+ exec ${qUpdateScriptPath}
407
+ fi
408
+
409
+ exit "$EXIT_CODE"
410
+ `;
411
+ }
412
+ /** Generate the update-and-restart.sh script for systemd */
413
+ function generateUpdateScript() {
414
+ const configDir = process.env.AI_SUPPORT_AGENT_CONFIG_DIR
415
+ ? path.resolve(process.env.AI_SUPPORT_AGENT_CONFIG_DIR)
416
+ : path.join(os.homedir(), '.ai-support-agent');
417
+ // Quote interpolated paths so a HOME / config dir containing whitespace
418
+ // or shell metacharacters doesn't word-split the generated script.
419
+ const qSystemdDir = shellQuote(getSystemdUserDir());
420
+ const qVersionFile = shellQuote(path.join(configDir, 'update-version.json'));
421
+ return `#!/bin/bash
422
+ set -uo pipefail
423
+ shopt -s nullglob
424
+
425
+ # Load nvm if available so that node/npm are on PATH when launched from systemd
426
+ export NVM_DIR="\${HOME}/.nvm"
427
+ # shellcheck disable=SC1091
428
+ [ -s "\${NVM_DIR}/nvm.sh" ] && source "\${NVM_DIR}/nvm.sh"
429
+ export PATH="/usr/local/bin:/usr/bin:/bin:\${PATH}"
430
+
431
+ LOG_PREFIX="[update-and-restart $(date -u '+%Y-%m-%dT%H:%M:%SZ')]"
432
+
433
+ # Best-effort secret redaction for command output that we echo to stderr.
434
+ redact_secrets() {
435
+ sed -E \\
436
+ -e 's#(Bearer )[A-Za-z0-9._-]+#\\1***REDACTED***#gi' \\
437
+ -e 's#(authToken[[:space:]]*[:=][[:space:]]*"?)[^"[:space:]]+#\\1***REDACTED***#gi' \\
438
+ -e 's#(_authToken[[:space:]]*[:=][[:space:]]*"?)[^"[:space:]]+#\\1***REDACTED***#gi' \\
439
+ -e 's#(X-Auth-Token:[[:space:]]*)[^[:space:]]+#\\1***REDACTED***#gi' \\
440
+ -e 's#(https?://)[^/:[:space:]@]+:[^@/[:space:]]+@#\\1***REDACTED***@#gi'
441
+ }
442
+
443
+ SYSTEMD_USER_DIR=${qSystemdDir}
444
+
445
+ # 1. Stop all per-project systemd services. systemctl does not glob unit
446
+ # names itself, so expand via the shell and stop each unit individually.
447
+ for unit_path in "$SYSTEMD_USER_DIR"/${SERVICE_PREFIX}-*.service; do
448
+ [ -f "$unit_path" ] || continue
449
+ unit_name=$(basename "$unit_path")
450
+ # Skip the legacy aggregate unit if it happens to match the prefix.
451
+ if [ "$unit_name" = "${SERVICE_NAME}" ]; then
452
+ continue
453
+ fi
454
+ systemctl --user stop "$unit_name" 2>/dev/null || true
455
+ done
456
+
457
+ # 2. Install new version if update-version.json exists.
458
+ VERSION_FILE=${qVersionFile}
459
+ _INSTALL_OK=true
460
+ if [ -f "$VERSION_FILE" ]; then
461
+ # Pass the path via env var so an apostrophe (or other JS string metachar) in
462
+ # HOME or AI_SUPPORT_AGENT_CONFIG_DIR cannot break the JS string literal.
463
+ NEW_VERSION=$(VERSION_FILE="$VERSION_FILE" node -e "try{console.log(JSON.parse(require('fs').readFileSync(process.env.VERSION_FILE,'utf-8')).version||'')}catch(e){console.log('')}" 2>/dev/null || echo "")
464
+ rm -f "$VERSION_FILE"
465
+ if [ -n "$NEW_VERSION" ]; then
466
+ NPM_OUTPUT=$(npm install -g "@ai-support-agent/cli@$NEW_VERSION" --quiet 2>&1)
467
+ NPM_STATUS=$?
468
+ if [ "$NPM_STATUS" -ne 0 ]; then
469
+ echo "$LOG_PREFIX ERROR: npm install -g @ai-support-agent/cli@$NEW_VERSION failed (exit $NPM_STATUS)" >&2
470
+ printf '%s\\n' "$NPM_OUTPUT" | redact_secrets >&2
471
+ _INSTALL_OK=false
472
+ else
473
+ SI_OUTPUT=$(ai-support-agent service install 2>&1)
474
+ SI_STATUS=$?
475
+ if [ "$SI_STATUS" -ne 0 ]; then
476
+ echo "$LOG_PREFIX ERROR: ai-support-agent service install failed (exit $SI_STATUS)" >&2
477
+ printf '%s\\n' "$SI_OUTPUT" | redact_secrets >&2
478
+ _INSTALL_OK=false
479
+ fi
480
+ fi
481
+ fi
482
+ fi
483
+
484
+ # 3. Reload systemd and restart all per-project services (always, even if install failed)
485
+ systemctl --user daemon-reload || true
486
+
487
+ _RELOAD_FAILED=0
488
+ for unit_path in "$SYSTEMD_USER_DIR"/${SERVICE_PREFIX}-*.service; do
489
+ [ -f "$unit_path" ] || continue
490
+ unit_name=$(basename "$unit_path")
491
+ # Skip the legacy aggregate unit
492
+ if [ "$unit_name" = "${SERVICE_NAME}" ]; then
493
+ continue
494
+ fi
495
+ if ! systemctl --user start "$unit_name" 2>&1; then
496
+ echo "$LOG_PREFIX ERROR: systemctl --user start $unit_name failed" >&2
497
+ _RELOAD_FAILED=$((_RELOAD_FAILED + 1))
498
+ fi
499
+ done
500
+
501
+ if [ "$_RELOAD_FAILED" -gt 0 ]; then
502
+ echo "$LOG_PREFIX ERROR: $_RELOAD_FAILED systemd unit(s) failed to restart" >&2
503
+ fi
504
+
505
+ if [ "$_INSTALL_OK" = "false" ] || [ "$_RELOAD_FAILED" -gt 0 ]; then
506
+ exit 1
507
+ fi
508
+
509
+ exit 0
510
+ `;
511
+ }
512
+ // ---------------------------------------------------------------------------
513
+ // Per-project file writing
514
+ // ---------------------------------------------------------------------------
515
+ /**
516
+ * Write run.sh and systemd unit for a single project. Does NOT systemctl start.
517
+ * Returns the unit file path.
518
+ */
519
+ function writeProjectServiceFiles(project, options = {}) {
520
+ const { tenantCode, projectCode } = project;
521
+ // Reject codes that would break the PROJECT_DIR_MAP env format.
522
+ assertProjectCodeIsSafe(projectCode);
523
+ assertProjectCodeIsSafe(tenantCode);
524
+ const projectKey = `${sanitize(tenantCode)}-${sanitize(projectCode)}`;
525
+ const logDir = getLogDir();
526
+ const projectLogDir = path.join(logDir, projectKey);
527
+ if (!fs.existsSync(projectLogDir)) {
528
+ fs.mkdirSync(projectLogDir, { recursive: true, mode: 0o700 });
529
+ }
530
+ const systemdDir = getSystemdUserDir();
531
+ if (!fs.existsSync(systemdDir)) {
532
+ fs.mkdirSync(systemdDir, { recursive: true });
533
+ }
534
+ const servicesDir = getServicesDir();
535
+ const projectServiceDir = path.join(servicesDir, projectKey);
536
+ if (!fs.existsSync(projectServiceDir)) {
537
+ fs.mkdirSync(projectServiceDir, { recursive: true, mode: 0o700 });
538
+ }
539
+ const projectConfigHostDir = getProjectConfigHostDir(tenantCode, projectCode);
540
+ if (!fs.existsSync(projectConfigHostDir)) {
541
+ fs.mkdirSync(projectConfigHostDir, { recursive: true, mode: 0o700 });
542
+ }
543
+ // Validate project.projectDir the same way buildProjectVolumeMounts does on
544
+ // the interactive path. If the user-supplied dir is empty, doesn't exist,
545
+ // or points at a blocked path (/etc, ~/.ssh, etc.), drop it and let
546
+ // generateWrapperScript fall back to the default mount derived from
547
+ // projectConfigHostDir. Without this check the wrapper would emit
548
+ // `-v <bad-path>:/workspace/projects/<code>:rw` unconditionally and
549
+ // either crash on start (empty/missing) or expose host secrets to the
550
+ // container (blocked prefix).
551
+ const validatedProjectDir = validateProjectDirForMount(project.projectDir);
552
+ const updateScriptPath = getUpdateScriptPath();
553
+ const wrapperScriptPath = path.join(projectServiceDir, 'run.sh');
554
+ const wrapperScript = generateWrapperScript({
555
+ imageName: docker_utils_1.IMAGE_NAME,
556
+ tenantCode,
557
+ projectCode,
558
+ projectConfigHostDir,
559
+ projectDir: validatedProjectDir,
560
+ token: project.token,
561
+ apiUrl: project.apiUrl,
562
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY,
563
+ claudeCodeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN,
564
+ verbose: options.verbose,
565
+ updateScriptPath,
566
+ });
567
+ // 0o700 — wrapper embeds the agent token (and optionally ANTHROPIC_API_KEY
568
+ // / CLAUDE_CODE_OAUTH_TOKEN) as shell-quoted literals. See writeFileEnsuringMode.
569
+ writeFileEnsuringMode(wrapperScriptPath, wrapperScript, 0o700);
570
+ const unitName = getProjectUnitName(tenantCode, projectCode);
571
+ const unitPath = getProjectUnitFilePath(tenantCode, projectCode);
572
+ const unit = generateProjectServiceUnit({ unitName, wrapperScriptPath, logDir: projectLogDir });
573
+ fs.writeFileSync(unitPath, unit, 'utf-8');
574
+ return unitPath;
575
+ }
576
+ /**
577
+ * Install service files and immediately start a single project via systemctl.
578
+ * Idempotent: stops any existing service before starting so that token/config
579
+ * updates take effect immediately.
580
+ */
581
+ function installAndStartProject(project, options = {}) {
582
+ const updateScriptPath = getUpdateScriptPath();
583
+ const updateScript = generateUpdateScript();
584
+ writeFileEnsuringMode(updateScriptPath, updateScript, 0o700);
585
+ writeProjectServiceFiles(project, options);
586
+ const unitName = getProjectUnitName(project.tenantCode, project.projectCode);
587
+ const unitFile = `${unitName}.service`;
588
+ // Reload systemd so the new/updated unit is recognised. Use the same
589
+ // diagnostic key as the strategy install() so support logs can correlate.
590
+ try {
591
+ (0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
592
+ }
593
+ catch (error) {
594
+ const message = error instanceof Error ? error.message : String(error);
595
+ logger_1.logger.warn((0, i18n_1.t)('service.daemonReloadFailed', { message }));
596
+ return;
597
+ }
598
+ // Stop first so that updated run.sh / token changes take effect.
599
+ try {
600
+ (0, child_process_1.execSync)(`systemctl --user stop "${unitFile}"`, { stdio: 'pipe' });
601
+ }
602
+ catch { /* not running */ }
603
+ // Enable so the service starts on login. Warn (don't fail) on errors —
604
+ // common cases are missing user instance (linger off on shared hosts),
605
+ // which still allows start to succeed for the current session.
606
+ try {
607
+ (0, child_process_1.execSync)(`systemctl --user enable "${unitFile}"`, { stdio: 'pipe' });
608
+ }
609
+ catch (error) {
610
+ const message = error instanceof Error ? error.message : String(error);
611
+ logger_1.logger.warn((0, i18n_1.t)('service.enableFailed', { unit: unitFile, message }));
612
+ }
613
+ try {
614
+ (0, child_process_1.execSync)(`systemctl --user start "${unitFile}"`, { stdio: 'pipe' });
615
+ }
616
+ catch (error) {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ logger_1.logger.warn((0, i18n_1.t)('service.loadWarning', { label: `${unitName}: ${message}` }));
619
+ return;
620
+ }
621
+ // Verify the service is actually active after start.
622
+ try {
623
+ const out = (0, child_process_1.execSync)(`systemctl --user is-active "${unitFile}"`, { stdio: 'pipe' }).toString().trim();
624
+ if (out !== 'active') {
625
+ logger_1.logger.warn((0, i18n_1.t)('service.loadWarning', { label: unitName }));
626
+ }
627
+ }
628
+ catch {
629
+ logger_1.logger.warn((0, i18n_1.t)('service.loadWarning', { label: unitName }));
630
+ }
631
+ }
632
+ // ---------------------------------------------------------------------------
633
+ // LinuxServiceStrategy
634
+ // ---------------------------------------------------------------------------
80
635
  class LinuxServiceStrategy {
81
636
  install(options) {
82
- const serviceFilePath = getServiceFilePath();
83
- const logDir = getLogDir();
84
- const nodePath = (0, node_paths_1.getNodePath)();
85
- const entryPoint = (0, node_paths_1.getCliEntryPoint)();
86
- if (!fs.existsSync(entryPoint)) {
87
- logger_1.logger.error((0, i18n_1.t)('service.entryPointNotFound', { path: entryPoint }));
637
+ const config = (0, config_manager_1.loadConfig)();
638
+ const projects = config ? (0, config_manager_1.getProjectList)(config) : [];
639
+ if (projects.length === 0) {
640
+ logger_1.logger.error((0, i18n_1.t)('service.noProjectsConfigured'));
88
641
  return;
89
642
  }
643
+ const logDir = getLogDir();
90
644
  if (!fs.existsSync(logDir)) {
91
645
  fs.mkdirSync(logDir, { recursive: true });
92
646
  }
93
- const systemdDir = path.dirname(serviceFilePath);
647
+ const systemdDir = getSystemdUserDir();
94
648
  if (!fs.existsSync(systemdDir)) {
95
649
  fs.mkdirSync(systemdDir, { recursive: true });
96
650
  }
97
- if (fs.existsSync(serviceFilePath)) {
98
- logger_1.logger.info((0, i18n_1.t)('service.overwriting', { path: serviceFilePath }));
99
- }
100
- const unit = generateServiceUnit({
101
- nodePath,
102
- entryPoint,
103
- logDir,
104
- verbose: options.verbose,
105
- docker: options.docker,
106
- });
107
- fs.writeFileSync(serviceFilePath, unit, 'utf-8');
108
- logger_1.logger.success((0, i18n_1.t)('service.installed.linux', { path: serviceFilePath }));
109
- logger_1.logger.info((0, i18n_1.t)('service.loadHint.linux'));
651
+ const updateScriptPath = getUpdateScriptPath();
652
+ const updateScript = generateUpdateScript();
653
+ writeFileEnsuringMode(updateScriptPath, updateScript, 0o700);
654
+ const writtenUnits = [];
655
+ const expectedUnitNames = new Set();
656
+ for (const project of projects) {
657
+ const unitPath = writeProjectServiceFiles(project, { verbose: options.verbose });
658
+ const unitName = getProjectUnitName(project.tenantCode, project.projectCode);
659
+ expectedUnitNames.add(unitName);
660
+ writtenUnits.push({
661
+ projectCode: project.projectCode,
662
+ unitPath,
663
+ unitFile: `${unitName}.service`,
664
+ });
665
+ }
666
+ // Remove orphaned per-project units for projects that are no longer in
667
+ // config. Without this, `service start` / `status` would keep iterating
668
+ // over stale units with outdated tokens because getAllProjectUnits()
669
+ // walks the filesystem, not the config.
670
+ for (const { unitName, unitPath } of getAllProjectUnits()) {
671
+ if (expectedUnitNames.has(unitName))
672
+ continue;
673
+ const orphanUnitFile = `${unitName}.service`;
674
+ try {
675
+ (0, child_process_1.execSync)(`systemctl --user disable --now "${orphanUnitFile}"`, { stdio: 'pipe' });
676
+ }
677
+ catch { /* not loaded — ignore */ }
678
+ try {
679
+ if (fs.existsSync(unitPath))
680
+ fs.unlinkSync(unitPath);
681
+ }
682
+ catch (error) {
683
+ const message = error instanceof Error ? error.message : String(error);
684
+ logger_1.logger.warn((0, i18n_1.t)('service.orphanUnitRemoveFailed', { unit: orphanUnitFile, message }));
685
+ }
686
+ }
687
+ // Reload systemd so the new/updated units are recognised, then enable
688
+ // each so they auto-start at next login/boot. Failures to enable surface
689
+ // as warnings (lingering may not be configured) — the unit file itself
690
+ // is still on disk and a subsequent `service start` will run it for the
691
+ // current session, so we still emit the per-project "installed" line
692
+ // after attempting enable. The warning makes clear that auto-start may
693
+ // not work until linger is enabled.
694
+ try {
695
+ (0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
696
+ }
697
+ catch (error) {
698
+ const message = error instanceof Error ? error.message : String(error);
699
+ logger_1.logger.warn((0, i18n_1.t)('service.daemonReloadFailed', { message }));
700
+ }
701
+ for (const { projectCode, unitPath, unitFile } of writtenUnits) {
702
+ try {
703
+ (0, child_process_1.execSync)(`systemctl --user enable "${unitFile}"`, { stdio: 'pipe' });
704
+ }
705
+ catch (error) {
706
+ const message = error instanceof Error ? error.message : String(error);
707
+ logger_1.logger.warn((0, i18n_1.t)('service.enableFailed', { unit: unitFile, message }));
708
+ }
709
+ logger_1.logger.success((0, i18n_1.t)('service.projectInstalled', { projectCode, path: unitPath }));
710
+ }
711
+ logger_1.logger.info((0, i18n_1.t)('service.loadHintMulti'));
110
712
  logger_1.logger.info((0, i18n_1.t)('service.logDir', { path: logDir }));
111
713
  logger_1.logger.info((0, i18n_1.t)('service.noLogRotation'));
112
714
  }
113
715
  uninstall() {
114
- const serviceFilePath = getServiceFilePath();
115
- if (!fs.existsSync(serviceFilePath)) {
116
- logger_1.logger.warn((0, i18n_1.t)('service.notInstalled.linux'));
716
+ const projectUnits = getAllProjectUnits();
717
+ if (projectUnits.length === 0) {
718
+ logger_1.logger.warn((0, i18n_1.t)('service.notInstalled'));
117
719
  return;
118
720
  }
119
- logger_1.logger.info((0, i18n_1.t)('service.unloadHint.linux'));
120
- fs.unlinkSync(serviceFilePath);
121
- logger_1.logger.success((0, i18n_1.t)('service.uninstalled.linux'));
721
+ for (const { unitName, unitPath } of projectUnits) {
722
+ const unitFile = `${unitName}.service`;
723
+ // Stop and disable; ignore failures (may not be loaded)
724
+ try {
725
+ (0, child_process_1.execSync)(`systemctl --user disable --now "${unitFile}"`, { stdio: 'pipe' });
726
+ }
727
+ catch { /* not loaded */ }
728
+ if (fs.existsSync(unitPath)) {
729
+ fs.unlinkSync(unitPath);
730
+ }
731
+ }
732
+ try {
733
+ (0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
734
+ }
735
+ catch { /* tolerate */ }
736
+ logger_1.logger.success((0, i18n_1.t)('service.uninstalled'));
122
737
  }
123
738
  start() {
124
- const serviceFilePath = getServiceFilePath();
125
- if (!fs.existsSync(serviceFilePath)) {
126
- logger_1.logger.error((0, i18n_1.t)('service.notInstalled.linux'));
739
+ const projectUnits = getAllProjectUnits();
740
+ if (projectUnits.length === 0) {
741
+ logger_1.logger.error((0, i18n_1.t)('service.notInstalled'));
127
742
  return;
128
743
  }
744
+ let failed = false;
129
745
  try {
130
- const serviceName = SERVICE_NAME.replace('.service', '');
131
746
  (0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
132
- (0, child_process_1.execSync)(`systemctl --user start ${serviceName}`, { stdio: 'pipe' });
133
- logger_1.logger.success((0, i18n_1.t)('service.started'));
134
747
  }
135
- catch (error) {
136
- const message = error instanceof Error ? error.message : String(error);
137
- logger_1.logger.error((0, i18n_1.t)('service.startFailed', { message }));
748
+ catch {
749
+ // tolerate start may still work
750
+ }
751
+ for (const { unitName } of projectUnits) {
752
+ const unit = `${unitName}.service`;
753
+ try {
754
+ (0, child_process_1.execSync)(`systemctl --user start "${unit}"`, { stdio: 'pipe' });
755
+ }
756
+ catch (error) {
757
+ const message = error instanceof Error ? error.message : String(error);
758
+ // Include unit name so operators can identify the failing project
759
+ // when start runs over N units.
760
+ logger_1.logger.error((0, i18n_1.t)('service.unitStartFailed', { unit, message }));
761
+ failed = true;
762
+ }
763
+ }
764
+ if (!failed) {
765
+ logger_1.logger.success((0, i18n_1.t)('service.started'));
138
766
  }
139
767
  }
140
768
  stop() {
141
- const serviceFilePath = getServiceFilePath();
142
- if (!fs.existsSync(serviceFilePath)) {
143
- logger_1.logger.error((0, i18n_1.t)('service.notInstalled.linux'));
769
+ const projectUnits = getAllProjectUnits();
770
+ if (projectUnits.length === 0) {
771
+ logger_1.logger.error((0, i18n_1.t)('service.notInstalled'));
144
772
  return;
145
773
  }
146
- try {
147
- const serviceName = SERVICE_NAME.replace('.service', '');
148
- (0, child_process_1.execSync)(`systemctl --user stop ${serviceName}`, { stdio: 'pipe' });
149
- logger_1.logger.success((0, i18n_1.t)('service.stopped'));
774
+ let failed = false;
775
+ for (const { unitName } of projectUnits) {
776
+ const unit = `${unitName}.service`;
777
+ try {
778
+ (0, child_process_1.execSync)(`systemctl --user stop "${unit}"`, { stdio: 'pipe' });
779
+ }
780
+ catch (error) {
781
+ const message = error instanceof Error ? error.message : String(error);
782
+ logger_1.logger.error((0, i18n_1.t)('service.unitStopFailed', { unit, message }));
783
+ failed = true;
784
+ }
150
785
  }
151
- catch (error) {
152
- const message = error instanceof Error ? error.message : String(error);
153
- logger_1.logger.error((0, i18n_1.t)('service.stopFailed', { message }));
786
+ if (!failed) {
787
+ logger_1.logger.success((0, i18n_1.t)('service.stopped'));
154
788
  }
155
789
  }
156
790
  restart() {
157
- const serviceFilePath = getServiceFilePath();
158
- if (!fs.existsSync(serviceFilePath)) {
159
- logger_1.logger.error((0, i18n_1.t)('service.notInstalled.linux'));
791
+ const projectUnits = getAllProjectUnits();
792
+ if (projectUnits.length === 0) {
793
+ logger_1.logger.error((0, i18n_1.t)('service.notInstalled'));
160
794
  return;
161
795
  }
796
+ let failed = false;
162
797
  try {
163
798
  (0, child_process_1.execSync)('systemctl --user daemon-reload', { stdio: 'pipe' });
164
- const serviceName = SERVICE_NAME.replace('.service', '');
165
- (0, child_process_1.execSync)(`systemctl --user restart ${serviceName}`, { stdio: 'pipe' });
166
- logger_1.logger.success((0, i18n_1.t)('service.restarted'));
167
799
  }
168
- catch (error) {
169
- const message = error instanceof Error ? error.message : String(error);
170
- logger_1.logger.error((0, i18n_1.t)('service.restartFailed', { message }));
800
+ catch {
801
+ // tolerate
802
+ }
803
+ for (const { unitName } of projectUnits) {
804
+ const unit = `${unitName}.service`;
805
+ try {
806
+ (0, child_process_1.execSync)(`systemctl --user restart "${unit}"`, { stdio: 'pipe' });
807
+ }
808
+ catch (error) {
809
+ const message = error instanceof Error ? error.message : String(error);
810
+ logger_1.logger.error((0, i18n_1.t)('service.unitRestartFailed', { unit, message }));
811
+ failed = true;
812
+ }
813
+ }
814
+ if (!failed) {
815
+ logger_1.logger.success((0, i18n_1.t)('service.restarted'));
171
816
  }
172
817
  }
173
818
  status() {
174
- const serviceFilePath = getServiceFilePath();
819
+ const projectUnits = getAllProjectUnits();
175
820
  const logDir = getLogDir();
176
- if (!fs.existsSync(serviceFilePath)) {
821
+ if (projectUnits.length === 0) {
177
822
  return { installed: false, running: false };
178
823
  }
179
- try {
180
- const serviceName = SERVICE_NAME.replace('.service', '');
181
- const output = (0, child_process_1.execSync)(`systemctl --user show ${serviceName} --property=ActiveState,MainPID --no-pager`, { stdio: 'pipe' }).toString();
182
- const activeMatch = output.match(/ActiveState=(\w+)/);
183
- const pidMatch = output.match(/MainPID=(\d+)/);
184
- const active = activeMatch?.[1] === 'active';
185
- const pid = pidMatch ? parseInt(pidMatch[1], 10) : undefined;
186
- return { installed: true, running: active, pid: active ? pid : undefined, logDir };
824
+ // Build a reverse map from unit name → original projectCode using the
825
+ // user's config. sanitize() collapses `_` and other chars to `-`, so
826
+ // splitting the unit name on `-` is lossy when tenant/project codes
827
+ // contain those characters; the config is the source of truth.
828
+ const config = (0, config_manager_1.loadConfig)();
829
+ const projectByUnitName = new Map();
830
+ if (config) {
831
+ for (const project of (0, config_manager_1.getProjectList)(config)) {
832
+ projectByUnitName.set(getProjectUnitName(project.tenantCode, project.projectCode), project.projectCode);
833
+ }
187
834
  }
188
- catch {
189
- return { installed: true, running: false, logDir };
835
+ const projects = [];
836
+ let anyRunning = false;
837
+ let firstPid;
838
+ for (const { unitName } of projectUnits) {
839
+ // Prefer the canonical projectCode from config. For orphaned units no
840
+ // longer in config, fall back to the unit name with the brand prefix
841
+ // stripped so users can see something close to the original code.
842
+ const fallback = unitName.startsWith(`${SERVICE_PREFIX}-`)
843
+ ? unitName.slice(SERVICE_PREFIX.length + 1)
844
+ : unitName;
845
+ const projectCode = projectByUnitName.get(unitName) ?? fallback;
846
+ let running = false;
847
+ let pid;
848
+ try {
849
+ const output = (0, child_process_1.execSync)(`systemctl --user show "${unitName}.service" --property=ActiveState,MainPID --no-pager`, { stdio: 'pipe' }).toString();
850
+ const activeMatch = output.match(/ActiveState=(\w+)/);
851
+ const pidMatch = output.match(/MainPID=(\d+)/);
852
+ if (activeMatch?.[1] === 'active') {
853
+ running = true;
854
+ if (pidMatch && pidMatch[1] !== '0') {
855
+ pid = parseInt(pidMatch[1], 10);
856
+ if (!firstPid)
857
+ firstPid = pid;
858
+ }
859
+ anyRunning = true;
860
+ }
861
+ }
862
+ catch {
863
+ // Not loaded — running stays false
864
+ }
865
+ projects.push({ label: unitName, projectCode, running, pid });
190
866
  }
867
+ return { installed: true, running: anyRunning, pid: firstPid, logDir, projects };
191
868
  }
192
869
  }
193
870
  exports.LinuxServiceStrategy = LinuxServiceStrategy;