@monster-orange/den 0.4.5 → 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.
@@ -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. */
@@ -181,6 +183,7 @@ export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
183
  {
182
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",
@@ -26,6 +26,7 @@ import { envSelector } from "../../shared/environment/env-selector.js";
26
26
  import {
27
27
  recordInvocation,
28
28
  recordFailure,
29
+ shipFromDetachedSender,
29
30
  startTimer,
30
31
  } from "../../shared/dev-metrics/emit.js";
31
32
  import type { Environment, Pillar } from "../../shared/types/index.js";
@@ -242,6 +243,8 @@ import {
242
243
  import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
243
244
 
244
245
  import { product } from "../../shared/product.js";
246
+ import { AGENT_SETUP_LINE } from "../../shared/agent-setup.js";
247
+ import { ExitRequested, printJson, quietStdout } from "./json-out.js";
245
248
  const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service, and workspace orchestration`;
246
249
 
247
250
  /**
@@ -360,6 +363,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
360
363
  "-w writes that workspace (active or not) and leaves focus alone.",
361
364
  ],
362
365
  },
366
+ repo: {
367
+ usage:
368
+ 'repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] [--run "<cmd>"] [--health <url>] [--json]',
369
+ lines: [
370
+ "Add a repo with no prompts, never writing into it. A repo with no",
371
+ "manifest (or --sidecar) gets a side-car manifest in manifests/, detected",
372
+ "from its Makefile, compose file or package.json scripts. Safe to re-run.",
373
+ "`{cli} repo add --help` says more.",
374
+ ],
375
+ },
363
376
  relocate: {
364
377
  usage: "relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]",
365
378
  lines: [
@@ -465,8 +478,11 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
465
478
  ],
466
479
  },
467
480
  doctor: {
468
- usage: "doctor [--probe]",
481
+ usage: "doctor [--probe] [--json]",
469
482
  lines: [
483
+ "--json: one object, { ok, checks: [{ id, status: ok|warn|fail, detail, fix?, ask? }] }:",
484
+ "Node 22.13+, git, Docker (only when a registered repo needs it), the web",
485
+ "server and Den's MCP through it, then each strategy below. Exit 1 on a fail.",
470
486
  "Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
471
487
  "and whether it can work here (a PATH check; nothing is run or prompted).",
472
488
  "--probe: start a short probe session (through the session host) for a",
@@ -575,6 +591,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
575
591
  "service",
576
592
  "theme",
577
593
  "plugin",
594
+ "repo",
578
595
  ]);
579
596
 
580
597
  /**
@@ -678,7 +695,18 @@ function printValidation(
678
695
  }
679
696
  }
680
697
 
698
+ /** What `up --json` collects while up runs (runServicesCommand fills it). */
699
+ interface UpReport {
700
+ started: string[];
701
+ failed: string[];
702
+ alreadyRunning: string[];
703
+ skipped: string[];
704
+ }
705
+
681
706
  class DenCLI {
707
+ /** Set while `up --json` runs. */
708
+ private upReport: UpReport | null = null;
709
+
682
710
  private static readonly serviceCommands = new Set([
683
711
  "up",
684
712
  "down",
@@ -715,6 +743,8 @@ class DenCLI {
715
743
  // synchronously. `telCmd` is always a REGISTERED command name (never raw
716
744
  // argv — PII); it is refined once the args parse successfully.
717
745
  const stopTimer = startTimer();
746
+ // The exit hook can't await a send; hand it to a detached sender.
747
+ shipFromDetachedSender();
718
748
  let telCmd = telemetryCommandName(args.find((a) => !a.startsWith("-")));
719
749
  process.once("exit", (code) => {
720
750
  recordInvocation({
@@ -868,13 +898,31 @@ class DenCLI {
868
898
  process.exit(0);
869
899
  break;
870
900
 
871
- case "web":
901
+ case "web": {
902
+ const sub = args[1];
903
+ if (
904
+ args.includes("--json") &&
905
+ (sub === undefined ||
906
+ sub.startsWith("-") ||
907
+ sub === "up" ||
908
+ sub === "restart")
909
+ ) {
910
+ process.exitCode = await this.cmdWebUpJson(
911
+ args.slice(1).filter((a) => a !== "--json"),
912
+ );
913
+ break;
914
+ }
872
915
  await cmdWeb(args.slice(1));
873
916
  break;
917
+ }
874
918
 
875
919
  // ── Environment manager commands ──────────────────────────────────
876
920
  case "up":
877
- await this.cmdServiceUp(args.slice(1));
921
+ if (args.slice(1).includes("--json"))
922
+ process.exitCode = await this.cmdServiceUpJson(
923
+ args.slice(1).filter((a) => a !== "--json"),
924
+ );
925
+ else await this.cmdServiceUp(args.slice(1));
878
926
  break;
879
927
 
880
928
  case "down":
@@ -906,6 +954,12 @@ class DenCLI {
906
954
  await this.cmdRegister(args.slice(1));
907
955
  break;
908
956
 
957
+ case "repo": {
958
+ const { cmdRepo } = await import("./repo-command.js");
959
+ process.exitCode = await cmdRepo(args.slice(1), validateManifest);
960
+ break;
961
+ }
962
+
909
963
  case "relocate":
910
964
  await this.cmdRelocate(args.slice(1));
911
965
  break;
@@ -967,7 +1021,7 @@ class DenCLI {
967
1021
  case "create": {
968
1022
  // bin/den routes `create` to its own entry (it needs no den.yml);
969
1023
  // this covers the CLI run directly from a checkout.
970
- const code = runCreate(args.slice(1), getInvokeCwd());
1024
+ const code = await runCreate(args.slice(1), getInvokeCwd());
971
1025
  if (code !== 0) process.exit(code);
972
1026
  break;
973
1027
  }
@@ -1829,8 +1883,10 @@ class DenCLI {
1829
1883
  if (isNamedTarget(address, opts?.named ?? [])) {
1830
1884
  console.log(chalk.red(` ✗ ${remote}`));
1831
1885
  process.exitCode = 1;
1886
+ this.upReport?.failed.push(address);
1832
1887
  } else {
1833
1888
  console.log(chalk.gray(` • ${remote}`));
1889
+ this.upReport?.skipped.push(address);
1834
1890
  }
1835
1891
  continue;
1836
1892
  }
@@ -1854,6 +1910,7 @@ class DenCLI {
1854
1910
  );
1855
1911
  // A dependency only warns; the service you named failed.
1856
1912
  if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
1913
+ this.upReport?.failed.push(address);
1857
1914
  continue;
1858
1915
  }
1859
1916
  if (health.health === "ok" && claim !== "conflict") {
@@ -1870,6 +1927,7 @@ class DenCLI {
1870
1927
  ` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
1871
1928
  ),
1872
1929
  );
