@monster-orange/den 0.4.20 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +167 -19
  4. package/package.json +1 -1
  5. package/service/install.sh +213 -63
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +6 -0
  8. package/src/shared/agents/runtime.ts +6 -0
  9. package/src/shared/agents/session-name.ts +21 -1
  10. package/src/shared/attest-code.ts +125 -0
  11. package/src/shared/claim-code.ts +6 -2
  12. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  13. package/src/shared/github-app.ts +4 -0
  14. package/src/shared/github-device.ts +38 -1
  15. package/src/shared/launchd-session.ts +92 -0
  16. package/src/shared/owner-github.ts +100 -0
  17. package/src/shared/product.ts +45 -5
  18. package/src/shared/profile.ts +26 -2
  19. package/src/shared/sessions/client.ts +24 -0
  20. package/src/shared/sessions/host.ts +114 -5
  21. package/src/shared/sessions/types.ts +5 -0
  22. package/src/shared/web-session.ts +112 -6
  23. package/src/ui/cli/agent-ops.ts +20 -2
  24. package/src/ui/cli/agents-command.ts +32 -1
  25. package/src/ui/cli/create.ts +92 -6
  26. package/src/ui/cli/doctor-command.ts +126 -15
  27. package/src/ui/cli/doctor-owner.ts +122 -0
  28. package/src/ui/cli/doctor-where.ts +203 -0
  29. package/src/ui/cli/index.ts +1 -1
  30. package/src/ui/cli/service-command.ts +233 -11
  31. package/src/ui/cli/sessions-command.ts +46 -12
  32. package/src/ui/cli/web-command.ts +78 -12
  33. package/src/ui/server/device-flows.ts +45 -10
  34. package/src/ui/server/github-routes.ts +169 -16
  35. package/src/ui/server/index.ts +147 -12
  36. package/src/ui/server/public/agents-page.js +103 -6
  37. package/src/ui/server/public/agents.html +30 -0
  38. package/src/ui/server/public/error-page.js +115 -33
  39. package/src/ui/server/public/error.html +16 -0
  40. package/src/ui/server/public/header.js +5 -7
  41. package/src/ui/server/public/i18n/es.js +24 -1
  42. package/src/ui/server/public/i18n/ja.js +24 -1
  43. package/src/ui/server/public/settings-registry.js +1 -1
  44. package/src/ui/server/public/shell.js +24 -1
  45. package/src/ui/server/route-classes.ts +7 -0
  46. package/src/ui/server/session-routes.ts +4 -0
@@ -51,6 +51,7 @@ import {
51
51
  } from "../../shared/sessions/den-pty.js";
52
52
  import {
53
53
  getStrategy,
54
+ strategyIds,
54
55
  strategyKinds,
55
56
  } from "../../shared/strategies/registry.js";
56
57
  import {
@@ -65,6 +66,14 @@ import {
65
66
  type OnboardDeps,
66
67
  } from "./doctor-onboard.js";
67
68
  import { printJson } from "./json-out.js";
69
+ import { ownerCheck, ownerLine } from "./doctor-owner.js";
70
+ import {
71
+ formatWhere,
72
+ serviceJobState,
73
+ whereRows,
74
+ type WhereDeps,
75
+ type WhereRow,
76
+ } from "./doctor-where.js";
68
77
 
69
78
  export interface DoctorRow {
70
79
  kind: string;
@@ -193,22 +202,45 @@ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
193
202
  ...kinds.filter((k) => !(k in SELECTORS)),
194
203
  ];
195
204
  const rows = ordered.map(
196
- (kind) =>
197
- SELECTORS[kind]?.(env) ?? {
198
- kind,
199
- selected: "",
200
- implicit: false,
201
- available: false,
202
- reason: "no selector for this kind",
203
- problem: false,
204
- warnings: [],
205
- },
205
+ (kind) => SELECTORS[kind]?.(env) ?? everyStrategyRow(kind, env),
206
206
  );
207
207
  const op = onePasswordTokenRow(env);
208
208
  if (op) rows.splice(rows.findIndex((r) => r.kind === "secrets") + 1, 0, op);
209
209
  return rows;
210
210
  }
211
211
 
