@monster-orange/den 0.2.4 → 0.2.5
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 +97 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +193 -29
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +63 -22
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/server/index.ts +89 -27
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +7 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose-stack health answered in-process, from one `docker ps` listing
|
|
3
|
+
* shared by every check that runs at the same time.
|
|
4
|
+
*
|
|
5
|
+
* Den gives a compose repo with no declared check a `<cli> stack health`
|
|
6
|
+
* shell check (composeHealthCmd), one per manifest service. Spawned, each
|
|
7
|
+
* was a full CLI start: 17-19 tsx processes per health cycle for three
|
|
8
|
+
* stacks, which on a loaded machine (load 35-55) ran past their budget and
|
|
9
|
+
* read as down. The checks Den generates itself are now judged here, with
|
|
10
|
+
* the verdict `stack health` gives; a distribution's own shell checks still
|
|
11
|
+
* run as shell commands.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "child_process";
|
|
14
|
+
import type { RawManifest } from "../types/index.js";
|
|
15
|
+
import { manifestStackName } from "./config.js";
|
|
16
|
+
import { composeHealthCmd } from "./manifest-commands.js";
|
|
17
|
+
import { projectName } from "./render.js";
|
|
18
|
+
import { judgeStackRows, type ComposeRow } from "./runtime.js";
|
|
19
|
+
import { resolveStackWorkspaces } from "./status.js";
|
|
20
|
+
|
|
21
|
+
export { judgeStackRows, type ComposeRow };
|
|
22
|
+
|
|
23
|
+
/** A Den-generated compose health check: what to look for in the listing. */
|
|
24
|
+
export interface ComposeHealthTarget {
|
|
25
|
+
/** The Compose project (`<stack>-<stack workspace>`). */
|
|
26
|
+
project: string;
|
|
27
|
+
/** A compose service to check on its own; unset: the whole stack. */
|
|
28
|
+
service?: string;
|
|
29
|
+
/** The equivalent `<cli> stack health` command (shown as the check). */
|
|
30
|
+
cmd: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A health verdict; `ok: null` when docker could not be asked. */
|
|
34
|
+
export interface ComposeVerdict {
|
|
35
|
+
ok: boolean | null;
|
|
36
|
+
detail: string;
|
|
37
|
+
/** With ok null: docker gave no answer within the budget. */
|
|
38
|
+
pending?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The compose health check Den generates for this service, or null. */
|
|
42
|
+
export function composeHealthTarget(
|
|
43
|
+
manifest: RawManifest,
|
|
44
|
+
subsystem: string,
|
|
45
|
+
repoName: string,
|
|
46
|
+
workspace: string,
|
|
47
|
+
): ComposeHealthTarget | null {
|
|
48
|
+
const cmd = composeHealthCmd(manifest, subsystem, repoName, workspace);
|
|
49
|
+
if (!cmd) return null;
|
|
50
|
+
const project = projectName(
|
|
51
|
+
manifestStackName(manifest, repoName),
|
|
52
|
+
resolveStackWorkspaces(workspace).stackWorkspace,
|
|
53
|
+
);
|
|
54
|
+
const service = cmd.includes(" --service ") ? subsystem : undefined;
|
|
55
|
+
return { project, ...(service ? { service } : {}), cmd };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Budget for the shared listing; past it the checks are pending. */
|
|
59
|
+
export const COMPOSE_LISTING_TIMEOUT_MS = 5_000;
|
|
60
|
+
|
|
61
|
+
type Listing = { rows: ComposeRow[] } | { error: string; pending?: boolean };
|
|
62
|
+
|
|
63
|
+
let inFlight: Promise<Listing> | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every compose container, from one `docker ps -a`. Concurrent callers share
|
|
67
|
+
* the call in flight, so a health cycle lists once however many checks it has.
|
|
68
|
+
*/
|
|
69
|
+
export function composeListing(): Promise<Listing> {
|
|
70
|
+
return (inFlight ??= listOnce().finally(() => {
|
|
71
|
+
inFlight = null;
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listOnce(): Promise<Listing> {
|
|
76
|
+
return new Promise((done) => {
|
|
77
|
+
// Backstop: execFile reports only once the pipes close, which a killed
|
|
78
|
+
// process's children can hold open; the checks must not wait on that.
|
|
79
|
+
const guard = setTimeout(
|
|
80
|
+
() =>
|
|
81
|
+
resolve({
|
|
82
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
83
|
+
pending: true,
|
|
84
|
+
}),
|
|
85
|
+
COMPOSE_LISTING_TIMEOUT_MS + 500,
|
|
86
|
+
);
|
|
87
|
+
let settled = false;
|
|
88
|
+
const resolve = (l: Listing): void => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
clearTimeout(guard);
|
|
92
|
+
done(l);
|
|
93
|
+
};
|
|
94
|
+
execFile(
|
|
95
|
+
"docker",
|
|
96
|
+
[
|
|
97
|
+
"ps",
|
|
98
|
+
"-a",
|
|
99
|
+
"--filter",
|
|
100
|
+
"label=com.docker.compose.project",
|
|
101
|
+
"--format",
|
|
102
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
103
|
+
],
|
|
104
|
+
{
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
timeout: COMPOSE_LISTING_TIMEOUT_MS,
|
|
107
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
108
|
+
},
|
|
109
|
+
(err, stdout, stderr) => {
|
|
110
|
+
if (err) {
|
|
111
|
+
const killed = (err as { killed?: boolean }).killed === true;
|
|
112
|
+
return resolve(
|
|
113
|
+
killed
|
|
114
|
+
? {
|
|
115
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
116
|
+
pending: true,
|
|
117
|
+
}
|
|
118
|
+
: {
|
|
119
|
+
error:
|
|
120
|
+
String(stderr || err.message).trim() || "docker ps failed",
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const rows = stdout
|
|
125
|
+
.split("\n")
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.map((line) => {
|
|
128
|
+
const [project = "", service = "", state = "", status = ""] =
|
|
129
|
+
line.split("\t");
|
|
130
|
+
return { project, service, state, status };
|
|
131
|
+
});
|
|
132
|
+
resolve({ rows });
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The health of one Den-generated compose check, from the shared listing. */
|
|
139
|
+
export async function composeHealth(
|
|
140
|
+
target: ComposeHealthTarget,
|
|
141
|
+
): Promise<ComposeVerdict> {
|
|
142
|
+
const listing = await composeListing();
|
|
143
|
+
if ("error" in listing)
|
|
144
|
+
return {
|
|
145
|
+
ok: null,
|
|
146
|
+
detail: `container state unknown: ${listing.error}`,
|
|
147
|
+
...(listing.pending ? { pending: true } : {}),
|
|
148
|
+
};
|
|
149
|
+
return judgeStackRows(listing.rows, target.project, target.service);
|
|
150
|
+
}
|
|
@@ -364,39 +364,86 @@ export function stackLogs(
|
|
|
364
364
|
|
|
365
365
|
/**
|
|
366
366
|
* Health of a running stack: ok when it has containers, all are running, and
|
|
367
|
-
* none reports a failing or pending Docker healthcheck.
|
|
367
|
+
* none reports a failing or pending Docker healthcheck. `ok: null` when
|
|
368
|
+
* docker could not be asked: container state unknown is not "not running".
|
|
368
369
|
*/
|
|
369
370
|
export function stackHealth(
|
|
370
371
|
ctx: StackContext,
|
|
371
372
|
s: StackConfig,
|
|
372
373
|
service?: string,
|
|
373
|
-
): { ok: boolean; detail: string } {
|
|
374
|
+
): { ok: boolean | null; detail: string } {
|
|
374
375
|
const project = projectName(s.name, ctx.workspace);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
376
|
+
let r: ReturnType<typeof run>;
|
|
377
|
+
try {
|
|
378
|
+
r = run("docker", [
|
|
379
|
+
"ps",
|
|
380
|
+
"-a",
|
|
381
|
+
"--filter",
|
|
382
|
+
`label=com.docker.compose.project=${project}`,
|
|
383
|
+
...(service
|
|
384
|
+
? ["--filter", `label=com.docker.compose.service=${service}`]
|
|
385
|
+
: []),
|
|
386
|
+
"--format",
|
|
387
|
+
'{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}',
|
|
388
|
+
]);
|
|
389
|
+
} catch (e) {
|
|
390
|
+
return {
|
|
391
|
+
ok: null,
|
|
392
|
+
detail: `container state unknown: ${(e as Error).message}`,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (r.code !== 0)
|
|
396
|
+
return {
|
|
397
|
+
ok: null,
|
|
398
|
+
detail: `container state unknown: ${r.err.trim() || "docker ps failed"}`,
|
|
399
|
+
};
|
|
400
|
+
const rows = r.out
|
|
401
|
+
.split("\n")
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
.map((line) => {
|
|
404
|
+
const [svc = "", state = "", status = ""] = line.split("\t");
|
|
405
|
+
return { project, service: svc, state, status };
|
|
406
|
+
});
|
|
407
|
+
return judgeStackRows(rows, project, service);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** One compose container, as a `docker ps` listing reports it. */
|
|
411
|
+
export interface ComposeRow {
|
|
412
|
+
project: string;
|
|
413
|
+
service: string;
|
|
414
|
+
state: string;
|
|
415
|
+
status: string;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The verdict `stack health` gives for `project` (and `service`): ok when it
|
|
420
|
+
* has containers, all running, none with a failing or starting Docker
|
|
421
|
+
* healthcheck. Shared by the CLI and the in-process check (compose-health).
|
|
422
|
+
*/
|
|
423
|
+
export function judgeStackRows(
|
|
424
|
+
rows: ComposeRow[],
|
|
425
|
+
project: string,
|
|
426
|
+
service?: string,
|
|
427
|
+
): { ok: boolean; detail: string } {
|
|
428
|
+
const mine = rows.filter(
|
|
429
|
+
(r) => r.project === project && (!service || r.service === service),
|
|
430
|
+
);
|
|
431
|
+
if (!mine.length)
|
|
389
432
|
return {
|
|
390
433
|
ok: false,
|
|
391
434
|
detail: `${project}${service ? `/${service}` : ""}: not running`,
|
|
392
435
|
};
|
|
393
|
-
const bad =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
});
|
|
436
|
+
const bad = mine.filter(
|
|
437
|
+
(r) =>
|
|
438
|
+
r.state !== "running" ||
|
|
439
|
+
/\((unhealthy|health: starting)\)/.test(r.status),
|
|
440
|
+
);
|
|
399
441
|
return bad.length
|
|
400
|
-
? {
|
|
401
|
-
|
|
442
|
+
? {
|
|
443
|
+
ok: false,
|
|
444
|
+
detail: bad
|
|
445
|
+
.map((r) => `${r.service}\t${r.state}\t${r.status}`)
|
|
446
|
+
.join("; "),
|
|
447
|
+
}
|
|
448
|
+
: { ok: true, detail: `${mine.length} container(s) running` };
|
|
402
449
|
}
|
|
@@ -34,7 +34,8 @@ export interface StackStatus {
|
|
|
34
34
|
routes: StackRoute[];
|
|
35
35
|
uses: string[];
|
|
36
36
|
/** Container health (only when asked for: it runs `docker ps`). */
|
|
37
|
-
|
|
37
|
+
/** `ok: null`: docker could not be asked, so the state is unknown. */
|
|
38
|
+
health?: { ok: boolean | null; detail: string };
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const WS_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
@@ -96,7 +97,7 @@ export function stackStatuses(
|
|
|
96
97
|
try {
|
|
97
98
|
status.health = healthOf(ctx, s);
|
|
98
99
|
} catch (e) {
|
|
99
|
-
status.health = { ok:
|
|
100
|
+
status.health = { ok: null, detail: (e as Error).message };
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
return status;
|
|
@@ -13,6 +13,7 @@ export function healthLabelText(health: HealthState): string {
|
|
|
13
13
|
export function formatHealthLabel(health: HealthState): string {
|
|
14
14
|
if (health === "ok") return chalk.green("● ok");
|
|
15
15
|
if (health === "error") return chalk.red("● down");
|
|
16
|
+
if (health === "pending") return chalk.gray("● pending");
|
|
16
17
|
return chalk.gray("● unknown");
|
|
17
18
|
}
|
|
18
19
|
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -85,9 +85,11 @@ import {
|
|
|
85
85
|
resolveServiceRuntime,
|
|
86
86
|
} from "../../shared/environment-manager/service-runner.js";
|
|
87
87
|
import {
|
|
88
|
-
|
|
88
|
+
blueprintWorkspaces,
|
|
89
89
|
defaultWorkspaceNeedsSeeding,
|
|
90
90
|
defaultWorkspaceHasServices,
|
|
91
|
+
isMultiBlueprint,
|
|
92
|
+
seedBlueprintWorkspaces,
|
|
91
93
|
seedDefaultWorkspace,
|
|
92
94
|
topUpDefaultWorkspace,
|
|
93
95
|
markDefaultWorkspaceDeclined,
|
|
@@ -252,6 +254,14 @@ const WORKSPACE_SUBCOMMANDS: {
|
|
|
252
254
|
usage: "code [n]",
|
|
253
255
|
lines: ["open it in your editor (VS Code: a .code-workspace)"],
|
|
254
256
|
},
|
|
257
|
+
{
|
|
258
|
+
name: "seed",
|
|
259
|
+
usage: "seed",
|
|
260
|
+
lines: [
|
|
261
|
+
"fill workspaces from the distribution's blueprint",
|
|
262
|
+
"(defaults.workspace / defaults.workspaces), all managed",
|
|
263
|
+
],
|
|
264
|
+
},
|
|
255
265
|
];
|
|
256
266
|
|
|
257
267
|
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
@@ -2483,36 +2493,42 @@ class DenCLI {
|
|
|
2483
2493
|
}
|
|
2484
2494
|
|
|
2485
2495
|
/**
|
|
2486
|
-
* Seed (or reset) the
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
2490
|
-
*
|
|
2496
|
+
* Seed (or reset) the blueprint's workspaces from the committed config:
|
|
2497
|
+
* `defaults.workspace` fills `default`; `defaults.workspaces` creates each
|
|
2498
|
+
* named workspace, activates them all and focuses the marked one. All
|
|
2499
|
+
* repos managed. Prints clear guidance and, on a TTY, asks first.
|
|
2500
|
+
* `reason: "auto"` is the one-time offer on a genuine fresh setup (TTY
|
|
2501
|
+
* only: see ensureWorkspaceSeeded); `reason: "init"` is the explicit
|
|
2502
|
+
* `<cli> workspace seed` / `<cli> init` in the root. Returns true if seeded.
|
|
2491
2503
|
*/
|
|
2492
2504
|
private async seedDefaultWorkspaceFlow(
|
|
2493
2505
|
reason: "auto" | "init",
|
|
2494
2506
|
): Promise<boolean> {
|
|
2495
|
-
const
|
|
2496
|
-
if (
|
|
2507
|
+
const bps = blueprintWorkspaces();
|
|
2508
|
+
if (bps.length === 0) {
|
|
2497
2509
|
if (reason === "init") {
|
|
2498
2510
|
console.log(
|
|
2499
2511
|
chalk.yellow(
|
|
2500
|
-
`\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
|
|
2512
|
+
`\n No defaults.workspace.repos or defaults.workspaces in ${product().configFile} — nothing to seed.\n`,
|
|
2501
2513
|
),
|
|
2502
2514
|
);
|
|
2503
2515
|
}
|
|
2504
2516
|
return false;
|
|
2505
2517
|
}
|
|
2518
|
+
const multi = isMultiBlueprint();
|
|
2506
2519
|
const willReset = defaultWorkspaceHasServices();
|
|
2507
2520
|
const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
2508
2521
|
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2522
|
+
for (const bp of bps) {
|
|
2523
|
+
const label = multi ? `Workspace ${bp.name}` : "Default workspace";
|
|
2524
|
+
console.log(
|
|
2525
|
+
chalk.cyan(`\n ${label} — ${bp.repos.length} repos, all managed:`),
|
|
2526
|
+
);
|
|
2527
|
+
console.log(chalk.gray(` ${bp.repos.join(", ")}`));
|
|
2528
|
+
}
|
|
2513
2529
|
console.log(
|
|
2514
2530
|
chalk.gray(
|
|
2515
|
-
` ${product().title} clones + tracks them on
|
|
2531
|
+
` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
|
|
2516
2532
|
),
|
|
2517
2533
|
);
|
|
2518
2534
|
console.log(
|
|
@@ -2522,16 +2538,17 @@ class DenCLI {
|
|
|
2522
2538
|
);
|
|
2523
2539
|
|
|
2524
2540
|
if (interactive) {
|
|
2541
|
+
const what = multi ? "these workspaces" : "the default workspace";
|
|
2525
2542
|
const question = willReset
|
|
2526
|
-
?
|
|
2527
|
-
:
|
|
2543
|
+
? ` Reset ${what} to this shipped set?`
|
|
2544
|
+
: ` Seed ${what} now?`;
|
|
2528
2545
|
const ok = await cliPrompts.confirm(question, !willReset);
|
|
2529
2546
|
if (!ok) {
|
|
2530
2547
|
if (reason === "auto") {
|
|
2531
2548
|
markDefaultWorkspaceDeclined(); // don't ask again
|
|
2532
2549
|
console.log(
|
|
2533
2550
|
chalk.gray(
|
|
2534
|
-
` Skipped — run \`${product().cli}
|
|
2551
|
+
` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
|
|
2535
2552
|
),
|
|
2536
2553
|
);
|
|
2537
2554
|
} else {
|
|
@@ -2541,6 +2558,17 @@ class DenCLI {
|
|
|
2541
2558
|
}
|
|
2542
2559
|
}
|
|
2543
2560
|
|
|
2561
|
+
if (multi) {
|
|
2562
|
+
const seeded = seedBlueprintWorkspaces();
|
|
2563
|
+
const focus = readWorkspacesFile().focus;
|
|
2564
|
+
console.log(
|
|
2565
|
+
chalk.green(
|
|
2566
|
+
` ✓ ${seeded.length} workspaces ready (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
|
|
2567
|
+
`Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
|
|
2568
|
+
),
|
|
2569
|
+
);
|
|
2570
|
+
return true;
|
|
2571
|
+
}
|
|
2544
2572
|
const seeded = seedDefaultWorkspace();
|
|
2545
2573
|
console.log(
|
|
2546
2574
|
chalk.green(
|
|
@@ -2552,13 +2580,22 @@ class DenCLI {
|
|
|
2552
2580
|
}
|
|
2553
2581
|
|
|
2554
2582
|
/**
|
|
2555
|
-
*
|
|
2556
|
-
*
|
|
2557
|
-
*
|
|
2583
|
+
* On a genuine fresh setup (nothing but an empty, never-seeded `default`),
|
|
2584
|
+
* offer to seed from the blueprint: on a TTY, ask; otherwise print a
|
|
2585
|
+
* one-line hint and change nothing. Afterwards keep a lone `default`
|
|
2586
|
+
* converged on the blueprint (repos added to defaults later join it
|
|
2587
|
+
* managed); never once the user has other workspaces.
|
|
2558
2588
|
*/
|
|
2559
2589
|
private async ensureWorkspaceSeeded(): Promise<void> {
|
|
2560
2590
|
if (defaultWorkspaceNeedsSeeding()) {
|
|
2561
|
-
|
|
2591
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2592
|
+
await this.seedDefaultWorkspaceFlow("auto");
|
|
2593
|
+
} else {
|
|
2594
|
+
const hint = isMultiBlueprint()
|
|
2595
|
+
? "no workspaces yet: run `{cli} workspace seed` to create them from the blueprint"
|
|
2596
|
+
: "default workspace is empty: run `{cli} workspace seed` to fill it from the blueprint";
|
|
2597
|
+
console.error(chalk.gray(` ${hint.replace("{cli}", product().cli)}`));
|
|
2598
|
+
}
|
|
2562
2599
|
return;
|
|
2563
2600
|
}
|
|
2564
2601
|
const added = topUpDefaultWorkspace();
|
|
@@ -3627,6 +3664,10 @@ class DenCLI {
|
|
|
3627
3664
|
);
|
|
3628
3665
|
break;
|
|
3629
3666
|
}
|
|
3667
|
+
case "seed": {
|
|
3668
|
+
await this.seedDefaultWorkspaceFlow("init");
|
|
3669
|
+
break;
|
|
3670
|
+
}
|
|
3630
3671
|
case "create": {
|
|
3631
3672
|
const name =
|
|
3632
3673
|
args[1] ??
|
|
@@ -3718,7 +3759,7 @@ class DenCLI {
|
|
|
3718
3759
|
default:
|
|
3719
3760
|
console.log(
|
|
3720
3761
|
chalk.gray(
|
|
3721
|
-
`Usage: ${product().cli} workspace
|
|
3762
|
+
`Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
|
|
3722
3763
|
),
|
|
3723
3764
|
);
|
|
3724
3765
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../../shared/stacks/runtime.js";
|
|
23
23
|
import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
|
|
24
24
|
import type { StackConfig } from "../../shared/stacks/types.js";
|
|
25
|
+
import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
|
|
25
26
|
|
|
26
27
|
function serviceFlag(args: string[]): { service?: string; rest: string[] } {
|
|
27
28
|
const rest: string[] = [];
|
|
@@ -56,9 +57,48 @@ export async function resolveStacksSecretEnv(
|
|
|
56
57
|
return out;
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* `stack health <name…>`: exit 0 when every stack is healthy, 1 when one is
|
|
62
|
+
* not, and STACK_HEALTH_UNKNOWN_EXIT when none failed but one could not be
|
|
63
|
+
* told (docker did not answer, the config could not be read). The web
|
|
64
|
+
* server's health row reads that as unknown, never as down.
|
|
65
|
+
*/
|
|
66
|
+
function stackHealthCommand(args: string[], service?: string): void {
|
|
67
|
+
let failed = false;
|
|
68
|
+
let unknown = false;
|
|
69
|
+
try {
|
|
70
|
+
const ctx = loadStackContext();
|
|
71
|
+
const names = args.filter((a) => !a.startsWith("-"));
|
|
72
|
+
if (!names.length)
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Usage: stack health <name…> (known: ${Object.keys(ctx.stacks).join(", ")})`,
|
|
75
|
+
);
|
|
76
|
+
for (const n of names) {
|
|
77
|
+
const s = ctx.stacks[n];
|
|
78
|
+
const h = s
|
|
79
|
+
? stackHealth(ctx, s, service)
|
|
80
|
+
: { ok: false, detail: `unknown stack "${n}"` };
|
|
81
|
+
if (h.ok === false) failed = true;
|
|
82
|
+
if (h.ok === null) unknown = true;
|
|
83
|
+
const mark =
|
|
84
|
+
h.ok === true
|
|
85
|
+
? chalk.green("✓")
|
|
86
|
+
: h.ok === false
|
|
87
|
+
? chalk.red("✗")
|
|
88
|
+
: chalk.gray("?");
|
|
89
|
+
console.log(`${mark} ${n} ${h.detail}`);
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error(chalk.gray(`? ${(e as Error).message}`));
|
|
93
|
+
unknown = true;
|
|
94
|
+
}
|
|
95
|
+
process.exitCode = failed ? 1 : unknown ? STACK_HEALTH_UNKNOWN_EXIT : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
export async function cmdStack(argv: string[]): Promise<void> {
|
|
60
99
|
const { service, rest } = serviceFlag(argv);
|
|
61
100
|
const [sub = "ls", ...args] = rest;
|
|
101
|
+
if (sub === "health") return stackHealthCommand(args, service);
|
|
62
102
|
// The global -w/--workspace is parsed (and checked) before we run and
|
|
63
103
|
// arrives as DEN_WORKSPACE_OVERRIDE; loadStackContext reads it.
|
|
64
104
|
const ctx = loadStackContext();
|
|
@@ -129,18 +169,6 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
129
169
|
}
|
|
130
170
|
return;
|
|
131
171
|
}
|
|
132
|
-
case "health": {
|
|
133
|
-
let ok = true;
|
|
134
|
-
for (const n of pick()) {
|
|
135
|
-
const h = stackHealth(ctx, stacks[n], service);
|
|
136
|
-
ok &&= h.ok;
|
|
137
|
-
console.log(
|
|
138
|
-
`${h.ok ? chalk.green("✓") : chalk.red("✗")} ${n} ${h.detail}`,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
process.exitCode = ok ? 0 : 1;
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
172
|
case "down":
|
|
145
173
|
for (const n of startOrder(pick(), stacks)
|
|
146
174
|
.reverse()
|
|
@@ -3,6 +3,7 @@ import { execFileSync, spawn, spawnSync } from "child_process";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as http from "http";
|
|
5
5
|
import * as path from "path";
|
|
6
|
+
import { pathToFileURL } from "url";
|
|
6
7
|
import {
|
|
7
8
|
CLI_SPAWN_TIMEOUT_MS,
|
|
8
9
|
DEFAULT_PORT,
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
} from "../../shared/constants.js";
|
|
11
12
|
import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
|
|
12
13
|
import { hasDenUiMarker } from "../../shared/ui-marker.js";
|
|
13
|
-
import { engineBin } from "../../shared/engine.js";
|
|
14
|
+
import { engineBin, engineModuleFile } from "../../shared/engine.js";
|
|
14
15
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
15
16
|
|
|
16
17
|
import { product } from "../../shared/product.js";
|
|
@@ -294,8 +295,19 @@ async function waitForDenWeb(port: number): Promise<boolean> {
|
|
|
294
295
|
return false;
|
|
295
296
|
}
|
|
296
297
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
298
|
+
/**
|
|
299
|
+
* How to run the web server: Den's own files, from the engine root.
|
|
300
|
+
*
|
|
301
|
+
* The TypeScript source runs as `node --import <tsx loader> index.ts`, never
|
|
302
|
+
* through the `tsx` CLI. The CLI is a parent process that relays signals to
|
|
303
|
+
* the server and, if the server has not acknowledged a SIGTERM within about
|
|
304
|
+
* 60ms (two 30ms windows), SIGKILLs it and exits 143. Under load the server
|
|
305
|
+
* misses that window, so `<cli> web down` killed it without its shutdown.
|
|
306
|
+
* With the loader there is one process, and the signal reaches the
|
|
307
|
+
* server's own handler. (The launchd service, service/install.sh, already
|
|
308
|
+
* runs this way.)
|
|
309
|
+
*/
|
|
310
|
+
export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
|
|
299
311
|
cmd: string;
|
|
300
312
|
args: string[];
|
|
301
313
|
} {
|
|
@@ -313,17 +325,23 @@ function resolveServerEntrypoint(engine: string = getEngineRoot()): {
|
|
|
313
325
|
// take effect. The server resolves PUBLIC_DIR relative to its own module, so
|
|
314
326
|
// running from src/ serves src/ui/server/public directly. Fall back to the
|
|
315
327
|
// compiled build only when devDependencies (tsx) aren't installed.
|
|
316
|
-
const
|
|
328
|
+
const loader = engineModuleFile("tsx/dist/loader.mjs", engine);
|
|
317
329
|
const srcServer = path.join(engine, "src/ui/server/index.ts");
|
|
318
|
-
if (
|
|
319
|
-
return {
|
|
330
|
+
if (loader && fs.existsSync(srcServer)) {
|
|
331
|
+
return {
|
|
332
|
+
cmd: process.execPath,
|
|
333
|
+
args: ["--import", pathToFileURL(loader).href, srcServer],
|
|
334
|
+
};
|
|
320
335
|
}
|
|
321
336
|
|
|
322
337
|
if (fs.existsSync(builtServer)) {
|
|
323
338
|
return { cmd: process.execPath, args: [builtServer] };
|
|
324
339
|
}
|
|
325
340
|
|
|
326
|
-
return {
|
|
341
|
+
return {
|
|
342
|
+
cmd: engineBin(process.platform === "win32" ? "tsx.cmd" : "tsx", engine),
|
|
343
|
+
args: [srcServer],
|
|
344
|
+
};
|
|
327
345
|
}
|
|
328
346
|
|
|
329
347
|
function startDenWebServer(options: WebCommandOptions): StartedServer {
|