@0xmaxma/claude-gateway 1.8.5 → 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 (68) hide show
  1. package/README.md +72 -5
  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/packages.d.ts.map +1 -1
  27. package/dist/api/packages.js +17 -1
  28. package/dist/api/packages.js.map +1 -1
  29. package/dist/api/router.d.ts.map +1 -1
  30. package/dist/api/router.js +5 -1
  31. package/dist/api/router.js.map +1 -1
  32. package/dist/apps/agent-manager.d.ts.map +1 -1
  33. package/dist/apps/agent-manager.js +7 -2
  34. package/dist/apps/agent-manager.js.map +1 -1
  35. package/dist/apps/installer.d.ts +11 -1
  36. package/dist/apps/installer.d.ts.map +1 -1
  37. package/dist/apps/installer.js +30 -16
  38. package/dist/apps/installer.js.map +1 -1
  39. package/dist/cli/args.d.ts +6 -0
  40. package/dist/cli/args.d.ts.map +1 -1
  41. package/dist/cli/args.js +9 -1
  42. package/dist/cli/args.js.map +1 -1
  43. package/dist/cli/commands/service.d.ts +34 -4
  44. package/dist/cli/commands/service.d.ts.map +1 -1
  45. package/dist/cli/commands/service.js +581 -71
  46. package/dist/cli/commands/service.js.map +1 -1
  47. package/dist/cli/index.d.ts.map +1 -1
  48. package/dist/cli/index.js +5 -2
  49. package/dist/cli/index.js.map +1 -1
  50. package/dist/config/bootstrap.d.ts.map +1 -1
  51. package/dist/config/bootstrap.js +4 -1
  52. package/dist/config/bootstrap.js.map +1 -1
  53. package/dist/config/loader.d.ts.map +1 -1
  54. package/dist/config/loader.js +36 -0
  55. package/dist/config/loader.js.map +1 -1
  56. package/dist/config/migrator.d.ts.map +1 -1
  57. package/dist/config/migrator.js +12 -1
  58. package/dist/config/migrator.js.map +1 -1
  59. package/dist/index.js +5 -3
  60. package/dist/index.js.map +1 -1
  61. package/dist/session/process.d.ts.map +1 -1
  62. package/dist/session/process.js +19 -2
  63. package/dist/session/process.js.map +1 -1
  64. package/dist/shutdown-signals.d.ts +18 -0
  65. package/dist/shutdown-signals.d.ts.map +1 -1
  66. package/dist/shutdown-signals.js +33 -2
  67. package/dist/shutdown-signals.js.map +1 -1
  68. 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)}"
168
- Restart=on-failure
252
+ ${extraEnvLines ? extraEnvLines + '\n' : ''}${envFileLine}ExecStart="${q(spec.node)}" "${q(spec.entry)}" gateway start --config "${q(spec.config)}"
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,72 +350,397 @@ async function waitForHealth(config, flags) {
240
350
  }
241
351
  return false;
242
352
  }
