@monster-orange/den 0.2.4 → 0.2.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 +195 -0
- package/docs/distributions.md +158 -16
- package/package.json +1 -1
- package/src/pillars/security/discover.ts +1 -1
- package/src/pillars/smoke/setup-global.ts +2 -1
- package/src/scripts/configure-aws-profiles.ts +2 -1
- package/src/scripts/register-repos.ts +4 -3
- 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 +229 -87
- package/src/shared/environment-manager/health.ts +235 -35
- package/src/shared/manifest/placement.ts +1 -1
- package/src/shared/manifest/schema.ts +47 -0
- package/src/shared/manifest/service-links.ts +1 -1
- package/src/shared/manifest/validate-manifest.ts +1 -1
- package/src/shared/plural.ts +13 -0
- package/src/shared/service-url.ts +2 -5
- package/src/shared/stacks/compose-health.ts +242 -0
- package/src/shared/stacks/runtime.ts +73 -24
- package/src/shared/stacks/status.ts +3 -2
- package/src/shared/types/index.ts +13 -1
- package/src/ui/cli/create.ts +2 -1
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +86 -41
- package/src/ui/cli/mcp-connect.ts +36 -5
- package/src/ui/cli/server-identity.ts +233 -0
- package/src/ui/cli/service-command.ts +136 -12
- package/src/ui/cli/spinner.ts +48 -0
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +101 -32
- package/src/ui/mcp/api.ts +94 -1
- package/src/ui/mcp/server.ts +6 -1
- package/src/ui/mcp/tools.ts +6 -2
- package/src/ui/server/health-row.ts +6 -3
- package/src/ui/server/index.ts +120 -40
- 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,242 @@
|
|
|
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
|
+
/** The shared listing: every container, or why docker could not answer. */
|
|
62
|
+
export type ComposeListing =
|
|
63
|
+
| { rows: ComposeRow[] }
|
|
64
|
+
| { error: string; pending?: boolean };
|
|
65
|
+
type Listing = ComposeListing;
|
|
66
|
+
|
|
67
|
+
let inFlight: Promise<Listing> | null = null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every container, from one `docker ps -a`. Concurrent callers share the
|
|
71
|
+
* call in flight, so a health cycle lists once however many checks it has
|
|
72
|
+
* (compose checks and `type: container` checks alike). Not filtered to
|
|
73
|
+
* compose containers: a container check may name a plain container.
|
|
74
|
+
*/
|
|
75
|
+
export function composeListing(): Promise<Listing> {
|
|
76
|
+
return (inFlight ??= listOnce().finally(() => {
|
|
77
|
+
inFlight = null;
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listOnce(): Promise<Listing> {
|
|
82
|
+
return new Promise((done) => {
|
|
83
|
+
// Backstop: execFile reports only once the pipes close, which a killed
|
|
84
|
+
// process's children can hold open; the checks must not wait on that.
|
|
85
|
+
const guard = setTimeout(
|
|
86
|
+
() =>
|
|
87
|
+
resolve({
|
|
88
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
89
|
+
pending: true,
|
|
90
|
+
}),
|
|
91
|
+
COMPOSE_LISTING_TIMEOUT_MS + 500,
|
|
92
|
+
);
|
|
93
|
+
let settled = false;
|
|
94
|
+
const resolve = (l: Listing): void => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(guard);
|
|
98
|
+
done(l);
|
|
99
|
+
};
|
|
100
|
+
execFile(
|
|
101
|
+
"docker",
|
|
102
|
+
[
|
|
103
|
+
"ps",
|
|
104
|
+
"-a",
|
|
105
|
+
"--format",
|
|
106
|
+
'{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}\t{{.State}}\t{{.Status}}\t{{.Names}}',
|
|
107
|
+
],
|
|
108
|
+
{
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: COMPOSE_LISTING_TIMEOUT_MS,
|
|
111
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
112
|
+
},
|
|
113
|
+
(err, stdout, stderr) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
const killed = (err as { killed?: boolean }).killed === true;
|
|
116
|
+
return resolve(
|
|
117
|
+
killed
|
|
118
|
+
? {
|
|
119
|
+
error: `docker gave no answer within ${COMPOSE_LISTING_TIMEOUT_MS / 1000}s`,
|
|
120
|
+
pending: true,
|
|
121
|
+
}
|
|
122
|
+
: {
|
|
123
|
+
error:
|
|
124
|
+
String(stderr || err.message).trim() || "docker ps failed",
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const rows = stdout
|
|
129
|
+
.split("\n")
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.map((line) => {
|
|
132
|
+
const [
|
|
133
|
+
project = "",
|
|
134
|
+
service = "",
|
|
135
|
+
state = "",
|
|
136
|
+
status = "",
|
|
137
|
+
name = "",
|
|
138
|
+
] = line.split("\t");
|
|
139
|
+
return { project, service, state, status, name };
|
|
140
|
+
});
|
|
141
|
+
resolve({ rows });
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The verdict when docker gave no listing, shared by compose and container
|
|
149
|
+
* checks so the two cannot drift: container state unknown is not "not
|
|
150
|
+
* running" (`ok: null`, never down, however often it repeats). Only a
|
|
151
|
+
* listing past its budget is `pending` (no answer in time), which, like any
|
|
152
|
+
* timeout, is down when it repeats on the next cycle.
|
|
153
|
+
*/
|
|
154
|
+
export function unknownListingVerdict(listing: {
|
|
155
|
+
error: string;
|
|
156
|
+
pending?: boolean;
|
|
157
|
+
}): ComposeVerdict {
|
|
158
|
+
return {
|
|
159
|
+
ok: null,
|
|
160
|
+
detail: `container state unknown: ${listing.error}`,
|
|
161
|
+
...(listing.pending ? { pending: true } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The health of one Den-generated compose check, from the shared listing. */
|
|
166
|
+
export async function composeHealth(
|
|
167
|
+
target: ComposeHealthTarget,
|
|
168
|
+
): Promise<ComposeVerdict> {
|
|
169
|
+
const listing = await composeListing();
|
|
170
|
+
if ("error" in listing) return unknownListingVerdict(listing);
|
|
171
|
+
return judgeStackRows(listing.rows, target.project, target.service);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── `health: { type: container }` ───────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A declared container check: a compose container by its project and service
|
|
178
|
+
* labels, or any container by name (`container_name`).
|
|
179
|
+
*/
|
|
180
|
+
export type ContainerHealthTarget =
|
|
181
|
+
| { project: string; service: string; container_name?: undefined }
|
|
182
|
+
| { container_name: string; project?: undefined; service?: undefined };
|
|
183
|
+
|
|
184
|
+
/** What a container check shows as its check: `container <project>/<service>`. */
|
|
185
|
+
export function containerCheckLabel(t: ContainerHealthTarget): string {
|
|
186
|
+
return `container ${containerTargetName(t)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function containerTargetName(t: ContainerHealthTarget): string {
|
|
190
|
+
return t.container_name ?? `${t.project}/${t.service}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchesTarget(r: ComposeRow, t: ContainerHealthTarget): boolean {
|
|
194
|
+
if (t.container_name)
|
|
195
|
+
return (r.name ?? "")
|
|
196
|
+
.split(",")
|
|
197
|
+
.map((n) => n.trim().replace(/^\//, ""))
|
|
198
|
+
.includes(t.container_name);
|
|
199
|
+
return r.project === t.project && r.service === t.service;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** One container's verdict: its healthcheck when it has one, else running. */
|
|
203
|
+
function rowVerdict(r: ComposeRow): ComposeVerdict & { rank: number } {
|
|
204
|
+
const what = `${r.name || `${r.project}/${r.service}`}\t${r.state}\t${r.status}`;
|
|
205
|
+
if (r.state !== "running") return { ok: false, detail: what, rank: 4 };
|
|
206
|
+
if (/\(unhealthy\)/.test(r.status))
|
|
207
|
+
return { ok: false, detail: what, rank: 3 };
|
|
208
|
+
// Starting is pending, but not "no answer": it never turns down on repeat.
|
|
209
|
+
if (/\(health: starting\)/.test(r.status))
|
|
210
|
+
return { ok: null, detail: what, rank: 2 };
|
|
211
|
+
if (/\(healthy\)/.test(r.status)) return { ok: true, detail: what, rank: 0 };
|
|
212
|
+
return { ok: true, detail: what, rank: 1 };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The verdict of a container check over the shared listing. More than one
|
|
217
|
+
* match: the best one (healthy, then running). No match: down. No listing:
|
|
218
|
+
* as a compose check (unknownListingVerdict), the reason in `detail`.
|
|
219
|
+
*/
|
|
220
|
+
export function judgeContainerListing(
|
|
221
|
+
listing: ComposeListing,
|
|
222
|
+
target: ContainerHealthTarget,
|
|
223
|
+
): ComposeVerdict {
|
|
224
|
+
if ("error" in listing) return unknownListingVerdict(listing);
|
|
225
|
+
const best = listing.rows
|
|
226
|
+
.filter((r) => matchesTarget(r, target))
|
|
227
|
+
.map(rowVerdict)
|
|
228
|
+
.sort((a, b) => a.rank - b.rank)[0];
|
|
229
|
+
if (!best)
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
detail: `${containerTargetName(target)}: no container`,
|
|
233
|
+
};
|
|
234
|
+
return { ok: best.ok, detail: best.detail };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The health of a declared container check, from the shared listing. */
|
|
238
|
+
export async function containerHealth(
|
|
239
|
+
target: ContainerHealthTarget,
|
|
240
|
+
): Promise<ComposeVerdict> {
|
|
241
|
+
return judgeContainerListing(await composeListing(), target);
|
|
242
|
+
}
|
|
@@ -364,39 +364,88 @@ 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
|
+
/** The container's name(s), comma-separated as docker lists them. */
|
|
417
|
+
name?: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The verdict `stack health` gives for `project` (and `service`): ok when it
|
|
422
|
+
* has containers, all running, none with a failing or starting Docker
|
|
423
|
+
* healthcheck. Shared by the CLI and the in-process check (compose-health).
|
|
424
|
+
*/
|
|
425
|
+
export function judgeStackRows(
|
|
426
|
+
rows: ComposeRow[],
|
|
427
|
+
project: string,
|
|
428
|
+
service?: string,
|
|
429
|
+
): { ok: boolean; detail: string } {
|
|
430
|
+
const mine = rows.filter(
|
|
431
|
+
(r) => r.project === project && (!service || r.service === service),
|
|
432
|
+
);
|
|
433
|
+
if (!mine.length)
|
|
389
434
|
return {
|
|
390
435
|
ok: false,
|
|
391
436
|
detail: `${project}${service ? `/${service}` : ""}: not running`,
|
|
392
437
|
};
|
|
393
|
-
const bad =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
});
|
|
438
|
+
const bad = mine.filter(
|
|
439
|
+
(r) =>
|
|
440
|
+
r.state !== "running" ||
|
|
441
|
+
/\((unhealthy|health: starting)\)/.test(r.status),
|
|
442
|
+
);
|
|
399
443
|
return bad.length
|
|
400
|
-
? {
|
|
401
|
-
|
|
444
|
+
? {
|
|
445
|
+
ok: false,
|
|
446
|
+
detail: bad
|
|
447
|
+
.map((r) => `${r.service}\t${r.state}\t${r.status}`)
|
|
448
|
+
.join("; "),
|
|
449
|
+
}
|
|
450
|
+
: { ok: true, detail: `${mine.length} container(s) running` };
|
|
402
451
|
}
|
|
@@ -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;
|
|
@@ -210,10 +210,22 @@ export type LogSource =
|
|
|
210
210
|
* type: shell
|
|
211
211
|
* cmd: "docker exec $(docker ps --filter ancestor=postgres:16 --format '{{.ID}}' | head -1) pg_isready -U postgres"
|
|
212
212
|
* label: "postgres" # optional — shown in UI instead of cmd
|
|
213
|
+
*
|
|
214
|
+
* Container (answered from the one `docker ps` a health cycle shares):
|
|
215
|
+
* type: container
|
|
216
|
+
* project: shop-main # com.docker.compose.project label
|
|
217
|
+
* service: db # com.docker.compose.service label
|
|
218
|
+
* -- or, for a container not run by compose --
|
|
219
|
+
* type: container
|
|
220
|
+
* container_name: shop-db
|
|
213
221
|
*/
|
|
214
222
|
export type ServiceHealthConfig =
|
|
215
223
|
| { type?: "http"; url: string; label?: string }
|
|
216
|
-
| { type: "shell"; cmd: string; label?: string }
|
|
224
|
+
| { type: "shell"; cmd: string; label?: string }
|
|
225
|
+
| ({ type: "container"; label?: string } & (
|
|
226
|
+
| { project: string; service: string; container_name?: undefined }
|
|
227
|
+
| { container_name: string; project?: undefined; service?: undefined }
|
|
228
|
+
));
|
|
217
229
|
|
|
218
230
|
/**
|
|
219
231
|
* A command can be a plain shell string (detached by default) or an object
|
package/src/ui/cli/create.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { ENGINE_PACKAGE, getEngineRoot } from "../../shared/paths.js";
|
|
17
17
|
import { profileFrom } from "../../shared/profile.js";
|
|
18
|
+
import { plural } from "../../shared/plural.js";
|
|
18
19
|
|
|
19
20
|
export interface CreateOptions {
|
|
20
21
|
/** Product name: lower-case `[a-z][a-z0-9-]*`. */
|
|
@@ -345,7 +346,7 @@ export function runCreate(argv: string[], cwd: string): number {
|
|
|
345
346
|
const cli = args.cli ?? args.name;
|
|
346
347
|
const rel = path.relative(cwd, args.dir) || ".";
|
|
347
348
|
console.log(
|
|
348
|
-
`Created ${args.name} in ${args.dir} (${written.length
|
|
349
|
+
`Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
|
|
349
350
|
);
|
|
350
351
|
console.log("");
|
|
351
352
|
console.log("Next:");
|
|
@@ -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
|
@@ -12,18 +12,10 @@ import {
|
|
|
12
12
|
} from "../../shared/stacks/status.js";
|
|
13
13
|
import { isNamedTarget } from "./named-target.js";
|
|
14
14
|
import chalk from "chalk";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* stderr as usual; piped (CI, scripts) their lines go to stdout so stderr
|
|
20
|
-
* carries only real warnings and errors.
|
|
21
|
-
*/
|
|
22
|
-
const ora = (text: string) =>
|
|
23
|
-
oraSpinner({
|
|
24
|
-
text,
|
|
25
|
-
stream: process.stderr.isTTY ? process.stderr : process.stdout,
|
|
26
|
-
});
|
|
15
|
+
import { noun, plural } from "../../shared/plural.js";
|
|
16
|
+
// Spinners: progress on stderr (a terminal) or stdout (piped); plain lines
|
|
17
|
+
// where they cannot animate. See spinner.ts.
|
|
18
|
+
import { spinner as ora } from "./spinner.js";
|
|
27
19
|
import Table from "cli-table3";
|
|
28
20
|
import { cliPrompts } from "./prompts.js";
|
|
29
21
|
import { resultsViewer } from "./results-viewer.js";
|
|
@@ -85,9 +77,12 @@ import {
|
|
|
85
77
|
resolveServiceRuntime,
|
|
86
78
|
} from "../../shared/environment-manager/service-runner.js";
|
|
87
79
|
import {
|
|
88
|
-
|
|
80
|
+
blueprintWorkspaces,
|
|
89
81
|
defaultWorkspaceNeedsSeeding,
|
|
82
|
+
blueprintAlreadySeeded,
|
|
90
83
|
defaultWorkspaceHasServices,
|
|
84
|
+
isMultiBlueprint,
|
|
85
|
+
seedBlueprintWorkspaces,
|
|
91
86
|
seedDefaultWorkspace,
|
|
92
87
|
topUpDefaultWorkspace,
|
|
93
88
|
markDefaultWorkspaceDeclined,
|
|
@@ -252,6 +247,14 @@ const WORKSPACE_SUBCOMMANDS: {
|
|
|
252
247
|
usage: "code [n]",
|
|
253
248
|
lines: ["open it in your editor (VS Code: a .code-workspace)"],
|
|
254
249
|
},
|
|
250
|
+
{
|
|
251
|
+
name: "seed",
|
|
252
|
+
usage: "seed",
|
|
253
|
+
lines: [
|
|
254
|
+
"fill workspaces from the distribution's blueprint",
|
|
255
|
+
"(defaults.workspace / defaults.workspaces), all managed",
|
|
256
|
+
],
|
|
257
|
+
},
|
|
255
258
|
];
|
|
256
259
|
|
|
257
260
|
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
@@ -486,8 +489,6 @@ function printValidation(
|
|
|
486
489
|
layer?: string,
|
|
487
490
|
): void {
|
|
488
491
|
const tag = layer ? `${layer}, ` : "";
|
|
489
|
-
const plural = (n: number, word: string): string =>
|
|
490
|
-
`${n} ${word}${n === 1 ? "" : "s"}`;
|
|
491
492
|
if (result.valid && result.warnings.length === 0) {
|
|
492
493
|
console.log(
|
|
493
494
|
chalk.green(` ✓ ${label}`) +
|
|
@@ -2274,7 +2275,7 @@ class DenCLI {
|
|
|
2274
2275
|
}
|
|
2275
2276
|
console.log(
|
|
2276
2277
|
chalk.cyan(
|
|
2277
|
-
`\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))}
|
|
2278
|
+
`\n ${only ? "Application" : "Workspace-wide"} up — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
|
|
2278
2279
|
),
|
|
2279
2280
|
);
|
|
2280
2281
|
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
@@ -2335,7 +2336,7 @@ class DenCLI {
|
|
|
2335
2336
|
const order = [...target.rawOrder].reverse();
|
|
2336
2337
|
console.log(
|
|
2337
2338
|
chalk.cyan(
|
|
2338
|
-
`\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))}
|
|
2339
|
+
`\n ${only ? "Application" : "Workspace-wide"} down — ${chalk.bold(String(order.length))} ${noun(order.length, "system")}`,
|
|
2339
2340
|
),
|
|
2340
2341
|
);
|
|
2341
2342
|
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
@@ -2483,36 +2484,55 @@ class DenCLI {
|
|
|
2483
2484
|
}
|
|
2484
2485
|
|
|
2485
2486
|
/**
|
|
2486
|
-
* Seed (or reset) the
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
2490
|
-
*
|
|
2487
|
+
* Seed (or reset) the blueprint's workspaces from the committed config:
|
|
2488
|
+
* `defaults.workspace` fills `default`; `defaults.workspaces` creates each
|
|
2489
|
+
* named workspace, activates them all and focuses the marked one. All
|
|
2490
|
+
* repos managed. Prints clear guidance and, on a TTY, asks first.
|
|
2491
|
+
* `reason: "auto"` is the one-time offer on a genuine fresh setup (TTY
|
|
2492
|
+
* only: see ensureWorkspaceSeeded); `reason: "init"` is the explicit
|
|
2493
|
+
* `<cli> workspace seed` / `<cli> init` in the root. Returns true if seeded.
|
|
2491
2494
|
*/
|
|
2492
2495
|
private async seedDefaultWorkspaceFlow(
|
|
2493
2496
|
reason: "auto" | "init",
|
|
2494
2497
|
): Promise<boolean> {
|
|
2495
|
-
const
|
|
2496
|
-
if (
|
|
2498
|
+
const bps = blueprintWorkspaces();
|
|
2499
|
+
if (bps.length === 0) {
|
|
2497
2500
|
if (reason === "init") {
|
|
2498
2501
|
console.log(
|
|
2499
2502
|
chalk.yellow(
|
|
2500
|
-
`\n No defaults.workspace.repos in ${product().configFile} — nothing to seed.\n`,
|
|
2503
|
+
`\n No defaults.workspace.repos or defaults.workspaces in ${product().configFile} — nothing to seed.\n`,
|
|
2501
2504
|
),
|
|
2502
2505
|
);
|
|
2503
2506
|
}
|
|
2504
2507
|
return false;
|
|
2505
2508
|
}
|
|
2509
|
+
const multi = isMultiBlueprint();
|
|
2510
|
+
// A repeat seed: say so, and touch nothing.
|
|
2511
|
+
if (blueprintAlreadySeeded()) {
|
|
2512
|
+
console.log(
|
|
2513
|
+
chalk.green(
|
|
2514
|
+
multi
|
|
2515
|
+
? `\n ✓ ${plural(bps.length, "workspace")} already seeded (${bps.map((b) => b.name).join(", ")}): nothing changed.\n`
|
|
2516
|
+
: `\n ✓ Default workspace already seeded — ${plural(bps[0].repos.length, "repo")} (managed): nothing changed.\n`,
|
|
2517
|
+
),
|
|
2518
|
+
);
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2506
2521
|
const willReset = defaultWorkspaceHasServices();
|
|
2507
2522
|
const interactive = !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
2508
2523
|
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2524
|
+
for (const bp of bps) {
|
|
2525
|
+
const label = multi ? `Workspace ${bp.name}` : "Default workspace";
|
|
2526
|
+
console.log(
|
|
2527
|
+
chalk.cyan(
|
|
2528
|
+
`\n ${label} — ${plural(bp.repos.length, "repo")}, all managed:`,
|
|
2529
|
+
),
|
|
2530
|
+
);
|
|
2531
|
+
console.log(chalk.gray(` ${bp.repos.join(", ")}`));
|
|
2532
|
+
}
|
|
2513
2533
|
console.log(
|
|
2514
2534
|
chalk.gray(
|
|
2515
|
-
` ${product().title} clones + tracks them on
|
|
2535
|
+
` ${product().title} clones + tracks them on their branch under ${product().servicesDir}/. Switch any`,
|
|
2516
2536
|
),
|
|
2517
2537
|
);
|
|
2518
2538
|
console.log(
|
|
@@ -2522,16 +2542,17 @@ class DenCLI {
|
|
|
2522
2542
|
);
|
|
2523
2543
|
|
|
2524
2544
|
if (interactive) {
|
|
2545
|
+
const what = multi ? "these workspaces" : "the default workspace";
|
|
2525
2546
|
const question = willReset
|
|
2526
|
-
?
|
|
2527
|
-
:
|
|
2547
|
+
? ` Reset ${what} to this shipped set?`
|
|
2548
|
+
: ` Seed ${what} now?`;
|
|
2528
2549
|
const ok = await cliPrompts.confirm(question, !willReset);
|
|
2529
2550
|
if (!ok) {
|
|
2530
2551
|
if (reason === "auto") {
|
|
2531
2552
|
markDefaultWorkspaceDeclined(); // don't ask again
|
|
2532
2553
|
console.log(
|
|
2533
2554
|
chalk.gray(
|
|
2534
|
-
` Skipped — run \`${product().cli}
|
|
2555
|
+
` Skipped — run \`${product().cli} workspace seed\` to set it up later.\n`,
|
|
2535
2556
|
),
|
|
2536
2557
|
);
|
|
2537
2558
|
} else {
|
|
@@ -2541,10 +2562,21 @@ class DenCLI {
|
|
|
2541
2562
|
}
|
|
2542
2563
|
}
|
|
2543
2564
|
|
|
2565
|
+
if (multi) {
|
|
2566
|
+
const seeded = seedBlueprintWorkspaces();
|
|
2567
|
+
const focus = readWorkspacesFile().focus;
|
|
2568
|
+
console.log(
|
|
2569
|
+
chalk.green(
|
|
2570
|
+
` ✓ ${plural(seeded.length, "workspace")} ${willReset ? "reset to the blueprint" : "ready"} (${seeded.map((w) => w.name).join(", ")}), all active; focus: ${focus}. ` +
|
|
2571
|
+
`Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
|
|
2572
|
+
),
|
|
2573
|
+
);
|
|
2574
|
+
return true;
|
|
2575
|
+
}
|
|
2544
2576
|
const seeded = seedDefaultWorkspace();
|
|
2545
2577
|
console.log(
|
|
2546
2578
|
chalk.green(
|
|
2547
|
-
` ✓ Default workspace ready — ${seeded.length}
|
|
2579
|
+
` ✓ Default workspace ${willReset ? "reset to the blueprint" : "ready"} — ${plural(seeded.length, "repo")} (managed). ` +
|
|
2548
2580
|
`Run \`${product().cli} up <repo>\` to bring up its closure.\n`,
|
|
2549
2581
|
),
|
|
2550
2582
|
);
|
|
@@ -2552,20 +2584,29 @@ class DenCLI {
|
|
|
2552
2584
|
}
|
|
2553
2585
|
|
|
2554
2586
|
/**
|
|
2555
|
-
*
|
|
2556
|
-
*
|
|
2557
|
-
*
|
|
2587
|
+
* On a genuine fresh setup (nothing but an empty, never-seeded `default`),
|
|
2588
|
+
* offer to seed from the blueprint: on a TTY, ask; otherwise print a
|
|
2589
|
+
* one-line hint and change nothing. Afterwards keep a lone `default`
|
|
2590
|
+
* converged on the blueprint (repos added to defaults later join it
|
|
2591
|
+
* managed); never once the user has other workspaces.
|
|
2558
2592
|
*/
|
|
2559
2593
|
private async ensureWorkspaceSeeded(): Promise<void> {
|
|
2560
2594
|
if (defaultWorkspaceNeedsSeeding()) {
|
|
2561
|
-
|
|
2595
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2596
|
+
await this.seedDefaultWorkspaceFlow("auto");
|
|
2597
|
+
} else {
|
|
2598
|
+
const hint = isMultiBlueprint()
|
|
2599
|
+
? "no workspaces yet: run `{cli} workspace seed` to create them from the blueprint"
|
|
2600
|
+
: "default workspace is empty: run `{cli} workspace seed` to fill it from the blueprint";
|
|
2601
|
+
console.error(chalk.gray(` ${hint.replace("{cli}", product().cli)}`));
|
|
2602
|
+
}
|
|
2562
2603
|
return;
|
|
2563
2604
|
}
|
|
2564
2605
|
const added = topUpDefaultWorkspace();
|
|
2565
2606
|
if (added.length > 0) {
|
|
2566
2607
|
console.log(
|
|
2567
2608
|
chalk.gray(
|
|
2568
|
-
` default workspace: added standard repo
|
|
2609
|
+
` default workspace: added standard ${noun(added.length, "repo")} ${added.join(", ")} (managed)`,
|
|
2569
2610
|
),
|
|
2570
2611
|
);
|
|
2571
2612
|
}
|
|
@@ -3627,6 +3668,10 @@ class DenCLI {
|
|
|
3627
3668
|
);
|
|
3628
3669
|
break;
|
|
3629
3670
|
}
|
|
3671
|
+
case "seed": {
|
|
3672
|
+
await this.seedDefaultWorkspaceFlow("init");
|
|
3673
|
+
break;
|
|
3674
|
+
}
|
|
3630
3675
|
case "create": {
|
|
3631
3676
|
const name =
|
|
3632
3677
|
args[1] ??
|
|
@@ -3718,7 +3763,7 @@ class DenCLI {
|
|
|
3718
3763
|
default:
|
|
3719
3764
|
console.log(
|
|
3720
3765
|
chalk.gray(
|
|
3721
|
-
`Usage: ${product().cli} workspace
|
|
3766
|
+
`Usage: ${product().cli} workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
|
|
3722
3767
|
),
|
|
3723
3768
|
);
|
|
3724
3769
|
}
|
|
@@ -3761,7 +3806,7 @@ class DenCLI {
|
|
|
3761
3806
|
if (missing.length > 0) {
|
|
3762
3807
|
console.log(
|
|
3763
3808
|
chalk.cyan(
|
|
3764
|
-
`\n Cloning ${missing.length
|
|
3809
|
+
`\n Cloning ${plural(missing.length, "managed repo")} not on disk yet: ${missing.join(", ")}`,
|
|
3765
3810
|
),
|
|
3766
3811
|
);
|
|
3767
3812
|
await this.ensureManagedClonesFor(missing);
|