@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.
- package/CHANGELOG.md +74 -0
- package/README.md +21 -7
- package/docs/distributions.md +97 -0
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +28 -1
- package/src/shared/port-pick.ts +60 -0
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +4 -0
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +1 -0
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/public/settings-registry.js +3 -3
- package/src/ui/server/request-guard.ts +1 -1
|
@@ -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",
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|