@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.
@@ -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 startOrder(pick(), stacks)) {
95
+ for (const n of order) {
66
96
  console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
67
- stackUp(ctx, stacks[n], { build: args.includes("--build") });
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], { build: args.includes("--build") });
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()) {
@@ -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,
@@ -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
- /** Run `<cli> mcp` until stdin closes. */
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(
@@ -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