@monster-orange/den 0.4.21 → 0.4.23

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 (37) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/docs/distributions.md +186 -42
  3. package/package.json +1 -1
  4. package/service/install.sh +213 -63
  5. package/src/shared/agents/den-claude.ts +13 -1
  6. package/src/shared/agents/guardrails/rotation-hook.ts +5 -0
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/rotate-settings.ts +309 -0
  9. package/src/shared/agents/rotation.ts +110 -5
  10. package/src/shared/agents/runtime.ts +6 -0
  11. package/src/shared/agents/session-name.ts +21 -1
  12. package/src/shared/den-config.ts +27 -12
  13. package/src/shared/launchd-session.ts +92 -0
  14. package/src/shared/sessions/client.ts +24 -0
  15. package/src/shared/sessions/host.ts +114 -5
  16. package/src/shared/sessions/types.ts +5 -0
  17. package/src/ui/cli/agent-ops.ts +469 -17
  18. package/src/ui/cli/agents-command.ts +200 -3
  19. package/src/ui/cli/doctor-command.ts +72 -0
  20. package/src/ui/cli/doctor-where.ts +203 -0
  21. package/src/ui/cli/index.ts +1 -1
  22. package/src/ui/cli/server-agent-ops.ts +2 -2
  23. package/src/ui/cli/service-command.ts +217 -11
  24. package/src/ui/cli/sessions-command.ts +46 -12
  25. package/src/ui/cli/web-command.ts +36 -5
  26. package/src/ui/cli/web-service.ts +160 -0
  27. package/src/ui/server/agent-routes.ts +149 -5
  28. package/src/ui/server/index.ts +17 -5
  29. package/src/ui/server/public/agents-page.js +316 -27
  30. package/src/ui/server/public/agents.html +62 -0
  31. package/src/ui/server/public/i18n/es.js +58 -2
  32. package/src/ui/server/public/i18n/ja.js +54 -2
  33. package/src/ui/server/public/settings-registry.js +204 -0
  34. package/src/ui/server/public/settings.html +43 -4
  35. package/src/ui/server/public/shell.js +56 -2
  36. package/src/ui/server/route-classes.ts +21 -0
  37. package/src/ui/server/session-routes.ts +27 -5
@@ -14,6 +14,19 @@
14
14
  * (install.sh print), the label, the paths and whose server answers on the
15
15
  * port, and writes and loads nothing; exit 1 when that server is foreign.
16
16
  * `status` also names a foreign server on the port.