243
- // ─── systemd (user scope) ─────────────────────────────────────────────────────
244
- function systemdState() {
353
+ // ─── systemd ───────────────────────────────────────────────────────────────
354
+ /** `is-enabled`/`is-active` for `UNIT_NAME` at the given scope — the query
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. */
360
+ function unitFlagState(userScope) {
361
+ const scopeArgs = userScope ? ['--user'] : [];
245
362
  let enabled = false;
246
363
  let active = false;
247
364
  try {
248
- enabled = capture('systemctl', ['--user', 'is-enabled', UNIT_NAME]).trim() === 'enabled';
365
+ enabled = capture('systemctl', [...scopeArgs, 'is-enabled', UNIT_NAME]).trim() === 'enabled';
249
366
  }
250
367
  catch {
251
368
  /* systemctl absent, or the unit is disabled/missing */
252
369
  }
253
370
  try {
254
- active = capture('systemctl', ['--user', 'is-active', UNIT_NAME]).trim() === 'active';
371
+ active = capture('systemctl', [...scopeArgs, 'is-active', UNIT_NAME]).trim() === 'active';
255
372
  }
256
373
  catch {
257
374
  /* systemctl absent, or the unit is inactive */
258
375
  }
259
- return { installed: fs.existsSync(unitPath()), enabled, active };
376
+ return { enabled, active };
377
+ }
378
+ function systemdState(scope) {
379
+ return { installed: fs.existsSync(unitPath(scope)), ...unitFlagState(scope === 'user') };
260
380
  }
261
- function systemdStatus(flags) {
262
- const state = systemdState();
263
- (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);
264
387
  return state.active ? 0 : 1;
265
388
  }
266
- async function systemdInstall(flags, config) {
267
- const spec = resolveLaunchSpec(flags);
389
+ /**
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.
398
+ *
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.
402
+ */
403
+ function crossScopeConflict(scope) {
404
+ const { enabled, active } = unitFlagState(scope === 'system');
405
+ return enabled || active;
406
+ }
407
+ /** Written to stderr when `crossScopeConflict()` refuses an install — shared
408
+ * by both the pre-prompt check and the re-check right after `confirm()`
409
+ * below, so the two call sites can't drift into different wording. */
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` +
415
+ `Disable the existing one first, then re-run this command:\n` +
416
+ disableHint +
417
+ 'Pass --force to install anyway.\n');
418
+ }
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');
268
609
  if (!spec)
269
610
  return 1;
270
- const unit = renderSystemdUnit(spec);
271
- const file = unitPath();
611
+ const unit = renderSystemdUnit(spec, unitOpts);
612
+ const file = unitPath(scope);
613
+ const scopeArgs = scopeCliArgs(scope);
272
614
  // stderr, not stdout: stdout carries the JSON result (see printJson).
273
615
  (0, prompt_1.printFilePreview)(file, unit, (line) => process.stderr.write(line + '\n'));
274
616
  if (flags.print === true)
275
617
  return 0;
276
- if (!(await confirm(flags, 'install', `Install and start ${UNIT_NAME} for user ${os.userInfo().username}?`))) {
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);
637
+ return 1;
638
+ }
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))) {
277
643
  process.stderr.write('Aborted — nothing was written.\n');
278
644
  return 1;
279
645
  }
646
+ // `confirm()` can block indefinitely on the y/N prompt — re-check right
647
+ // before writing anything, in case a conflicting unit appeared while it
648
+ // was waiting on the operator.
649
+ if (flags.force !== true && crossScopeConflict(scope)) {
650
+ writeCrossScopeConflictRefusal(scope);
651
+ return 1;
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;
280
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;
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);
281
687
  fs.mkdirSync(spec.cwd, { recursive: true });
282
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
283
- fs.writeFileSync(file, unit, { encoding: 'utf8', mode: 0o600 });
284
- run('systemctl', ['--user', 'daemon-reload']);
285
- 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
+ }
286
715
  }
287
716
  catch (err) {
288
- process.stderr.write(`Could not install or start the user service: ${err.message}\n` +
289
- `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`);
290
719
  return 1;
291
720
  }
292
721
  const healthy = await waitForHealth(config, flags);
293
- (0, output_1.printJson)({ manager: 'systemd-user', unit: file, ...systemdState(), health: healthy ? 'up' : 'down' }, flags);
294
- process.stderr.write(`Installed ${UNIT_NAME}.\n` +
295
- `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`);
296
726
  if (!healthy) {
297
- process.stderr.write(`Service did not answer /health yet — check: journalctl --user -u ${UNIT_NAME} -n 50 --no-pager\n`);
298
- 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;
299
729
  }
300
730
  return 0;
301
731
  }
302
- async function systemdUninstall(flags) {
303
- const file = unitPath();
304
- 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);
305
740
  if (!before.installed && !before.enabled && !before.active) {
306
741
  // Nothing to stop — prompting to stop a service that isn't there only
307
742
  // teaches people to answer these prompts without reading them.
308
- (0, output_1.printJson)({ manager: 'systemd-user', unit: file, ...before }, flags);
743
+ (0, output_1.printJson)({ manager: systemdManagerLabel(scope), unit: file, ...before }, flags);
309
744
  process.stderr.write(`${UNIT_NAME} is not installed — nothing to remove.\n`);
310
745
  return 0;
311
746
  }
@@ -315,7 +750,7 @@ async function systemdUninstall(flags) {
315
750
  return 1;
316
751
  }
317
752
  try {
318
- run('systemctl', ['--user', 'disable', '--now', UNIT_NAME]);
753
+ run('systemctl', [...scopeArgs, 'disable', '--now', UNIT_NAME]);
319
754
  }
320
755
  catch {
321
756
  /* already stopped, or never installed */
@@ -330,7 +765,7 @@ async function systemdUninstall(flags) {
330
765
  }
331
766
  }
332
767
  try {
333
- run('systemctl', ['--user', 'daemon-reload']);
768
+ run('systemctl', [...scopeArgs, 'daemon-reload']);
334
769
  }
335
770
  catch {
336
771
  /* nothing to reload without systemd */
@@ -338,10 +773,10 @@ async function systemdUninstall(flags) {
338
773
  // Report what systemd actually says, not what was intended: the disable above
339
774
  // is best-effort, and claiming a stopped service that is still running would
340
775
  // be exactly the silent failure this codebase forbids.
341
- const state = systemdState();
342
- (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);
343
778
  if (state.active || state.installed) {
344
- 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`);
345
780
  return 1;
346
781
  }
