@monster-orange/den 0.4.4 → 0.4.6

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 (57) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +24 -9
  3. package/docs/distributions.md +320 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +4 -3
  6. package/skills/install-den/SKILL.md +114 -0
  7. package/src/shared/agent-setup.ts +9 -0
  8. package/src/shared/auth/sudo-vault.ts +115 -11
  9. package/src/shared/cli-command.ts +186 -0
  10. package/src/shared/confirm-code.ts +34 -0
  11. package/src/shared/constants.ts +7 -0
  12. package/src/shared/den-config.ts +7 -0
  13. package/src/shared/dev-metrics/emit.ts +59 -2
  14. package/src/shared/edge/config.ts +39 -0
  15. package/src/shared/edge/render.ts +92 -18
  16. package/src/shared/edge/runtime.ts +57 -1
  17. package/src/shared/edge/types.ts +11 -1
  18. package/src/shared/github-app.ts +94 -0
  19. package/src/shared/github-device.ts +286 -0
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/shared/secrets/onepassword.ts +27 -4
  22. package/src/shared/web-owner.ts +91 -0
  23. package/src/shared/web-session.ts +135 -1
  24. package/src/ui/cli/create-main.ts +1 -1
  25. package/src/ui/cli/create.ts +263 -30
  26. package/src/ui/cli/doctor-command.ts +67 -4
  27. package/src/ui/cli/doctor-onboard.ts +291 -0
  28. package/src/ui/cli/flag-parser.ts +5 -1
  29. package/src/ui/cli/index.ts +246 -4
  30. package/src/ui/cli/json-out.ts +49 -0
  31. package/src/ui/cli/mcp-connect.ts +179 -15
  32. package/src/ui/cli/repo-command.ts +490 -0
  33. package/src/ui/cli/repo-detect.ts +192 -0
  34. package/src/ui/cli/web-command.ts +513 -33
  35. package/src/ui/mcp/connect.ts +134 -0
  36. package/src/ui/server/device-flows.ts +274 -0
  37. package/src/ui/server/edge-exposure.ts +16 -4
  38. package/src/ui/server/github-routes.ts +321 -0
  39. package/src/ui/server/http-errors.ts +10 -0
  40. package/src/ui/server/index.ts +463 -27
  41. package/src/ui/server/login-waits.ts +203 -0
  42. package/src/ui/server/pair-requests.ts +237 -0
  43. package/src/ui/server/pair-routes.ts +196 -0
  44. package/src/ui/server/public/error-page.js +452 -40
  45. package/src/ui/server/public/error.html +59 -0
  46. package/src/ui/server/public/i18n/es.js +74 -8
  47. package/src/ui/server/public/i18n/ja.js +74 -8
  48. package/src/ui/server/public/local-dev.html +16 -4
  49. package/src/ui/server/public/qr.js +317 -0
  50. package/src/ui/server/public/settings-page.js +10 -3
  51. package/src/ui/server/public/settings-registry.js +392 -1
  52. package/src/ui/server/public/shell.js +123 -10
  53. package/src/ui/server/public/topbar.js +7 -3
  54. package/src/ui/server/public/workspace-picker.js +1 -1
  55. package/src/ui/server/request-guard.ts +103 -36
  56. package/src/ui/server/route-classes.ts +102 -2
  57. package/src/ui/server/ui-assets.ts +2 -0
@@ -40,6 +40,14 @@ import {
40
40
  DEFAULT_RUNTIME_ID,
41
41
  RUNTIME_KIND,
42
42
  } from "../../shared/agents/runtimes.js";
43
+ import {
44
+ defaultOnboardDeps,
45
+ onboardingChecks,
46
+ runnableCli,
47
+ type OnboardCheck,
48
+ type OnboardDeps,
49
+ } from "./doctor-onboard.js";
50
+ import { printJson } from "./json-out.js";
43
51
 
44
52
  export interface DoctorRow {
45
53
  kind: string;
@@ -219,15 +227,70 @@ function readConfigOrNull(): DenConfig | null {
219
227
  }
220
228
  }
