@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/docs/distributions.md +51 -0
  3. package/package.json +1 -1
  4. package/scripts/setup-secrets.ts +11 -0
  5. package/service/install.sh +4 -2
  6. package/src/shared/edge/config.ts +16 -0
  7. package/src/shared/environment-manager/health.ts +36 -1
  8. package/src/shared/environment-manager/opctl.ts +83 -1
  9. package/src/shared/environment-manager/service-runner.ts +205 -8
  10. package/src/shared/git/origin-url.ts +77 -0
  11. package/src/shared/manifest/read-manifest.ts +39 -15
  12. package/src/shared/manifest/schema.ts +15 -0
  13. package/src/shared/paths.ts +42 -0
  14. package/src/shared/product.ts +7 -1
  15. package/src/shared/profile.ts +8 -0
  16. package/src/shared/read-cache.ts +77 -0
  17. package/src/shared/secrets/doppler.ts +10 -5
  18. package/src/shared/secrets/index.ts +1 -0
  19. package/src/shared/secrets/keys.ts +2 -0
  20. package/src/shared/secrets/secret-env.ts +149 -0
  21. package/src/shared/secrets/types.ts +10 -0
  22. package/src/shared/stacks/config.ts +6 -0
  23. package/src/shared/stacks/runtime.ts +51 -3
  24. package/src/shared/stacks/status.ts +109 -1
  25. package/src/shared/stacks/types.ts +8 -0
  26. package/src/shared/types/index.ts +15 -0
  27. package/src/ui/cli/index.ts +151 -10
  28. package/src/ui/cli/mcp-connect.ts +77 -15
  29. package/src/ui/cli/stack-command.ts +47 -5
  30. package/src/ui/cli/workspace-notes.ts +43 -0
  31. package/src/ui/mcp/connect.ts +3 -1
  32. package/src/ui/server/graph-order.ts +22 -0
  33. package/src/ui/server/index.ts +101 -183
  34. package/src/ui/server/lifecycle-exec.ts +23 -0
  35. package/src/ui/server/services-list.ts +210 -0
  36. package/src/ui/server/single-flight.ts +53 -0
  37. package/src/ui/server/workspace-routes.ts +272 -181
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * Project/config come from `secrets.doppler.{project,config}` in
6
6
  * config/den.yml, else from the directory's `doppler setup` scope,
7
- * else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN.
7
+ * else from DOPPLER_PROJECT / DOPPLER_CONFIG / DOPPLER_TOKEN. A scoped ref
8
+ * (a `secret_env` value, `<project>/<config>/<key>`) names its own.
8
9
  */
9
10
  import { execFile, spawn } from "child_process";
10
11
  import { promisify } from "util";
@@ -29,8 +30,12 @@ export class DopplerProvider implements SecretsProvider {
29
30
  this.timeout = opts.timeoutMs ?? 30_000;
30
31
  }
31
32
 