1930
+ if (command === "up") this.upReport?.alreadyRunning.push(address);
1873
1931
  continue;
1874
1932
  }
1875
1933
  }
@@ -2103,6 +2161,8 @@ class DenCLI {
2103
2161
  // Any target that failed makes the whole command fail (scripts and
2104
2162
  // agents read the exit code).
2105
2163
  if (!started) process.exitCode = 1;
2164
+ if (command === "up")
2165
+ this.upReport?.[started ? "started" : "failed"].push(address);
2106
2166
  if (plan?.failFast && !started) {
2107
2167
  console.log(
2108
2168
  chalk.red(
@@ -2746,6 +2806,10 @@ class DenCLI {
2746
2806
  "act in that (active) workspace instead of the focused one; focus stays",
2747
2807
  );
2748
2808
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2809
+ row(
2810
+ "--json",
2811
+ "log on stderr; stdout: { ok, services: [{ address, result, health }], fix? }",
2812
+ );
2749
2813
  row("-h, --help", "show this help");
2750
2814
  console.log();
2751
2815
  }
@@ -3099,6 +3163,177 @@ class DenCLI {
3099
3163
  );
3100
3164
  }
3101
3165
 
3166
+ /**
3167
+ * `up --json` (ON-2): up as it always runs, its log on stderr, then one
3168
+ * JSON object on stdout: each service it touched, what happened to it
3169
+ * (started, already-running, failed, skipped) and its health once started
3170
+ * (a started service with a health check is waited on, up to 30s). Safe to
3171
+ * re-run: a healthy service is already-running. Every failure names a fix.
3172
+ */
3173
+ private async cmdServiceUpJson(args: string[]): Promise<number> {
3174
+ const report: UpReport = {
3175
+ started: [],
3176
+ failed: [],
3177
+ alreadyRunning: [],
3178
+ skipped: [],
3179
+ };
3180
+ const bin = fs.existsSync(
3181
+ nodePath.join(getDistRoot(), "bin", product().cli),
3182
+ )
3183
+ ? `./bin/${product().cli}`
3184
+ : product().cli;
3185
+ const before = process.exitCode;
3186
+ process.exitCode = undefined;
3187
+ let error: string | undefined;
3188
+ this.upReport = report;
3189
+ try {
3190
+ await quietStdout(() => this.cmdServiceUp(args));
3191
+ } catch (e) {
3192
+ if (!(e instanceof ExitRequested)) error = (e as Error).message;
3193
+ else if (e.code !== 0) process.exitCode = e.code;
3194
+ } finally {
3195
+ this.upReport = null;
3196
+ }
3197
+ const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
3198
+ process.exitCode = before;
3199
+ const workspace =
3200
+ process.env.DEN_WORKSPACE_OVERRIDE ??
3201
+ getFocusedWorkspaceName() ??
3202
+ undefined;
3203
+ const result = (address: string): string =>
3204
+ report.failed.includes(address)
3205
+ ? "failed"
3206
+ : report.started.includes(address)
3207
+ ? "started"
3208
+ : report.alreadyRunning.includes(address)
3209
+ ? "already-running"
3210
+ : "skipped";
3211
+ const touched = [
3212
+ ...new Set([
3213
+ ...report.started,
3214
+ ...report.alreadyRunning,
3215
+ ...report.failed,
3216
+ ...report.skipped,
3217
+ ]),
3218
+ ];
3219
+ const services = await Promise.all(
3220
+ touched.map(async (address) => {
3221
+ const resolved = resolveService(address, workspace);
3222
+ let health = "unknown";
3223
+ let detail: string | undefined;
3224
+ if (resolved && result(address) !== "skipped") {
3225
+ const ready =
3226
+ result(address) === "started"
3227
+ ? await waitForReady(resolved, "local", { timeoutMs: 30_000 })
3228
+ : null;
3229
+ const h = await checkServiceHealth(resolved, "local");
3230
+ health = h.health;
3231
+ detail = h.healthDetail ?? ready?.detail;
3232
+ }
3233
+ return {
3234
+ address,
3235
+ result: result(address),
3236
+ health,
3237
+ ...(detail ? { detail } : {}),
3238
+ };
3239
+ }),
3240
+ );
3241
+ const unhealthy = services.filter(
3242
+ (s) => s.result !== "skipped" && s.health === "error",
3243
+ );
3244
+ const out: Record<string, unknown> = {
3245
+ ok:
3246
+ !error &&
3247
+ !failedExit &&
3248
+ report.failed.length === 0 &&
3249
+ unhealthy.length === 0 &&
3250
+ touched.length > 0,
3251
+ workspace: workspace ?? null,
3252
+ services,
3253
+ };
3254
+ if (error) {
3255
+ out.error = error;
3256
+ out.fix = `${bin} doctor --json`;
3257
+ } else if (report.failed.length > 0 || unhealthy.length > 0) {
3258
+ const names = [
3259
+ ...new Set([...report.failed, ...unhealthy.map((s) => s.address)]),
3260
+ ];
3261
+ out.error = `${names.join(", ")} did not start healthy`;
3262
+ out.fix = `${bin} logs ${names[0].split("/")[0]}`;
3263
+ } else if (failedExit) {
3264
+ out.error = "up failed (its log is on stderr)";
3265
+ out.fix = `${bin} up ${args.join(" ")} --plan`.replace(/\s+/g, " ");
3266
+ } else if (touched.length === 0) {
3267
+ out.error =
3268
+ "nothing was started: no repo named, and the folder up ran in is not a repo in the workspace";
3269
+ out.fix = `${bin} up -a --json`;
3270
+ out.ask = "Which repo should start? (its name, as `repo add` printed it)";
3271
+ }
3272
+ out.next = out.ok
3273
+ ? [`${bin} web up --no-open --json`, `${bin} doctor --json`]
3274
+ : [];
3275
+ printJson(out);
3276
+ return out.ok ? 0 : 1;
3277
+ }
3278
+
3279
+ /**
3280
+ * `web up|restart --json` (ON-2): the web server started as usual (its
3281
+ * log on stderr), then `{ ok, url, port, version? }` once it answers
3282
+ * (up to 30s), so an agent hands back the right URL.
3283
+ */
3284
+ private async cmdWebUpJson(args: string[]): Promise<number> {
3285
+ const { parseWebCommandArgs } = await import("./web-command.js");
3286
+ const { probeWebServer } = await import("./mcp-connect.js");
3287
+ const bin = fs.existsSync(
3288
+ nodePath.join(getDistRoot(), "bin", product().cli),
3289
+ )
3290
+ ? `./bin/${product().cli}`
3291
+ : product().cli;
3292
+ let port: number;
3293
+ try {
3294
+ port = parseWebCommandArgs(args).port;
3295
+ } catch (e) {
3296
+ printJson({
3297
+ ok: false,
3298
+ error: (e as Error).message,
3299
+ fix: `${bin} web up --no-open --json`,
3300
+ });
3301
+ return 2;
3302
+ }
3303
+ const url = `http://localhost:${port}`;
3304
+ let error: string | undefined;
3305
+ try {
3306
+ await quietStdout(() => cmdWeb(args));
3307
+ } catch (e) {
3308
+ if (!(e instanceof ExitRequested) || e.code !== 0)
3309
+ error = (e as Error).message;
3310
+ }
3311
+ let probe = await probeWebServer(url);
3312
+ const deadline = Date.now() + 30_000;
3313
+ while (!error && probe.kind !== "up" && probe.kind !== "foreign") {
3314
+ if (Date.now() > deadline) break;
3315
+ await new Promise((r) => setTimeout(r, 500));
3316
+ probe = await probeWebServer(url);
3317
+ }
3318
+ const out: Record<string, unknown> = {
3319
+ ok: probe.kind === "up",
3320
+ url,
3321
+ port,
3322
+ ...(probe.kind === "up" && probe.version
3323
+ ? { version: probe.version }
3324
+ : {}),
3325
+ };
3326
+ if (probe.kind === "foreign") {
3327
+ out.error = `${probe.who} answers at ${url}, not this distribution's web server`;
3328
+ out.ask = `Another Den distribution uses port ${port}. Which port should ${product().title} use? (web.port in config/local/user.yml)`;
3329
+ } else if (probe.kind !== "up") {
3330
+ out.error = error ?? `the web server is not answering at ${url}`;
3331
+ out.fix = `${bin} web restart --no-open --json`;
3332
+ }
3333
+ printJson(out);
3334
+ return out.ok ? 0 : 1;
3335
+ }
3336
+
3102
3337
  /** Legacy: start the given services via manifest depends_on. */
3103
3338
  private async cmdServiceUpManifest(
3104
3339
  addresses: string[],
@@ -4880,6 +5115,10 @@ class DenCLI {
4880
5115
  */
4881
5116
  private displayHelp(): void {
4882
5117
  this.displayHelpBanner();
5118
+ // ON-5: the line a developer pastes into their agent.
5119
+ console.log(`Set up by your agent: paste this into it:`);
5120
+ console.log(` ${AGENT_SETUP_LINE}`);
5121
+ console.log();
4883
5122
  console.log("Usage:");
4884
5123
  console.log(` ${product().cli} [command] [options]`);
4885
5124
  console.log("\nEnvironment commands:");
@@ -4917,6 +5156,9 @@ class DenCLI {
4917
5156
  console.log(
4918
5157
  " register [path] Register a repo in the service catalog",
4919
5158
  );
5159
+ console.log(
5160
+ " repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --json)",
5161
+ );
4920
5162
  console.log(
4921
5163
  " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4922
5164
  );