@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.
- package/dist/cli/service/darwin-service.d.ts +12 -0
- package/dist/cli/service/darwin-service.d.ts.map +1 -1
- package/dist/cli/service/darwin-service.js +67 -5
- package/dist/cli/service/darwin-service.js.map +1 -1
- package/dist/cli/service/linux-service.d.ts +74 -0
- package/dist/cli/service/linux-service.d.ts.map +1 -1
- package/dist/cli/service/linux-service.js +746 -69
- package/dist/cli/service/linux-service.js.map +1 -1
- package/dist/cli/service-command.d.ts +5 -1
- package/dist/cli/service-command.d.ts.map +1 -1
- package/dist/cli/service-command.js +12 -4
- package/dist/cli/service-command.js.map +1 -1
- package/dist/docker/volume-mount-builder.d.ts.map +1 -1
- package/dist/docker/volume-mount-builder.js +35 -4
- package/dist/docker/volume-mount-builder.js.map +1 -1
- package/dist/locales/en.json +15 -11
- package/dist/locales/ja.json +15 -11
- package/dist/project-agent.d.ts.map +1 -1
- package/dist/project-agent.js +14 -17
- package/dist/project-agent.js.map +1 -1
- package/dist/security.d.ts +8 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +26 -0
- package/dist/security.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
647
|
+
const systemdDir = getSystemdUserDir();
|
|
94
648
|
if (!fs.existsSync(systemdDir)) {
|
|
95
649
|
fs.mkdirSync(systemdDir, { recursive: true });
|
|
96
650
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
115
|
-
if (
|
|
116
|
-
logger_1.logger.warn((0, i18n_1.t)('service.notInstalled
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
125
|
-
if (
|
|
126
|
-
logger_1.logger.error((0, i18n_1.t)('service.notInstalled
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
142
|
-
if (
|
|
143
|
-
logger_1.logger.error((0, i18n_1.t)('service.notInstalled
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
if (
|
|
159
|
-
logger_1.logger.error((0, i18n_1.t)('service.notInstalled
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
819
|
+
const projectUnits = getAllProjectUnits();
|
|
175
820
|
const logDir = getLogDir();
|
|
176
|
-
if (
|
|
821
|
+
if (projectUnits.length === 0) {
|
|
177
822
|
return { installed: false, running: false };
|
|
178
823
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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;
|