17
+ *
18
+ * D18: `install --mode login|always`. login: LaunchAgents in the desktop
19
+ * session, stopped at logout. always: LaunchDaemons that run as you, kept
20
+ * across logout (one sudo). Without --mode, the mode already installed
21
+ * (else login): re-running install never changes it. Both modes also
22
+ * install the session host as its own job, on Den's node copy
23
+ * (`<stateDir>/bin/<cli>-sessions-host`, the path Full Disk Access is
24
+ * granted to), and `<cli> sessions` then waits for that job instead of
25
+ * starting a host of its own.
26
+ *
27
+ * D26: "running" means the server answering on the port is the job's own
28
+ * process (its pid is launchd's). A plain `web up` server of this install
29
+ * holding the port is stopped first; `status` says when one holds it.
17
30
  */
18
31
  import {
19
32
  ownerGithubLine,
@@ -21,8 +34,15 @@ import {
21
34
  type OwnerGithubResult,
22
35
  } from "../../shared/owner-github.js";
23
36
  import { spawnSync } from "child_process";
37
+ import * as fs from "fs";
24
38
  import * as os from "os";
25
39
  import * as path from "path";
40
+ import { getStateDir } from "../../shared/paths.js";
41
+ import {
42
+ serviceJobState,
43
+ type ServiceJob,
44
+ type ServiceMode,
45
+ } from "./doctor-where.js";
26
46
  import {
27
47
  readDenConfig,
28
48
  readWebServiceConfig,
@@ -31,6 +51,7 @@ import {
31
51
  import { readWebPort } from "../../shared/edge/config.js";
32
52
  import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
33
53
  import { product } from "../../shared/product.js";
54
+ import { ALWAYS_UNPROVEN } from "../../shared/launchd-session.js";
34
55
  import {
35
56
  classifyServerInfo,
36
57
  ForeignServerError,
@@ -53,7 +74,7 @@ const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
53
74
 
54
75
  /** The flags each subcommand takes; any other is refused before acting. */
55
76
  const SUBCOMMAND_FLAGS: Record<(typeof SUBCOMMANDS)[number], string[]> = {
56
- install: ["--plan"],
77
+ install: ["--plan", "--mode"],
57
78
  status: [],
58
79
  print: [],
59
80
  uninstall: [],
@@ -105,6 +126,14 @@ export async function waitForServerInfo(
105
126
 
106
127
  export interface ServiceDeps {
107
128
  run?: ServiceRunner;
129
+ /** The state dir (default getStateDir()): Den's node copy, the host job file. */
130
+ stateDir?: string;
131
+ /** D18: the job installed for a label, if any (default: plists + launchctl). */
132
+ job?: (label: string) => ServiceJob | null;
133
+ /** D26: the pid launchd runs a job as, or null (default: launchctl print). */
134
+ jobPid?: (job: ServiceJob, label: string) => number | null;
135
+ /** D26: stop a plain server of this install (default: SIGTERM, wait). */
136
+ stopPlain?: (pid: number, port: number) => Promise<boolean>;
108
137
  /** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
109
138
  recordOwner?: () => OwnerGithubResult;
110
139
  config?: DenConfig;
@@ -142,20 +171,108 @@ export function identityLine(port: number, id: ServerIdentity): string {
142
171
  }
143
172
  }
144
173
 
174
+ /** D18: the session host's job label: the web label's `.web` → `.sessions`. */
175
+ export function hostLabel(label: string): string {
176
+ return `${label.replace(/\.web$/, "")}.sessions`;
177
+ }
178
+
179
+ /** D18: Den's node copy for the session host, the path Full Disk Access names. */
180
+ export function hostExePath(stateDir: string, cli: string): string {
181
+ return path.join(stateDir, "bin", `${cli}-sessions-host`);
182
+ }
183
+
184
+ /**
185
+ * D18: the file that says the session host runs as a launchd job
186
+ * (`<cli> sessions` waits for it instead of starting one).
187
+ */
188
+ export function hostJobFile(stateDir: string): string {
189
+ return path.join(stateDir, "sessions-host-job.json");
190
+ }
191
+
192
+ /** `--mode <m>` / `--mode=<m>` from install's args; undefined when absent. */
193
+ function modeArg(args: string[]): ServiceMode | undefined {
194
+ let v: string | undefined;
195
+ for (let i = 0; i < args.length; i++) {
196
+ if (args[i] === "--mode") v = args[i + 1];
197
+ else if (args[i].startsWith("--mode=")) v = args[i].slice(7);
198
+ }
199
+ if (v === undefined) return undefined;
200
+ if (v === "login" || v === "always") return v;
201
+ throw new Error(`service install --mode takes login or always, not "${v}"`);
202
+ }
203
+
204
+ /** Install's args without `--mode <m>`, for the unknown-argument check. */
205
+ function withoutMode(args: string[]): string[] {
206
+ const out: string[] = [];
207
+ for (let i = 0; i < args.length; i++) {
208
+ if (args[i] === "--mode") i++;
209
+ else if (!args[i].startsWith("--mode=")) out.push(args[i]);
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function defaultJobPid(job: ServiceJob, label: string): number | null {
215
+ const r = spawnSync("launchctl", ["print", `${job.domain}/${label}`], {
216
+ encoding: "utf-8",
217
+ timeout: 3000,
218
+ });
219
+ if (r.status !== 0) return null;
220
+ const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
221
+ return m ? Number(m[1]) : null;
222
+ }
223
+
224
+ async function defaultStopPlain(pid: number, port: number): Promise<boolean> {
225
+ const { killWebProcess } = await import("./web-command.js");
226
+ killWebProcess(pid, "SIGTERM");
227
+ const deadline = Date.now() + 10_000;
228
+ while (Date.now() < deadline) {
229
+ try {
230
+ process.kill(pid, 0);
231
+ } catch {
232
+ return true;
233
+ }
234
+ await new Promise((r) => setTimeout(r, 200));
235
+ }
236
+ void port;
237
+ return false;
238
+ }
239
+
240
+ /** `service help`: the subcommands and the two install modes. */
241
+ export function serviceHelp(cli: string): string[] {
242
+ return [
243
+ `${cli} service: run the web UI and the session host as launchd jobs (macOS)`,
244
+ ``,
245
+ ` install [--mode login|always] install (or refresh) and start them`,
246
+ ` install --plan what install would do; writes and loads nothing`,
247
+ ` status launchd's view: state, pid, last exit code`,
248
+ ` print the web plist it would install; installs nothing`,
249
+ ` uninstall stop them and remove their plists`,
250
+ ``,
251
+ ` --mode login (default) LaunchAgents in the desktop session: stop at logout, with every agent`,
252
+ ` --mode always LaunchDaemons running as you, one sudo: meant to keep agents running across logout. Opt-in, ${ALWAYS_UNPROVEN}`,
253
+ ` Without --mode, install keeps the mode already installed.`,
254
+ ];
255
+ }
256
+
145
257
  /** Run `service <sub>`; returns the exit code. */
146
258
  export async function cmdService(
147
259
  args: string[],
148
260
  deps: ServiceDeps = {},
149
261
  ): Promise<number> {
150
- const sub = args[0] as (typeof SUBCOMMANDS)[number] | undefined;
151
- if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
262
+ if (args[0] === undefined || ["help", "--help", "-h"].includes(args[0])) {
263
+ for (const l of serviceHelp(product().cli))
264
+ (deps.log ?? ((x: string) => console.log(x)))(l);
265
+ return 0;
266
+ }
267
+ const sub = args[0] as (typeof SUBCOMMANDS)[number];
268
+ if (!(SUBCOMMANDS as readonly string[]).includes(sub))
152
269
  throw new Error(
153
- `${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
270
+ `Unknown service command "${sub}". Use: ${SUBCOMMANDS.join(" | ")} (${product().cli} service help)`,
154
271
  );
155
272
  // A typo'd --plan must not install for real.
156
- const extra = args
157
- .slice(1)
158
- .filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
273
+ const extra = (
274
+ sub === "install" ? withoutMode(args.slice(1)) : args.slice(1)
275
+ ).filter((a) => !SUBCOMMAND_FLAGS[sub!].includes(a));
159
276
  if (extra.length)
160
277
  throw new Error(
161
278
  `service ${sub}: unknown argument${extra.length > 1 ? "s" : ""} ${extra.join(" ")}` +
@@ -164,6 +281,7 @@ export async function cmdService(
164
281
  : ""),
165
282
  );
166
283
  const plan = sub === "install" && args.includes("--plan");
284
+ const askedMode = sub === "install" ? modeArg(args) : undefined;
167
285
  // print (and install --plan) only write the plist to stdout; the rest
168
286
  // talk to launchd.
169
287
  if (
@@ -185,10 +303,24 @@ export async function cmdService(
185
303
  "service",
186
304
  "install.sh",
187
305
  );
188
- const env = {
306
+ const home = deps.home ?? os.homedir();
307
+ const jobOf =
308
+ deps.job ??
309
+ ((l: string) => serviceJobState(l, home, process.getuid?.() ?? 0));
310
+ const installed = jobOf(label);
311
+ // D18: without --mode, the mode it has (re-running install keeps it).
312
+ const mode: ServiceMode = askedMode ?? installed?.mode ?? "login";
313
+ const stateDir = deps.stateDir ?? getStateDir();
314
+ const env: Record<string, string> = {
189
315
  DEN_ROOT: distRoot,
190
316
  DEN_SERVICE_LABEL: label,
191
317
  DEN_SERVICE_LOGS: logs,
318
+ ...(sub === "install" || sub === "print"
319
+ ? {
320
+ DEN_SERVICE_MODE: mode,
321
+ DEN_SERVICE_HOST_EXE: hostExePath(stateDir, product().cli),
322
+ }
323
+ : {}),
192
324
  };
193
325
  const run = deps.run ?? defaultRunner;
194
326
  const log = deps.log ?? ((l: string) => console.log(l));
@@ -197,12 +329,20 @@ export async function cmdService(
197
329
  const identify =
198
330
  deps.identify ?? ((p: number, o: OwnServer) => identifyServer(p, o));
199
331
 
332
+ const jobPid = deps.jobPid ?? defaultJobPid;
200
333
  if (plan) {
201
- const home = deps.home ?? os.homedir();
334
+ const dir =
335
+ mode === "always"
336
+ ? "/Library/LaunchDaemons"
337
+ : path.join(home, "Library", "LaunchAgents");
202
338
  log(`service install --plan (read-only: nothing is written or loaded)`);
339
+ log(
340
+ ` mode: ${mode}${mode === "always" ? ` (LaunchDaemons running as you: meant to keep running across logout, ${ALWAYS_UNPROVEN}; opt-in; one sudo)` : " (LaunchAgents: stop at logout)"}`,
341
+ );
203
342
  log(` label: ${label}`);
343
+ log(` plist: ${path.join(dir, `${label}.plist`)}`);
204
344
  log(
205
- ` plist: ${path.join(home, "Library", "LaunchAgents", `${label}.plist`)}`,
345
+ ` host: ${hostLabel(label)} on ${hostExePath(stateDir, product().cli)} (grant this path Full Disk Access for protected folders)`,
206
346
  );
207
347
  log(` logs: ${path.join(logs, "web.log")}`);
208
348
  log(` root: ${distRoot}`);
@@ -210,6 +350,15 @@ export async function cmdService(
210
350
  log(` url: http://localhost:${port}`);
211
351
  const id = await identify(port, own);
212
352
  log(` server: ${identityLine(port, id)}`);
353
+ // D26: what install does with a plain server of this install.
354
+ if (
355
+ id.kind === "ours" &&
356
+ id.pid &&
357
+ id.pid !== (installed ? jobPid(installed, label) : null)
358
+ )
359
+ log(
360
+ ` install stops this plain server (pid ${id.pid}) first; the service runs it then`,
361
+ );
213
362
  log(` plist it would write:`);
214
363
  // install.sh print writes the plist to stdout only.
215
364
  const code = run("bash", [script, "print"], env);
@@ -221,6 +370,26 @@ export async function cmdService(
221
370
  return 0;
222
371
  }
223
372
 
373
+ // D26: a plain `web up` server of this install on the port would answer
374
+ // for the job (which then crash-loops on the taken port): stop it first.
375
+ if (sub === "install") {
376
+ const id = await identify(port, own);
377
+ const pid = id.kind === "ours" ? id.pid : undefined;
378
+ const theJob = installed ? jobPid(installed, label) : null;
379
+ if (pid && pid !== theJob) {
380
+ const stopped = await (deps.stopPlain ?? defaultStopPlain)(pid, port);
381
+ if (!stopped) {
382
+ log(
383
+ `✗ a plain server of this install (pid ${pid}) holds port ${port} and did not stop: ${product().cli} web down, then service install again`,
384
+ );
385
+ return 1;
386
+ }
387
+ log(
388
+ `… stopped the plain server of this install (pid ${pid}) on port ${port}: the service runs it now`,
389
+ );
390
+ }
391
+ }
392
+
224
393
  // D17: whoever installs it can connect their GitHub account from any
225
394
  // browser (web.owner_github, from gh), before the server reads it.
226
395
  if (sub === "install") {
@@ -229,18 +398,43 @@ export async function cmdService(
229
398
  (deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
230
399
  const line = ownerGithubLine(rec(), product().cli);
231
400
  if (line) log(line);
401
+ if (mode === "always")
402
+ log(`note: --mode always is opt-in and ${ALWAYS_UNPROVEN}`);
232
403
  }
233
404
  const code = run("bash", [script, sub!], {
234
405
  ...env,
235
406
  // The script then leaves saying "running" to us (after the poll below).
236
407
  ...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
237
408
  });
409
+ if (sub === "uninstall" && code === 0)
410
+ fs.rmSync(hostJobFile(stateDir), { force: true });
238
411
  if (sub === "status") {
239
412
  const id = await identify(port, own);
240
413
  if (id.kind === "foreign") log(`✗ ${identityLine(port, id)}`);
414
+ // D26: the server on the port is the job's, or say whose it is.
415
+ if (id.kind === "ours" && id.pid && installed) {
416
+ const theJob = jobPid(installed, label);
417
+ if (theJob !== id.pid) {
418
+ log(
419
+ `✗ port ${port} is served by pid ${id.pid}, not the service (${theJob ? `pid ${theJob}` : "not running"}): a plain server of this install holds it. ${product().cli} web down, then ${product().cli} service install`,
420
+ );
421
+ return 1;
422
+ }
423
+ }
241
424
  return code;
242
425
  }
243
426
  if (sub !== "install" || code !== 0) return code;
427
+ // D18: `<cli> sessions` waits for the host job instead of starting one.
428
+ try {
429
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
430
+ fs.writeFileSync(
431
+ hostJobFile(stateDir),
432
+ JSON.stringify({ label: hostLabel(label), mode }) + "\n",
433
+ { mode: 0o600 },
434
+ );
435
+ } catch {
436
+ /* sessions then start a host of their own, as before */
437
+ }
244
438
 
245
439
  const url = `http://localhost:${port}/api/server-info`;
246
440
  const waitReady =
@@ -258,8 +452,20 @@ export async function cmdService(
258
452
  return 1;
259
453
  }
260
454
  if (ready) {
455
+ // D26: the answer must come from the job itself, not from another
456
+ // process of this install on the port (the job then crash-loops).
457
+ const job = jobOf(label);
458
+ const id = await identify(port, own);
459
+ const served = id.kind === "ours" ? id.pid : undefined;
460
+ const theJob = job ? jobPid(job, label) : null;
461
+ if (job && served && theJob !== served) {
462
+ log(
463
+ `✗ ${label} installed, but port ${port} is served by pid ${served}, not the job (${theJob ? `pid ${theJob}` : "not running"}): ${product().cli} web down, then ${product().cli} service install`,
464
+ );
465
+ return 1;
466
+ }
261
467
  log(
262
- `✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
468
+ `✓ ${label} running (${mode}) at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
263
469
  );
264
470
  return 0;
265
471
  }
@@ -133,6 +133,33 @@ export interface Resolved {
133
133
  stdout: NodeJS.WriteStream;
134
134
  }
135
135
 
136
+ /**
137
+ * D18: the session host's launchd job, when `service install` set one up
138
+ * and its plist is still there: then the job starts the host (in the
139
+ * session its mode chose), never a process of ours, which would run in
140
+ * this caller's launchd session (D20: a desktop one ends at logout).
141
+ */
142
+ export function hostJob(
143
+ stateDir: string,
144
+ home: string = process.env.HOME ?? "",
145
+ exists: (p: string) => boolean = fs.existsSync,
146
+ ): { label: string; mode: string } | null {
147
+ try {
148
+ const v = JSON.parse(
149
+ fs.readFileSync(path.join(stateDir, "sessions-host-job.json"), "utf-8"),
150
+ ) as { label?: unknown; mode?: unknown };
151
+ if (typeof v.label !== "string" || !/^[\w.-]+$/.test(v.label)) return null;
152
+ const plists = [
153
+ path.join("/Library/LaunchDaemons", `${v.label}.plist`),
154
+ path.join(home, "Library", "LaunchAgents", `${v.label}.plist`),
155
+ ];
156
+ if (!plists.some((p) => exists(p))) return null;
157
+ return { label: v.label, mode: String(v.mode ?? "") };
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
136
163
  /** Start `<cli> sessions host` in the background, detached, logging to the state dir. */
137
164
  function spawnHost(stateDir: string): void {
138
165
  const cmd = cliEntrypoint();
@@ -164,17 +191,17 @@ export function resolveDeps(d: SessionsDeps): Resolved {
164
191
  cli: d.cli ?? product().cli,
165
192
  connect:
166
193
  d.connect ??
167
- (() =>
168
- ensureHost({
194
+ (() => {
195
+ const job = hostJob(stateDir());
196
+ return ensureHost({
169
197
  socket: sessionPaths(stateDir()).socket,
170
- spawnHost: () => spawnHost(stateDir()),
171
- logHint: path.join(
172
- stateDir(),
173
- "logs",
174
- product().cli,
175
- "sessions-host.log",
176
- ),
177
- })),
198
+ // D18: launchd starts (and restarts) the host job; wait for it.
199
+ spawnHost: job ? () => undefined : () => spawnHost(stateDir()),
200
+ logHint: job
201
+ ? `its launchd job ${job.label} (${job.mode}): ${product().cli} service status`
202
+ : path.join(stateDir(), "logs", product().cli, "sessions-host.log"),
203
+ });
204
+ }),
178
205
  connectExisting:
179
206
  d.connectExisting ??
180
207
  (d.connect
@@ -314,6 +341,8 @@ export function contractInfo(s: SessionInfo): SessionInfo {
314
341
  startedAt: s.startedAt,
315
342
  ...(s.exited ? { exited: true } : {}),
316
343
  ...(s.exitCode !== undefined ? { exitCode: s.exitCode } : {}),
344
+ // D20: where its host ran (Aqua: it ends at logout).
345
+ ...(s.launchd ? { launchd: s.launchd } : {}),
317
346
  };
318
347
  }
319
348
 
@@ -677,10 +706,15 @@ function attachTerminal(
677
706
 
678
707
  async function host(args: string[], d: Resolved): Promise<number> {
679
708
  const where = "sessions host";
680
- const p = parseArgs(where, args, { "--stop": false });
709
+ const p = parseArgs(where, args, { "--stop": false, "--stand-by": false });
681
710
  noExtra(where, p);
682
711
  if (!p.flags["--stop"])
683
- return runSessionHost({ stateDir: d.stateDir(), pty: () => loadPty() });
712
+ return runSessionHost({
713
+ stateDir: d.stateDir(),
714
+ pty: () => loadPty(),
715
+ // D18: the launchd job's: wait out a host started before it.
716
+ standBy: Boolean(p.flags["--stand-by"]),
717
+ });
684
718
  const c = await d.connectExisting();
685
719
  if (!c) {
686
720
  d.out("no session host is running");
@@ -2,6 +2,7 @@ import {
2
2
  ownerGithubLine,
3
3
  recordOwnerGithub,
4
4
  } from "../../shared/owner-github.js";
5
+ import { launchdSession, webUpWarning } from "../../shared/launchd-session.js";
5
6
  import chalk from "chalk";
6
7
  import { execFileSync, spawn, spawnSync } from "child_process";
7
8
  import * as fs from "fs";
@@ -69,6 +70,7 @@ import {
69
70
  } from "./server-identity.js";
70
71
 
71
72
  import { product } from "../../shared/product.js";
73
+ import { webServiceAction, type WebServiceDeps } from "./web-service.js";
72
74
  import {
73
75
  CLAIM_CODE_TTL_MS,
74
76
  claimLink,
@@ -1264,6 +1266,12 @@ export function cmdWebToken(
1264
1266
  return 0;
1265
1267
  }
1266
1268
 
1269
+ /** Tests: the launchd service seams (web-service.ts); never the real launchctl. */
1270
+ let webServiceDeps: WebServiceDeps | undefined;
1271
+ export function setWebServiceDepsForTest(d: WebServiceDeps | undefined): void {
1272
+ webServiceDeps = d;
1273
+ }
1274
+
1267
1275
  export async function cmdWeb(args: string[]): Promise<void> {
1268
1276
  // A subcommand with its own help answers its own --help (it is
1269
1277
  // dispatched first); the rest (up, down, restart, open) share this one.
@@ -1313,7 +1321,26 @@ export async function cmdWeb(args: string[]): Promise<void> {
1313
1321
  const identity = await refuseForeignServer(options.port, ownServer());
1314
1322
  const vouched = identity.kind === "ours" ? identity.pid : undefined;
1315
1323
 
1316
- if (options.action === "down") {
1324
+ // With the launchd service installed, it owns the server: restart and up
1325
+ // go through launchd, down is refused (launchd would start it again).
1326
+ const svc = await webServiceAction(
1327
+ options.action,
1328
+ options.port,
1329
+ identity.kind === "ours",
1330
+ (p) => waitForDenWeb(p),
1331
+ webServiceDeps,
1332
+ );
1333
+ if (svc.handled) {
1334
+ if (svc.code !== 0) {
1335
+ process.exitCode = svc.code;
1336
+ return;
1337
+ }
1338
+ recordRunningDenWebServer(options.port);
1339
+ }
1340
+
1341
+ if (svc.handled) {
1342
+ /* the service runs it: on to opening the browser */
1343
+ } else if (options.action === "down") {
1317
1344
  const stopped = await stopDenWebServer(options.port, vouched);
1318
1345
  if (stopped.stopped) {
1319
1346
  const mode = stopped.forced ? "force stopped" : "stopped";
@@ -1342,7 +1369,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1342
1369
  if (line) console.log(chalk.dim(`\n ${line}`));
1343
1370
  }
1344
1371
 
1345
- if (options.action === "restart") {
1372
+ if (options.action === "restart" && !svc.handled) {
1346
1373
  const stopped = await stopDenWebServer(options.port, vouched);
1347
1374
  if (stopped.pids.length > 0 && !stopped.stopped) {
1348
1375
  throw new Error(
@@ -1368,8 +1395,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
1368
1395
  );
1369
1396
  if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
1370
1397
 
1371
- const apiRunning = await isDenWebApiRunning(options.port);
1372
- let running = apiRunning && (await isDenWebUiReady(options.port));
1398
+ const apiRunning = svc.handled || (await isDenWebApiRunning(options.port));
1399
+ let running =
1400
+ svc.handled || (apiRunning && (await isDenWebUiReady(options.port)));
1373
1401
  let started: StartedServer | null = null;
1374
1402
 
1375
1403
  if (apiRunning && !running) {
@@ -1388,6 +1416,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
1388
1416
  }
1389
1417
 
1390
1418
  if (!running) {
1419
+ // D20: started from an ssh (Background) shell, it lives there.
1420
+ const where = webUpWarning(launchdSession(), product().cli);
1421
+ if (where) console.log(chalk.yellow(`\n ⚠ ${where}`));
1391
1422
  console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
1392
1423
  try {
1393
1424
  started = startDenWebServer(options);
@@ -1411,7 +1442,7 @@ export async function cmdWeb(args: string[]): Promise<void> {
1411
1442
  console.log(chalk.red(` ERROR Failed to start ${product().cli} web`));
1412
1443
  throw error;
1413
1444
  }
1414
- } else {
1445
+ } else if (!svc.handled) {
1415
1446
  recordRunningDenWebServer(options.port);
1416
1447
  console.log(
1417
1448
  chalk.green(`\n OK ${product().title} web is already running at ${url}`),
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `web up | down | restart` when this install's web server runs as a
3
+ * launchd service (`<cli> service install`).
4
+ *
5
+ * Before, `web restart` stopped the job's server and started a plain one.
6
+ * launchd brought the job straight back (KeepAlive), it took the port, and
7
+ * the plain server died seconds after "OK ... started". Now, with the
8
+ * service installed and the command on its port:
9
+ *
10
+ * - restart: `launchctl kickstart -k`, then the job's new pid and the
11
+ * service's log;
12
+ * - up: a job that is not answering is started the same way;
13
+ * - down: refused, because launchd would only start it again; it names
14
+ * `<cli> service uninstall`.
15
+ *
16
+ * In always mode the job is in the system domain: kickstart there needs
17
+ * sudo, so the command is printed for the person to run, never run here.
18
+ */
19
+ import { spawnSync } from "child_process";
20
+ import * as os from "os";
21
+ import * as path from "path";
22
+ import {
23
+ readDenConfig,
24
+ readWebServiceConfig,
25
+ } from "../../shared/den-config.js";
26
+ import { readWebPort } from "../../shared/edge/config.js";
27
+ import { getDistRoot } from "../../shared/paths.js";
28
+ import { product } from "../../shared/product.js";
29
+ import { serviceJobState, type ServiceJob } from "./doctor-where.js";
30
+
31
+ export interface WebService {
32
+ label: string;
33
+ /** The service's web.log. */
34
+ log: string;
35
+ job: ServiceJob;
36
+ }
37
+
38
+ export interface WebServiceDeps {
39
+ /** The installed service for this install's port, or null. */
40
+ service?: (port: number) => WebService | null;
41
+ /** launchctl kickstart [-k] <domain>/<label>; its exit code. */
42
+ kickstart?: (target: string, kill: boolean) => number;
43
+ /** The pid launchd runs the job as, or null. */
44
+ jobPid?: (svc: WebService) => number | null;
45
+ /** Until the job's server answers as this install's (true) or time runs out. */
46
+ waitReady?: (port: number) => Promise<boolean>;
47
+ log?: (line: string) => void;
48
+ /** Between pid checks while the job comes back (default 250 ms). */
49
+ pollMs?: number;
50
+ /** How long to wait for the job's new pid (default 30 s). */
51
+ pidTimeoutMs?: number;
52
+ }
53
+
54
+ export type WebServiceResult =
55
+ | { handled: false }
56
+ | { handled: true; code: number; pid?: number };
57
+
58
+ function defaultService(port: number): WebService | null {
59
+ if (process.platform !== "darwin") return null;
60
+ const distRoot = getDistRoot();
61
+ // The service serves this install's configured port only.
62
+ if (port !== readWebPort(distRoot)) return null;
63
+ const { label, logs } = readWebServiceConfig(
64
+ readDenConfig(),
65
+ distRoot,
66
+ os.homedir(),
67
+ );
68
+ const job = serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
69
+ if (!job || job.state !== "loaded") return null;
70
+ return { label, log: path.join(logs, "web.log"), job };
71
+ }
72
+
73
+ function defaultKickstart(target: string, kill: boolean): number {
74
+ return (
75
+ spawnSync("launchctl", ["kickstart", ...(kill ? ["-k"] : []), target], {
76
+ stdio: "ignore",
77
+ timeout: 10_000,
78
+ }).status ?? 1
79
+ );
80
+ }
81
+
82
+ function defaultJobPid(svc: WebService): number | null {
83
+ const r = spawnSync(
84
+ "launchctl",
85
+ ["print", `${svc.job.domain}/${svc.label}`],
86
+ { encoding: "utf-8", timeout: 3000 },
87
+ );
88
+ if (r.status !== 0) return null;
89
+ const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
90
+ return m ? Number(m[1]) : null;
91
+ }
92
+
93
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
94
+
95
+ /**
96
+ * What `web <action>` does when the service is installed. `answering`:
97
+ * this install's server already answers on the port. Not handled: no
98
+ * service (or another port), and `up` while it answers.
99
+ */
100
+ export async function webServiceAction(
101
+ action: "up" | "down" | "restart" | "open",
102
+ port: number,
103
+ answering: boolean,
104
+ waitReadyDefault: (port: number) => Promise<boolean>,
105
+ deps: WebServiceDeps = {},
106
+ ): Promise<WebServiceResult> {
107
+ if (action === "open") return { handled: false };
108
+ const svc = (deps.service ?? defaultService)(port);
109
+ if (!svc) return { handled: false };
110
+ if (action === "up" && answering) return { handled: false };
111
+ const log = deps.log ?? ((l: string) => console.log(l));
112
+ const { cli, title } = product();
113
+ const target = `${svc.job.domain}/${svc.label}`;
114
+
115
+ if (action === "down") {
116
+ log(
117
+ `✗ ${title} web runs as the service ${svc.label} (${svc.job.mode}): stopping it only makes launchd start it again.`,
118
+ );
119
+ log(` To stop it for good: ${cli} service uninstall`);
120
+ log(` To restart it: ${cli} web restart`);
121
+ return { handled: true, code: 1 };
122
+ }
123
+
124
+ if (svc.job.mode === "always") {
125
+ log(
126
+ `${title} web runs as the service ${svc.label} (always). Restarting it needs sudo:`,
127
+ );
128
+ log(` sudo launchctl kickstart -k ${target}`);
129
+ return { handled: true, code: 1 };
130
+ }
131
+
132
+ const jobPid = deps.jobPid ?? defaultJobPid;
133
+ const before = jobPid(svc);
134
+ const code = (deps.kickstart ?? defaultKickstart)(target, true);
135
+ if (code !== 0) {
136
+ log(`✗ launchctl kickstart -k ${target} failed (exit ${code})`);
137
+ log(` See ${svc.log}`);
138
+ return { handled: true, code: 1 };
139
+ }
140
+ // A new pid, then the server answering.
141
+ const deadline = Date.now() + (deps.pidTimeoutMs ?? 30_000);
142
+ let pid = jobPid(svc);
143
+ while ((pid === null || pid === before) && Date.now() < deadline) {
144
+ await sleep(deps.pollMs ?? 250);
145
+ pid = jobPid(svc);
146
+ }
147
+ const ready =
148
+ pid !== null &&
149
+ pid !== before &&
150
+ (await (deps.waitReady ?? waitReadyDefault)(port));
151
+ if (!ready) {
152
+ log(`✗ the service ${svc.label} did not come back. See ${svc.log}`);
153
+ return { handled: true, code: 1 };
154
+ }
155
+ log(
156
+ `OK ${title} web ${action === "up" ? "started" : "restarted"} by its service ${svc.label} (pid ${pid})`,
157
+ );
158
+ log(` Log: ${svc.log}`);
159
+ return { handled: true, code: 0, pid: pid! };
160
+ }