@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.
Files changed (54) hide show
  1. package/README.md +63 -4
  2. package/dist/agent/dreaming/config.d.ts +3 -1
  3. package/dist/agent/dreaming/config.d.ts.map +1 -1
  4. package/dist/agent/dreaming/config.js +14 -3
  5. package/dist/agent/dreaming/config.js.map +1 -1
  6. package/dist/agent/dreaming/index.d.ts +2 -0
  7. package/dist/agent/dreaming/index.d.ts.map +1 -1
  8. package/dist/agent/dreaming/index.js +1 -1
  9. package/dist/agent/dreaming/index.js.map +1 -1
  10. package/dist/agent/knowledge/config.d.ts +3 -1
  11. package/dist/agent/knowledge/config.d.ts.map +1 -1
  12. package/dist/agent/knowledge/config.js +13 -3
  13. package/dist/agent/knowledge/config.js.map +1 -1
  14. package/dist/agent/runner.d.ts +19 -0
  15. package/dist/agent/runner.d.ts.map +1 -1
  16. package/dist/agent/runner.js +56 -2
  17. package/dist/agent/runner.js.map +1 -1
  18. package/dist/agent/skill-learning/config.d.ts +3 -1
  19. package/dist/agent/skill-learning/config.d.ts.map +1 -1
  20. package/dist/agent/skill-learning/config.js +13 -3
  21. package/dist/agent/skill-learning/config.js.map +1 -1
  22. package/dist/agent/skill-learning/index.d.ts +2 -0
  23. package/dist/agent/skill-learning/index.d.ts.map +1 -1
  24. package/dist/agent/skill-learning/index.js +1 -1
  25. package/dist/agent/skill-learning/index.js.map +1 -1
  26. package/dist/api/router.d.ts.map +1 -1
  27. package/dist/api/router.js +5 -1
  28. package/dist/api/router.js.map +1 -1
  29. package/dist/apps/agent-manager.d.ts.map +1 -1
  30. package/dist/apps/agent-manager.js +7 -2
  31. package/dist/apps/agent-manager.js.map +1 -1
  32. package/dist/apps/installer.d.ts +3 -1
  33. package/dist/apps/installer.d.ts.map +1 -1
  34. package/dist/apps/installer.js +8 -2
  35. package/dist/apps/installer.js.map +1 -1
  36. package/dist/cli/commands/service.d.ts +34 -4
  37. package/dist/cli/commands/service.d.ts.map +1 -1
  38. package/dist/cli/commands/service.js +552 -96
  39. package/dist/cli/commands/service.js.map +1 -1
  40. package/dist/config/bootstrap.d.ts.map +1 -1
  41. package/dist/config/bootstrap.js +4 -1
  42. package/dist/config/bootstrap.js.map +1 -1
  43. package/dist/config/loader.d.ts.map +1 -1
  44. package/dist/config/loader.js +36 -0
  45. package/dist/config/loader.js.map +1 -1
  46. package/dist/config/migrator.d.ts.map +1 -1
  47. package/dist/config/migrator.js +12 -1
  48. package/dist/config/migrator.js.map +1 -1
  49. package/dist/index.js +5 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/session/process.d.ts.map +1 -1
  52. package/dist/session/process.js +19 -2
  53. package/dist/session/process.js.map +1 -1
  54. 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 installs a *user* unit (`~/.config/systemd/user/`) so no privilege