347
782
  return 0;
@@ -400,7 +835,7 @@ async function pm2Install(flags, config) {
400
835
  process.stderr.write('PM2 process list saved. Run `pm2 startup` once if you also want start-on-boot.\n');
401
836
  if (!healthy) {
402
837
  process.stderr.write(`Service did not answer /health yet — check: pm2 logs ${PM2_NAME}\n`);
403
- return 1;
838
+ return EXIT_HEALTH_TIMEOUT;
404
839
  }
405
840
  return 0;
406
841
  }
@@ -454,14 +889,15 @@ async function pm2Uninstall(flags) {
454
889
  return entry ? 1 : 0;
455
890
  }
456
891
  // ─── entry point ──────────────────────────────────────────────────────────────
457
- const USAGE_LINE = 'claude-gateway service <install|status|uninstall> [--manager systemd|pm2] [--config <path>] [--yes] [--print]';
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]';
458
894
  /** Pick the manager to act on when `--manager` is omitted. `status`/`uninstall`
459
895
  * act on whatever is actually installed; `install` always defaults to systemd
460
896
  * so it can't silently pick a different manager than the one documented. */
461
- function detectServiceManager(action) {
897
+ function detectServiceManager(action, scope) {
462
898
  if (action === 'install')
463
899
  return 'systemd';
464
- if (fs.existsSync(unitPath()))
900
+ if (fs.existsSync(unitPath(scope)))
465
901
  return 'systemd';
466
902
  try {
467
903
  if (pm2Entry())
@@ -472,20 +908,77 @@ function detectServiceManager(action) {
472
908
  }
473
909
  return 'systemd';
474
910
  }
475
- function parseManager(flags, action) {
911
+ function parseManager(flags, action, scope) {
476
912
  const raw = flags.manager;
477
913
  if (raw === undefined)
478
- return detectServiceManager(action);
914
+ return detectServiceManager(action, scope);
479
915
  if (raw === 'systemd' || raw === 'pm2')
480
916
  return raw;
481
917
  process.stderr.write('Unknown --manager. Expected systemd or pm2.\n');
482
918
  return null;
483
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
+ }
484
970
  async function runService(positionals, flags, config = {}) {
485
971
  const action = positionals[0];
486
972
  if (!action) {
487
973
  // `service --help` is a help request (0); a bare `service` is a usage error (1).
488
- (0, output_2.writeCommandHelp)(flags.help === true, 'service', 'run the gateway as a systemd-user or PM2 service', USAGE_LINE, [' systemd installs a user unit in ~/.config/systemd/user (no sudo).']);
974
+ (0, output_2.writeCommandHelp)(flags.help === true, 'service', 'run the gateway as a systemd-user or PM2 service', USAGE_LINE, [
975
+ ' systemd installs a user unit in ~/.config/systemd/user (no sudo) by default.',
976
+ ' install refuses if a claude-gateway unit already exists at system scope;',
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).',
981
+ ]);
489
982
  return flags.help === true ? 0 : 1;
490
983
  }
491
984
  if (action !== 'install' && action !== 'status' && action !== 'uninstall') {
@@ -498,13 +991,30 @@ async function runService(positionals, flags, config = {}) {
498
991
  process.stderr.write(`--print only applies to \`service install\` (it previews what would be written).\n`);
499
992
  return 1;
500
993
  }
501
- const manager = parseManager(flags, action);
994
+ const scope = parseScope(flags, action);
995
+ if (!scope)
996
+ return 1;
997
+ const manager = parseManager(flags, action, scope);
502
998
  if (!manager)
503
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
+ }
504
1014
  if (manager === 'systemd') {
505
1015
  if (action === 'install')
506
- return systemdInstall(flags, config);
507
- 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);
508
1018
  }
509
1019
  if (action === 'install')
510
1020
  return pm2Install(flags, config);