212
+ /**
213
+ * A kind no config selects one of (usage sources, D9): every registered
214
+ * strategy is in use, so the row names them all (implicit) and is available
215
+ * when any is. Never a problem: nothing was configured wrong. (It said
216
+ * "(invalid config) ✗ no selector for this kind" on every fresh install.)
217
+ */
218
+ function everyStrategyRow(kind: string, env: NodeJS.ProcessEnv): DoctorRow {
219
+ const ids = strategyIds(kind);
220
+ const found = ids.map((id) => {
221
+ try {
222
+ return getStrategy(kind, id)?.detect(env) ?? { available: false };
223
+ } catch (e) {
224
+ return { available: false, reason: (e as Error).message };
225
+ }
226
+ });
227
+ const available = found.some((d) => d.available);
228
+ return {
229
+ kind,
230
+ selected: ids.join(", "),
231
+ implicit: true,
232
+ available,
233
+ reason: available
234
+ ? undefined
235
+ : found
236
+ .map((d) => d.reason)
237
+ .filter(Boolean)
238
+ .join("; ") || undefined,
239
+ problem: false,
240
+ warnings: [],
241
+ };
242
+ }
243
+
212
244
  /**
213
245
  * The 1password row: shown only when 1Password is the configured provider
214
246
  * (secret_env refs and the engine's own secrets resolve through it) and the
@@ -241,15 +273,19 @@ function onePasswordTokenRow(env: NodeJS.ProcessEnv): DoctorRow | null {
241
273
  export function formatDoctor(rows: DoctorRow[], title: string): string[] {
242
274
  const lines = [chalk.bold(`${title} doctor: strategies`), ""];
243
275
  const width = Math.max(...rows.map((r) => r.kind.length));
244
- for (const r of rows) {
245
- const sel = r.selected
276
+ const selOf = (r: DoctorRow) =>
277
+ r.selected
246
278
  ? `${r.selected}${r.implicit ? " (implicit)" : ""}`
247
279
  : "(invalid config)";
280
+ // The state column lines up even past 20 ("den-claude (implicit)").
281
+ const selWidth = Math.max(20, ...rows.map((r) => selOf(r).length));
282
+ for (const r of rows) {
283
+ const sel = selOf(r);
248
284
  const state = r.available
249
285
  ? chalk.green("✓ available")
250
286
  : (r.problem ? chalk.red : chalk.yellow)("✗ unavailable");
251
287
  lines.push(
252
- ` ${r.kind.padEnd(width)} ${sel.padEnd(20)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
288
+ ` ${r.kind.padEnd(width)} ${sel.padEnd(selWidth)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
253
289
  );
254
290
  for (const w of r.warnings) lines.push(chalk.yellow(` ⚠ ${w}`));
255
291
  }
@@ -347,14 +383,73 @@ export function strategyCheck(r: DoctorRow): OnboardCheck {
347
383
  export async function doctorJson(
348
384
  deps: OnboardDeps,
349
385
  rows: DoctorRow[] = doctorRows(),
386
+ owner: () => Promise<OnboardCheck> = () => ownerCheck(),
350
387
  ): Promise<{ ok: boolean; checks: OnboardCheck[] }> {
351
388
  const checks = [
352
389
  ...(await onboardingChecks(deps)),
353
390
  ...rows.map(strategyCheck),
391
+ // D17: who can connect GitHub to this instance from the browser.
392
+ await owner(),
354
393
  ];
355
394
  return { ok: !checks.some((c) => c.status === "fail"), checks };
356
395
  }
357
396
 
397
+ /** D18/D20: the real probes for doctor's "where it runs" rows. */
398
+ export async function defaultWhereDeps(): Promise<WhereDeps> {
399
+ const os = await import("os");
400
+ const { readDenConfig: cfg, readWebServiceConfig } =
401
+ await import("../../shared/den-config.js");
402
+ const { readWebPort } = await import("../../shared/edge/config.js");
403
+ const { getDistRoot } = await import("../../shared/paths.js");
404
+ const { connectHost } = await import("../../shared/sessions/client.js");
405
+ const { sessionPaths } = await import("../../shared/sessions/paths.js");
406
+ return {
407
+ cli: product().cli,
408
+ platform: process.platform,
409
+ server: async () => {
410
+ const port = readWebPort(getDistRoot());
411
+ const res = await fetch(`http://localhost:${port}/api/server-info`, {
412
+ signal: AbortSignal.timeout(1500),
413
+ });
414
+ if (!res.ok) return null;
415
+ const body = (await res.json()) as { launchd?: string | null };
416
+ return { launchd: body.launchd ?? null };
417
+ },
418
+ host: async () => {
419
+ const c = await connectHost(sessionPaths(getStateDir()).socket);
420
+ try {
421
+ return {
422
+ launchd: c.hostLaunchd,
423
+ access: await c.access().catch(() => null),
424
+ };
425
+ } finally {
426
+ c.close();
427
+ }
428
+ },
429
+ service: () => {
430
+ try {
431
+ const { label } = readWebServiceConfig(
432
+ cfg(),
433
+ getDistRoot(),
434
+ os.homedir(),
435
+ );
436
+ return serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
437
+ } catch {
438
+ return null;
439
+ }
440
+ },
441
+ };
442
+ }
443
+
444
+ /** A "where it runs" row as a `doctor --json` check. */
445
+ export function whereCheck(r: WhereRow): OnboardCheck {
446
+ return {
447
+ id: `where:${r.what.replace(" ", "-")}`,
448
+ status: r.status,
449
+ detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
450
+ };
451
+ }
452
+
358
453
  export async function cmdDoctor(args: string[]): Promise<number> {
359
454
  const probe = args.includes("--probe");
360
455
  const json = args.includes("--json");
@@ -383,6 +478,9 @@ export async function cmdDoctor(args: string[]): Promise<number> {
383
478
  const r = await doctorJson(
384
479
  defaultOnboardDeps((url) => probeWebServer(url)),
385
480
  );
481
+ r.checks.push(
482
+ ...(await whereRows(await defaultWhereDeps())).map(whereCheck),
483
+ );
386
484
  printJson(r);
387
485
  return r.ok ? 0 : 1;
388
486
  }
@@ -390,6 +488,17 @@ export async function cmdDoctor(args: string[]): Promise<number> {
390
488
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
391
489
  const held = approvalsLines(() => readMcpConfig().approvals);
392
490
  for (const l of held.lines) console.log(l);
491
+ // D17: who can connect GitHub to this instance from the browser.
492
+ const owner = await ownerCheck();
493
+ console.log("");
494
+ console.log(ownerLine(owner));
495
+ const ownerProblem = owner.status === "fail";
496
+ // D18/D20: where the server and the session host run (logout).
497
+ for (const l of formatWhere(
498
+ await whereRows(await defaultWhereDeps()),
499
+ readBrand().name,
500
+ ))
501
+ console.log(l);
393
502
  for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
394
503
  const cli = product().cli;
395
504
  if (!probe) {
@@ -398,7 +507,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
398
507
  agentsConfigured(readConfigOrNull(), getStateDir()),
399
508
  ))
400
509
  console.log(l);
401
- return rows.some((r) => r.problem) || held.problem ? 1 : 0;
510
+ return rows.some((r) => r.problem) || held.problem || ownerProblem ? 1 : 0;
402
511
  }