55
- * escalation is needed and the service runs as the same user that owns
56
- * ~/.claude-gateway. PM2 is offered for hosts that already standardise on it.
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
- function gatewayHome() {
69
- return path.join(os.homedir(), '.claude-gateway');
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
- function configPath(flags) {
72
- const explicit = typeof flags.config === 'string' ? flags.config : process.env.GATEWAY_CONFIG;
73
- return path.resolve((0, paths_1.expandHome)(explicit ?? path.join(gatewayHome(), 'config.json')));
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(os.homedir(), '.local', 'bin'),
90
- path.join(os.homedir(), '.bun', 'bin'),
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
- function resolveLaunchSpec(flags) {
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: os.homedir(),
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=network-online.target
236
+ After=${after}
157
237
 
158
238
  [Service]
159
- Type=simple
160
- # WorkingDirectory is deliberately unquoted, unlike every other value here:
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=default.target
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 path.join(os.homedir(), '.config', 'systemd', 'user', UNIT_NAME);
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 (user scope) ─────────────────────────────────────────────────────
353
+ // ─── systemd ───────────────────────────────────────────────────────────────
244
354
  /** `is-enabled`/`is-active` for `UNIT_NAME` at the given scope — the query
245
- * both `systemdState()` (user scope, for `service status`) and
246
- * `systemScopeConflict()` (system scope, for the install-time check below)
247
- * need, differing only in scope. A named boolean rather than a raw argv
248
- * fragment (`['--user']`/`[]`) so a future call site passing the wrong array
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(true) };
378
+ function systemdState(scope) {
379
+ return { installed: fs.existsSync(unitPath(scope)), ...unitFlagState(scope === 'user') };
270
380
  }
271
- function systemdStatus(flags) {
272
- const state = systemdState();
273
- (0, output_1.printJson)({ manager: 'systemd-user', unit: unitPath(), ...state }, flags);
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
- * *system* scope (`/etc/systemd/system/`) — e.g. from an externally
279
- * provisioned image. Installing the user-scope unit alongside it would leave
280
- * both `enabled`, racing for the port on the next reboot (see issue #450).
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 system scope needs no privileges: `systemctl is-enabled`/`is-active`
283
- * without `--user` queries it as any user, so this check costs nothing extra.
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 systemScopeConflict() {
286
- const { enabled, active } = unitFlagState(false);
403
+ function crossScopeConflict(scope) {
404
+ const { enabled, active } = unitFlagState(scope === 'system');
287
405
  return enabled || active;
288
406
  }
289
- /** Written to stderr when `systemScopeConflict()` refuses an install — shared
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 writeSystemScopeConflictRefusal() {
293
- process.stderr.write(`A ${UNIT_NAME} unit already exists at system scope (/etc/systemd/system/${UNIT_NAME}) and is enabled or active.\n` +
294
- `Installing a second, independent unit at user scope would race it for the port on the next reboot.\n` +
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
- ` sudo systemctl disable --now ${UNIT_NAME}\n` +
416
+ disableHint +
297
417
  'Pass --force to install anyway.\n');
298
418
  }
299
- async function systemdInstall(flags, config) {
300
- const spec = resolveLaunchSpec(flags);
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
- // Refuse by default rather than only warning: a warning a user proceeds
310
- // past (or never reads) still ends up with two enabled units. Disabling the
311
- // conflicting unit automatically would need sudodeliberately outside
312
- // this command's scope (see the file-level comment) and it may belong to
313
- // a provisioning system this installer has no context on, so this stops
314
- // and hands back the exact command to resolve it instead.
315
- if (flags.force !== true && systemScopeConflict()) {
316
- writeSystemScopeConflictRefusal();
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
- if (!(await confirm(flags, 'install', `Install and start ${UNIT_NAME} for user ${os.userInfo().username}?`))) {
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 && systemScopeConflict()) {
327
- writeSystemScopeConflictRefusal();
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.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
333
- fs.writeFileSync(file, unit, { encoding: 'utf8', mode: 0o600 });
334
- run('systemctl', ['--user', 'daemon-reload']);
335
- run('systemctl', ['--user', 'enable', '--now', UNIT_NAME]);
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 user service: ${err.message}\n` +
339
- `Inspect it with: systemctl --user status ${UNIT_NAME} --no-pager\n`);
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: 'systemd-user', unit: file, ...systemdState(), health: healthy ? 'up' : 'down' }, flags);
344
- process.stderr.write(`Installed ${UNIT_NAME}.\n` +
345
- `To keep it running after you log out: loginctl enable-linger ${os.userInfo().username}\n`);
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 --user -u ${UNIT_NAME} -n 50 --no-pager\n`);
348
- return 1;
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
- const file = unitPath();
354
- const before = systemdState();
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: 'systemd-user', unit: file, ...before }, flags);
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', ['--user', 'disable', '--now', UNIT_NAME]);
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', ['--user', 'daemon-reload']);
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: 'systemd-user', unit: file, ...state }, flags);
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 --user status ${UNIT_NAME} --no-pager\n`);
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 1;
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] [--config <path>] [--yes] [--print] [--force]';
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 manager = parseManager(flags, action);
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);