@0xmaxma/claude-gateway 1.8.6 → 1.8.7
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/README.md +63 -4
- package/dist/agent/dreaming/config.d.ts +3 -1
- package/dist/agent/dreaming/config.d.ts.map +1 -1
- package/dist/agent/dreaming/config.js +14 -3
- package/dist/agent/dreaming/config.js.map +1 -1
- package/dist/agent/dreaming/index.d.ts +2 -0
- package/dist/agent/dreaming/index.d.ts.map +1 -1
- package/dist/agent/dreaming/index.js +1 -1
- package/dist/agent/dreaming/index.js.map +1 -1
- package/dist/agent/knowledge/config.d.ts +3 -1
- package/dist/agent/knowledge/config.d.ts.map +1 -1
- package/dist/agent/knowledge/config.js +13 -3
- package/dist/agent/knowledge/config.js.map +1 -1
- package/dist/agent/runner.d.ts +19 -0
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +56 -2
- package/dist/agent/runner.js.map +1 -1
- package/dist/agent/skill-learning/config.d.ts +3 -1
- package/dist/agent/skill-learning/config.d.ts.map +1 -1
- package/dist/agent/skill-learning/config.js +13 -3
- package/dist/agent/skill-learning/config.js.map +1 -1
- package/dist/agent/skill-learning/index.d.ts +2 -0
- package/dist/agent/skill-learning/index.d.ts.map +1 -1
- package/dist/agent/skill-learning/index.js +1 -1
- package/dist/agent/skill-learning/index.js.map +1 -1
- package/dist/api/router.d.ts.map +1 -1
- package/dist/api/router.js +5 -1
- package/dist/api/router.js.map +1 -1
- package/dist/apps/agent-manager.d.ts.map +1 -1
- package/dist/apps/agent-manager.js +7 -2
- package/dist/apps/agent-manager.js.map +1 -1
- package/dist/apps/installer.d.ts +3 -1
- package/dist/apps/installer.d.ts.map +1 -1
- package/dist/apps/installer.js +8 -2
- package/dist/apps/installer.js.map +1 -1
- package/dist/cli/commands/service.d.ts +34 -4
- package/dist/cli/commands/service.d.ts.map +1 -1
- package/dist/cli/commands/service.js +552 -96
- package/dist/cli/commands/service.js.map +1 -1
- package/dist/config/bootstrap.d.ts.map +1 -1
- package/dist/config/bootstrap.js +4 -1
- package/dist/config/bootstrap.js.map +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +36 -0
- package/dist/config/loader.js.map +1 -1
- package/dist/config/migrator.d.ts.map +1 -1
- package/dist/config/migrator.js +12 -1
- package/dist/config/migrator.js.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/session/process.d.ts.map +1 -1
- package/dist/session/process.js +19 -2
- package/dist/session/process.js.map +1 -1
- package/package.json +1 -1
|
@@ -46,31 +46,62 @@ const http_client_1 = require("../http-client");
|
|
|
46
46
|
const prompt_1 = require("../prompt");
|
|
47
47
|
const health_1 = require("../health");
|
|
48
48
|
const output_1 = require("../output");
|
|
49
|
-
const paths_1 = require("../../utils/paths");
|
|
50
49
|
const output_2 = require("../output");
|
|
51
50
|
/**
|
|
52
51
|
* `service install|status|uninstall` — run the gateway under a process manager.
|
|
53
52
|
*
|
|
54
|
-
* systemd
|
|
55
|
-
* escalation is needed and the service runs as the same user that owns
|
|
56
|
-
* ~/.claude-gateway.
|
|
53
|
+
* systemd defaults to installing a *user* unit (`~/.config/systemd/user/`) so no
|
|
54
|
+
* privilege escalation is needed and the service runs as the same user that owns
|
|
55
|
+
* ~/.claude-gateway. `--scope system` opts into a root-owned unit at
|
|
56
|
+
* `/etc/systemd/system/` for automated/infra provisioning that needs the gateway
|
|
57
|
+
* to run under a fixed system account — it never auto-escalates via sudo; the
|
|
58
|
+
* caller must already be root. PM2 is offered for hosts that already standardise
|
|
59
|
+
* on it (user scope only — `--scope system` is systemd-only).
|
|
57
60
|
*
|
|
58
61
|
* Everything the generated unit references is an absolute path resolved here
|
|
59
62
|
* (node binary, entry point, config, working directory) — a unit that inherits
|
|
60
63
|
* PATH or cwd from an interactive shell breaks the moment systemd starts it at
|
|
61
64
|
* boot. Secrets are never written into the unit: the gateway reads
|
|
62
|
-
* ~/.claude-gateway/.env itself.
|
|
65
|
+
* ~/.claude-gateway/.env itself. `--env` is for non-secret overrides only — use
|
|
66
|
+
* `--env-file` (EnvironmentFile=) to point at a file holding actual secrets.
|
|
63
67
|
*/
|
|
64
68
|
const UNIT_NAME = 'claude-gateway.service';
|
|
65
69
|
const PM2_NAME = 'gateway';
|
|
66
70
|
const HEALTH_ATTEMPTS = 20;
|
|
67
71
|
const HEALTH_INTERVAL_MS = 500;
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
/** `install`'s exit code when everything succeeded (unit written, enabled/
|
|
73
|
+
* started) but `/health` never answered within the poll window — distinct
|
|
74
|
+
* from `1` (install/enable itself failed, or a validation/confirmation gate
|
|
75
|
+
* refused) so a caller checking the exit code alone, not just the JSON
|
|
76
|
+
* result on stdout, can tell "didn't happen" apart from "happened, health
|
|
77
|
+
* unconfirmed". */
|
|
78
|
+
const EXIT_HEALTH_TIMEOUT = 2;
|
|
79
|
+
/** Env var names the installer itself sets — `--env` may not override these. */
|
|
80
|
+
const RESERVED_ENV_KEYS = new Set(['HOME', 'PATH', 'GATEWAY_CONFIG']);
|
|
81
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
82
|
+
function gatewayHome(home = os.homedir()) {
|
|
83
|
+
return path.join(home, '.claude-gateway');
|
|
70
84
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
/** Same `~`/`~/...` expansion as the shared `expandHome()` (src/utils/paths.ts),
|
|
86
|
+
* but against an explicit `home` rather than always `os.homedir()`. That
|
|
87
|
+
* shared helper is used by the server too and means "this process's own
|
|
88
|
+
* home" everywhere else — for a `--scope system --run-as <user>` install,
|
|
89
|
+
* this process's own home is root's, not the target user's, so a `~` in
|
|
90
|
+
* `--config`/`--env-file` needs its own expansion here instead. */
|
|
91
|
+
function expandHomeAs(p, home) {
|
|
92
|
+
if (p === '~')
|
|
93
|
+
return home;
|
|
94
|
+
if (p.startsWith('~/'))
|
|
95
|
+
return path.join(home, p.slice(2));
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
/** `allowEnvFallback` is false for a `--scope system` install: `$GATEWAY_CONFIG`
|
|
99
|
+
* belongs to the *installing* (root) process's environment, which has no
|
|
100
|
+
* reliable relationship to `--run-as`'s intended config — inheriting it
|
|
101
|
+
* would silently point the unit at a path the run-as user can't read. */
|
|
102
|
+
function configPath(flags, home = os.homedir(), allowEnvFallback = true) {
|
|
103
|
+
const explicit = typeof flags.config === 'string' ? flags.config : allowEnvFallback ? process.env.GATEWAY_CONFIG : undefined;
|
|
104
|
+
return path.resolve(expandHomeAs(explicit ?? path.join(gatewayHome(home), 'config.json'), home));
|
|
74
105
|
}
|
|
75
106
|
/**
|
|
76
107
|
* Build the PATH the service will run with.
|
|
@@ -81,13 +112,18 @@ function configPath(flags) {
|
|
|
81
112
|
* directories the gateway actually needs — the node that will run it, the
|
|
82
113
|
* `claude` binary it spawns, and the standard system paths — keeping only
|
|
83
114
|
* those that exist.
|
|
115
|
+
*
|
|
116
|
+
* `home` defaults to the *installing* process's home (`os.homedir()`), but a
|
|
117
|
+
* `--scope system --run-as <user>` install passes the run-as user's home
|
|
118
|
+
* instead — a root-run install must not bake root's own `~/.local/bin` /
|
|
119
|
+
* `~/.bun/bin` into a unit that runs as someone else entirely.
|
|
84
120
|
*/
|
|
85
|
-
function servicePath() {
|
|
121
|
+
function servicePath(home = os.homedir()) {
|
|
86
122
|
const candidates = [
|
|
87
123
|
path.dirname(process.execPath),
|
|
88
124
|
claudeBinDir(),
|
|
89
|
-
path.join(
|
|
90
|
-
path.join(
|
|
125
|
+
path.join(home, '.local', 'bin'),
|
|
126
|
+
path.join(home, '.bun', 'bin'),
|
|
91
127
|
'/usr/local/sbin',
|
|
92
128
|
'/usr/local/bin',
|
|
93
129
|
'/usr/sbin',
|
|
@@ -114,10 +150,39 @@ function claudeBinDir() {
|
|
|
114
150
|
return null;
|
|
115
151
|
}
|
|
116
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* `getent passwd <user>` → that user's uid/gid/home, or null if the user
|
|
155
|
+
* doesn't exist or `getent` itself isn't available. NSS-aware (works for
|
|
156
|
+
* LDAP/sssd-backed accounts, not just local `/etc/passwd` entries), which a
|
|
157
|
+
* naive `/etc/passwd` file parse would silently miss — the standard,
|
|
158
|
+
* distro-agnostic way to resolve this on Linux.
|
|
159
|
+
*/
|
|
160
|
+
function resolveRunAsUser(username) {
|
|
161
|
+
try {
|
|
162
|
+
const line = capture('getent', ['passwd', username]).trim();
|
|
163
|
+
// name:password:UID:GID:GECOS:directory:shell
|
|
164
|
+
const fields = line.split(':');
|
|
165
|
+
const uid = Number(fields[2]);
|
|
166
|
+
const gid = Number(fields[3]);
|
|
167
|
+
const home = fields[5];
|
|
168
|
+
if (!Number.isInteger(uid) || !Number.isInteger(gid) || !home || !path.isAbsolute(home))
|
|
169
|
+
return null;
|
|
170
|
+
return { uid, gid, home };
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
117
176
|
/** Resolve the absolute launch triple (node, entry, cwd) for a generated unit.
|
|
118
177
|
* Returns null — with a message — when the entry point can't be located, so a
|
|
119
|
-
* broken install never produces a unit that silently fails at boot.
|
|
120
|
-
|
|
178
|
+
* broken install never produces a unit that silently fails at boot.
|
|
179
|
+
*
|
|
180
|
+
* `home` defaults to the installing process's own home. A `--scope system
|
|
181
|
+
* --run-as <user>` install passes that user's real home instead — the unit
|
|
182
|
+
* runs as them (`User=<user>`), so its WorkingDirectory/HOME/config path
|
|
183
|
+
* must be theirs, not the root process that wrote the unit. `allowEnvConfig`
|
|
184
|
+
* is false for that same case — see `configPath()`. */
|
|
185
|
+
function resolveLaunchSpec(flags, home = os.homedir(), allowEnvConfig = true) {
|
|
121
186
|
// dist/cli/commands/service.js → dist/entry.js, the thin dispatcher that
|
|
122
187
|
// loads only the side it needs. index.js is still a working boot entry and is
|
|
123
188
|
// used when a partially-updated install predates the split, so a unit is
|
|
@@ -134,10 +199,10 @@ function resolveLaunchSpec(flags) {
|
|
|
134
199
|
return {
|
|
135
200
|
node,
|
|
136
201
|
entry,
|
|
137
|
-
cwd: gatewayHome(),
|
|
138
|
-
config: configPath(flags),
|
|
139
|
-
home
|
|
140
|
-
pathEnv: servicePath(),
|
|
202
|
+
cwd: gatewayHome(home),
|
|
203
|
+
config: configPath(flags, home, allowEnvConfig),
|
|
204
|
+
home,
|
|
205
|
+
pathEnv: servicePath(home),
|
|
141
206
|
};
|
|
142
207
|
}
|
|
143
208
|
/** systemd unit values are double-quoted here, so backslashes and quotes must
|
|
@@ -145,31 +210,57 @@ function resolveLaunchSpec(flags) {
|
|
|
145
210
|
function systemdQuote(value) {
|
|
146
211
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
147
212
|
}
|
|
213
|
+
/** Default when a caller (or an existing test) passes no options — matches the
|
|
214
|
+
* behavior this command had before --scope existed exactly, so `--scope user`
|
|
215
|
+
* (or omitting it) is a strict no-op change. */
|
|
216
|
+
const DEFAULT_SYSTEMD_UNIT_OPTIONS = { scope: 'user', after: [], extraEnv: {} };
|
|
148
217
|
/** Pure renderer — exported so `--print` and the tests see the exact bytes that
|
|
149
218
|
* would be written to disk. */
|
|
150
|
-
function renderSystemdUnit(spec) {
|
|
219
|
+
function renderSystemdUnit(spec, opts = DEFAULT_SYSTEMD_UNIT_OPTIONS) {
|
|
151
220
|
const q = systemdQuote;
|
|
221
|
+
const after = ['network-online.target', ...opts.after].join(' ');
|
|
222
|
+
const extraEnvLines = Object.entries(opts.extraEnv)
|
|
223
|
+
.map(([key, value]) => `Environment="${key}=${q(value)}"`)
|
|
224
|
+
.join('\n');
|
|
225
|
+
const userLine = opts.scope === 'system' && opts.runAs ? `User=${opts.runAs}\n` : '';
|
|
226
|
+
// Unquoted, like WorkingDirectory= above: EnvironmentFile= takes a single
|
|
227
|
+
// bare path (with an optional leading `-`), not a quoted value — wrapping it
|
|
228
|
+
// in quotes makes systemd read the quote character as part of the path and
|
|
229
|
+
// reject it as "not absolute" (verified with `systemd-analyze verify`).
|
|
230
|
+
const envFileLine = opts.envFile ? `EnvironmentFile=-${opts.envFile}\n` : '';
|
|
231
|
+
const wantedBy = opts.scope === 'system' ? 'multi-user.target' : 'default.target';
|
|
152
232
|
return `[Unit]
|
|
153
233
|
Description=Claude Gateway
|
|
154
234
|
Documentation=https://github.com/0xMaxMa/claude-gateway
|
|
155
235
|
Wants=network-online.target
|
|
156
|
-
After
|
|
236
|
+
After=${after}
|
|
157
237
|
|
|
158
238
|
[Service]
|
|
159
|
-
|
|
160
|
-
#
|
|
239
|
+
# exec (not simple): the unit is only considered "started" once the actual
|
|
240
|
+
# execve() succeeds — simple would report started right after fork(), before
|
|
241
|
+
# knowing whether ExecStart could even run at all, which reports a healthier
|
|
242
|
+
# state than reality if the binary/entry can't be exec'd. No known downside
|
|
243
|
+
# for a plain, long-running daemon like this.
|
|
244
|
+
Type=exec
|
|
245
|
+
${userLine}# WorkingDirectory is deliberately unquoted, unlike every other value here:
|
|
161
246
|
# systemd takes the rest of the line as the path, and rejects a quoted one
|
|
162
247
|
# ("path is not absolute"). Escaping is unnecessary for the same reason.
|
|
163
248
|
WorkingDirectory=${spec.cwd}
|
|
164
249
|
Environment="HOME=${q(spec.home)}"
|
|
165
250
|
Environment="PATH=${q(spec.pathEnv)}"
|
|
166
251
|
Environment="GATEWAY_CONFIG=${q(spec.config)}"
|
|
167
|
-
ExecStart="${q(spec.node)}" "${q(spec.entry)}" gateway start --config "${q(spec.config)}"
|
|
252
|
+
${extraEnvLines ? extraEnvLines + '\n' : ''}${envFileLine}ExecStart="${q(spec.node)}" "${q(spec.entry)}" gateway start --config "${q(spec.config)}"
|
|
168
253
|
Restart=always
|
|
169
254
|
RestartSec=5
|
|
255
|
+
# Without this, systemd's default OOMPolicy=stop treats an OOM-killed
|
|
256
|
+
# process ANYWHERE in this unit's cgroup (e.g. a dev server an agent spawned
|
|
257
|
+
# on its own) as the whole unit failing, and Restart=always then restarts
|
|
258
|
+
# the entire gateway — dropping every agent's session for an OOM kill that
|
|
259
|
+
# had nothing to do with gateway health.
|
|
260
|
+
OOMPolicy=continue
|
|
170
261
|
|
|
171
262
|
[Install]
|
|
172
|
-
WantedBy
|
|
263
|
+
WantedBy=${wantedBy}
|
|
173
264
|
`;
|
|
174
265
|
}
|
|
175
266
|
/** The exact argv `service install --manager pm2` would run. Exported for
|
|
@@ -190,8 +281,27 @@ function pm2StartArgs(spec) {
|
|
|
190
281
|
spec.config,
|
|
191
282
|
];
|
|
192
283
|
}
|
|
193
|
-
function unitPath() {
|
|
194
|
-
return
|
|
284
|
+
function unitPath(scope) {
|
|
285
|
+
return scope === 'system'
|
|
286
|
+
? path.join('/etc', 'systemd', 'system', UNIT_NAME)
|
|
287
|
+
: path.join(os.homedir(), '.config', 'systemd', 'user', UNIT_NAME);
|
|
288
|
+
}
|
|
289
|
+
/** True when this process is already root. Systemd `--scope system` never
|
|
290
|
+
* auto-escalates via sudo (unlike `gateway restart`/`stop`, which is driving
|
|
291
|
+
* an *existing* unit rather than deciding what account a new one runs as) —
|
|
292
|
+
* it fails fast instead, so the caller stays in control of the escalation. */
|
|
293
|
+
function isRoot() {
|
|
294
|
+
return typeof process.getuid === 'function' && process.getuid() === 0;
|
|
295
|
+
}
|
|
296
|
+
/** `systemctl`/`journalctl` args for the given scope. */
|
|
297
|
+
function scopeCliArgs(scope) {
|
|
298
|
+
return scope === 'user' ? ['--user'] : [];
|
|
299
|
+
}
|
|
300
|
+
/** Same, rendered for a hint message — with a trailing space so `system`
|
|
301
|
+
* scope (an empty args array) doesn't leave a double space before the next
|
|
302
|
+
* word, e.g. "journalctl -u ...". */
|
|
303
|
+
function scopeHintPrefix(scope) {
|
|
304
|
+
return scope === 'user' ? '--user ' : '';
|
|
195
305
|
}
|
|
196
306
|
function run(file, args) {
|
|
197
307
|
(0, child_process_1.execFileSync)(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -240,13 +350,13 @@ async function waitForHealth(config, flags) {
|
|
|
240
350
|
}
|
|
241
351
|
return false;
|
|
242
352
|
}
|
|
243
|
-
// ─── systemd
|
|
353
|
+
// ─── systemd ───────────────────────────────────────────────────────────────
|
|
244
354
|
/** `is-enabled`/`is-active` for `UNIT_NAME` at the given scope — the query
|
|
245
|
-
* both `systemdState()` (
|
|
246
|
-
* `
|
|
247
|
-
* need, differing only in scope. A named boolean rather than a raw
|
|
248
|
-
* fragment (`['--user']`/`[]`) so a future call site passing the wrong
|
|
249
|
-
* fails to compile instead of silently querying the wrong scope. */
|
|
355
|
+
* both `systemdState()` (the install's own scope, for `service status`) and
|
|
356
|
+
* `crossScopeConflict()` (the opposite scope, for the install-time check
|
|
357
|
+
* below) need, differing only in scope. A named boolean rather than a raw
|
|
358
|
+
* argv fragment (`['--user']`/`[]`) so a future call site passing the wrong
|
|
359
|
+
* array fails to compile instead of silently querying the wrong scope. */
|
|
250
360
|
function unitFlagState(userScope) {
|
|
251
361
|
const scopeArgs = userScope ? ['--user'] : [];
|
|
252
362
|
let enabled = false;
|
|
@@ -265,97 +375,372 @@ function unitFlagState(userScope) {
|
|
|
265
375
|
}
|
|
266
376
|
return { enabled, active };
|
|
267
377
|
}
|
|
268
|
-
function systemdState() {
|
|
269
|
-
return { installed: fs.existsSync(unitPath()), ...unitFlagState(
|
|
378
|
+
function systemdState(scope) {
|
|
379
|
+
return { installed: fs.existsSync(unitPath(scope)), ...unitFlagState(scope === 'user') };
|
|
270
380
|
}
|
|
271
|
-
function
|
|
272
|
-
|
|
273
|
-
|
|
381
|
+
function systemdManagerLabel(scope) {
|
|
382
|
+
return scope === 'user' ? 'systemd-user' : 'systemd-system';
|
|
383
|
+
}
|
|
384
|
+
function systemdStatus(flags, scope) {
|
|
385
|
+
const state = systemdState(scope);
|
|
386
|
+
(0, output_1.printJson)({ manager: systemdManagerLabel(scope), unit: unitPath(scope), ...state }, flags);
|
|
274
387
|
return state.active ? 0 : 1;
|
|
275
388
|
}
|
|
276
389
|
/**
|
|
277
|
-
* True when a same-named unit already exists and is enabled or active at
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
390
|
+
* True when a same-named unit already exists and is enabled or active at the
|
|
391
|
+
* scope OPPOSITE to the one being installed — e.g. installing user scope
|
|
392
|
+
* while an externally-provisioned system-scope unit is already enabled (the
|
|
393
|
+
* original case, issue #450), or installing system scope while a prior
|
|
394
|
+
* user-scope install is still active (issue #457 review — a system-scope
|
|
395
|
+
* install only ever self-checked, missing this direction entirely). Either
|
|
396
|
+
* way, two independent units end up racing for the same port on the next
|
|
397
|
+
* boot.
|
|
281
398
|
*
|
|
282
|
-
* Reading
|
|
283
|
-
*
|
|
399
|
+
* Reading the opposite scope needs no privileges: `systemctl
|
|
400
|
+
* is-enabled`/`is-active` in either scope queries as any user, so this check
|
|
401
|
+
* costs nothing extra.
|
|
284
402
|
*/
|
|
285
|
-
function
|
|
286
|
-
const { enabled, active } = unitFlagState(
|
|
403
|
+
function crossScopeConflict(scope) {
|
|
404
|
+
const { enabled, active } = unitFlagState(scope === 'system');
|
|
287
405
|
return enabled || active;
|
|
288
406
|
}
|
|
289
|
-
/** Written to stderr when `
|
|
407
|
+
/** Written to stderr when `crossScopeConflict()` refuses an install — shared
|
|
290
408
|
* by both the pre-prompt check and the re-check right after `confirm()`
|
|
291
409
|
* below, so the two call sites can't drift into different wording. */
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
410
|
+
function writeCrossScopeConflictRefusal(scope) {
|
|
411
|
+
const otherScope = scope === 'user' ? 'system' : 'user';
|
|
412
|
+
const disableHint = otherScope === 'system' ? ` sudo systemctl disable --now ${UNIT_NAME}\n` : ` systemctl --user disable --now ${UNIT_NAME}\n`;
|
|
413
|
+
process.stderr.write(`A ${UNIT_NAME} unit already exists at ${otherScope} scope (${unitPath(otherScope)}) and is enabled or active.\n` +
|
|
414
|
+
`Installing a second, independent unit at ${scope} scope would race it for the port on the next reboot.\n` +
|
|
295
415
|
`Disable the existing one first, then re-run this command:\n` +
|
|
296
|
-
|
|
416
|
+
disableHint +
|
|
297
417
|
'Pass --force to install anyway.\n');
|
|
298
418
|
}
|
|
299
|
-
|
|
300
|
-
|
|
419
|
+
/** Every flag parsed below is spliced into a single line of the rendered unit
|
|
420
|
+
* verbatim (target names, env values, the run-as user, the env-file path) —
|
|
421
|
+
* a NUL or newline in any of them would let the caller inject an extra
|
|
422
|
+
* directive, or a whole new `[Section]`, into unit text meant to hold one
|
|
423
|
+
* value (NUL also throws downstream at spawn — same convention as the
|
|
424
|
+
* settings.json value guard in src/session/process.ts). Reject outright
|
|
425
|
+
* rather than encoding: this codebase never trusts external input without
|
|
426
|
+
* validating it at the boundary. */
|
|
427
|
+
function hasUnsafeUnitChars(value) {
|
|
428
|
+
return /[\0\r\n]/.test(value);
|
|
429
|
+
}
|
|
430
|
+
/** `--after <target1,target2,...>` — extra `After=` ordering targets, appended
|
|
431
|
+
* to the unit's default `network-online.target`. Comma-separated rather than
|
|
432
|
+
* a repeatable flag: the shared CLI parser (src/cli/args.ts) has no concept
|
|
433
|
+
* of repeated flags accumulating into an array, and adding one there would
|
|
434
|
+
* change every command's flag type, not just this one. */
|
|
435
|
+
function parseAfterTargets(flags) {
|
|
436
|
+
const raw = flags.after;
|
|
437
|
+
if (raw === undefined)
|
|
438
|
+
return [];
|
|
439
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
440
|
+
process.stderr.write('--after requires a comma-separated list of systemd unit/target names.\n');
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
const targets = raw
|
|
444
|
+
.split(',')
|
|
445
|
+
.map((s) => s.trim())
|
|
446
|
+
.filter(Boolean);
|
|
447
|
+
for (const target of targets) {
|
|
448
|
+
if (hasUnsafeUnitChars(target)) {
|
|
449
|
+
process.stderr.write(`Invalid --after target "${target}" — must not contain a NUL byte or line break.\n`);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
// No valid systemd unit/target name contains whitespace — a space
|
|
453
|
+
// inside one comma-separated entry almost always means the caller meant
|
|
454
|
+
// two separate targets and used a space instead of a comma by mistake.
|
|
455
|
+
// Silently accepting it would splice an unintended second `After=`
|
|
456
|
+
// target in via renderSystemdUnit()'s space-join, rather than erroring
|
|
457
|
+
// on the typo.
|
|
458
|
+
if (/\s/.test(target)) {
|
|
459
|
+
process.stderr.write(`Invalid --after target "${target}" — systemd unit/target names cannot contain whitespace (use a comma to separate multiple targets).\n`);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return targets;
|
|
464
|
+
}
|
|
465
|
+
/** `--env KEY=VALUE[,KEY=VALUE...]` — extra `Environment=` lines. Rejects
|
|
466
|
+
* malformed pairs and the three variable names the installer itself sets, so
|
|
467
|
+
* a typo silently overriding HOME/PATH/GATEWAY_CONFIG fails loudly instead of
|
|
468
|
+
* producing a unit that starts the wrong binary. Never for secrets — this
|
|
469
|
+
* text is written straight into the unit file; point at --env-file instead. */
|
|
470
|
+
function parseExtraEnv(flags) {
|
|
471
|
+
const raw = flags.env;
|
|
472
|
+
if (raw === undefined)
|
|
473
|
+
return {};
|
|
474
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
475
|
+
process.stderr.write('--env requires a comma-separated list of KEY=VALUE pairs.\n');
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const out = {};
|
|
479
|
+
for (const pair of raw.split(',')) {
|
|
480
|
+
const trimmed = pair.trim();
|
|
481
|
+
if (!trimmed)
|
|
482
|
+
continue;
|
|
483
|
+
const eq = trimmed.indexOf('=');
|
|
484
|
+
if (eq <= 0) {
|
|
485
|
+
process.stderr.write(`Invalid --env entry "${trimmed}" — expected KEY=VALUE.\n`);
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const key = trimmed.slice(0, eq);
|
|
489
|
+
const value = trimmed.slice(eq + 1);
|
|
490
|
+
if (!ENV_KEY_RE.test(key)) {
|
|
491
|
+
process.stderr.write(`Invalid --env key "${key}" — must match [A-Za-z_][A-Za-z0-9_]*.\n`);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
if (RESERVED_ENV_KEYS.has(key)) {
|
|
495
|
+
process.stderr.write(`--env cannot override "${key}" — it is set by the installer itself.\n`);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
if (hasUnsafeUnitChars(value)) {
|
|
499
|
+
process.stderr.write(`Invalid --env value for "${key}" — must not contain a NUL byte or line break.\n`);
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
out[key] = value;
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
/** `--env-file <path>` — adds `EnvironmentFile=-<path>` so a caller can feed
|
|
507
|
+
* secrets to the unit without ever putting them in its text. `-` means
|
|
508
|
+
* systemd tolerates the file being absent. Returns null (with a message
|
|
509
|
+
* already on stderr) for a path containing a line break — everything else
|
|
510
|
+
* reuses the null-on-invalid contract `resolveSystemdUnitOptions()` expects.
|
|
511
|
+
* `home` expands a leading `~` — the --run-as user's home for a system-scope
|
|
512
|
+
* install, not the installing (root) process's own. */
|
|
513
|
+
function parseEnvFile(flags, home) {
|
|
514
|
+
const raw = flags['env-file'];
|
|
515
|
+
if (raw === undefined)
|
|
516
|
+
return undefined;
|
|
517
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
518
|
+
// Distinct from "not passed at all": the shared CLI parser (src/cli/args.ts)
|
|
519
|
+
// reads a flag with no following value (last token, or immediately
|
|
520
|
+
// followed by another flag) as boolean `true` — silently treating that as
|
|
521
|
+
// "omitted" would install with no EnvironmentFile= line while the caller
|
|
522
|
+
// believes their secrets file is wired in.
|
|
523
|
+
process.stderr.write('--env-file requires a path.\n');
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const resolved = path.resolve(expandHomeAs(raw, home));
|
|
527
|
+
if (hasUnsafeUnitChars(resolved)) {
|
|
528
|
+
process.stderr.write('Invalid --env-file path — must not contain a NUL byte or line break.\n');
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
return resolved;
|
|
532
|
+
}
|
|
533
|
+
/** Validate/trim `--run-as`'s format and its combination with `scope` — not
|
|
534
|
+
* whether the named user actually exists (that's `resolveRunAsUser()`, a
|
|
535
|
+
* separate concern this function knows nothing about, since it also needs
|
|
536
|
+
* no privilege but does need a subprocess call). Returns `undefined` when
|
|
537
|
+
* `scope !== 'system'` and `--run-as` wasn't passed (the normal case), or
|
|
538
|
+
* null (message already on stderr) on any invalid combination. Split out
|
|
539
|
+
* from `resolveSystemdUnitOptions()` so the caller can resolve `--run-as`'s
|
|
540
|
+
* home *before* parsing `--env-file`, which needs that home to expand a
|
|
541
|
+
* leading `~` correctly. */
|
|
542
|
+
function resolveRunAsFlag(flags, scope) {
|
|
543
|
+
if (scope !== 'system') {
|
|
544
|
+
if (flags['run-as'] !== undefined) {
|
|
545
|
+
// Loudly rejected rather than silently dropped, like every other
|
|
546
|
+
// nonsensical combination this function checks — --run-as only means
|
|
547
|
+
// anything for a unit that runs as a fixed system account.
|
|
548
|
+
process.stderr.write('--run-as only applies to --scope system.\n');
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
if (typeof flags['run-as'] !== 'string' || flags['run-as'].trim() === '') {
|
|
554
|
+
process.stderr.write('--scope system requires --run-as <user>.\n');
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
if (hasUnsafeUnitChars(flags['run-as'])) {
|
|
558
|
+
process.stderr.write('Invalid --run-as value — must not contain a NUL byte or line break.\n');
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return flags['run-as'].trim();
|
|
562
|
+
}
|
|
563
|
+
/** Resolve and validate `--after`/`--env`/`--env-file` beyond the shared
|
|
564
|
+
* LaunchSpec triple. Returns null (with a message already on stderr) on any
|
|
565
|
+
* invalid input — install must never write a unit from a half-parsed flag
|
|
566
|
+
* set. `runAs` and `home` are already resolved by the caller (see
|
|
567
|
+
* `resolveRunAsFlag()`/`resolveRunAsUser()`). */
|
|
568
|
+
function resolveSystemdUnitOptions(flags, scope, runAs, home) {
|
|
569
|
+
const after = parseAfterTargets(flags);
|
|
570
|
+
if (after === null)
|
|
571
|
+
return null;
|
|
572
|
+
const extraEnv = parseExtraEnv(flags);
|
|
573
|
+
if (extraEnv === null)
|
|
574
|
+
return null;
|
|
575
|
+
const envFile = parseEnvFile(flags, home);
|
|
576
|
+
if (envFile === null)
|
|
577
|
+
return null;
|
|
578
|
+
return { scope, runAs, after, envFile, extraEnv };
|
|
579
|
+
}
|
|
580
|
+
async function systemdInstall(flags, config, scope) {
|
|
581
|
+
const runAsFlag = resolveRunAsFlag(flags, scope);
|
|
582
|
+
if (runAsFlag === null)
|
|
583
|
+
return 1;
|
|
584
|
+
// A --scope system install runs this CLI as root, but the rendered unit
|
|
585
|
+
// runs the gateway as runAsFlag — WorkingDirectory/HOME/config must be
|
|
586
|
+
// *that* user's, not root's, or the process starts in the wrong place with
|
|
587
|
+
// the wrong HOME entirely (getent is a read-only NSS lookup, so this needs
|
|
588
|
+
// no privilege and can run even for a --print preview). Resolved before
|
|
589
|
+
// parsing --env-file/--config below, which need this home to expand a
|
|
590
|
+
// leading `~` against the right account.
|
|
591
|
+
let home = os.homedir();
|
|
592
|
+
let runAsIds = null;
|
|
593
|
+
if (scope === 'system' && runAsFlag) {
|
|
594
|
+
const resolved = resolveRunAsUser(runAsFlag);
|
|
595
|
+
if (!resolved) {
|
|
596
|
+
process.stderr.write(`Could not resolve --run-as ${runAsFlag} via \`getent passwd\` — does that user exist on this host?\n`);
|
|
597
|
+
return 1;
|
|
598
|
+
}
|
|
599
|
+
home = resolved.home;
|
|
600
|
+
runAsIds = { uid: resolved.uid, gid: resolved.gid };
|
|
601
|
+
}
|
|
602
|
+
const unitOpts = resolveSystemdUnitOptions(flags, scope, runAsFlag, home);
|
|
603
|
+
if (!unitOpts)
|
|
604
|
+
return 1;
|
|
605
|
+
// $GATEWAY_CONFIG belongs to the installing (root) process's own
|
|
606
|
+
// environment for a system-scope install — not reliably meaningful for
|
|
607
|
+
// runAsFlag, so it's not consulted there; only an explicit --config is.
|
|
608
|
+
const spec = resolveLaunchSpec(flags, home, scope !== 'system');
|
|
301
609
|
if (!spec)
|
|
302
610
|
return 1;
|
|
303
|
-
const unit = renderSystemdUnit(spec);
|
|
304
|
-
const file = unitPath();
|
|
611
|
+
const unit = renderSystemdUnit(spec, unitOpts);
|
|
612
|
+
const file = unitPath(scope);
|
|
613
|
+
const scopeArgs = scopeCliArgs(scope);
|
|
305
614
|
// stderr, not stdout: stdout carries the JSON result (see printJson).
|
|
306
615
|
(0, prompt_1.printFilePreview)(file, unit, (line) => process.stderr.write(line + '\n'));
|
|
307
616
|
if (flags.print === true)
|
|
308
617
|
return 0;
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
618
|
+
// The root check goes here, after the print short-circuit above, matching
|
|
619
|
+
// the crossScopeConflict() check below: both are STATE/PRIVILEGE gates
|
|
620
|
+
// unrelated to what the unit would contain, so `--print` — a pure read —
|
|
621
|
+
// must never need them just to render a preview. This is narrower than
|
|
622
|
+
// "print always succeeds": resolveSystemdUnitOptions() above (e.g. the
|
|
623
|
+
// --run-as requirement) still runs first, because without valid input
|
|
624
|
+
// there is no unit content to preview at all — print can't show bytes
|
|
625
|
+
// that could never be written.
|
|
626
|
+
if (scope === 'system' && !isRoot()) {
|
|
627
|
+
process.stderr.write(`--scope system must be run as root — it writes ${unitPath('system')} and can restart a system-wide unit.\n` +
|
|
628
|
+
'Re-run as root; this command never escalates via sudo on its own.\n');
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
// Guards against installing alongside an already-enabled/active unit at the
|
|
632
|
+
// OTHER scope (either direction — see crossScopeConflict()'s doc comment).
|
|
633
|
+
// A scope's install only ever conflicts with the opposite scope, never with
|
|
634
|
+
// itself, so a repeated install of the same scope never trips this.
|
|
635
|
+
if (flags.force !== true && crossScopeConflict(scope)) {
|
|
636
|
+
writeCrossScopeConflictRefusal(scope);
|
|
317
637
|
return 1;
|
|
318
638
|
}
|
|
319
|
-
|
|
639
|
+
const confirmQuestion = scope === 'system'
|
|
640
|
+
? `Install and start ${UNIT_NAME} at system scope, running as ${unitOpts.runAs}?`
|
|
641
|
+
: `Install and start ${UNIT_NAME} for user ${os.userInfo().username}?`;
|
|
642
|
+
if (!(await confirm(flags, 'install', confirmQuestion))) {
|
|
320
643
|
process.stderr.write('Aborted — nothing was written.\n');
|
|
321
644
|
return 1;
|
|
322
645
|
}
|
|
323
646
|
// `confirm()` can block indefinitely on the y/N prompt — re-check right
|
|
324
647
|
// before writing anything, in case a conflicting unit appeared while it
|
|
325
648
|
// was waiting on the operator.
|
|
326
|
-
if (flags.force !== true &&
|
|
327
|
-
|
|
649
|
+
if (flags.force !== true && crossScopeConflict(scope)) {
|
|
650
|
+
writeCrossScopeConflictRefusal(scope);
|
|
328
651
|
return 1;
|
|
329
652
|
}
|
|
653
|
+
// Read the state a repeated install needs to decide enable vs. restart,
|
|
654
|
+
// before anything is overwritten: whether the unit is already running, and
|
|
655
|
+
// whether its content is about to change. A `daemon-reload` alone reloads
|
|
656
|
+
// systemd's cached definition but does not restart an active unit, so a
|
|
657
|
+
// repeated install used to apply an updated unit silently kept the stale
|
|
658
|
+
// one running (issue #457) — restart explicitly when content changes under
|
|
659
|
+
// an active unit, and leave an unchanged active unit alone otherwise.
|
|
660
|
+
let previousContent;
|
|
661
|
+
try {
|
|
662
|
+
previousContent = fs.readFileSync(file, 'utf8');
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
// Distinct from "no prior unit" (ENOENT, the normal first-install case):
|
|
666
|
+
// any other error (e.g. EACCES) is surfaced rather than silently treated
|
|
667
|
+
// as a fresh install, matching the unlinkSync error handling below.
|
|
668
|
+
if (err.code !== 'ENOENT') {
|
|
669
|
+
process.stderr.write(`Could not read the existing unit at ${file}: ${err.message}\n`);
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
previousContent = null;
|
|
673
|
+
}
|
|
674
|
+
const wasActive = unitFlagState(scope === 'user').active;
|
|
330
675
|
try {
|
|
676
|
+
// A --scope system install creates this directory as root — if it didn't
|
|
677
|
+
// already exist, it comes out root-owned, and the gateway (running as
|
|
678
|
+
// runAsIds, not root) would be unable to write its pid file/logs into
|
|
679
|
+
// its own WorkingDirectory. Also reassert ownership when the directory
|
|
680
|
+
// already exists but belongs to someone other than the *current*
|
|
681
|
+
// --run-as target — e.g. a prior install used a different --run-as user
|
|
682
|
+
// and this one reassigns the service to another account — rather than
|
|
683
|
+
// only checking "did this install just create it", which missed that
|
|
684
|
+
// case entirely. A directory that already belongs to the right user is
|
|
685
|
+
// left alone (no redundant chown).
|
|
686
|
+
const cwdExisted = fs.existsSync(spec.cwd);
|
|
331
687
|
fs.mkdirSync(spec.cwd, { recursive: true });
|
|
332
|
-
fs.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
688
|
+
if (runAsIds && (!cwdExisted || fs.statSync(spec.cwd).uid !== runAsIds.uid)) {
|
|
689
|
+
fs.chownSync(spec.cwd, runAsIds.uid, runAsIds.gid);
|
|
690
|
+
}
|
|
691
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: scope === 'user' ? 0o700 : 0o755 });
|
|
692
|
+
fs.writeFileSync(file, unit, { encoding: 'utf8', mode: scope === 'user' ? 0o600 : 0o644 });
|
|
693
|
+
// writeFileSync's `mode` option only applies when the file is newly
|
|
694
|
+
// created — overwriting an existing one (e.g. adopting an
|
|
695
|
+
// externally-provisioned unit, or a repeated install) leaves its current
|
|
696
|
+
// permission bits untouched. chmod explicitly so the intended mode is
|
|
697
|
+
// guaranteed either way — this file can carry --env values, so its
|
|
698
|
+
// permissions are a real boundary, not cosmetic.
|
|
699
|
+
fs.chmodSync(file, scope === 'user' ? 0o600 : 0o644);
|
|
700
|
+
run('systemctl', [...scopeArgs, 'daemon-reload']);
|
|
701
|
+
if (wasActive && previousContent !== null && previousContent !== unit) {
|
|
702
|
+
// `restart` alone doesn't guarantee the unit is enabled — if it had
|
|
703
|
+
// drifted to active-but-disabled (started manually, or an
|
|
704
|
+
// externally-provisioned unit that was only ever started, not
|
|
705
|
+
// enabled), only restarting would leave it silently unable to survive
|
|
706
|
+
// the next reboot. The pre-#457 code always ran `enable --now`
|
|
707
|
+
// unconditionally on every install, which held that invariant
|
|
708
|
+
// regardless of prior state; this branch has to keep holding it too.
|
|
709
|
+
run('systemctl', [...scopeArgs, 'enable', UNIT_NAME]);
|
|
710
|
+
run('systemctl', [...scopeArgs, 'restart', UNIT_NAME]);
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
run('systemctl', [...scopeArgs, 'enable', '--now', UNIT_NAME]);
|
|
714
|
+
}
|
|
336
715
|
}
|
|
337
716
|
catch (err) {
|
|
338
|
-
process.stderr.write(`Could not install or start the
|
|
339
|
-
`Inspect it with: systemctl
|
|
717
|
+
process.stderr.write(`Could not install or start the ${scope} service: ${err.message}\n` +
|
|
718
|
+
`Inspect it with: systemctl ${scopeHintPrefix(scope)}status ${UNIT_NAME} --no-pager\n`);
|
|
340
719
|
return 1;
|
|
341
720
|
}
|
|
342
721
|
const healthy = await waitForHealth(config, flags);
|
|
343
|
-
(0, output_1.printJson)({ manager:
|
|
344
|
-
process.stderr.write(
|
|
345
|
-
`
|
|
722
|
+
(0, output_1.printJson)({ manager: systemdManagerLabel(scope), unit: file, ...systemdState(scope), health: healthy ? 'up' : 'down' }, flags);
|
|
723
|
+
process.stderr.write(scope === 'system'
|
|
724
|
+
? `Installed ${UNIT_NAME} at system scope, running as ${unitOpts.runAs}.\n`
|
|
725
|
+
: `Installed ${UNIT_NAME}.\n` + `To keep it running after you log out: loginctl enable-linger ${os.userInfo().username}\n`);
|
|
346
726
|
if (!healthy) {
|
|
347
|
-
process.stderr.write(`Service did not answer /health yet — check: journalctl
|
|
348
|
-
return
|
|
727
|
+
process.stderr.write(`Service did not answer /health yet — check: journalctl ${scopeHintPrefix(scope)}-u ${UNIT_NAME} -n 50 --no-pager\n`);
|
|
728
|
+
return EXIT_HEALTH_TIMEOUT;
|
|
349
729
|
}
|
|
350
730
|
return 0;
|
|
351
731
|
}
|
|
352
|
-
async function systemdUninstall(flags) {
|
|
353
|
-
|
|
354
|
-
|
|
732
|
+
async function systemdUninstall(flags, scope) {
|
|
733
|
+
if (scope === 'system' && !isRoot()) {
|
|
734
|
+
process.stderr.write(`--scope system must be run as root — it removes ${unitPath('system')}.\n`);
|
|
735
|
+
return 1;
|
|
736
|
+
}
|
|
737
|
+
const file = unitPath(scope);
|
|
738
|
+
const scopeArgs = scopeCliArgs(scope);
|
|
739
|
+
const before = systemdState(scope);
|
|
355
740
|
if (!before.installed && !before.enabled && !before.active) {
|
|
356
741
|
// Nothing to stop — prompting to stop a service that isn't there only
|
|
357
742
|
// teaches people to answer these prompts without reading them.
|
|
358
|
-
(0, output_1.printJson)({ manager:
|
|
743
|
+
(0, output_1.printJson)({ manager: systemdManagerLabel(scope), unit: file, ...before }, flags);
|
|
359
744
|
process.stderr.write(`${UNIT_NAME} is not installed — nothing to remove.\n`);
|
|
360
745
|
return 0;
|
|
361
746
|
}
|
|
@@ -365,7 +750,7 @@ async function systemdUninstall(flags) {
|
|
|
365
750
|
return 1;
|
|
366
751
|
}
|
|
367
752
|
try {
|
|
368
|
-
run('systemctl', [
|
|
753
|
+
run('systemctl', [...scopeArgs, 'disable', '--now', UNIT_NAME]);
|
|
369
754
|
}
|
|
370
755
|
catch {
|
|
371
756
|
/* already stopped, or never installed */
|
|
@@ -380,7 +765,7 @@ async function systemdUninstall(flags) {
|
|
|
380
765
|
}
|
|
381
766
|
}
|
|
382
767
|
try {
|
|
383
|
-
run('systemctl', [
|
|
768
|
+
run('systemctl', [...scopeArgs, 'daemon-reload']);
|
|
384
769
|
}
|
|
385
770
|
catch {
|
|
386
771
|
/* nothing to reload without systemd */
|
|
@@ -388,10 +773,10 @@ async function systemdUninstall(flags) {
|
|
|
388
773
|
// Report what systemd actually says, not what was intended: the disable above
|
|
389
774
|
// is best-effort, and claiming a stopped service that is still running would
|
|
390
775
|
// be exactly the silent failure this codebase forbids.
|
|
391
|
-
const state = systemdState();
|
|
392
|
-
(0, output_1.printJson)({ manager:
|
|
776
|
+
const state = systemdState(scope);
|
|
777
|
+
(0, output_1.printJson)({ manager: systemdManagerLabel(scope), unit: file, ...state }, flags);
|
|
393
778
|
if (state.active || state.installed) {
|
|
394
|
-
process.stderr.write(`${UNIT_NAME} is still present — check: systemctl
|
|
779
|
+
process.stderr.write(`${UNIT_NAME} is still present — check: systemctl ${scopeHintPrefix(scope)}status ${UNIT_NAME} --no-pager\n`);
|
|
395
780
|
return 1;
|
|
396
781
|
}
|
|
397
782
|
return 0;
|
|
@@ -450,7 +835,7 @@ async function pm2Install(flags, config) {
|
|
|
450
835
|
process.stderr.write('PM2 process list saved. Run `pm2 startup` once if you also want start-on-boot.\n');
|
|
451
836
|
if (!healthy) {
|
|
452
837
|
process.stderr.write(`Service did not answer /health yet — check: pm2 logs ${PM2_NAME}\n`);
|
|
453
|
-
return
|
|
838
|
+
return EXIT_HEALTH_TIMEOUT;
|
|
454
839
|
}
|
|
455
840
|
return 0;
|
|
456
841
|
}
|
|
@@ -504,14 +889,15 @@ async function pm2Uninstall(flags) {
|
|
|
504
889
|
return entry ? 1 : 0;
|
|
505
890
|
}
|
|
506
891
|
// ─── entry point ──────────────────────────────────────────────────────────────
|
|
507
|
-
const USAGE_LINE = 'claude-gateway service <install|status|uninstall> [--manager systemd|pm2] [--
|
|
892
|
+
const USAGE_LINE = 'claude-gateway service <install|status|uninstall> [--manager systemd|pm2] [--scope user|system] [--run-as <user>] ' +
|
|
893
|
+
'[--after <target,...>] [--env-file <path>] [--env KEY=VALUE,...] [--config <path>] [--yes] [--print] [--force]';
|
|
508
894
|
/** Pick the manager to act on when `--manager` is omitted. `status`/`uninstall`
|
|
509
895
|
* act on whatever is actually installed; `install` always defaults to systemd
|
|
510
896
|
* so it can't silently pick a different manager than the one documented. */
|
|
511
|
-
function detectServiceManager(action) {
|
|
897
|
+
function detectServiceManager(action, scope) {
|
|
512
898
|
if (action === 'install')
|
|
513
899
|
return 'systemd';
|
|
514
|
-
if (fs.existsSync(unitPath()))
|
|
900
|
+
if (fs.existsSync(unitPath(scope)))
|
|
515
901
|
return 'systemd';
|
|
516
902
|
try {
|
|
517
903
|
if (pm2Entry())
|
|
@@ -522,23 +908,76 @@ function detectServiceManager(action) {
|
|
|
522
908
|
}
|
|
523
909
|
return 'systemd';
|
|
524
910
|
}
|
|
525
|
-
function parseManager(flags, action) {
|
|
911
|
+
function parseManager(flags, action, scope) {
|
|
526
912
|
const raw = flags.manager;
|
|
527
913
|
if (raw === undefined)
|
|
528
|
-
return detectServiceManager(action);
|
|
914
|
+
return detectServiceManager(action, scope);
|
|
529
915
|
if (raw === 'systemd' || raw === 'pm2')
|
|
530
916
|
return raw;
|
|
531
917
|
process.stderr.write('Unknown --manager. Expected systemd or pm2.\n');
|
|
532
918
|
return null;
|
|
533
919
|
}
|
|
920
|
+
/** Pick the systemd scope to act on when `--scope` is omitted. `install`
|
|
921
|
+
* always defaults to `user` — it must never silently write a root-owned
|
|
922
|
+
* unit nobody explicitly asked for, no matter what already exists.
|
|
923
|
+
* `status`/`uninstall` instead detect: prefer an installed user-scope unit,
|
|
924
|
+
* fall back to system-scope. Without this, a bare `service status` after a
|
|
925
|
+
* `--scope system` install silently checks the (nonexistent) user-scope path
|
|
926
|
+
* and reports "not installed" — and `service uninstall` would report success
|
|
927
|
+
* (exit 0) while the system-scope unit keeps running untouched (issue #457
|
|
928
|
+
* review). Mirrors `detectServiceManager()`'s "detect what's actually there"
|
|
929
|
+
* approach for --manager. */
|
|
930
|
+
function detectServiceScope(action) {
|
|
931
|
+
if (action === 'install')
|
|
932
|
+
return 'user';
|
|
933
|
+
const userInstalled = fs.existsSync(unitPath('user'));
|
|
934
|
+
const systemInstalled = fs.existsSync(unitPath('system'));
|
|
935
|
+
// Both installed at once (e.g. a --force system-scope install alongside a
|
|
936
|
+
// still-enabled user-scope one) is exactly the state crossScopeConflict()
|
|
937
|
+
// exists to prevent — but --force means it can happen anyway. Silently
|
|
938
|
+
// picking one would leave the other's real state unreported by `status`,
|
|
939
|
+
// or untouched by `uninstall` while it reports success (issue #457
|
|
940
|
+
// review) — refuse and make the caller say which one explicitly instead.
|
|
941
|
+
if (userInstalled && systemInstalled)
|
|
942
|
+
return null;
|
|
943
|
+
if (userInstalled)
|
|
944
|
+
return 'user';
|
|
945
|
+
if (systemInstalled)
|
|
946
|
+
return 'system';
|
|
947
|
+
return 'user';
|
|
948
|
+
}
|
|
949
|
+
function parseScope(flags, action) {
|
|
950
|
+
const raw = flags.scope;
|
|
951
|
+
if (raw === undefined) {
|
|
952
|
+
// Scope is a systemd-only concept — skip probing for it entirely when
|
|
953
|
+
// the manager is explicitly pm2. Beyond being pointless work, treating
|
|
954
|
+
// unrelated systemd unit paths as ambiguous input to a decision pm2
|
|
955
|
+
// never uses could wrongly refuse a pm2 action over a conflict that
|
|
956
|
+
// doesn't apply to it at all.
|
|
957
|
+
if (flags.manager === 'pm2')
|
|
958
|
+
return 'user';
|
|
959
|
+
const detected = detectServiceScope(action);
|
|
960
|
+
if (detected === null) {
|
|
961
|
+
process.stderr.write(`Both a user-scope and a system-scope ${UNIT_NAME} unit are installed — pass --scope user or --scope system to say which one.\n`);
|
|
962
|
+
}
|
|
963
|
+
return detected;
|
|
964
|
+
}
|
|
965
|
+
if (raw === 'user' || raw === 'system')
|
|
966
|
+
return raw;
|
|
967
|
+
process.stderr.write('Unknown --scope. Expected user or system.\n');
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
534
970
|
async function runService(positionals, flags, config = {}) {
|
|
535
971
|
const action = positionals[0];
|
|
536
972
|
if (!action) {
|
|
537
973
|
// `service --help` is a help request (0); a bare `service` is a usage error (1).
|
|
538
974
|
(0, output_2.writeCommandHelp)(flags.help === true, 'service', 'run the gateway as a systemd-user or PM2 service', USAGE_LINE, [
|
|
539
|
-
' systemd installs a user unit in ~/.config/systemd/user (no sudo).',
|
|
975
|
+
' systemd installs a user unit in ~/.config/systemd/user (no sudo) by default.',
|
|
540
976
|
' install refuses if a claude-gateway unit already exists at system scope;',
|
|
541
977
|
' --force overrides that check.',
|
|
978
|
+
' --scope system installs a root-owned unit in /etc/systemd/system instead —',
|
|
979
|
+
' requires running as root already (never escalates via sudo) and --run-as <user>.',
|
|
980
|
+
' --after, --env-file, and --env customize the generated unit further (systemd only).',
|
|
542
981
|
]);
|
|
543
982
|
return flags.help === true ? 0 : 1;
|
|
544
983
|
}
|
|
@@ -552,13 +991,30 @@ async function runService(positionals, flags, config = {}) {
|
|
|
552
991
|
process.stderr.write(`--print only applies to \`service install\` (it previews what would be written).\n`);
|
|
553
992
|
return 1;
|
|
554
993
|
}
|
|
555
|
-
const
|
|
994
|
+
const scope = parseScope(flags, action);
|
|
995
|
+
if (!scope)
|
|
996
|
+
return 1;
|
|
997
|
+
const manager = parseManager(flags, action, scope);
|
|
556
998
|
if (!manager)
|
|
557
999
|
return 1;
|
|
1000
|
+
if (manager === 'pm2') {
|
|
1001
|
+
if (scope === 'system') {
|
|
1002
|
+
process.stderr.write('--scope system only applies to the systemd manager, not pm2.\n');
|
|
1003
|
+
return 1;
|
|
1004
|
+
}
|
|
1005
|
+
// Every other systemd-only unit customization flag: reject rather than
|
|
1006
|
+
// silently drop, so `--manager pm2 --env-file secrets.env` doesn't exit 0
|
|
1007
|
+
// having wired in nothing the caller asked for.
|
|
1008
|
+
const systemdOnlyFlag = ['after', 'env', 'env-file', 'run-as'].find((name) => flags[name] !== undefined);
|
|
1009
|
+
if (systemdOnlyFlag) {
|
|
1010
|
+
process.stderr.write(`--${systemdOnlyFlag} only applies to the systemd manager, not pm2.\n`);
|
|
1011
|
+
return 1;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
558
1014
|
if (manager === 'systemd') {
|
|
559
1015
|
if (action === 'install')
|
|
560
|
-
return systemdInstall(flags, config);
|
|
561
|
-
return action === 'status' ? systemdStatus(flags) : await systemdUninstall(flags);
|
|
1016
|
+
return systemdInstall(flags, config, scope);
|
|
1017
|
+
return action === 'status' ? systemdStatus(flags, scope) : await systemdUninstall(flags, scope);
|
|
562
1018
|
}
|
|
563
1019
|
if (action === 'install')
|
|
564
1020
|
return pm2Install(flags, config);
|