32
- /** `--project/--config` flags when configured explicitly. */
33
- scopeArgs(): string[] {
33
+ /**
34
+ * `--project/--config` flags: a scoped ref's own vault/item, else the
35
+ * configured ones (when set explicitly).
36
+ */
37
+ scopeArgs(ref?: SecretRef): string[] {
38
+ if (ref?.scoped) return ["--project", ref.vault, "--config", ref.item];
34
39
  const a: string[] = [];
35
40
  if (this.opts.project) a.push("--project", this.opts.project);
36
41
  if (this.opts.config) a.push("--config", this.opts.config);
@@ -56,7 +61,7 @@ export class DopplerProvider implements SecretsProvider {
56
61
  const key = this.keyFor(ref);
57
62
  const { stdout } = await execFileAsync(
58
63
  "doppler",
59
- ["secrets", "get", key, "--plain", ...this.scopeArgs()],
64
+ ["secrets", "get", key, "--plain", ...this.scopeArgs(ref)],
60
65
  { timeout: this.timeout },
61
66
  );
62
67
  return String(stdout).trim();
@@ -77,7 +82,7 @@ export class DopplerProvider implements SecretsProvider {
77
82
  await new Promise<void>((resolve, reject) => {
78
83
  const child = spawn(
79
84
  "doppler",
80
- ["secrets", "set", key, "--silent", ...this.scopeArgs()],
85
+ ["secrets", "set", key, "--silent", ...this.scopeArgs(ref)],
81
86
  { stdio: ["pipe", "ignore", "pipe"] },
82
87
  );
83
88
  let stderr = "";
@@ -26,6 +26,7 @@ export { flatKeyFor, toEnvName, refPath } from "./keys.js";
26
26
  export { DopplerProvider } from "./doppler.js";
27
27
  export { EnvProvider } from "./env.js";
28
28
  export { OnePasswordProvider } from "./onepassword.js";
29
+ export * from "./secret-env.js";
29
30
 
30
31
  export interface SecretsConfig {
31
32
  provider?: SecretsProviderName;
@@ -16,6 +16,7 @@ export function refPath(ref: SecretRef): string {
16
16
 
17
17
  /**
18
18
  * Resolve the flat key a key/value backend should use for `ref`:
19
+ * 0. a scoped ref's field, as is (it is fully qualified: no overrides);
19
20
  * 1. `ref.key` when the caller pinned one;
20
21
  * 2. the `secrets.keys` override map (`"vault/item/field": KEY`);
21
22
  * 3. the field itself when it already looks like an env var (API keys);
@@ -25,6 +26,7 @@ export function flatKeyFor(
25
26
  ref: SecretRef,
26
27
  overrides: Record<string, string> = {},
27
28
  ): string {
29
+ if (ref.scoped) return ref.field;
28
30
  if (ref.key) return ref.key;
29
31
  const mapped = overrides[refPath(ref)];
30
32
  if (mapped) return mapped;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `secret_env` (R16): environment variables whose values Den resolves from
3
+ * the configured secrets provider when it starts or restarts a target.
4
+ *
5
+ * compose:
6
+ * secret_env:
7
+ * OPENAI_API_KEY: proj/dev_personal/OPENAI_API_KEY
8
+ * services:
9
+ * api:
10
+ * secret_env:
11
+ * SOME_KEY: vault/item/field
12
+ *
13
+ * Each value is a fully qualified `a/b/c` path, parsed into a SCOPED
14
+ * {@link SecretRef} (see its `scoped` field for what each provider does with
15
+ * it). Values only ever travel in the spawned process's env: never into the
16
+ * compose override, logs, activity records, MCP output, dry runs or errors.
17
+ * Everything here that produces text names keys and refs, never values.
18
+ *
19
+ * This module only imports types, so the manifest schema can use the parser.
20
+ */
21
+ import type { SecretRef, SecretsProvider } from "./types.js";
22
+
23
+ /** A `secret_env` block: env var name → `a/b/c` ref. */
24
+ export type SecretEnvSpec = Record<string, string>;
25
+
26
+ /** Env var names Den will set (POSIX-portable). */
27
+ export const SECRET_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
+
29
+ /** Why `raw` is not a valid secret_env ref, or null when it is. */
30
+ export function secretEnvRefError(raw: unknown): string | null {
31
+ if (typeof raw !== "string")
32
+ return 'must be a string "<vault>/<item>/<field>"';
33
+ const parts = raw.split("/");
34
+ if (
35
+ parts.length !== 3 ||
36
+ parts.some((p) => p.trim() === "" || p !== p.trim())
37
+ )
38
+ return `"${raw}" must be exactly three non-empty parts, "<vault>/<item>/<field>" (Doppler: <project>/<config>/<key>)`;
39
+ return null;
40
+ }
41
+
42
+ /** Parse an `a/b/c` ref into a scoped SecretRef. Throws when malformed. */
43
+ export function parseSecretEnvRef(raw: string): SecretRef {
44
+ const err = secretEnvRefError(raw);
45
+ if (err) throw new Error(`secret_env ref ${err}`);
46
+ const [vault, item, field] = raw.split("/");
47
+ return { vault, item, field, scoped: true };
48
+ }
49
+
50
+ /**
51
+ * Problems with a whole `secret_env` block, as `KEY: message` strings ([]
52
+ * when it is valid). `where` prefixes each message.
53
+ */
54
+ export function secretEnvSpecErrors(spec: unknown, where: string): string[] {
55
+ if (spec === undefined) return [];
56
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec))
57
+ return [`${where} must be a map of ENV_NAME: "<vault>/<item>/<field>"`];
58
+ const out: string[] = [];
59
+ for (const [k, v] of Object.entries(spec)) {
60
+ if (!SECRET_ENV_KEY_RE.test(k))
61
+ out.push(`${where}.${k}: "${k}" is not a valid env var name`);
62
+ const err = secretEnvRefError(v);
63
+ if (err) out.push(`${where}.${k}: ref ${err}`);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** The validated block, or throws listing every problem. */
69
+ export function assertSecretEnvSpec(
70
+ spec: unknown,
71
+ where: string,
72
+ ): SecretEnvSpec | undefined {
73
+ if (spec === undefined) return undefined;
74
+ const errors = secretEnvSpecErrors(spec, where);
75
+ if (errors.length) throw new Error(errors.join("\n"));
76
+ return { ...(spec as SecretEnvSpec) };
77
+ }
78
+
79
+ /** One key that could not be resolved. Never carries a value. */
80
+ export interface SecretEnvMissing {
81
+ key: string;
82
+ ref: string;
83
+ }
84
+
85
+ export type SecretEnvResolution =
86
+ | { ok: true; env: Record<string, string> }
87
+ | { ok: false; missing: SecretEnvMissing[]; message: string };
88
+
89
+ /**
90
+ * The failure text for unresolved keys: key, ref and provider, never a value
91
+ * and never the provider's own error output (which could echo one).
92
+ */
93
+ export function describeSecretEnvMissing(
94
+ target: string,
95
+ missing: SecretEnvMissing[],
96
+ provider: string,
97
+ ): string {
98
+ const lines = missing.map(
99
+ (m) => ` ${m.key} (ref ${m.ref}, provider ${provider}): not resolved`,
100
+ );
101
+ return (
102
+ `${target}: ${missing.length} secret_env key(s) could not be resolved; not starting it:\n` +
103
+ lines.join("\n")
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Resolve every key of `spec` through `provider`. All or nothing: when any
109
+ * key is missing (unset, empty, unreadable) the result is a failure naming
110
+ * each missing key, and no partial env is returned.
111
+ */
112
+ export async function resolveSecretEnv(
113
+ spec: SecretEnvSpec | undefined,
114
+ provider: SecretsProvider,
115
+ target: string,
116
+ ): Promise<SecretEnvResolution> {
117
+ const entries = Object.entries(spec ?? {});
118
+ if (!entries.length) return { ok: true, env: {} };
119
+ const results = await Promise.all(
120
+ entries.map(async ([key, raw]) => {
121
+ if (secretEnvRefError(raw)) return { key, raw, value: null };
122
+ let value: string | null = null;
123
+ try {
124
+ value = await provider.tryGet(parseSecretEnvRef(raw));
125
+ } catch {
126
+ value = null;
127
+ }
128
+ return { key, raw, value };
129
+ }),
130
+ );
131
+ const missing = results
132
+ .filter((r) => !r.value)
133
+ .map((r) => ({ key: r.key, ref: String(r.raw) }));
134
+ if (missing.length)
135
+ return {
136
+ ok: false,
137
+ missing,
138
+ message: describeSecretEnvMissing(target, missing, provider.name),
139
+ };
140
+ return {
141
+ ok: true,
142
+ env: Object.fromEntries(results.map((r) => [r.key, r.value as string])),
143
+ };
144
+ }
145
+
146
+ /** The keys of a block, sorted: what a dry run or status may show. */
147
+ export function secretEnvKeys(spec: SecretEnvSpec | undefined): string[] {
148
+ return Object.keys(spec ?? {}).sort();
149
+ }
@@ -22,6 +22,16 @@ export interface SecretRef {
22
22
  * `flatKeyFor`.
23
23
  */
24
24
  key?: string;
25
+ /**
26
+ * Set on a fully qualified ref (a `secret_env` value, `a/b/c`): every part
27
+ * names a real location and no provider config fills it in.
28
+ * - Doppler: vault = `--project`, item = `--config`, field = the key
29
+ * (`secrets.doppler` and `secrets.keys` are not consulted).
30
+ * - 1Password: vault / item / field as for any ref.
31
+ * - env: field is the env var name (`secrets.keys` is not consulted).
32
+ * Unscoped refs (the default) keep the provider's configured scope.
33
+ */
34
+ scoped?: true;
25
35
  }
26
36
 
27
37
  export type SecretsProviderName = "doppler" | "1password" | "env";
@@ -17,6 +17,7 @@ import {
17
17
  } from "../environment-manager/workspaces.js";
18
18
  import { readManifest } from "../manifest/read-manifest.js";
19
19
  import type { ManifestComposeConfig } from "../types/index.js";
20
+ import { assertSecretEnvSpec } from "../secrets/secret-env.js";
20
21
  import { assertStackName, parseTarget } from "./render.js";
21
22
  import type { StackConfig } from "./types.js";
22
23
 
@@ -41,6 +42,10 @@ function toStack(
41
42
  const routes = raw.routes ?? {};
42
43
  Object.values(routes).forEach(parseTarget);
43
44
  const files = list(raw.files, `${where}.files`);
45
+ const secretEnv =
46
+ "secret_env" in raw
47
+ ? assertSecretEnvSpec(raw.secret_env, `${where}.secret_env`)
48
+ : undefined;
44
49
  return {
45
50
  name,
46
51
  repo,
@@ -69,6 +74,7 @@ function toStack(
69
74
  ),
70
75
  }
71
76
  : {}),
77
+ ...(secretEnv && Object.keys(secretEnv).length ? { secretEnv } : {}),
72
78
  };
73
79
  }
74
80
 
@@ -16,6 +16,12 @@ import {
16
16
  renderStackRoutes,
17
17
  routedNetworks,
18
18
  } from "./render.js";
19
+ import {
20
+ resolveSecretEnv,
21
+ secretEnvKeys,
22
+ type SecretEnvResolution,
23
+ } from "../secrets/secret-env.js";
24
+ import type { SecretsProvider } from "../secrets/types.js";
19
25
  import type { ResolvedProject, StackConfig } from "./types.js";
20
26
 
21
27
  function run(
@@ -117,6 +123,35 @@ function composeEnv(ctx: StackContext, s: StackConfig): NodeJS.ProcessEnv {
117
123
  };
118
124
  }
119
125
 
126
+ /**
127
+ * Resolve a stack's `secret_env` through `provider` (all or nothing). Call
128
+ * it before anything starts or stops, and hand the env to stackUp /
129
+ * stackRestartService. Nothing in the result's message carries a value.
130
+ */
131
+ export function resolveStackSecretEnv(
132
+ s: StackConfig,
133
+ provider: SecretsProvider,
134
+ ): Promise<SecretEnvResolution> {
135
+ return resolveSecretEnv(s.secretEnv, provider, `stack ${s.name}`);
136
+ }
137
+
138
+ /**
139
+ * The resolved secrets for a compose process that starts containers. Fails
140
+ * closed: a stack that declares `secret_env` is never started without every
141
+ * key (that is how a plain restart used to drop one).
142
+ */
143
+ function secretComposeEnv(
144
+ s: StackConfig,
145
+ secretEnv: Record<string, string> | undefined,
146
+ ): Record<string, string> {
147
+ const missing = secretEnvKeys(s.secretEnv).filter((k) => !secretEnv?.[k]);
148
+ if (missing.length)
149
+ throw new Error(
150
+ `stack ${s.name}: secret_env ${missing.join(", ")} not resolved; not starting it`,
151
+ );
152
+ return secretEnv ?? {};
153
+ }
154
+
120
155
  function baseArgs(ctx: StackContext, s: StackConfig): string[] {
121
156
  return [
122
157
  "compose",
@@ -168,8 +203,15 @@ function running(container: string): boolean {
168
203
  export function stackUp(
169
204
  ctx: StackContext,
170
205
  s: StackConfig,
171
- opts: { build?: boolean } = {},
206
+ opts: {
207
+ build?: boolean;
208
+ /** The stack's resolved `secret_env` (see resolveStackSecretEnv). */
209
+ secretEnv?: Record<string, string>;
210
+ } = {},
172
211
  ) {
212
+ const secrets = secretComposeEnv(s, opts.secretEnv);
213
+ // `compose config` runs without them: its JSON (which feeds the override)
214
+ // must not carry a secret.
173
215
  const resolved = resolve(ctx, s);
174
216
  // Provider side of each link: its referenced services join a private network.
175
217
  const needs = providerServices(s);
@@ -200,7 +242,11 @@ export function stackUp(
200
242
  ...(opts.build ? ["--build"] : []),
201
243
  "--remove-orphans",
202
244
  ],
203
- { cwd: repoDir(s), env: composeEnv(ctx, s), inherit: true },
245
+ {
246
+ cwd: repoDir(s),
247
+ env: { ...composeEnv(ctx, s), ...secrets },
248
+ inherit: true,
249
+ },
204
250
  );
205
251
  // This stack as a provider: consumers already running keep their link
206
252
  // network, but a restarted provider's new containers must rejoin it.
@@ -274,14 +320,16 @@ export function stackRestartService(
274
320
  ctx: StackContext,
275
321
  s: StackConfig,
276
322
  service: string,
323
+ opts: { secretEnv?: Record<string, string> } = {},
277
324
  ): void {
325
+ const secrets = secretComposeEnv(s, opts.secretEnv);
278
326
  const override = path.join(stackDir(ctx, s), "override.yml");
279
327
  const args = fs.existsSync(override)
280
328
  ? overrideArgs(ctx, s)
281
329
  : baseArgs(ctx, s);
282
330
  must("docker", [...args, "restart", service], {
283
331
  cwd: repoDir(s),
284
- env: composeEnv(ctx, s),
332
+ env: { ...composeEnv(ctx, s), ...secrets },
285
333
  inherit: true,
286
334
  });
287
335
  }
@@ -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
+ }
@@ -10,6 +10,8 @@
10
10
  * uses: [ragstack] # start first + private link network
11
11
  * env: # compose ${VAR} substitution
12
12
  * RAG_PROXY_TARGET: "http://{ragstack.ragstack-server}:80"
13
+ * secret_env: # from the secrets provider, at up/restart
14
+ * OPENAI_API_KEY: proj/dev/OPENAI_API_KEY
13
15
  *
14
16
  * `{stack.service}` expands to that service's container name.
15
17
  */
@@ -36,6 +38,12 @@ export interface StackConfig {
36
38
  * `${VAR}` substitution) cannot change.
37
39
  */
38
40
  serviceEnv?: Record<string, Record<string, string>>;
41
+ /**
42
+ * `secret_env` (R16): env var → `<vault>/<item>/<field>` ref. Resolved
43
+ * through the secrets provider by `stack up` / `stack restart` and passed
44
+ * only in the docker compose process's env, never in the override file.
45
+ */
46
+ secretEnv?: Record<string, string>;
39
47
  }
40
48
 
41
49
  /** What `docker compose config --format json` tells us about a service. */
@@ -276,6 +276,12 @@ export interface ServiceManagementConfig {
276
276
  runs?: Partial<Record<Environment, ServiceRunsValue>>;
277
277
  /** Where a remote service runs: the env, an account label, a URL to probe. */
278
278
  target?: ServiceTargetConfig;
279
+ /**
280
+ * R16: env var → `<vault>/<item>/<field>` ref, resolved through the
281
+ * secrets provider when this service's up/restart command runs, and set
282
+ * only in that process's env (see src/shared/secrets/secret-env.ts).
283
+ */
284
+ secret_env?: Record<string, string>;
279
285
  }
280
286
 
281
287
  export type ServiceRunsValue = "local" | "remote";
@@ -572,6 +578,15 @@ export interface ManifestComposeConfig {
572
578
  profiles?: string[];
573
579
  /** service → replacement healthcheck `test` (written to den's override, not the repo). */
574
580
  healthchecks?: Record<string, string[]>;
581
+ /** service → env vars set on that service in the override (wins by key). */
582
+ service_env?: Record<string, Record<string, string | number | boolean>>;
583
+ /**
584
+ * R16: env var → `<vault>/<item>/<field>` ref, resolved through the
585
+ * secrets provider at stack up/restart and set only in the docker compose
586
+ * process's env (for the compose file's `${VAR}` / pass-through
587
+ * `environment:` entries). Never written to the override.
588
+ */
589
+ secret_env?: Record<string, string>;
575
590
  }
576
591
 
577
592
  export interface MigrationResult {