221
229
 
230
+ /** A strategy row as a `doctor --json` check. */
231
+ export function strategyCheck(r: DoctorRow): OnboardCheck {
232
+ const sel = r.selected
233
+ ? `${r.selected}${r.implicit ? " (implicit)" : ""}`
234
+ : "(invalid config)";
235
+ const why = r.reason ? `: ${r.reason}` : "";
236
+ return {
237
+ id: `strategy:${r.kind}`,
238
+ status: r.problem
239
+ ? "fail"
240
+ : !r.available || r.warnings.length
241
+ ? "warn"
242
+ : "ok",
243
+ detail: `${sel} ${r.available ? "available" : "unavailable"}${why}${r.warnings.length ? ` (${r.warnings.join("; ")})` : ""}`,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * `doctor --json` (ON-2): one JSON object, `{ ok, checks }`. The
249
+ * onboarding checks first (doctor-onboard.ts), then every strategy row;
250
+ * ok is false when any check fails. Exit 1 then, as for the human doctor.
251
+ */
252
+ export async function doctorJson(
253
+ deps: OnboardDeps,
254
+ rows: DoctorRow[] = doctorRows(),
255
+ ): Promise<{ ok: boolean; checks: OnboardCheck[] }> {
256
+ const checks = [
257
+ ...(await onboardingChecks(deps)),
258
+ ...rows.map(strategyCheck),
259
+ ];
260
+ return { ok: !checks.some((c) => c.status === "fail"), checks };
261
+ }
262
+
222
263
  export async function cmdDoctor(args: string[]): Promise<number> {
223
264
  const probe = args.includes("--probe");
224
- const unknown = args.filter((a) => a !== "--probe");
265
+ const json = args.includes("--json");
266
+ const unknown = args.filter((a) => a !== "--probe" && a !== "--json");
225
267
  if (unknown.length) {
226
- console.error(
227
- `✗ doctor: unknown argument ${unknown[0]} (doctor [--probe])`,
228
- );
268
+ const msg = `unknown argument ${unknown[0]} (doctor [--probe] [--json])`;
269
+ if (json)
270
+ printJson({
271
+ ok: false,
272
+ error: msg,
273
+ fix: `${runnableCli()} doctor --json`,
274
+ });
275
+ else console.error(`✗ doctor: ${msg}`);
229
276
  return 2;
230
277
  }
278
+ if (json) {
279
+ if (probe) {
280
+ printJson({
281
+ ok: false,
282
+ error: "--probe and --json do not combine: the probe starts sessions",
283
+ fix: `${runnableCli()} doctor --json`,
284
+ });
285
+ return 2;
286
+ }
287
+ const { probeWebServer } = await import("./mcp-connect.js");
288
+ const r = await doctorJson(
289
+ defaultOnboardDeps((url) => probeWebServer(url)),
290
+ );
291
+ printJson(r);
292
+ return r.ok ? 0 : 1;
293
+ }
231
294
  const rows = doctorRows();
232
295
  for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
233
296
  const cli = product().cli;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * `doctor --json`'s onboarding checks (ON-2): what an agent setting Den up
3
+ * needs to know, each `ok`, `warn` or `fail`, with a command the agent can
4
+ * run (`fix`) or a question for the person (`ask`).
5
+ *
6
+ * - node: Node 22.13 or later.
7
+ * - git: on PATH.
8
+ * - docker: only when a registered repo needs it (a compose file or block,
9
+ * or a docker command), then installed and its daemon answering.
10
+ * - web: the web server answers at its URL (and is this distribution's).
11
+ * - mcp: Den's MCP one-shot (`<cli> mcp --call workspaces`) gets an answer
12
+ * from that web server: the path every agent's tools take.
13
+ *
14
+ * Every probe is injected (OnboardDeps), so tests never depend on this
15
+ * machine's Docker, Node or web server. defaultOnboardDeps() wires the real
16
+ * ones; only the docker and mcp probes spawn, each bounded by a timeout.
17
+ */
18
+ import { spawnSync } from "child_process";
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import { product } from "../../shared/product.js";
22
+ import { getDistRoot } from "../../shared/paths.js";
23
+ import { readManifest } from "../../shared/manifest/read-manifest.js";
24
+ import {
25
+ DEN_SERVICES_DIR,
26
+ readWorkspacesFile,
27
+ } from "../../shared/environment-manager/workspaces.js";
28
+ import { readWebPort } from "../../shared/edge/config.js";
29
+ import { DEFAULT_PORT } from "../../shared/constants.js";
30
+ import { needsDocker } from "./repo-detect.js";
31
+ import type { WebProbe } from "./mcp-connect.js";
32
+
33
+ export type CheckStatus = "ok" | "warn" | "fail";
34
+
35
+ export interface OnboardCheck {
36
+ id: string;
37
+ status: CheckStatus;
38
+ detail: string;
39
+ /** A command the agent can run to fix it. */
40
+ fix?: string;
41
+ /** A question for the person, when only they can fix it. */
42
+ ask?: string;
43
+ /** The web server's URL and port (the web check). */
44
+ url?: string;
45
+ port?: number;
46
+ }
47
+
48
+ export interface OnboardDeps {
49
+ /** process.versions.node */
50
+ nodeVersion: string;
51
+ platform: NodeJS.Platform;
52
+ /** Whether an executable is on PATH. */
53
+ which: (bin: string) => boolean;
54
+ /** The registered repos that need Docker. */
55
+ dockerRepos: () => string[];
56
+ /** Whether the Docker daemon answers (`docker info`). */
57
+ dockerUp: () => boolean;
58
+ webUrl: string;
59
+ probeWeb: (url: string) => Promise<WebProbe>;
60
+ /** The MCP one-shot against the web server: exit 0, or why not. */
61
+ mcpCall: () => { ok: boolean; detail?: string };
62
+ /** The command an agent runs, e.g. `./bin/acme`. */
63
+ bin: string;
64
+ }
65
+
66
+ export const MIN_NODE = [22, 13] as const;
67
+
68
+ /** Whether `version` ("22.13.0", "v24.1.0") is at least 22.13. */
69
+ export function nodeOk(version: string): boolean {
70
+ const [maj, min] = version.replace(/^v/, "").split(".").map(Number);
71
+ if (!Number.isFinite(maj)) return false;
72
+ return (
73
+ maj > MIN_NODE[0] || (maj === MIN_NODE[0] && (min ?? 0) >= MIN_NODE[1])
74
+ );
75
+ }
76
+
77
+ export async function onboardingChecks(
78
+ d: OnboardDeps,
79
+ ): Promise<OnboardCheck[]> {
80
+ const out: OnboardCheck[] = [];
81
+ const mac = d.platform === "darwin";
82
+
83
+ out.push(
84
+ nodeOk(d.nodeVersion)
85
+ ? { id: "node", status: "ok", detail: `Node ${d.nodeVersion}` }
86
+ : {
87
+ id: "node",
88
+ status: "fail",
89
+ detail: `Node ${d.nodeVersion} is older than ${MIN_NODE.join(".")}`,
90
+ fix: "nvm install 22 && nvm use 22",
91
+ ask: `Den needs Node ${MIN_NODE.join(".")} or later. May I install it with your usual Node installer (nvm, or brew install node)?`,
92
+ },
93
+ );
94
+
95
+ out.push(
96
+ d.which("git")
97
+ ? { id: "git", status: "ok", detail: "git is on PATH" }
98
+ : {
99
+ id: "git",
100
+ status: "fail",
101
+ detail: "git is not on PATH",
102
+ ...(mac ? { fix: "xcode-select --install" } : {}),
103
+ ask: "Den needs git. May I install it (on macOS: xcode-select --install)?",
104
+ },
105
+ );
106
+
107
+ const repos = d.dockerRepos();
108
+ if (repos.length === 0)
109
+ out.push({
110
+ id: "docker",
111
+ status: "ok",
112
+ detail: "no registered repo needs Docker",
113
+ });
114
+ else if (!d.which("docker"))
115
+ out.push({
116
+ id: "docker",
117
+ status: "fail",
118
+ detail: `${repos.join(", ")} need${repos.length === 1 ? "s" : ""} Docker, and docker is not on PATH`,
119
+ ask: "These repos run in containers. Can you install Docker Desktop (or another Docker engine)?",
120
+ });
121
+ else if (!d.dockerUp())
122
+ out.push({
123
+ id: "docker",
124
+ status: "fail",
125
+ detail: `${repos.join(", ")} need${repos.length === 1 ? "s" : ""} Docker, and the Docker daemon is not answering`,
126
+ ...(mac ? { fix: "open -a Docker" } : {}),
127
+ ask: "Docker is installed but not running. Can you start it?",
128
+ });
129
+ else
130
+ out.push({
131
+ id: "docker",
132
+ status: "ok",
133
+ detail: `Docker is running (needed by ${repos.join(", ")})`,
134
+ });
135
+
136
+ const probe = await d.probeWeb(d.webUrl);
137
+ const webPort = Number(new URL(d.webUrl).port) || 80;
138
+ const webFix = `${d.bin} web up --no-open --json`;
139
+ if (probe.kind === "up")
140
+ out.push({
141
+ id: "web",
142
+ status: "ok",
143
+ detail: `web server up${probe.version ? ` (Den ${probe.version})` : ""}`,
144
+ url: d.webUrl,
145
+ port: webPort,
146
+ });
147
+ else if (probe.kind === "foreign")
148
+ out.push({
149
+ id: "web",
150
+ status: "fail",
151
+ detail: `${probe.who} answers at ${d.webUrl}, not this distribution's web server`,
152
+ url: d.webUrl,
153
+ port: webPort,
154
+ ask: `Another Den distribution already uses ${d.webUrl}. Which port should ${product().title} use instead? (web.port in config/local/user.yml)`,
155
+ });
156
+ else
157
+ out.push({
158
+ id: "web",
159
+ status: "fail",
160
+ detail:
161
+ probe.kind === "timeout"
162
+ ? `web server at ${d.webUrl} is not answering in time`
163
+ : `web server not running at ${d.webUrl}`,
164
+ url: d.webUrl,
165
+ port: webPort,
166
+ fix:
167
+ probe.kind === "timeout"
168
+ ? `${d.bin} web restart --no-open --json`
169
+ : webFix,
170
+ });
171
+
172
+ if (probe.kind !== "up")
173
+ out.push({
174
+ id: "mcp",
175
+ status: "fail",
176
+ detail: "Den's MCP reads through the web server, which is not up",
177
+ fix: webFix,
178
+ });
179
+ else {
180
+ const m = d.mcpCall();
181
+ out.push(
182
+ m.ok
183
+ ? {
184
+ id: "mcp",
185
+ status: "ok",
186
+ detail: `${d.bin} mcp --call workspaces answered`,
187
+ }
188
+ : {
189
+ id: "mcp",
190
+ status: "fail",
191
+ detail: `${d.bin} mcp --call workspaces failed${m.detail ? `: ${m.detail}` : ""}`,
192
+ fix: `${d.bin} web restart --no-open --json`,
193
+ },
194
+ );
195
+ }
196
+ return out;
197
+ }
198
+
199
+ function onPath(bin: string): boolean {
200
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
201
+ if (!dir) continue;
202
+ try {
203
+ fs.accessSync(path.join(dir, bin), fs.constants.X_OK);
204
+ return true;
205
+ } catch {
206
+ /* next */
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+
212
+ /** The repos in every active workspace whose manifest or files need Docker. */
213
+ export function registeredDockerRepos(): string[] {
214
+ const out: string[] = [];
215
+ let file;
216
+ try {
217
+ file = readWorkspacesFile();
218
+ } catch {
219
+ return out;
220
+ }
221
+ for (const ws of file.active) {
222
+ for (const [addr, entry] of Object.entries(
223
+ file.workspaces[ws]?.services ?? {},
224
+ )) {
225
+ const repo = addr.split("/")[0];
226
+ if (out.includes(repo)) continue;
227
+ const dir =
228
+ entry.mode === "dev-managed"
229
+ ? entry.path
230
+ : path.join(DEN_SERVICES_DIR, repo, entry.branch ?? "main");
231
+ if (!dir) continue;
232
+ if (needsDocker(readManifest(dir), fs.existsSync(dir) ? dir : undefined))
233
+ out.push(repo);
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+
239
+ /** The command an agent runs from the distribution root. */
240
+ export function runnableCli(): string {
241
+ const cli = product().cli;
242
+ return fs.existsSync(path.join(getDistRoot(), "bin", cli))
243
+ ? `./bin/${cli}`
244
+ : cli;
245
+ }
246
+
247
+ export function defaultOnboardDeps(
248
+ probeWeb: (url: string) => Promise<WebProbe>,
249
+ ): OnboardDeps {
250
+ let port: number;
251
+ try {
252
+ port = readWebPort();
253
+ } catch {
254
+ port = DEFAULT_PORT;
255
+ }
256
+ const cli = product().cli;
257
+ const shim = path.join(getDistRoot(), "bin", cli);
258
+ return {
259
+ nodeVersion: process.versions.node,
260
+ platform: process.platform,
261
+ which: onPath,
262
+ dockerRepos: registeredDockerRepos,
263
+ dockerUp: () =>
264
+ spawnSync("docker", ["info", "--format", "{{.ServerVersion}}"], {
265
+ stdio: "ignore",
266
+ timeout: 10_000,
267
+ }).status === 0,
268
+ webUrl: `http://localhost:${port}`,
269
+ probeWeb,
270
+ mcpCall: () => {
271
+ // The shim when there is one (a distribution), else this Den's bin.
272
+ const cmd = fs.existsSync(shim)
273
+ ? shim
274
+ : path.join(getDistRoot(), "bin", "den");
275
+ const r = spawnSync(cmd, ["mcp", "--call", "workspaces"], {
276
+ encoding: "utf-8",
277
+ stdio: ["ignore", "pipe", "pipe"],
278
+ timeout: 30_000,
279
+ });
280
+ return r.status === 0
281
+ ? { ok: true }
282
+ : {
283
+ ok: false,
284
+ detail:
285
+ (r.stderr ?? "").trim().split("\n").pop()?.slice(0, 200) ||
286
+ `exit ${r.status ?? "timeout"}`,
287
+ };
288
+ },
289
+ bin: runnableCli(),
290
+ };
291
+ }
@@ -88,6 +88,8 @@ const OWN_WORKSPACE_FLAG_COMMANDS = new Set([
88
88
  // CLI's workspace override here.
89
89
  "sessions",
90
90
  "agents",
91
+ // `repo add -w <ws>` names (or creates) the workspace to write.
92
+ "repo",
91
93
  ]);
92
94
 
93
95
  /** Whether `-w <name>` on this command requires `<name>` to be active. */
@@ -179,8 +181,9 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
179
181
  */
180
182
  export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
183
  {
182
- web: ["token", "login", "sessions", "logout"],
184
+ web: ["token", "login", "sessions", "logout", "owner", "approve"],
183
185
  mcp: ["connect", "disconnect", "clients", "config"],
186
+ repo: ["add"],
184
187
  };
185
188
 
186
189
  /** Whether `<command> <sub> --help` is the subcommand's own to answer. */
@@ -209,6 +212,7 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
209
212
  "--pull",
210
213
  "-A",
211
214
  "--app",
215
+ "--json",
212
216
  ],
213
217
  down: [
214
218
  "--manifest",