@monster-orange/den 0.2.3 → 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 +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +247 -181
package/src/ui/cli/index.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Main entry point for the interactive CLI
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { composeStacksNotice } from "./workspace-notes.js";
|
|
9
|
+
import {
|
|
10
|
+
runningComposeProjects,
|
|
11
|
+
runningWorkspaceStacks,
|
|
12
|
+
} from "../../shared/stacks/status.js";
|
|
8
13
|
import { isNamedTarget } from "./named-target.js";
|
|
9
14
|
import chalk from "chalk";
|
|
10
15
|
import oraSpinner from "ora";
|
|
@@ -46,7 +51,9 @@ import {
|
|
|
46
51
|
isWorkspaceActive,
|
|
47
52
|
activateWorkspace,
|
|
48
53
|
deactivateWorkspace,
|
|
54
|
+
getActiveWorkspaces,
|
|
49
55
|
setActiveWorkspace,
|
|
56
|
+
setFocusedWorkspace,
|
|
50
57
|
createWorkspace,
|
|
51
58
|
deleteWorkspaceConfig,
|
|
52
59
|
parseAddress,
|
|
@@ -78,9 +85,11 @@ import {
|
|
|
78
85
|
resolveServiceRuntime,
|
|
79
86
|
} from "../../shared/environment-manager/service-runner.js";
|
|
80
87
|
import {
|
|
81
|
-
|
|
88
|
+
blueprintWorkspaces,
|
|
82
89
|
defaultWorkspaceNeedsSeeding,
|
|
83
90
|
defaultWorkspaceHasServices,
|
|
91
|
+
isMultiBlueprint,
|
|
92
|
+
seedBlueprintWorkspaces,
|
|
84
93
|
seedDefaultWorkspace,
|
|
85
94
|
topUpDefaultWorkspace,
|
|
86
95
|
markDefaultWorkspaceDeclined,
|
|
@@ -195,6 +204,66 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
|
|
|
195
204
|
* Concise `<command> -h` help for commands without a dedicated help renderer.
|
|
196
205
|
* Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
|
|
197
206
|
*/
|
|
207
|
+
/** `workspace` subcommands, for `workspace --help` and `workspace <sub> --help`. */
|
|
208
|
+
const WORKSPACE_SUBCOMMANDS: {
|
|
209
|
+
name: string;
|
|
210
|
+
usage: string;
|
|
211
|
+
lines: string[];
|
|
212
|
+
}[] = [
|
|
213
|
+
{
|
|
214
|
+
name: "list",
|
|
215
|
+
usage: "list",
|
|
216
|
+
lines: ["every workspace, the active set and the focus"],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "activate",
|
|
220
|
+
usage: "activate <n>",
|
|
221
|
+
lines: ["add a workspace to the active set"],
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "deactivate",
|
|
225
|
+
usage: "deactivate <n>",
|
|
226
|
+
lines: [
|
|
227
|
+
"remove it from the active set and stop the services",
|
|
228
|
+
"only it owns. Only services {cli} tracks: compose",
|
|
229
|
+
"stacks keep running; stop them with `{cli} down <repo>`",
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "focus",
|
|
234
|
+
usage: "focus <n>",
|
|
235
|
+
lines: ["make an active workspace the default for commands"],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "use",
|
|
239
|
+
usage: "use <n>",
|
|
240
|
+
lines: ["activate and focus a workspace"],
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "create",
|
|
244
|
+
usage: "create [n]",
|
|
245
|
+
lines: ["create an empty workspace"],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "delete",
|
|
249
|
+
usage: "delete <n>",
|
|
250
|
+
lines: ["delete a workspace (and its directory)"],
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "code",
|
|
254
|
+
usage: "code [n]",
|
|
255
|
+
lines: ["open it in your editor (VS Code: a .code-workspace)"],
|
|
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
|
+
},
|
|
265
|
+
];
|
|
266
|
+
|
|
198
267
|
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
199
268
|
down: {
|
|
200
269
|
usage: "down [repo] [flags]",
|
|
@@ -252,8 +321,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
252
321
|
lines: ["Check managed repos for upstream updates and offer to pull."],
|
|
253
322
|
},
|
|
254
323
|
workspace: {
|
|
255
|
-
usage:
|
|
256
|
-
lines: [
|
|
324
|
+
usage: `workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
|
|
325
|
+
lines: [
|
|
326
|
+
"Manage workspaces (named sets of services + their modes).",
|
|
327
|
+
"",
|
|
328
|
+
...WORKSPACE_SUBCOMMANDS.flatMap((c) =>
|
|
329
|
+
c.lines.map(
|
|
330
|
+
(line, i) => ` ${(i === 0 ? c.usage : "").padEnd(16)} ${line}`,
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
],
|
|
257
334
|
},
|
|
258
335
|
run: {
|
|
259
336
|
usage: "run [options]",
|
|
@@ -583,7 +660,7 @@ class DenCLI {
|
|
|
583
660
|
!["-h", "--help", "-v", "--version"].includes(command) &&
|
|
584
661
|
(ownArgs.includes("-h") || ownArgs.includes("--help"))
|
|
585
662
|
) {
|
|
586
|
-
this.printCommandHelp(command);
|
|
663
|
+
this.printCommandHelp(command, ownArgs[1]);
|
|
587
664
|
return;
|
|
588
665
|
}
|
|
589
666
|
|
|
@@ -2375,7 +2452,19 @@ class DenCLI {
|
|
|
2375
2452
|
}
|
|
2376
2453
|
|
|
2377
2454
|
/** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
|
|
2378
|
-
private printCommandHelp(command: string): void {
|
|
2455
|
+
private printCommandHelp(command: string, sub?: string): void {
|
|
2456
|
+
// `workspace <sub> --help`: that subcommand only.
|
|
2457
|
+
const wsSub =
|
|
2458
|
+
command === "workspace"
|
|
2459
|
+
? WORKSPACE_SUBCOMMANDS.find((c) => c.name === sub)
|
|
2460
|
+
: undefined;
|
|
2461
|
+
if (wsSub) {
|
|
2462
|
+
console.log(`\n ${product().cli} workspace ${wsSub.usage}\n`);
|
|
2463
|
+
for (const line of wsSub.lines)
|
|
2464
|
+
console.log(` ${line.replaceAll("{cli}", product().cli)}`);
|
|
2465
|
+
console.log();
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2379
2468
|
switch (command) {
|
|
2380
2469
|
case "up":
|
|
2381
2470
|
return this.printUpHelp();
|
|
@@ -2398,41 +2487,48 @@ class DenCLI {
|
|
|
2398
2487
|
return;
|
|
2399
2488
|
}
|
|
2400
2489
|
console.log(`\n ${product().cli} ${entry.usage}\n`);
|
|
2401
|
-
for (const line of entry.lines)
|
|
2490
|
+
for (const line of entry.lines)
|
|
2491
|
+
console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
|
|
2402
2492
|
console.log();
|
|
2403
2493
|
}
|
|
2404
2494
|
|
|
2405
2495
|
/**
|
|
2406
|
-
* Seed (or reset) the
|
|
2407
|
-
*
|
|
2408
|
-
*
|
|
2409
|
-
*
|
|
2410
|
-
*
|
|
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.
|
|
2411
2503
|
*/
|
|
2412
2504
|
private async seedDefaultWorkspaceFlow(
|
|
2413
2505
|
reason: "auto" | "init",
|
|
2414
2506
|
): Promise<boolean> {
|
|
2415
|
-
const
|
|
2416
|
-
if (
|
|
2507
|
+
const bps = blueprintWorkspaces();
|
|
2508
|
+
if (bps.length === 0) {
|
|
2417
2509
|
if (reason === "init") {
|
|
2418
2510
|
console.log(
|
|
2419
2511
|
chalk.yellow(
|
|
2420
|
-
`\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`,
|
|
2421
2513
|
),
|
|
2422
2514
|
);
|
|
2423
2515
|
}
|
|
2424
2516
|
return false;
|
|
2425
2517
|
}
|
|
2518
|
+
const multi = isMultiBlueprint();
|
|
2426
2519
|
const willReset = defaultWorkspaceHasServices();
|
|
2427
2520
|
const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
2428
2521
|
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
+
}
|
|
2433
2529
|
console.log(
|
|
2434
2530
|
chalk.gray(
|
|
2435
|
-
` ${product().title} clones + tracks them on
|
|
2531
|
+
` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
|
|
2436
2532
|
),
|
|
2437
2533
|
);
|
|
2438
2534
|
console.log(
|
|
@@ -2442,16 +2538,17 @@ class DenCLI {
|
|
|
2442
2538
|
);
|
|
2443
2539
|
|
|
2444
2540
|
if (interactive) {
|
|
2541
|
+
const what = multi ? "these workspaces" : "the default workspace";
|
|
2445
2542
|
const question = willReset
|
|
2446
|
-
?
|
|
2447
|
-
:
|
|
2543
|
+
? ` Reset ${what} to this shipped set?`
|
|
2544
|
+
: ` Seed ${what} now?`;
|
|
2448
2545
|
const ok = await cliPrompts.confirm(question, !willReset);
|
|
2449
2546
|
if (!ok) {
|
|
2450
2547
|
if (reason === "auto") {
|
|
2451
2548
|
markDefaultWorkspaceDeclined(); // don't ask again
|
|
2452
2549
|
console.log(
|
|
2453
2550
|
chalk.gray(
|
|
2454
|
-
` Skipped — run \`${product().cli}
|
|
2551
|
+
` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
|
|
2455
2552
|
),
|
|
2456
2553
|
);
|
|
2457
2554
|
} else {
|
|
@@ -2461,6 +2558,17 @@ class DenCLI {
|
|
|
2461
2558
|
}
|
|
2462
2559
|
}
|
|
2463
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
|
+
}
|
|
2464
2572
|
const seeded = seedDefaultWorkspace();
|
|
2465
2573
|
console.log(
|
|
2466
2574
|
chalk.green(
|
|
@@ -2472,13 +2580,22 @@ class DenCLI {
|
|
|
2472
2580
|
}
|
|
2473
2581
|
|
|
2474
2582
|
/**
|
|
2475
|
-
*
|
|
2476
|
-
*
|
|
2477
|
-
*
|
|
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.
|
|
2478
2588
|
*/
|
|
2479
2589
|
private async ensureWorkspaceSeeded(): Promise<void> {
|
|
2480
2590
|
if (defaultWorkspaceNeedsSeeding()) {
|
|
2481
|
-
|
|
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
|
+
}
|
|
2482
2599
|
return;
|
|
2483
2600
|
}
|
|
2484
2601
|
const added = topUpDefaultWorkspace();
|
|
@@ -3501,9 +3618,36 @@ class DenCLI {
|
|
|
3501
3618
|
}
|
|
3502
3619
|
}
|
|
3503
3620
|
}
|
|
3621
|
+
// Deactivate releases tracked daemons only; say what it left up.
|
|
3622
|
+
// One docker ps for this workspace and every other active one.
|
|
3623
|
+
let up: ReturnType<typeof runningComposeProjects> | undefined;
|
|
3624
|
+
const running = () =>
|
|
3625
|
+
up === undefined ? (up = runningComposeProjects()) : up;
|
|
3626
|
+
const stacks = runningWorkspaceStacks(name, running);
|
|
3627
|
+
const heldBy = new Map<string, string[]>();
|
|
3628
|
+
if (stacks?.length)
|
|
3629
|
+
for (const other of getActiveWorkspaces())
|
|
3630
|
+
for (const s of runningWorkspaceStacks(other, running) ?? [])
|
|
3631
|
+
heldBy.set(s.project, [...(heldBy.get(s.project) ?? []), other]);
|
|
3632
|
+
const stacksNote = composeStacksNotice(product().cli, stacks, heldBy);
|
|
3633
|
+
if (stacksNote) console.log(chalk.yellow(` ${stacksNote}`));
|
|
3504
3634
|
console.log("");
|
|
3505
3635
|
break;
|
|
3506
3636
|
}
|
|
3637
|
+
case "focus": {
|
|
3638
|
+
const name = args[1];
|
|
3639
|
+
if (!name) {
|
|
3640
|
+
console.log(
|
|
3641
|
+
chalk.red(`Usage: ${product().cli} workspace focus <name>`),
|
|
3642
|
+
);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
setFocusedWorkspace(name);
|
|
3646
|
+
console.log(
|
|
3647
|
+
chalk.green(`\n ✓ Focused workspace: ${chalk.bold(name)}\n`),
|
|
3648
|
+
);
|
|
3649
|
+
break;
|
|
3650
|
+
}
|
|
3507
3651
|
case "use": {
|
|
3508
3652
|
const name = args[1];
|
|
3509
3653
|
if (!name) {
|
|
@@ -3520,6 +3664,10 @@ class DenCLI {
|
|
|
3520
3664
|
);
|
|
3521
3665
|
break;
|
|
3522
3666
|
}
|
|
3667
|
+
case "seed": {
|
|
3668
|
+
await this.seedDefaultWorkspaceFlow("init");
|
|
3669
|
+
break;
|
|
3670
|
+
}
|
|
3523
3671
|
case "create": {
|
|
3524
3672
|
const name =
|
|
3525
3673
|
args[1] ??
|
|
@@ -3611,7 +3759,7 @@ class DenCLI {
|
|
|
3611
3759
|
default:
|
|
3612
3760
|
console.log(
|
|
3613
3761
|
chalk.gray(
|
|
3614
|
-
`Usage: ${product().cli} workspace
|
|
3762
|
+
`Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
|
|
3615
3763
|
),
|
|
3616
3764
|
);
|
|
3617
3765
|
}
|
|
@@ -3871,7 +4019,6 @@ class DenCLI {
|
|
|
3871
4019
|
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
3872
4020
|
);
|
|
3873
4021
|
console.log();
|
|
3874
|
-
console.log(" workspace list List workspaces");
|
|
3875
4022
|
console.log(
|
|
3876
4023
|
" workspace list List workspaces (active set + focus)",
|
|
3877
4024
|
);
|
|
@@ -3879,11 +4026,14 @@ class DenCLI {
|
|
|
3879
4026
|
console.log(
|
|
3880
4027
|
" workspace deactivate <n> Remove workspace from the active set",
|
|
3881
4028
|
);
|
|
4029
|
+
console.log(
|
|
4030
|
+
" workspace focus <n> Focus an active workspace (the default)",
|
|
4031
|
+
);
|
|
3882
4032
|
console.log(" workspace use <n> Activate and focus a workspace");
|
|
3883
|
-
console.log(" workspace create [n]
|
|
3884
|
-
console.log(" workspace delete <n>
|
|
4033
|
+
console.log(" workspace create [n] Create a new workspace");
|
|
4034
|
+
console.log(" workspace delete <n> Delete a workspace");
|
|
3885
4035
|
console.log(
|
|
3886
|
-
" workspace code [n]
|
|
4036
|
+
" workspace code [n] Open workspace in VS Code (generates .code-workspace)",
|
|
3887
4037
|
);
|
|
3888
4038
|
console.log("\nTest commands:");
|
|
3889
4039
|
console.log(
|
|
@@ -25,7 +25,7 @@ import { product } from "../../shared/product.js";
|
|
|
25
25
|
import { getDistRoot } from "../../shared/paths.js";
|
|
26
26
|
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
27
|
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
28
|
-
import { readMcpConfig } from "../../shared/den-config.js";
|
|
28
|
+
import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
|
|
29
29
|
|
|
30
30
|
export const MCP_CONNECT_SUBCOMMANDS = [
|
|
31
31
|
"connect",
|
|
@@ -199,6 +199,78 @@ function status(
|
|
|
199
199
|
: chalk.gray("not connected");
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/** How long `mcp connect` waits for /api/server-info before giving up. */
|
|
203
|
+
export const WEB_PROBE_TIMEOUT_MS = 1500;
|
|
204
|
+
|
|
205
|
+
/** What a probe of the web server's /api/server-info found. */
|
|
206
|
+
export type WebProbe =
|
|
207
|
+
| { kind: "up"; version?: string }
|
|
208
|
+
/** Something accepted the connection but sent no answer in time. */
|
|
209
|
+
| { kind: "timeout" }
|
|
210
|
+
/** Nothing answered: connection refused, or not the web server. */
|
|
211
|
+
| { kind: "down" };
|
|
212
|
+
|
|
213
|
+
export async function probeWebServer(
|
|
214
|
+
url: string,
|
|
215
|
+
timeoutMs: number = WEB_PROBE_TIMEOUT_MS,
|
|
216
|
+
fetchImpl: typeof fetch = fetch,
|
|
217
|
+
): Promise<WebProbe> {
|
|
218
|
+
try {
|
|
219
|
+
const r = await fetchImpl(`${url}/api/server-info`, {
|
|
220
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
221
|
+
});
|
|
222
|
+
const info = (await r.json()) as { version?: string };
|
|
223
|
+
return { kind: "up", version: info.version };
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return (e as Error).name === "TimeoutError"
|
|
226
|
+
? { kind: "timeout" }
|
|
227
|
+
: { kind: "down" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Whether the org config runs the web UI as a service (`web.service`). */
|
|
232
|
+
function webRunsAsService(): boolean {
|
|
233
|
+
try {
|
|
234
|
+
return readDenConfig().web?.service !== undefined;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The status line for a probe. A server that is up but slow is "not
|
|
242
|
+
* answering", never "not running"; and when the web UI runs as a service
|
|
243
|
+
* (`web.service`), the advice is the service's status, not `web up`.
|
|
244
|
+
*/
|
|
245
|
+
export function webServerMessage(
|
|
246
|
+
probe: WebProbe,
|
|
247
|
+
url: string,
|
|
248
|
+
opts: { cli: string; asService: boolean; timeoutMs?: number },
|
|
249
|
+
): string {
|
|
250
|
+
if (probe.kind === "up")
|
|
251
|
+
return (
|
|
252
|
+
chalk.green(`web server up at ${url}`) +
|
|
253
|
+
(probe.version ? chalk.gray(` (Den ${probe.version})`) : "")
|
|
254
|
+
);
|
|
255
|
+
const svc = `\`${opts.cli} service status\``;
|
|
256
|
+
if (probe.kind === "timeout") {
|
|
257
|
+
const secs = (opts.timeoutMs ?? WEB_PROBE_TIMEOUT_MS) / 1000;
|
|
258
|
+
return chalk.yellow(
|
|
259
|
+
`web server at ${url} is not answering within ${secs}s (busy or stuck): ` +
|
|
260
|
+
`agents' reads wait on it too; if it stays slow, ` +
|
|
261
|
+
(opts.asService
|
|
262
|
+
? `check ${svc}`
|
|
263
|
+
: `restart it with \`${opts.cli} web restart\``),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return chalk.yellow(
|
|
267
|
+
`web server not running at ${url}: agents get read errors until ` +
|
|
268
|
+
(opts.asService
|
|
269
|
+
? `the service is up (check ${svc})`
|
|
270
|
+
: `\`${opts.cli} web up\``),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
202
274
|
async function webServerLine(port?: number): Promise<string> {
|
|
203
275
|
let p = port;
|
|
204
276
|
if (p === undefined) {
|
|
@@ -209,20 +281,10 @@ async function webServerLine(port?: number): Promise<string> {
|
|
|
209
281
|
}
|
|
210
282
|
}
|
|
211
283
|
const url = `http://localhost:${p}`;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const info = (await r.json()) as { version?: string };
|
|
217
|
-
return (
|
|
218
|
-
chalk.green(`web server up at ${url}`) +
|
|
219
|
-
(info.version ? chalk.gray(` (Den ${info.version})`) : "")
|
|
220
|
-
);
|
|
221
|
-
} catch {
|
|
222
|
-
return chalk.yellow(
|
|
223
|
-
`web server not running at ${url}: agents get read errors until \`${product().cli} web up\``,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
284
|
+
return webServerMessage(await probeWebServer(url), url, {
|
|
285
|
+
cli: product().cli,
|
|
286
|
+
asService: webRunsAsService(),
|
|
287
|
+
});
|
|
226
288
|
}
|
|
227
289
|
|
|
228
290
|
function usage(): string {
|
|
@@ -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 {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `<cli> workspace deactivate` says about what it leaves running: it
|
|
3
|
+
* releases only the daemons Den tracks, so a compose stack started from one
|
|
4
|
+
* of the workspace's repos keeps running until `<cli> down <repo>`. A stack
|
|
5
|
+
* another active workspace still holds is named as in use, never with a
|
|
6
|
+
* command that would stop it.
|
|
7
|
+
*/
|
|
8
|
+
import type { WorkspaceStack } from "../../shared/stacks/status.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The note for `stacks`: the running ones (null when docker could not be
|
|
12
|
+
* asked, so it is said generically), or nothing when none is running.
|
|
13
|
+
* `heldBy` maps a Compose project to the other active workspaces that hold
|
|
14
|
+
* it.
|
|
15
|
+
*/
|
|
16
|
+
export function composeStacksNotice(
|
|
17
|
+
cli: string,
|
|
18
|
+
stacks: WorkspaceStack[] | null,
|
|
19
|
+
heldBy: ReadonlyMap<string, string[]> = new Map(),
|
|
20
|
+
): string | null {
|
|
21
|
+
if (stacks === null)
|
|
22
|
+
return `compose stacks keep running: stop them with \`${cli} down <repo>\``;
|
|
23
|
+
if (stacks.length === 0) return null;
|
|
24
|
+
const held = stacks.filter((s) => heldBy.get(s.project)?.length);
|
|
25
|
+
// `down <repo>` stops all of a repo's stacks: never suggest it for a repo
|
|
26
|
+
// with a stack still in use.
|
|
27
|
+
const heldRepos = new Set(held.map((s) => s.repo));
|
|
28
|
+
const free = stacks.filter((s) => !heldRepos.has(s.repo));
|
|
29
|
+
const inUse = held.map(
|
|
30
|
+
(s) => `${s.project} (still in ${heldBy.get(s.project)!.join(", ")})`,
|
|
31
|
+
);
|
|
32
|
+
const parts: string[] = [];
|
|
33
|
+
if (free.length) {
|
|
34
|
+
const repos = [...new Set(free.map((s) => s.repo))];
|
|
35
|
+
parts.push(
|
|
36
|
+
`compose stacks keep running (${free.map((s) => s.project).join(", ")}): ` +
|
|
37
|
+
`stop them with ${repos.map((r) => `\`${cli} down ${r}\``).join(", ")}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (inUse.length)
|
|
41
|
+
parts.push(`in use by another workspace: ${inUse.join(", ")}`);
|
|
42
|
+
return parts.join("; ");
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dependency graph's top-down repo order (entry repo first, infra last),
|
|
3
|
+
* which the health row and /api/services rank by.
|
|
4
|
+
*
|
|
5
|
+
* Deriving the graph walks every repo's sources (fingerprints, scans), so it
|
|
6
|
+
* is far too slow to redo on each 5s poll. In the web server it is kept for
|
|
7
|
+
* 30s and then recomputed after the request that noticed, never inside it
|
|
8
|
+
* (read-cache.ts); a stale order only means a briefly stale sort.
|
|
9
|
+
*/
|
|
10
|
+
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
11
|
+
import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
|
|
12
|
+
import { cachedReadStale } from "../../shared/read-cache.js";
|
|
13
|
+
|
|
14
|
+
const GRAPH_ORDER_MAX_AGE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export function graphOrder(workspace: string | undefined): string[] {
|
|
17
|
+
return cachedReadStale(
|
|
18
|
+
`graph-order:${workspace ?? ""}`,
|
|
19
|
+
GRAPH_ORDER_MAX_AGE_MS,
|
|
20
|
+
() => orderForDown(deriveGraph({ scope: "all", workspace })),
|
|
21
|
+
);
|
|
22
|
+
}
|