@monster-orange/den 0.2.1 → 0.2.3
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 +129 -0
- package/README.md +1 -1
- package/docs/distributions.md +116 -1
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +370 -6
- package/src/shared/manifest/schema.ts +15 -0
- package/src/shared/manifest/validate-layers.ts +40 -1
- 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/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +70 -2
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +216 -34
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +55 -4
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/mcp/server.ts +82 -1
- package/src/ui/server/index.ts +3 -0
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/workspace-routes.ts +32 -0
|
@@ -8,15 +8,20 @@
|
|
|
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,
|
|
16
19
|
stackPs,
|
|
20
|
+
stackRestartService,
|
|
17
21
|
stackUp,
|
|
18
22
|
} from "../../shared/stacks/runtime.js";
|
|
19
23
|
import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
|
|
24
|
+
import type { StackConfig } from "../../shared/stacks/types.js";
|
|
20
25
|
|
|
21
26
|
function serviceFlag(args: string[]): { service?: string; rest: string[] } {
|
|
22
27
|
const rest: string[] = [];
|
|
@@ -28,6 +33,29 @@ function serviceFlag(args: string[]): { service?: string; rest: string[] } {
|
|
|
28
33
|
return { service, rest };
|
|
29
34
|
}
|
|
30
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
|
+
|
|
31
59
|
export async function cmdStack(argv: string[]): Promise<void> {
|
|
32
60
|
const { service, rest } = serviceFlag(argv);
|
|
33
61
|
const [sub = "ls", ...args] = rest;
|
|
@@ -61,23 +89,46 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
61
89
|
return;
|
|
62
90
|
case "up": {
|
|
63
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]));
|
|
64
94
|
await edgeUp(edge);
|
|
65
|
-
for (const n of
|
|
95
|
+
for (const n of order) {
|
|
66
96
|
console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
|
|
67
|
-
stackUp(ctx, stacks[n], {
|
|
97
|
+
stackUp(ctx, stacks[n], {
|
|
98
|
+
build: args.includes("--build"),
|
|
99
|
+
secretEnv: secrets.get(n),
|
|
100
|
+
});
|
|
68
101
|
for (const route of Object.keys(stacks[n].routes))
|
|
69
102
|
console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
|
|
70
103
|
}
|
|
71
104
|
return;
|
|
72
105
|
}
|
|
73
|
-
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
|
+
);
|
|
111
|
+
if (service) {
|
|
112
|
+
// One compose service, in place: no edge re-render, no recreate.
|
|
113
|
+
for (const n of pick()) {
|
|
114
|
+
console.log(chalk.cyan(`↻ ${n} ${service}`));
|
|
115
|
+
stackRestartService(ctx, stacks[n], service, {
|
|
116
|
+
secretEnv: secrets.get(n),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
74
121
|
await edgeUp(edge);
|
|
75
122
|
for (const n of pick()) {
|
|
76
123
|
console.log(chalk.cyan(`↻ ${n}`));
|
|
77
124
|
stackDown(ctx, stacks[n]);
|
|
78
|
-
stackUp(ctx, stacks[n], {
|
|
125
|
+
stackUp(ctx, stacks[n], {
|
|
126
|
+
build: args.includes("--build"),
|
|
127
|
+
secretEnv: secrets.get(n),
|
|
128
|
+
});
|
|
79
129
|
}
|
|
80
130
|
return;
|
|
131
|
+
}
|
|
81
132
|
case "health": {
|
|
82
133
|
let ok = true;
|
|
83
134
|
for (const n of pick()) {
|
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,
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -115,15 +115,78 @@ function parsePort(argv: string[]): number | undefined {
|
|
|
115
115
|
export const MCP_USAGE = (cli: string): string =>
|
|
116
116
|
[
|
|
117
117
|
`Usage: ${cli} mcp [--port <web port>]`,
|
|
118
|
+
` ${cli} mcp --list`,
|
|
119
|
+
` ${cli} mcp --call <tool> ['<json args>']`,
|
|
118
120
|
"",
|
|
119
121
|
`An MCP server over stdio for agents, a client of the running web server`,
|
|
120
122
|
`(\`${cli} web up\`; default port: config web.port). Read tools always;`,
|
|
121
123
|
"actions only as named in the org config's mcp.actions. See docs/distributions.md.",
|
|
122
124
|
"",
|
|
125
|
+
"--list prints the tool names; --call runs one tool through the same guards,",
|
|
126
|
+
"prints its text result and exits 1 if the tool reports an error.",
|
|
127
|
+
"",
|
|
123
128
|
`Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
|
|
124
129
|
].join("\n");
|
|
125
130
|
|
|
126
|
-
/**
|
|
131
|
+
/** A one-shot invocation: `--list`, or `--call <tool> [json]`. */
|
|
132
|
+
export type OneShot =
|
|
133
|
+
| { kind: "list" }
|
|
134
|
+
| { kind: "call"; tool: string; args: Record<string, unknown> };
|
|
135
|
+
|
|
136
|
+
/** The one-shot `argv` asks for, or null for the stdio server. */
|
|
137
|
+
export function parseOneShot(argv: string[]): OneShot | null {
|
|
138
|
+
const i = argv.indexOf("--call");
|
|
139
|
+
if (i !== -1) {
|
|
140
|
+
const tool = argv[i + 1];
|
|
141
|
+
if (!tool || tool.startsWith("-"))
|
|
142
|
+
throw new Error("--call needs a tool name: --call <tool> ['<json>']");
|
|
143
|
+
const raw = argv[i + 2];
|
|
144
|
+
if (raw === undefined || raw.startsWith("--"))
|
|
145
|
+
return { kind: "call", tool, args: {} };
|
|
146
|
+
let args: unknown;
|
|
147
|
+
try {
|
|
148
|
+
args = JSON.parse(raw);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`--call ${tool}: arguments are not JSON (${(e as Error).message})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
155
|
+
throw new Error(`--call ${tool}: arguments must be a JSON object`);
|
|
156
|
+
return { kind: "call", tool, args: args as Record<string, unknown> };
|
|
157
|
+
}
|
|
158
|
+
return argv.includes("--list") ? { kind: "list" } : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface OneShotOptions {
|
|
162
|
+
cfg: ResolvedMcpConfig;
|
|
163
|
+
cli: string;
|
|
164
|
+
api: DenApi;
|
|
165
|
+
out?: (text: string) => void;
|
|
166
|
+
err?: (text: string) => void;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run a one-shot through the tool layer the stdio server uses (callTool:
|
|
171
|
+
* mcp.actions, every guard, redaction). Returns the exit code.
|
|
172
|
+
*/
|
|
173
|
+
export async function runOneShot(
|
|
174
|
+
shot: OneShot,
|
|
175
|
+
opts: OneShotOptions,
|
|
176
|
+
): Promise<number> {
|
|
177
|
+
const out = opts.out ?? ((t: string) => process.stdout.write(t));
|
|
178
|
+
if (shot.kind === "list") {
|
|
179
|
+
const names = toolsFor(opts.cfg).map((t) => t.name);
|
|
180
|
+
out(names.length ? `${names.join("\n")}\n` : "");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
|
|
184
|
+
const r = await callTool(shot.tool, shot.args, ctx);
|
|
185
|
+
out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
|
|
186
|
+
return r.isError ? 1 : 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
|
|
127
190
|
export async function runMcp(argv: string[]): Promise<void> {
|
|
128
191
|
const cli = product().cli;
|
|
129
192
|
if (argv.includes("-h") || argv.includes("--help")) {
|
|
@@ -140,6 +203,24 @@ export async function runMcp(argv: string[]): Promise<void> {
|
|
|
140
203
|
}
|
|
141
204
|
const cfg = readMcpConfig();
|
|
142
205
|
const baseUrl = `http://localhost:${port}`;
|
|
206
|
+
const shot = parseOneShot(argv);
|
|
207
|
+
if (shot) {
|
|
208
|
+
// Recorded like any MCP action: actor agent, this process, one session.
|
|
209
|
+
const session = randomUUID();
|
|
210
|
+
const api = createDenApi({
|
|
211
|
+
baseUrl,
|
|
212
|
+
cli,
|
|
213
|
+
headers: () =>
|
|
214
|
+
agentAuditHeaders({
|
|
215
|
+
client: `${cli} mcp --call`,
|
|
216
|
+
clientVersion: engineVersion() ?? "unknown",
|
|
217
|
+
mcpPid: process.pid,
|
|
218
|
+
session,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
process.exitCode = await runOneShot(shot, { cfg, cli, api });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
143
224
|
const server = createDenMcpServer({ cfg, baseUrl, cli });
|
|
144
225
|
await server.connect(new StdioServerTransport());
|
|
145
226
|
process.stderr.write(
|
package/src/ui/server/index.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
} from "../../shared/environment-manager/health.js";
|
|
69
69
|
import {
|
|
70
70
|
runServiceCommand,
|
|
71
|
+
prepareServiceSecretEnv,
|
|
71
72
|
resolveService,
|
|
72
73
|
listDaemons,
|
|
73
74
|
unregisterDaemon,
|
|
@@ -1477,6 +1478,8 @@ async function handleRequest(
|
|
|
1477
1478
|
{
|
|
1478
1479
|
run: runServiceCommand,
|
|
1479
1480
|
health: (r) => checkServiceHealth(r, "local"),
|
|
1481
|
+
secretEnv: (address, cmd, workspace) =>
|
|
1482
|
+
prepareServiceSecretEnv(address, cmd, workspace),
|
|
1480
1483
|
},
|
|
1481
1484
|
);
|
|
1482
1485
|
return;
|
|
@@ -21,6 +21,7 @@ import type { ResolvedService } from "../../shared/types/index.js";
|
|
|
21
21
|
import type {
|
|
22
22
|
RunCommandOptions,
|
|
23
23
|
RunCommandResult,
|
|
24
|
+
ServiceSecretEnvResult,
|
|
24
25
|
} from "../../shared/environment-manager/service-runner.js";
|
|
25
26
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
26
27
|
|
|
@@ -34,6 +35,16 @@ export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
|
|
|
34
35
|
export interface LifecycleExecDeps {
|
|
35
36
|
run: (o: RunCommandOptions) => RunCommandResult;
|
|
36
37
|
health: (r: ResolvedService) => Promise<ServiceHealthResult>;
|
|
38
|
+
/**
|
|
39
|
+
* R16: resolve the service's secret_env (prepareServiceSecretEnv). Unset,
|
|
40
|
+
* nothing is resolved, and the runner refuses to start a service that
|
|
41
|
+
* declares secret_env.
|
|
42
|
+
*/
|
|
43
|
+
secretEnv?: (
|
|
44
|
+
address: string,
|
|
45
|
+
cmd: LifecycleExecCmd,
|
|
46
|
+
workspace?: string,
|
|
47
|
+
) => Promise<ServiceSecretEnvResult>;
|
|
37
48
|
/** Health poll interval after a detached start (default 1s). */
|
|
38
49
|
pollMs?: number;
|
|
39
50
|
/** Stop polling after this long (default 120s). */
|
|
@@ -106,12 +117,24 @@ export async function streamLifecycleExec(
|
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// R16: all of the service's secret_env, or nothing starts. The message
|
|
121
|
+
// names keys, refs and the provider, never a value.
|
|
122
|
+
const secrets = deps.secretEnv
|
|
123
|
+
? await deps.secretEnv(resolved.address, cmd, o.workspace)
|
|
124
|
+
: { ok: true as const, secretEnv: undefined };
|
|
125
|
+
if (!secrets.ok) {
|
|
126
|
+
if (!clientGone) emit({ type: "error", text: secrets.message });
|
|
127
|
+
finish(1, true);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
109
131
|
const result = deps.run({
|
|
110
132
|
address: resolved.address,
|
|
111
133
|
command: cmd,
|
|
112
134
|
workspace: o.workspace,
|
|
113
135
|
runtime: "daemon",
|
|
114
136
|
env: o.env,
|
|
137
|
+
secretEnv: secrets.secretEnv,
|
|
115
138
|
onLine: (text) => {
|
|
116
139
|
if (!clientGone) emit({ type: "line", text });
|
|
117
140
|
},
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
checkServiceHealth,
|
|
45
45
|
claimHealthyService,
|
|
46
46
|
runServiceCommand,
|
|
47
|
+
prepareServiceSecretEnv,
|
|
47
48
|
resolveService,
|
|
48
49
|
hasRunningOpctlContainers,
|
|
49
50
|
releaseWorkspaceDaemons,
|
|
@@ -1411,6 +1412,10 @@ export async function handleWorkspaceRoutes(
|
|
|
1411
1412
|
const startTime = Date.now();
|
|
1412
1413
|
let overallExit = 0;
|
|
1413
1414
|
let ranCount = 0;
|
|
1415
|
+
// R16: each started service's resolved secret_env, by position in
|
|
1416
|
+
// `ordered`. Resolved before a restart's down, so a missing key leaves
|
|
1417
|
+
// the service running; the values only go into the runner's spawn env.
|
|
1418
|
+
const preparedSecrets = new Map<number, Record<string, string>>();
|
|
1414
1419
|
|
|
1415
1420
|
// Run services sequentially in dependency order
|
|
1416
1421
|
const runNext = (i: number): void => {
|
|
@@ -1508,6 +1513,32 @@ export async function handleWorkspaceRoutes(
|
|
|
1508
1513
|
}
|
|
1509
1514
|
}
|
|
1510
1515
|
|
|
1516
|
+
if ((cmd === "up" || cmd === "restart") && !preparedSecrets.has(i)) {
|
|
1517
|
+
void (async () => {
|
|
1518
|
+
// Phase 2 of a restart is a true `up`, so resolve for that.
|
|
1519
|
+
const r = await prepareServiceSecretEnv(address, "up", ws.name);
|
|
1520
|
+
if (cancelled) return;
|
|
1521
|
+
if (!r.ok) {
|
|
1522
|
+
ranCount++;
|
|
1523
|
+
overallExit = 1;
|
|
1524
|
+
emit({ type: "label", text: address });
|
|
1525
|
+
emit({ type: "error", text: r.message });
|
|
1526
|
+
emit({
|
|
1527
|
+
type: "svc-done",
|
|
1528
|
+
address,
|
|
1529
|
+
exitCode: 1,
|
|
1530
|
+
repoName: svcRepoName,
|
|
1531
|
+
subsystem: svcSubsystem,
|
|
1532
|
+
});
|
|
1533
|
+
runNext(i + 1);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
preparedSecrets.set(i, r.secretEnv);
|
|
1537
|
+
runNext(i);
|
|
1538
|
+
})();
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1511
1542
|
ranCount++;
|
|
1512
1543
|
|
|
1513
1544
|
emit({ type: "label", text: address });
|
|
@@ -1540,6 +1571,7 @@ export async function handleWorkspaceRoutes(
|
|
|
1540
1571
|
// the UI just hides the command they came here to watch.
|
|
1541
1572
|
runtime: "daemon",
|
|
1542
1573
|
env: getSudoEnv(),
|
|
1574
|
+
secretEnv: preparedSecrets.get(i),
|
|
1543
1575
|
onLine: (line) => emit({ type: "line", text: line }),
|
|
1544
1576
|
onClose: (exitCode) => {
|
|
1545
1577
|
// Release outer-scope refs so the disconnect handler no longer
|