@monster-orange/den 0.2.2 → 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 +151 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- 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 +205 -8
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/manifest/schema.ts +15 -0
- 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/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/runtime.ts +51 -3
- package/src/shared/stacks/status.ts +109 -1
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +151 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +101 -183
- package/src/ui/server/lifecycle-exec.ts +23 -0
- 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 +272 -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,
|
|
@@ -99,6 +106,8 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
|
|
|
99
106
|
import {
|
|
100
107
|
canonicalServiceAddress,
|
|
101
108
|
previewServiceCommand,
|
|
109
|
+
previewServiceSecretEnv,
|
|
110
|
+
prepareServiceSecretEnv,
|
|
102
111
|
resolveService,
|
|
103
112
|
type ServiceCommandPreview,
|
|
104
113
|
} from "../../shared/environment-manager/service-runner.js";
|
|
@@ -193,6 +202,58 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
|
|
|
193
202
|
* Concise `<command> -h` help for commands without a dedicated help renderer.
|
|
194
203
|
* Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
|
|
195
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
|
+
|
|
196
257
|
const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
197
258
|
down: {
|
|
198
259
|
usage: "down [repo] [flags]",
|
|
@@ -250,8 +311,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
250
311
|
lines: ["Check managed repos for upstream updates and offer to pull."],
|
|
251
312
|
},
|
|
252
313
|
workspace: {
|
|
253
|
-
usage:
|
|
254
|
-
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
|
+
],
|
|
255
324
|
},
|
|
256
325
|
run: {
|
|
257
326
|
usage: "run [options]",
|
|
@@ -581,7 +650,7 @@ class DenCLI {
|
|
|
581
650
|
!["-h", "--help", "-v", "--version"].includes(command) &&
|
|
582
651
|
(ownArgs.includes("-h") || ownArgs.includes("--help"))
|
|
583
652
|
) {
|
|
584
|
-
this.printCommandHelp(command);
|
|
653
|
+
this.printCommandHelp(command, ownArgs[1]);
|
|
585
654
|
return;
|
|
586
655
|
}
|
|
587
656
|
|
|
@@ -1453,6 +1522,20 @@ class DenCLI {
|
|
|
1453
1522
|
console.log(chalk.gray(` ${p.note}`));
|
|
1454
1523
|
if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
|
|
1455
1524
|
}
|
|
1525
|
+
// R16: key names only. A dry run never asks the secrets provider.
|
|
1526
|
+
for (const a of new Set(runs)) {
|
|
1527
|
+
const sec = previewServiceSecretEnv(a, command, workspace);
|
|
1528
|
+
if (!sec) continue;
|
|
1529
|
+
const parts = [
|
|
1530
|
+
...(sec.service.length ? [sec.service.join(", ")] : []),
|
|
1531
|
+
...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
|
|
1532
|
+
];
|
|
1533
|
+
console.log(
|
|
1534
|
+
chalk.gray(
|
|
1535
|
+
` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
|
|
1536
|
+
),
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1456
1539
|
if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
|
|
1457
1540
|
console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
|
|
1458
1541
|
return;
|
|
@@ -1590,7 +1673,22 @@ class DenCLI {
|
|
|
1590
1673
|
}
|
|
1591
1674
|
}
|
|
1592
1675
|
|
|
1676
|
+
// R16: resolve the service's secret_env before anything spawns. A
|
|
1677
|
+
// missing key fails this target; nothing starts with a partial set.
|
|
1678
|
+
const secrets = await prepareServiceSecretEnv(
|
|
1679
|
+
address,
|
|
1680
|
+
command,
|
|
1681
|
+
workspace,
|
|
1682
|
+
);
|
|
1683
|
+
|
|
1593
1684
|
const started = await new Promise<boolean>((resolve) => {
|
|
1685
|
+
if (!secrets.ok) {
|
|
1686
|
+
console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
|
|
1687
|
+
for (const l of secrets.message.split("\n"))
|
|
1688
|
+
console.log(chalk.gray(` ${l}`));
|
|
1689
|
+
resolve(false);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1594
1692
|
const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
|
|
1595
1693
|
let detachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1596
1694
|
let settled = false;
|
|
@@ -1672,6 +1770,7 @@ class DenCLI {
|
|
|
1672
1770
|
runtime,
|
|
1673
1771
|
workspace,
|
|
1674
1772
|
force: command === "down" ? force : false,
|
|
1773
|
+
secretEnv: secrets.secretEnv,
|
|
1675
1774
|
onLine: (line) => {
|
|
1676
1775
|
spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
|
|
1677
1776
|
const trimmed = line.trim();
|
|
@@ -2343,7 +2442,19 @@ class DenCLI {
|
|
|
2343
2442
|
}
|
|
2344
2443
|
|
|
2345
2444
|
/** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
|
|
2346
|
-
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
|
+
}
|
|
2347
2458
|
switch (command) {
|
|
2348
2459
|
case "up":
|
|
2349
2460
|
return this.printUpHelp();
|
|
@@ -2366,7 +2477,8 @@ class DenCLI {
|
|
|
2366
2477
|
return;
|
|
2367
2478
|
}
|
|
2368
2479
|
console.log(`\n ${product().cli} ${entry.usage}\n`);
|
|
2369
|
-
for (const line of entry.lines)
|
|
2480
|
+
for (const line of entry.lines)
|
|
2481
|
+
console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
|
|
2370
2482
|
console.log();
|
|
2371
2483
|
}
|
|
2372
2484
|
|
|
@@ -3469,9 +3581,36 @@ class DenCLI {
|
|
|
3469
3581
|
}
|
|
3470
3582
|
}
|
|
3471
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}`));
|
|
3472
3597
|
console.log("");
|
|
3473
3598
|
break;
|
|
3474
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
|
+
}
|
|
3475
3614
|
case "use": {
|
|
3476
3615
|
const name = args[1];
|
|
3477
3616
|
if (!name) {
|
|
@@ -3579,7 +3718,7 @@ class DenCLI {
|
|
|
3579
3718
|
default:
|
|
3580
3719
|
console.log(
|
|
3581
3720
|
chalk.gray(
|
|
3582
|
-
`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]`,
|
|
3583
3722
|
),
|
|
3584
3723
|
);
|
|
3585
3724
|
}
|
|
@@ -3839,7 +3978,6 @@ class DenCLI {
|
|
|
3839
3978
|
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
3840
3979
|
);
|
|
3841
3980
|
console.log();
|
|
3842
|
-
console.log(" workspace list List workspaces");
|
|
3843
3981
|
console.log(
|
|
3844
3982
|
" workspace list List workspaces (active set + focus)",
|
|
3845
3983
|
);
|
|
@@ -3847,11 +3985,14 @@ class DenCLI {
|
|
|
3847
3985
|
console.log(
|
|
3848
3986
|
" workspace deactivate <n> Remove workspace from the active set",
|
|
3849
3987
|
);
|
|
3988
|
+
console.log(
|
|
3989
|
+
" workspace focus <n> Focus an active workspace (the default)",
|
|
3990
|
+
);
|
|
3850
3991
|
console.log(" workspace use <n> Activate and focus a workspace");
|
|
3851
|
-
console.log(" workspace create [n]
|
|
3852
|
-
console.log(" workspace delete <n>
|
|
3992
|
+
console.log(" workspace create [n] Create a new workspace");
|
|
3993
|
+
console.log(" workspace delete <n> Delete a workspace");
|
|
3853
3994
|
console.log(
|
|
3854
|
-
" workspace code [n]
|
|
3995
|
+
" workspace code [n] Open workspace in VS Code (generates .code-workspace)",
|
|
3855
3996
|
);
|
|
3856
3997
|
console.log("\nTest commands:");
|
|
3857
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 {
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
import chalk from "chalk";
|
|
9
9
|
import { routeUrl } from "../../shared/edge/render.js";
|
|
10
10
|
import { edgeUp } from "../../shared/edge/runtime.js";
|
|
11
|
+
import { getSecretsProvider } from "../../shared/secrets/index.js";
|
|
12
|
+
import type { SecretsProvider } from "../../shared/secrets/types.js";
|
|
11
13
|
import { projectName, startOrder } from "../../shared/stacks/render.js";
|
|
12
14
|
import {
|
|
15
|
+
resolveStackSecretEnv,
|
|
13
16
|
stackDown,
|
|
14
17
|
stackHealth,
|
|
15
18
|
stackLogs,
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
stackUp,
|
|
19
22
|
} from "../../shared/stacks/runtime.js";
|
|
20
23
|
import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
|
|
24
|
+
import type { StackConfig } from "../../shared/stacks/types.js";
|
|
21
25
|
|
|
22
26
|
function serviceFlag(args: string[]): { service?: string; rest: string[] } {
|
|
23
27
|
const rest: string[] = [];
|
|
@@ -29,6 +33,29 @@ function serviceFlag(args: string[]): { service?: string; rest: string[] } {
|
|
|
29
33
|
return { service, rest };
|
|
30
34
|
}
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Every named stack's `secret_env`, resolved before anything starts or
|
|
38
|
+
* stops: one missing key fails the whole command with nothing changed.
|
|
39
|
+
* The provider is only asked when some stack declares secret_env.
|
|
40
|
+
*/
|
|
41
|
+
export async function resolveStacksSecretEnv(
|
|
42
|
+
targets: StackConfig[],
|
|
43
|
+
provider: () => SecretsProvider = getSecretsProvider,
|
|
44
|
+
): Promise<Map<string, Record<string, string>>> {
|
|
45
|
+
const out = new Map<string, Record<string, string>>();
|
|
46
|
+
const needing = targets.filter((s) => Object.keys(s.secretEnv ?? {}).length);
|
|
47
|
+
if (!needing.length) return out;
|
|
48
|
+
const p = provider();
|
|
49
|
+
const failures: string[] = [];
|
|
50
|
+
for (const s of needing) {
|
|
51
|
+
const r = await resolveStackSecretEnv(s, p);
|
|
52
|
+
if (r.ok) out.set(s.name, r.env);
|
|
53
|
+
else failures.push(r.message);
|
|
54
|
+
}
|
|
55
|
+
if (failures.length) throw new Error(failures.join("\n"));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
32
59
|
export async function cmdStack(argv: string[]): Promise<void> {
|
|
33
60
|
const { service, rest } = serviceFlag(argv);
|
|
34
61
|
const [sub = "ls", ...args] = rest;
|
|
@@ -62,21 +89,32 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
62
89
|
return;
|
|
63
90
|
case "up": {
|
|
64
91
|
// Re-render the edge so its cert covers every stack, then start the stacks.
|
|
92
|
+
const order = startOrder(pick(), stacks);
|
|
93
|
+
const secrets = await resolveStacksSecretEnv(order.map((n) => stacks[n]));
|
|
65
94
|
await edgeUp(edge);
|
|
66
|
-
for (const n of
|
|
95
|
+
for (const n of order) {
|
|
67
96
|
console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
|
|
68
|
-
stackUp(ctx, stacks[n], {
|
|
97
|
+
stackUp(ctx, stacks[n], {
|
|
98
|
+
build: args.includes("--build"),
|
|
99
|
+
secretEnv: secrets.get(n),
|
|
100
|
+
});
|
|
69
101
|
for (const route of Object.keys(stacks[n].routes))
|
|
70
102
|
console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
|
|
71
103
|
}
|
|
72
104
|
return;
|
|
73
105
|
}
|
|
74
|
-
case "restart":
|
|
106
|
+
case "restart": {
|
|
107
|
+
// Resolved before the down: a missing secret leaves the stack running.
|
|
108
|
+
const secrets = await resolveStacksSecretEnv(
|
|
109
|
+
pick().map((n) => stacks[n]),
|
|
110
|
+
);
|
|
75
111
|
if (service) {
|
|
76
112
|
// One compose service, in place: no edge re-render, no recreate.
|
|
77
113
|
for (const n of pick()) {
|
|
78
114
|
console.log(chalk.cyan(`↻ ${n} ${service}`));
|
|
79
|
-
stackRestartService(ctx, stacks[n], service
|
|
115
|
+
stackRestartService(ctx, stacks[n], service, {
|
|
116
|
+
secretEnv: secrets.get(n),
|
|
117
|
+
});
|
|
80
118
|
}
|
|
81
119
|
return;
|
|
82
120
|
}
|
|
@@ -84,9 +122,13 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
84
122
|
for (const n of pick()) {
|
|
85
123
|
console.log(chalk.cyan(`↻ ${n}`));
|
|
86
124
|
stackDown(ctx, stacks[n]);
|
|
87
|
-
stackUp(ctx, stacks[n], {
|
|
125
|
+
stackUp(ctx, stacks[n], {
|
|
126
|
+
build: args.includes("--build"),
|
|
127
|
+
secretEnv: secrets.get(n),
|
|
128
|
+
});
|
|
88
129
|
}
|
|
89
130
|
return;
|
|
131
|
+
}
|
|
90
132
|
case "health": {
|
|
91
133
|
let ok = true;
|
|
92
134
|
for (const n of pick()) {
|
|
@@ -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
|
+
}
|
package/src/ui/mcp/connect.ts
CHANGED
|
@@ -107,10 +107,12 @@ export const MCP_CLIENTS: readonly McpClient[] = [
|
|
|
107
107
|
"claude",
|
|
108
108
|
"mcp",
|
|
109
109
|
"add",
|
|
110
|
+
// The name first: claude's -e takes several values, so a name
|
|
111
|
+
// after the env pairs is read as one more pair.
|
|
112
|
+
s.name,
|
|
110
113
|
"--scope",
|
|
111
114
|
"user",
|
|
112
115
|
...envFlags("-e", s.env),
|
|
113
|
-
s.name,
|
|
114
116
|
"--",
|
|
115
117
|
s.command,
|
|
116
118
|
...s.args,
|
|
@@ -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
|
+
}
|