@monster-orange/den 0.2.3 → 0.2.4
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 +111 -0
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/environment-manager/health.ts +36 -1
- 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/status.ts +109 -1
- package/src/ui/cli/index.ts +119 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +98 -183
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/workspace-routes.ts +240 -181
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short-lived cache for the web server's repeated reads (manifests, the
|
|
3
|
+
* edge config, the derived graph), so a request does not re-stat and
|
|
4
|
+
* re-read the same files dozens of times, and polls a few seconds apart
|
|
5
|
+
* share one read.
|
|
6
|
+
*
|
|
7
|
+
* Off unless the web server enables it: a CLI command and the tests read
|
|
8
|
+
* fresh every time, as before. The server clears it on every mutating
|
|
9
|
+
* request, so a write made through the UI is seen by the very next read;
|
|
10
|
+
* an edit made outside the server (an editor, the CLI) is seen within the
|
|
11
|
+
* TTL. Values are cloned on the way out, so a caller that mutates its copy
|
|
12
|
+
* never changes what the next caller gets.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let ttlMs = 0;
|
|
16
|
+
const entries = new Map<string, { at: number; value: unknown }>();
|
|
17
|
+
/** cachedReadStale's values: kept across a clear, only marked stale. */
|
|
18
|
+
const stale = new Map<string, { at: number; value: unknown }>();
|
|
19
|
+
const refreshing = new Set<string>();
|
|
20
|
+
|
|
21
|
+
/** Turn the cache on (web server only). */
|
|
22
|
+
export function enableReadCache(ms: number): void {
|
|
23
|
+
ttlMs = ms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Drop every cached read (after a mutation, or in tests). */
|
|
27
|
+
export function clearReadCache(): void {
|
|
28
|
+
entries.clear();
|
|
29
|
+
for (const e of stale.values()) e.at = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function copy<T>(v: T): T {
|
|
33
|
+
return v !== null && typeof v === "object" ? structuredClone(v) : v;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `load()`'s value for `key`, reused for the TTL when the cache is on. */
|
|
37
|
+
export function cachedRead<T>(key: string, load: () => T): T {
|
|
38
|
+
if (ttlMs <= 0) return load();
|
|
39
|
+
const hit = entries.get(key);
|
|
40
|
+
if (hit && Date.now() - hit.at < ttlMs) return copy(hit.value as T);
|
|
41
|
+
const value = load();
|
|
42
|
+
entries.set(key, { at: Date.now(), value });
|
|
43
|
+
return copy(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Like cachedRead, but past the TTL it still answers with the last value at
|
|
48
|
+
* once and recomputes after the current request (setImmediate), so no
|
|
49
|
+
* request waits on the recompute. For expensive, slow-changing results
|
|
50
|
+
* (the derived dependency graph) whose staleness only affects ordering.
|
|
51
|
+
*/
|
|
52
|
+
export function cachedReadStale<T>(
|
|
53
|
+
key: string,
|
|
54
|
+
maxAgeMs: number,
|
|
55
|
+
load: () => T,
|
|
56
|
+
): T {
|
|
57
|
+
if (ttlMs <= 0) return load();
|
|
58
|
+
const hit = stale.get(key);
|
|
59
|
+
if (!hit) {
|
|
60
|
+
const value = load();
|
|
61
|
+
stale.set(key, { at: Date.now(), value });
|
|
62
|
+
return copy(value);
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() - hit.at >= maxAgeMs && !refreshing.has(key)) {
|
|
65
|
+
refreshing.add(key);
|
|
66
|
+
setImmediate(() => {
|
|
67
|
+
try {
|
|
68
|
+
stale.set(key, { at: Date.now(), value: load() });
|
|
69
|
+
} catch {
|
|
70
|
+
/* keep the last good value */
|
|
71
|
+
} finally {
|
|
72
|
+
refreshing.delete(key);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return copy(hit.value as T);
|
|
77
|
+
}
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
* The CLI prints it; the web server serves it at /api/stacks (and through it
|
|
4
4
|
* the MCP `stacks` tool).
|
|
5
5
|
*/
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { readCatalog } from "../environment-manager/catalog.js";
|
|
6
10
|
import { readEdgeConfig } from "../edge/config.js";
|
|
7
11
|
import { routeUrl } from "../edge/render.js";
|
|
8
12
|
import {
|
|
9
13
|
getFocusedWorkspaceName,
|
|
10
14
|
getWorkspaceByName,
|
|
15
|
+
listWorkspaceServices,
|
|
11
16
|
} from "../environment-manager/workspaces.js";
|
|
12
|
-
import { readStacks } from "./config.js";
|
|
17
|
+
import { readManifestStacks, readStacks } from "./config.js";
|
|
13
18
|
import { projectName, stackWorkspace } from "./render.js";
|
|
14
19
|
import { stackHealth, type StackContext } from "./runtime.js";
|
|
15
20
|
|
|
@@ -97,3 +102,106 @@ export function stackStatuses(
|
|
|
97
102
|
return status;
|
|
98
103
|
});
|
|
99
104
|
}
|
|
105
|
+
|
|
106
|
+
/** A running Compose project started from one of a workspace's repos. */
|
|
107
|
+
export interface WorkspaceStack {
|
|
108
|
+
/** The Den stack name, or the project name for a stack the repo runs. */
|
|
109
|
+
name: string;
|
|
110
|
+
repo: string;
|
|
111
|
+
project: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A running Compose project and the directory it was started from. */
|
|
115
|
+
export interface RunningProject {
|
|
116
|
+
project: string;
|
|
117
|
+
/** `com.docker.compose.project.working_dir`; "" when unlabelled. */
|
|
118
|
+
workingDir: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The Compose projects with running containers, from one `docker ps`; null
|
|
123
|
+
* when docker can't be asked (not installed, daemon down, too slow).
|
|
124
|
+
*/
|
|
125
|
+
export function runningComposeProjects(): RunningProject[] | null {
|
|
126
|
+
const r = spawnSync(
|
|
127
|
+
"docker",
|
|
128
|
+
[
|
|
129
|
+
"ps",
|
|
130
|
+
"--format",
|
|
131
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}',
|
|
132
|
+
],
|
|
133
|
+
{ encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
|
|
134
|
+
);
|
|
135
|
+
if (r.error || r.status !== 0) return null;
|
|
136
|
+
const seen = new Map<string, RunningProject>();
|
|
137
|
+
for (const line of (r.stdout ?? "").split("\n")) {
|
|
138
|
+
const [project = "", workingDir = ""] = line.trim().split("\t");
|
|
139
|
+
if (project && !seen.has(project))
|
|
140
|
+
seen.set(project, { project, workingDir: workingDir.trim() });
|
|
141
|
+
}
|
|
142
|
+
return [...seen.values()];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `p` and its realpath (compose may record either form). */
|
|
146
|
+
function pathForms(p: string): string[] {
|
|
147
|
+
try {
|
|
148
|
+
const real = fs.realpathSync(p);
|
|
149
|
+
return real === p ? [p] : [p, real];
|
|
150
|
+
} catch {
|
|
151
|
+
return [p];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `workspace`'s running compose stacks: what `workspace deactivate` leaves
|
|
157
|
+
* up, since it releases only the daemons Den tracks. A project counts when
|
|
158
|
+
* it is a Den stack one of the workspace's repos declares (`compose:`, named
|
|
159
|
+
* by projectName), or when Compose started it from inside one of the
|
|
160
|
+
* workspace's checkouts (its working_dir label): the stacks a repo runs
|
|
161
|
+
* itself, e.g. from `make up`. Null when docker could not be asked.
|
|
162
|
+
*/
|
|
163
|
+
export function runningWorkspaceStacks(
|
|
164
|
+
workspace: string,
|
|
165
|
+
running: () => RunningProject[] | null = runningComposeProjects,
|
|
166
|
+
): WorkspaceStack[] | null {
|
|
167
|
+
const ws = getWorkspaceByName(workspace);
|
|
168
|
+
if (!ws) return [];
|
|
169
|
+
const denStacks = new Map<string, { name: string; repo: string }>();
|
|
170
|
+
const checkouts: { repo: string; forms: string[] }[] = [];
|
|
171
|
+
try {
|
|
172
|
+
const { stackWorkspace: suffix } = resolveStackWorkspaces(workspace);
|
|
173
|
+
for (const s of Object.values(readManifestStacks(workspace)))
|
|
174
|
+
denStacks.set(projectName(s.name, suffix), {
|
|
175
|
+
name: s.name,
|
|
176
|
+
repo: s.repo,
|
|
177
|
+
});
|
|
178
|
+
const seen = new Set<string>();
|
|
179
|
+
for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name))
|
|
180
|
+
if (svc.repoPath && !seen.has(svc.repoPath)) {
|
|
181
|
+
seen.add(svc.repoPath);
|
|
182
|
+
checkouts.push({ repo: svc.repoName, forms: pathForms(svc.repoPath) });
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
return null; // an unreadable manifest or workspace name: can't tell
|
|
186
|
+
}
|
|
187
|
+
if (denStacks.size === 0 && checkouts.length === 0) return [];
|
|
188
|
+
const up = running();
|
|
189
|
+
if (!up) return null;
|
|
190
|
+
const inside = (dir: string, root: string): boolean =>
|
|
191
|
+
dir === root || dir.startsWith(`${root}${path.sep}`);
|
|
192
|
+
const out: WorkspaceStack[] = [];
|
|
193
|
+
for (const { project, workingDir } of up) {
|
|
194
|
+
const den = denStacks.get(project);
|
|
195
|
+
if (den) {
|
|
196
|
+
out.push({ ...den, project });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!workingDir) continue;
|
|
200
|
+
const dirs = pathForms(workingDir);
|
|
201
|
+
const owner = checkouts.find((c) =>
|
|
202
|
+
c.forms.some((root) => dirs.some((d) => inside(d, root))),
|
|
203
|
+
);
|
|
204
|
+
if (owner) out.push({ name: project, repo: owner.repo, project });
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
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,
|
|
@@ -195,6 +202,58 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
|
|
|
195
202
|
* Concise `<command> -h` help for commands without a dedicated help renderer.
|
|
196
203
|
* Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
|
|
197
204
|
*/
|
|
205
|
+
/** `workspace` subcommands, for `workspace --help` and `workspace <sub> --help`. */
|
|
206
|
+
const WORKSPACE_SUBCOMMANDS: {
|
|
207
|
+
name: string;
|
|
208
|
+
usage: string;
|
|
209
|
+
lines: string[];
|
|
210
|
+
}[] = [
|
|
211
|
+
{
|
|
212
|
+
name: "list",
|
|
213
|
+
usage: "list",
|
|
214
|
+
lines: ["every workspace, the active set and the focus"],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: "activate",
|
|
218
|
+
usage: "activate <n>",
|
|
219
|
+
lines: ["add a workspace to the active set"],
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "deactivate",
|
|
223
|
+
usage: "deactivate <n>",
|
|
224
|
+
lines: [
|
|
225
|
+
"remove it from the active set and stop the services",
|
|
226
|
+
"only it owns. Only services {cli} tracks: compose",
|
|
227
|
+
"stacks keep running; stop them with `{cli} down <repo>`",
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "focus",
|
|
232
|
+
usage: "focus <n>",
|
|
233
|
+
lines: ["make an active workspace the default for commands"],
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
name: "use",
|
|
237
|
+
usage: "use <n>",
|
|
238
|
+
lines: ["activate and focus a workspace"],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "create",
|
|
242
|
+
usage: "create [n]",
|
|
243
|
+
lines: ["create an empty workspace"],
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
name: "delete",
|
|
247
|
+
usage: "delete <n>",
|
|
248
|
+
lines: ["delete a workspace (and its directory)"],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "code",
|
|
252
|
+
usage: "code [n]",
|
|
253
|
+
lines: ["open it in your editor (VS Code: a .code-workspace)"],
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
198
257
|
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
199
258
|
down: {
|
|
200
259
|
usage: "down [repo] [flags]",
|
|
@@ -252,8 +311,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
252
311
|
lines: ["Check managed repos for upstream updates and offer to pull."],
|
|
253
312
|
},
|
|
254
313
|
workspace: {
|
|
255
|
-
usage:
|
|
256
|
-
lines: [
|
|
314
|
+
usage: `workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
|
|
315
|
+
lines: [
|
|
316
|
+
"Manage workspaces (named sets of services + their modes).",
|
|
317
|
+
"",
|
|
318
|
+
...WORKSPACE_SUBCOMMANDS.flatMap((c) =>
|
|
319
|
+
c.lines.map(
|
|
320
|
+
(line, i) => ` ${(i === 0 ? c.usage : "").padEnd(16)} ${line}`,
|
|
321
|
+
),
|
|
322
|
+
),
|
|
323
|
+
],
|
|
257
324
|
},
|
|
258
325
|
run: {
|
|
259
326
|
usage: "run [options]",
|
|
@@ -583,7 +650,7 @@ class DenCLI {
|
|
|
583
650
|
!["-h", "--help", "-v", "--version"].includes(command) &&
|
|
584
651
|
(ownArgs.includes("-h") || ownArgs.includes("--help"))
|
|
585
652
|
) {
|
|
586
|
-
this.printCommandHelp(command);
|
|
653
|
+
this.printCommandHelp(command, ownArgs[1]);
|
|
587
654
|
return;
|
|
588
655
|
}
|
|
589
656
|
|
|
@@ -2375,7 +2442,19 @@ class DenCLI {
|
|
|
2375
2442
|
}
|
|
2376
2443
|
|
|
2377
2444
|
/** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
|
|
2378
|
-
private printCommandHelp(command: string): void {
|
|
2445
|
+
private printCommandHelp(command: string, sub?: string): void {
|
|
2446
|
+
// `workspace <sub> --help`: that subcommand only.
|
|
2447
|
+
const wsSub =
|
|
2448
|
+
command === "workspace"
|
|
2449
|
+
? WORKSPACE_SUBCOMMANDS.find((c) => c.name === sub)
|
|
2450
|
+
: undefined;
|
|
2451
|
+
if (wsSub) {
|
|
2452
|
+
console.log(`\n ${product().cli} workspace ${wsSub.usage}\n`);
|
|
2453
|
+
for (const line of wsSub.lines)
|
|
2454
|
+
console.log(` ${line.replaceAll("{cli}", product().cli)}`);
|
|
2455
|
+
console.log();
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2379
2458
|
switch (command) {
|
|
2380
2459
|
case "up":
|
|
2381
2460
|
return this.printUpHelp();
|
|
@@ -2398,7 +2477,8 @@ class DenCLI {
|
|
|
2398
2477
|
return;
|
|
2399
2478
|
}
|
|
2400
2479
|
console.log(`\n ${product().cli} ${entry.usage}\n`);
|
|
2401
|
-
for (const line of entry.lines)
|
|
2480
|
+
for (const line of entry.lines)
|
|
2481
|
+
console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
|
|
2402
2482
|
console.log();
|
|
2403
2483
|
}
|
|
2404
2484
|
|
|
@@ -3501,9 +3581,36 @@ class DenCLI {
|
|
|
3501
3581
|
}
|
|
3502
3582
|
}
|
|
3503
3583
|
}
|
|
3584
|
+
// Deactivate releases tracked daemons only; say what it left up.
|
|
3585
|
+
// One docker ps for this workspace and every other active one.
|
|
3586
|
+
let up: ReturnType<typeof runningComposeProjects> | undefined;
|
|
3587
|
+
const running = () =>
|
|
3588
|
+
up === undefined ? (up = runningComposeProjects()) : up;
|
|
3589
|
+
const stacks = runningWorkspaceStacks(name, running);
|
|
3590
|
+
const heldBy = new Map<string, string[]>();
|
|
3591
|
+
if (stacks?.length)
|
|
3592
|
+
for (const other of getActiveWorkspaces())
|
|
3593
|
+
for (const s of runningWorkspaceStacks(other, running) ?? [])
|
|
3594
|
+
heldBy.set(s.project, [...(heldBy.get(s.project) ?? []), other]);
|
|
3595
|
+
const stacksNote = composeStacksNotice(product().cli, stacks, heldBy);
|
|
3596
|
+
if (stacksNote) console.log(chalk.yellow(` ${stacksNote}`));
|
|
3504
3597
|
console.log("");
|
|
3505
3598
|
break;
|
|
3506
3599
|
}
|
|
3600
|
+
case "focus": {
|
|
3601
|
+
const name = args[1];
|
|
3602
|
+
if (!name) {
|
|
3603
|
+
console.log(
|
|
3604
|
+
chalk.red(`Usage: ${product().cli} workspace focus <name>`),
|
|
3605
|
+
);
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
setFocusedWorkspace(name);
|
|
3609
|
+
console.log(
|
|
3610
|
+
chalk.green(`\n ✓ Focused workspace: ${chalk.bold(name)}\n`),
|
|
3611
|
+
);
|
|
3612
|
+
break;
|
|
3613
|
+
}
|
|
3507
3614
|
case "use": {
|
|
3508
3615
|
const name = args[1];
|
|
3509
3616
|
if (!name) {
|
|
@@ -3611,7 +3718,7 @@ class DenCLI {
|
|
|
3611
3718
|
default:
|
|
3612
3719
|
console.log(
|
|
3613
3720
|
chalk.gray(
|
|
3614
|
-
`Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
|
|
3721
|
+
`Usage: ${product().cli} workspace <list|activate|deactivate|focus|use|create|delete|code> [name]`,
|
|
3615
3722
|
),
|
|
3616
3723
|
);
|
|
3617
3724
|
}
|
|
@@ -3871,7 +3978,6 @@ class DenCLI {
|
|
|
3871
3978
|
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
3872
3979
|
);
|
|
3873
3980
|
console.log();
|
|
3874
|
-
console.log(" workspace list List workspaces");
|
|
3875
3981
|
console.log(
|
|
3876
3982
|
" workspace list List workspaces (active set + focus)",
|
|
3877
3983
|
);
|
|
@@ -3879,11 +3985,14 @@ class DenCLI {
|
|
|
3879
3985
|
console.log(
|
|
3880
3986
|
" workspace deactivate <n> Remove workspace from the active set",
|
|
3881
3987
|
);
|
|
3988
|
+
console.log(
|
|
3989
|
+
" workspace focus <n> Focus an active workspace (the default)",
|
|
3990
|
+
);
|
|
3882
3991
|
console.log(" workspace use <n> Activate and focus a workspace");
|
|
3883
|
-
console.log(" workspace create [n]
|
|
3884
|
-
console.log(" workspace delete <n>
|
|
3992
|
+
console.log(" workspace create [n] Create a new workspace");
|
|
3993
|
+
console.log(" workspace delete <n> Delete a workspace");
|
|
3885
3994
|
console.log(
|
|
3886
|
-
" workspace code [n]
|
|
3995
|
+
" workspace code [n] Open workspace in VS Code (generates .code-workspace)",
|
|
3887
3996
|
);
|
|
3888
3997
|
console.log("\nTest commands:");
|
|
3889
3998
|
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 {
|
|
@@ -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
|
+
}
|