@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.
- package/CHANGELOG.md +164 -0
- package/README.md +7 -7
- package/docs/distributions.md +167 -19
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +126 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +233 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +78 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +147 -12
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +24 -1
- package/src/ui/server/public/i18n/ja.js +24 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +24 -1
- package/src/ui/server/route-classes.ts +7 -0
- 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
|
-
|
|
245
|
-
|
|
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(
|
|
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 ||
|
|
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
|
+
}
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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)",
|