403
512
  const { runEnvProbe, formatProbe } = await import("./env-probe.js");
404
513
  const { resolveDeps, plainSessionEnv } =
@@ -417,5 +526,7 @@ export async function cmdDoctor(args: string[]): Promise<number> {
417
526
  console.log("");
418
527
  for (const l of formatProbe(results)) console.log(l);
419
528
  const found = results.some((r) => r.findings.length);
420
- return rows.some((r) => r.problem) || held.problem || found ? 1 : 0;
529
+ return rows.some((r) => r.problem) || held.problem || ownerProblem || found
530
+ ? 1
531
+ : 0;
421
532
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * doctor's owner check (D17): who can connect their GitHub account to this
3
+ * instance from the browser.
4
+ *
5
+ * - An owner is linked: ok, naming it.
6
+ * - No owner, but web.owner_github or web.allowed_github_org says who may
7
+ * claim it: ok, naming them.
8
+ * - Neither, and the edge is reachable from other machines: ✗ (a fail):
9
+ * nobody can get in from a browser there, and the page can only name
10
+ * the settings.
11
+ * - Neither, edge not reachable beyond this machine: a warning.
12
+ */
13
+ import { readWebOwner } from "../../shared/web-owner.js";
14
+ import {
15
+ readWebAuthConfig,
16
+ type WebAuthConfig,
17
+ } from "../../shared/web-session.js";
18
+ import { getDistRoot, getStateDir } from "../../shared/paths.js";
19
+ import { product } from "../../shared/product.js";
20
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
21
+ import { edgeNames, webRouteHosts } from "../../shared/edge/render.js";
22
+ import { edgeExposure } from "../../shared/edge/exposure.js";
23
+ import type { OnboardCheck } from "./doctor-onboard.js";
24
+ import { ghLogin, ghNotSignedInHint } from "../../shared/owner-github.js";
25
+
26
+ export interface OwnerCheckDeps {
27
+ owner?: () => { login: string } | null;
28
+ web?: () => WebAuthConfig;
29
+ /** Why the edge is reachable beyond this machine, or null. */
30
+ exposed?: () => Promise<string | null>;
31
+ configFile?: string;
32
+ /** The login gh is signed in as here, or null (default: ask gh). */
33
+ ghLogin?: () => string | null;
34
+ }
35
+
36
+ /**
37
+ * What to do when web.owner_github isn't set: gh is signed in but nothing
38
+ * recorded it yet (a restart does), or gh isn't signed in.
39
+ */
40
+ export function ownerGithubHint(login: string | null, cli: string): string {
41
+ return login
42
+ ? `gh is signed in as @${login}: run \`${cli} web restart\` to record it as this Den's owner (web.owner_github), then connect your GitHub account from any browser.`
43
+ : ghNotSignedInHint(cli);
44
+ }
45
+
46
+ async function defaultExposed(): Promise<string | null> {
47
+ try {
48
+ const cfg = readEdgeConfig();
49
+ const hosts = webRouteHosts(cfg, new Set([readWebPort()])).map(
50
+ (r) => r.host,
51
+ );
52
+ return await edgeExposure(
53
+ hosts,
54
+ cfg.ports ? edgeNames(cfg.org).container : null,
55
+ );
56
+ } catch {
57
+ return null; // no edge
58
+ }
59
+ }
60
+
61
+ export async function ownerCheck(
62
+ deps: OwnerCheckDeps = {},
63
+ ): Promise<OnboardCheck> {
64
+ const owner = (deps.owner ?? (() => readWebOwner(getStateDir())))();
65
+ if (owner)
66
+ return {
67
+ id: "web:owner",
68
+ status: "ok",
69
+ detail: `owner: @${owner.login} (linked; Sign in with GitHub from any browser)`,
70
+ };
71
+ let web: WebAuthConfig;
72
+ try {
73
+ web = (
74
+ deps.web ?? (() => readWebAuthConfig(getDistRoot(), product().configFile))
75
+ )();
76
+ } catch (e) {
77
+ return { id: "web:owner", status: "fail", detail: (e as Error).message };
78
+ }
79
+ const configFile = deps.configFile ?? product().configFile;
80
+ const who = [
81
+ ...(web.ownerGithub
82
+ ? [`@${web.ownerGithub} from any browser (web.owner_github)`]
83
+ : []),
84
+ ...(web.allowedGithubOrg
85
+ ? [
86
+ `a member of ${web.allowedGithubOrg} on this computer (web.allowed_github_org)`,
87
+ ]
88
+ : []),
89
+ ];
90
+ // D17: owner_github carries most people (org memberships are usually
91
+ // private); without it, say how to get it.
92
+ const ghHint = web.ownerGithub
93
+ ? {}
94
+ : { ask: ownerGithubHint((deps.ghLogin ?? ghLogin)(), product().cli) };
95
+ if (who.length)
96
+ return {
97
+ id: "web:owner",
98
+ status: "ok",
99
+ detail: `no owner yet; who may connect GitHub to claim it: ${who.join(", or ")}`,
100
+ ...ghHint,
101
+ };
102
+ const exposed = await (deps.exposed ?? defaultExposed)();
103
+ // Only this machine reaches it: a note, not a warning (every fresh
104
+ // install starts this way, D12).
105
+ return {
106
+ id: "web:owner",
107
+ status: exposed ? "fail" : "ok",
108
+ detail:
109
+ `no owner, and nothing says who may claim it, so nobody can connect GitHub from the browser` +
110
+ (exposed
111
+ ? `; the edge is reachable from other machines (${exposed})`
112
+ : ""),
113
+ ask: `Which GitHub org's members may claim it (web.allowed_github_org in ${configFile}), or which GitHub login runs it here (web.owner_github in config/local/user.yml)?`,
114
+ };
115
+ }
116
+
117
+ /** The check as doctor prints it. */
118
+ export function ownerLine(c: OnboardCheck): string {
119
+ const mark =
120
+ c.status === "fail" ? "✗" : c.status === "warn" ? "⚠" : c.ask ? "ℹ" : "✓";
121
+ return ` ${mark} ${c.detail}${c.ask ? `\n ${c.ask}` : ""}`;
122
+ }
@@ -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)",