@monster-orange/den 0.4.21 → 0.4.22

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * D18/D20: doctor's "where it runs" rows (macOS only).
3
+ *
4
+ * server the web server's launchd session (/api/server-info)
5
+ * session host the session host's (its hello), where every agent runs
6
+ * full disk access whether the host (and so every agent) has it, and
7
+ * the executable to grant it to: Den's node copy, never the
8
+ * shared node
9
+ * service the installed launchd job: always (a LaunchDaemon, kept
10
+ * across logout) or login (a LaunchAgent in the desktop
11
+ * session), and "plist present, job not loaded" with the
12
+ * bootstrap that loads it
13
+ *
14
+ * A process in the desktop session (Aqua) stops at logout: a warning with
15
+ * the fix. None of these fails doctor.
16
+ */
17
+ import { spawnSync } from "child_process";
18
+ import * as fs from "fs";
19
+ import * as path from "path";
20
+ import {
21
+ ALWAYS_UNPROVEN,
22
+ endsAtLogout,
23
+ sessionWarning,
24
+ } from "../../shared/launchd-session.js";
25
+
26
+ export type ServiceMode = "always" | "login";
27
+
28
+ export interface ServiceJob {
29
+ /** unknown: launchctl could not be asked. */
30
+ state: "loaded" | "not-loaded" | "unknown";
31
+ mode: ServiceMode;
32
+ plist: string;
33
+ domain: string;
34
+ }
35
+
36
+ export interface WhereDeps {
37
+ cli: string;
38
+ platform: NodeJS.Platform;
39
+ /** The web server's launchd session; null when it does not answer. */
40
+ server: () => Promise<{ launchd?: string | null } | null>;
41
+ /** The running session host's; null when none runs. */
42
+ host: () => Promise<{
43
+ launchd: string | null;
44
+ access?: { exe: string; fullDiskAccess: boolean | null } | null;
45
+ } | null>;
46
+ service: () => ServiceJob | null;
47
+ }
48
+
49
+ export interface WhereRow {
50
+ what: "server" | "session host" | "full disk access" | "service";
51
+ session: string;
52
+ status: "ok" | "warn";
53
+ detail?: string;
54
+ }
55
+
56
+ function sessionRow(
57
+ what: "server" | "session host",
58
+ launchd: string | null | undefined,
59
+ cli: string,
60
+ ): WhereRow {
61
+ const session = launchd || "unknown";
62
+ return {
63
+ what,
64
+ session,
65
+ status: endsAtLogout(launchd) ? "warn" : "ok",
66
+ ...(sessionWarning(launchd, cli, what)
67
+ ? { detail: sessionWarning(launchd, cli, what)! }
68
+ : {}),
69
+ };
70
+ }
71
+
72
+ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
73
+ if (d.platform !== "darwin") return [];
74
+ const rows: WhereRow[] = [];
75
+ const server = await d.server().catch(() => null);
76
+ rows.push(
77
+ server
78
+ ? sessionRow("server", server.launchd, d.cli)
79
+ : {
80
+ // Not started yet is not a problem (a fresh install).
81
+ what: "server",
82
+ session: "not running",
83
+ status: "ok",
84
+ detail: `start it: ${d.cli} web up, or ${d.cli} service install`,
85
+ },
86
+ );
87
+ const host = await d.host().catch(() => null);
88
+ rows.push(
89
+ host
90
+ ? sessionRow("session host", host.launchd, d.cli)
91
+ : {
92
+ what: "session host",
93
+ session: "not running",
94
+ status: "ok",
95
+ detail: "it starts when the first terminal or agent does",
96
+ },
97
+ );
98
+ const fda = host?.access;
99
+ if (fda && fda.fullDiskAccess !== null) {
100
+ const own = /-sessions-host$/.test(fda.exe);
101
+ rows.push(
102
+ fda.fullDiskAccess
103
+ ? {
104
+ what: "full disk access",
105
+ session: "granted",
106
+ status: "ok",
107
+ detail: `to ${fda.exe}`,
108
+ }
109
+ : {
110
+ what: "full disk access",
111
+ session: "not granted",
112
+ status: "warn",
113
+ detail: own
114
+ ? `agents can't open protected folders (other volumes, Desktop, Documents): System Settings → Privacy & Security → Full Disk Access → + → ${fda.exe}, then ${d.cli} sessions host --stop (it restarts with the grant)`
115
+ : `agents can't open protected folders. The host runs the shared node (${fda.exe}): ${d.cli} service install gives it its own copy to grant, so the grant never goes to every node program`,
116
+ },
117
+ );
118
+ }
119
+ const svc = d.service();
120
+ if (!svc)
121
+ rows.push({
122
+ what: "service",
123
+ session: "not installed",
124
+ status: "ok",
125
+ detail: `${d.cli} service install (login, the default; --mode always is opt-in and ${ALWAYS_UNPROVEN})`,
126
+ });
127
+ else if (svc.state === "not-loaded")
128
+ rows.push({
129
+ what: "service",
130
+ session: `${svc.mode} (${svc.domain})`,
131
+ status: "warn",
132
+ detail: `plist present, job not loaded: ${svc.domain === "system" ? "sudo " : ""}launchctl bootstrap ${svc.domain} ${svc.plist}`,
133
+ });
134
+ else
135
+ rows.push({
136
+ what: "service",
137
+ session: `${svc.mode} (${svc.domain})`,
138
+ status: "ok",
139
+ ...(svc.state === "unknown"
140
+ ? { detail: "launchctl could not be asked whether it is loaded" }
141
+ : svc.mode === "always"
142
+ ? { detail: `opt-in: ${ALWAYS_UNPROVEN}` }
143
+ : {}),
144
+ });
145
+ return rows;
146
+ }
147
+
148
+ export interface JobProbe {
149
+ exists: (p: string) => boolean;
150
+ /** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
151
+ print: (target: string) => number | null;
152
+ }
153
+
154
+ const realProbe: JobProbe = {
155
+ exists: (p) => fs.existsSync(p),
156
+ print: (target) =>
157
+ spawnSync("launchctl", ["print", target], {
158
+ stdio: "ignore",
159
+ timeout: 3000,
160
+ }).status,
161
+ };
162
+
163
+ /**
164
+ * The installed service job for `label`: the LaunchDaemon (always) when
165
+ * its plist exists, else the LaunchAgent (login); null when neither does.
166
+ */
167
+ export function serviceJobState(
168
+ label: string,
169
+ home: string,
170
+ uid: number,
171
+ probe: JobProbe = realProbe,
172
+ ): ServiceJob | null {
173
+ const daemon = path.join("/Library/LaunchDaemons", `${label}.plist`);
174
+ const agent = path.join(home, "Library", "LaunchAgents", `${label}.plist`);
175
+ const found: [ServiceMode, string, string] | null = probe.exists(daemon)
176
+ ? ["always", daemon, "system"]
177
+ : probe.exists(agent)
178
+ ? ["login", agent, `gui/${uid}`]
179
+ : null;
180
+ if (!found) return null;
181
+ const [mode, plist, domain] = found;
182
+ const code = probe.print(`${domain}/${label}`);
183
+ const state =
184
+ code === 0
185
+ ? "loaded"
186
+ : code === null || code === 127
187
+ ? "unknown"
188
+ : "not-loaded";
189
+ return { state, mode, plist, domain };
190
+ }
191
+
192
+ /** The rows as doctor prints them, under a heading. */
193
+ export function formatWhere(rows: WhereRow[], title: string): string[] {
194
+ if (!rows.length) return [];
195
+ const lines = ["", `${title} doctor: where it runs`, ""];
196
+ const w = Math.max(...rows.map((r) => r.session.length));
197
+ for (const r of rows) {
198
+ const mark = r.status === "ok" ? "✓" : "⚠";
199
+ lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
200
+ if (r.detail) lines.push(` ${r.status === "ok" ? "" : "⚠ "}${r.detail}`);
201
+ }
202
+ return lines;
203
+ }
@@ -5317,7 +5317,7 @@ class DenCLI {
5317
5317
  " mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
5318
5318
  );
5319
5319
  console.log(
5320
- " service install Run the web UI as a launchd service (status, print, uninstall)",
5320
+ " service install Run the web UI as a launchd service (status, print, uninstall, help)",
5321
5321
  );
5322
5322
  console.log(
5323
5323
  " theme add <repo>@<ref> Install a web UI theme (list, validate, remove)",
@@ -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";
@@ -1388,6 +1389,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
1388
1389
  }
1389
1390
 
1390
1391
  if (!running) {
1392
+ // D20: started from an ssh (Background) shell, it lives there.
1393
+ const where = webUpWarning(launchdSession(), product().cli);
1394
+ if (where) console.log(chalk.yellow(`\n ⚠ ${where}`));
1391
1395
  console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
1392
1396
  try {
1393
1397
  started = startDenWebServer(options);
@@ -918,6 +918,7 @@ import {
918
918
 
919
919
  import { product } from "../../shared/product.js";
920
920
  import { readBrand } from "../../shared/brand.js";
921
+ import { launchdSession } from "../../shared/launchd-session.js";
921
922
  /** config `web.port` (user > team), falling back to the built-in default. */
922
923
  function readWebPortSafe(): number {
923
924
  try {
@@ -4422,6 +4423,9 @@ async function handleRequest(
4422
4423
  // another distribution's server (see cli/server-identity.ts).
4423
4424
  root: ROOT,
4424
4425
  product: product().name,
4426
+ // D20: the launchd session it runs in (Aqua ends at logout); doctor
4427
+ // shows it. null off macOS.
4428
+ launchd: launchdSession(),
4425
4429
  startedAt: new Date(SERVER_STARTED_AT).toISOString(),
4426
4430
  uptimeMs: Date.now() - SERVER_STARTED_AT,
4427
4431
  stale,