@monster-orange/den 0.2.2 → 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 +40 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/src/shared/environment-manager/service-runner.ts +156 -4
- package/src/shared/manifest/schema.ts +15 -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/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +32 -0
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/mcp/connect.ts +3 -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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.2.3] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`secret_env`: secrets resolved by Den into a stack's or a service's env**
|
|
12
|
+
(R16), so a consumer no longer wraps `<provider> run -- <cli> restart
|
|
13
|
+
<repo>` by hand, and a plain restart no longer drops a key. In a manifest,
|
|
14
|
+
`compose.secret_env` (for the docker compose process: the compose file's
|
|
15
|
+
`${VAR}` or pass-through `environment:` entries) and
|
|
16
|
+
`services.<svc>.secret_env` (for that service's own up/restart command)
|
|
17
|
+
map an env var to a fully qualified ref, `<vault>/<item>/<field>`. For
|
|
18
|
+
Doppler that is `<project>/<config>/<key>`; for 1Password vault / item /
|
|
19
|
+
field; for `env` the field is the env var to read. Existing refs (the
|
|
20
|
+
engine's named secrets, test users) are unchanged. Resolved through the
|
|
21
|
+
configured `secrets.provider` at `up` and `restart` (also the in-place
|
|
22
|
+
restart of one compose service), all or nothing, before anything stops or
|
|
23
|
+
starts. A missing key fails that target, exit 1, naming the key, ref and
|
|
24
|
+
provider. Values go only into the spawned process's env: never into the
|
|
25
|
+
compose override, logs, activity records, MCP output or messages. A dry
|
|
26
|
+
run (`-n`) never asks the provider; it lists the key names it would
|
|
27
|
+
inject. A CLI-tabs start of a service with `secret_env` runs as a daemon,
|
|
28
|
+
so the values stay out of the tab's shell. `validate` rejects a ref that
|
|
29
|
+
is not exactly three non-empty parts, and a key that is not an env var
|
|
30
|
+
name. See "Secrets in a target's env" in `docs/distributions.md`.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`<cli> mcp connect` for Claude Code failed whenever the server has env**
|
|
35
|
+
("Invalid environment variable format: <name>"): `claude mcp add`'s `-e`
|
|
36
|
+
takes several values, so the server name after the env pairs was read as
|
|
37
|
+
one more. The name now comes right after `add`.
|
|
38
|
+
- **The up/restart secrets check said the backend was "not ready" when it
|
|
39
|
+
was signed in with no default scope.** `secret_env` refs name their own
|
|
40
|
+
scope, so they still resolve; it now says "signed in, no default scope
|
|
41
|
+
set" instead of warning.
|
|
42
|
+
|
|
43
|
+
### Migrations
|
|
44
|
+
|
|
45
|
+
- None required.
|
|
46
|
+
|
|
7
47
|
## [0.2.2] - 2026-11-04
|
|
8
48
|
|
|
9
49
|
### Added
|
package/docs/distributions.md
CHANGED
|
@@ -213,6 +213,57 @@ ones (`⇄ hybrid`).
|
|
|
213
213
|
production_envs: [prod] # default: [prod, production]
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
## Secrets in a target's env
|
|
217
|
+
|
|
218
|
+
`secret_env` puts secrets from the configured secrets provider
|
|
219
|
+
(`secrets.provider`: `doppler`, `1password` or `env`) into the env of what
|
|
220
|
+
Den starts, instead of wrapping `<provider> run -- <cli> restart <repo>` by
|
|
221
|
+
hand:
|
|
222
|
+
|
|
223
|
+
```yaml
|
|
224
|
+
# .acme.yml
|
|
225
|
+
compose:
|
|
226
|
+
secret_env: # the docker compose process's env, at stack up / restart
|
|
227
|
+
OPENAI_API_KEY: shop/dev_personal/OPENAI_API_KEY
|
|
228
|
+
services:
|
|
229
|
+
worker:
|
|
230
|
+
secret_env: # the env of this service's own up / restart command
|
|
231
|
+
STRIPE_KEY: Engineering/Stripe Test/secret key
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Each value is a fully qualified ref, `<vault>/<item>/<field>`:
|
|
235
|
+
|
|
236
|
+
| Provider | `a/b/c` means |
|
|
237
|
+
| ----------- | -------------------------------------------------------------------------- |
|
|
238
|
+
| `doppler` | `--project a --config b`, key `c` (`secrets.doppler` is not consulted) |
|
|
239
|
+
| `1password` | vault `a`, item `b`, field `c` |
|
|
240
|
+
| `env` | the env var `c` of the process running Den (`secrets.keys` is not applied) |
|
|
241
|
+
|
|
242
|
+
A `compose.secret_env` key reaches a container only through the compose
|
|
243
|
+
file: `${OPENAI_API_KEY}` in it, or a pass-through `environment:
|
|
244
|
+
[OPENAI_API_KEY]`.
|
|
245
|
+
|
|
246
|
+
- **When.** `up` and `restart`, including `restart <repo>/<service>` of one
|
|
247
|
+
compose service (`stack up`, `stack restart`, `stack restart --service`),
|
|
248
|
+
from the CLI, the web UI and MCP. Every key is resolved before anything
|
|
249
|
+
stops or starts.
|
|
250
|
+
- **Missing.** A key the provider cannot resolve (unset, empty, no access)
|
|
251
|
+
fails that target, exit 1, naming the key, the ref and the provider. The
|
|
252
|
+
rest of the set is not used: nothing starts with a partial set, and a
|
|
253
|
+
restart leaves the old instance running.
|
|
254
|
+
- **Where values go.** Only into the spawned process's env (the docker
|
|
255
|
+
compose process, or the service's command). Never into the generated
|
|
256
|
+
compose override, logs, activity records, MCP output, dry runs or
|
|
257
|
+
messages. A CLI-tabs (`cli_tabs`) start of a service with `secret_env`
|
|
258
|
+
runs as a daemon, since a tab would put them in its shell history.
|
|
259
|
+
- **Dry run.** `-n`/`--dry-run` never asks the provider. It lists the key
|
|
260
|
+
names it would inject: `shop: would inject secret_env compose:
|
|
261
|
+
OPENAI_API_KEY (names only; not resolved in a dry run)`.
|
|
262
|
+
- **Validation.** `<cli> validate` rejects a ref that is not exactly three
|
|
263
|
+
non-empty parts and a key that is not an env var name
|
|
264
|
+
(`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
|
|
265
|
+
`secret_env` too.
|
|
266
|
+
|
|
216
267
|
## den.yml: the product profile
|
|
217
268
|
|
|
218
269
|
Every runtime name derives from `product.name`. Set only what you want to
|
package/package.json
CHANGED
package/scripts/setup-secrets.ts
CHANGED
|
@@ -72,6 +72,17 @@ async function main(): Promise<number> {
|
|
|
72
72
|
const r = await provider.preflight();
|
|
73
73
|
if (r.ok) return 0;
|
|
74
74
|
if (gate && !cfg.required) {
|
|
75
|
+
// Signed in but no default scope: secret_env refs name their own
|
|
76
|
+
// (<project>/<config>/<key>), so only the engine's own secrets lack one.
|
|
77
|
+
if (await provider.isReady()) {
|
|
78
|
+
console.error(
|
|
79
|
+
chalk.gray(
|
|
80
|
+
` ${label}: signed in, no default scope set (secret_env refs still resolve;` +
|
|
81
|
+
` test logins and the saved sudo password need one: \`make ${product().cli}-setup-secrets\`).`,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
75
86
|
console.error(
|
|
76
87
|
chalk.yellow(
|
|
77
88
|
`⚠️ ${label} is not ready — continuing.\n` +
|
|
@@ -27,6 +27,15 @@ import {
|
|
|
27
27
|
shellQuote,
|
|
28
28
|
} from "../terminal/tabs.js";
|
|
29
29
|
import { readCatalog } from "./catalog.js";
|
|
30
|
+
import { getSecretsProvider } from "../secrets/index.js";
|
|
31
|
+
import {
|
|
32
|
+
assertSecretEnvSpec,
|
|
33
|
+
resolveSecretEnv,
|
|
34
|
+
secretEnvKeys,
|
|
35
|
+
type SecretEnvSpec,
|
|
36
|
+
} from "../secrets/secret-env.js";
|
|
37
|
+
import type { SecretsProvider } from "../secrets/types.js";
|
|
38
|
+
import { composeCommands } from "../stacks/manifest-commands.js";
|
|
30
39
|
import { buildServiceEnv } from "./service-env.js";
|
|
31
40
|
import {
|
|
32
41
|
devOpContainerNames,
|
|
@@ -1069,6 +1078,12 @@ export interface RunCommandOptions {
|
|
|
1069
1078
|
* the detach window is invisible to fail-fast callers.
|
|
1070
1079
|
*/
|
|
1071
1080
|
onServiceExit?: (exitCode: number) => void;
|
|
1081
|
+
/**
|
|
1082
|
+
* R16: the service's resolved `secret_env` (see prepareServiceSecretEnv),
|
|
1083
|
+
* set only in the spawned process's env. An up/restart of a service that
|
|
1084
|
+
* declares secret_env is refused unless every key is here.
|
|
1085
|
+
*/
|
|
1086
|
+
secretEnv?: Record<string, string>;
|
|
1072
1087
|
}
|
|
1073
1088
|
|
|
1074
1089
|
export interface RunCommandResult {
|
|
@@ -1135,6 +1150,108 @@ function repoStartedFallback(
|
|
|
1135
1150
|
};
|
|
1136
1151
|
}
|
|
1137
1152
|
|
|
1153
|
+
// ── secret_env (R16) ─────────────────────────────────────────────────────────
|
|
1154
|
+
|
|
1155
|
+
/** Commands that start a service process, and so get its secret_env. */
|
|
1156
|
+
function startsService(command: string): boolean {
|
|
1157
|
+
return command === "up" || command === "restart";
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** A service's declared `services.<svc>.secret_env` (validated), or undefined. */
|
|
1161
|
+
function serviceSecretEnvSpec(
|
|
1162
|
+
resolved: ResolvedService,
|
|
1163
|
+
): SecretEnvSpec | undefined {
|
|
1164
|
+
const raw = readManifest(resolved.repoPath)?.services?.[resolved.subsystem]
|
|
1165
|
+
?.secret_env;
|
|
1166
|
+
return assertSecretEnvSpec(
|
|
1167
|
+
raw,
|
|
1168
|
+
`${resolved.address}: services.${resolved.subsystem}.secret_env`,
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The address whose command a run of `command` on `address` actually spawns
|
|
1174
|
+
* (a service with no up of its own runs its repo's up), or null when none.
|
|
1175
|
+
*/
|
|
1176
|
+
function spawningAddress(
|
|
1177
|
+
address: string,
|
|
1178
|
+
command: string,
|
|
1179
|
+
workspace?: string,
|
|
1180
|
+
): string | null {
|
|
1181
|
+
const p = previewServiceCommand(address, command, workspace);
|
|
1182
|
+
return p.kind === "run" || p.kind === "via-repo" ? p.address : null;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The secret_env key NAMES a run of `command` on `address` would inject,
|
|
1187
|
+
* for a dry run or status: never a value, and the provider is never asked.
|
|
1188
|
+
* `service` is the service's own secret_env (set in its command's env);
|
|
1189
|
+
* `compose` is the repo's compose.secret_env, which Den's synthesized stack
|
|
1190
|
+
* command resolves for the docker compose process.
|
|
1191
|
+
*/
|
|
1192
|
+
export function previewServiceSecretEnv(
|
|
1193
|
+
address: string,
|
|
1194
|
+
command: string,
|
|
1195
|
+
workspace?: string,
|
|
1196
|
+
): { address: string; service: string[]; compose: string[] } | null {
|
|
1197
|
+
if (!startsService(command)) return null;
|
|
1198
|
+
const target = spawningAddress(address, command, workspace);
|
|
1199
|
+
const resolved = target ? resolveService(target, workspace) : null;
|
|
1200
|
+
if (!resolved) return null;
|
|
1201
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1202
|
+
const service = Object.keys(
|
|
1203
|
+
manifest?.services?.[resolved.subsystem]?.secret_env ?? {},
|
|
1204
|
+
).sort();
|
|
1205
|
+
const declared = manifest?.services?.[resolved.subsystem]?.commands?.local;
|
|
1206
|
+
const synthesized =
|
|
1207
|
+
manifest?.compose && !declared?.[command]
|
|
1208
|
+
? composeCommands(
|
|
1209
|
+
manifest,
|
|
1210
|
+
resolved.subsystem,
|
|
1211
|
+
resolved.repoName,
|
|
1212
|
+
workspace ?? "default",
|
|
1213
|
+
)[command]
|
|
1214
|
+
: undefined;
|
|
1215
|
+
const compose = synthesized
|
|
1216
|
+
? Object.keys(manifest?.compose?.secret_env ?? {}).sort()
|
|
1217
|
+
: [];
|
|
1218
|
+
if (!service.length && !compose.length) return null;
|
|
1219
|
+
return { address: resolved.address, service, compose };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
export type ServiceSecretEnvResult =
|
|
1223
|
+
| { ok: true; secretEnv: Record<string, string> }
|
|
1224
|
+
| { ok: false; message: string };
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Resolve the `secret_env` of the service a run of `command` on `address`
|
|
1228
|
+
* spawns, for runServiceCommand's `secretEnv`. All or nothing; the provider
|
|
1229
|
+
* is only asked when the service declares secret_env, and only for up and
|
|
1230
|
+
* restart. Messages name keys, refs and the provider, never values.
|
|
1231
|
+
*/
|
|
1232
|
+
export async function prepareServiceSecretEnv(
|
|
1233
|
+
address: string,
|
|
1234
|
+
command: string,
|
|
1235
|
+
workspace?: string,
|
|
1236
|
+
provider: () => SecretsProvider = getSecretsProvider,
|
|
1237
|
+
): Promise<ServiceSecretEnvResult> {
|
|
1238
|
+
if (!startsService(command)) return { ok: true, secretEnv: {} };
|
|
1239
|
+
const target = spawningAddress(address, command, workspace);
|
|
1240
|
+
const resolved = target ? resolveService(target, workspace) : null;
|
|
1241
|
+
if (!resolved) return { ok: true, secretEnv: {} };
|
|
1242
|
+
let spec: SecretEnvSpec | undefined;
|
|
1243
|
+
try {
|
|
1244
|
+
spec = serviceSecretEnvSpec(resolved);
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
return { ok: false, message: (e as Error).message };
|
|
1247
|
+
}
|
|
1248
|
+
if (!secretEnvKeys(spec).length) return { ok: true, secretEnv: {} };
|
|
1249
|
+
const r = await resolveSecretEnv(spec, provider(), resolved.address);
|
|
1250
|
+
return r.ok
|
|
1251
|
+
? { ok: true, secretEnv: r.env }
|
|
1252
|
+
: { ok: false, message: r.message };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1138
1255
|
/** What a run of `command` on `address` would do, without running it. */
|
|
1139
1256
|
export type ServiceCommandPreview =
|
|
1140
1257
|
| { kind: "run"; address: string }
|
|
@@ -1516,6 +1633,41 @@ export function runServiceCommand(
|
|
|
1516
1633
|
onLine,
|
|
1517
1634
|
);
|
|
1518
1635
|
|
|
1636
|
+
// R16: a service that declares secret_env never starts without every key
|
|
1637
|
+
// (a caller that did not resolve them is refused, not run without them).
|
|
1638
|
+
let secretEnv: Record<string, string> = {};
|
|
1639
|
+
if (startsService(command)) {
|
|
1640
|
+
let declared: string[];
|
|
1641
|
+
try {
|
|
1642
|
+
declared = secretEnvKeys(serviceSecretEnvSpec(resolved));
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
onLine(`Error: ${(e as Error).message}`);
|
|
1645
|
+
closeAsync(1);
|
|
1646
|
+
return dummy();
|
|
1647
|
+
}
|
|
1648
|
+
const missing = declared.filter((k) => !options.secretEnv?.[k]);
|
|
1649
|
+
if (missing.length) {
|
|
1650
|
+
onLine(
|
|
1651
|
+
`Error: ${resolved.address} declares secret_env ${missing.join(", ")}, which were not resolved; not starting it`,
|
|
1652
|
+
);
|
|
1653
|
+
closeAsync(1);
|
|
1654
|
+
return dummy();
|
|
1655
|
+
}
|
|
1656
|
+
secretEnv = Object.fromEntries(
|
|
1657
|
+
declared.map((k) => [k, options.secretEnv?.[k] as string]),
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
const hasSecrets = Object.keys(secretEnv).length > 0;
|
|
1661
|
+
// Secrets go only in the spawned process's env. A tab would need them in
|
|
1662
|
+
// its command line (and so its shell history), so such a start runs as a
|
|
1663
|
+
// daemon instead.
|
|
1664
|
+
const runEnv: NodeJS.ProcessEnv = { ...env, ...secretEnv };
|
|
1665
|
+
if (hasSecrets && runtime === "tab" && startsService(command))
|
|
1666
|
+
onLine(
|
|
1667
|
+
`${resolved.address} has secret_env: running as a daemon, not in a tab, to keep the values out of the tab's shell`,
|
|
1668
|
+
);
|
|
1669
|
+
const useTab = runtime === "tab" && !hasSecrets;
|
|
1670
|
+
|
|
1519
1671
|
// Manifest downs that are just `opctl container down <name>` must not be
|
|
1520
1672
|
// spawned raw: opctl re-elevates with `sudo -E` and fails on sudoers without
|
|
1521
1673
|
// SETENV even when there is nothing to stop — which is why files-api/api
|
|
@@ -1571,7 +1723,7 @@ export function runServiceCommand(
|
|
|
1571
1723
|
// HITL command — inherit the full TTY; onLine is not called.
|
|
1572
1724
|
const proc = spawnInteractive(
|
|
1573
1725
|
cmdString,
|
|
1574
|
-
{ ...process.env, ...
|
|
1726
|
+
{ ...process.env, ...runEnv },
|
|
1575
1727
|
onClose,
|
|
1576
1728
|
repoPath,
|
|
1577
1729
|
);
|
|
@@ -1621,11 +1773,11 @@ export function runServiceCommand(
|
|
|
1621
1773
|
}
|
|
1622
1774
|
};
|
|
1623
1775
|
|
|
1624
|
-
if (
|
|
1776
|
+
if (useTab && command === "up") {
|
|
1625
1777
|
return openCommandInTab(cmdString);
|
|
1626
1778
|
}
|
|
1627
1779
|
|
|
1628
|
-
if (
|
|
1780
|
+
if (useTab && command === "restart") {
|
|
1629
1781
|
closeExistingTab();
|
|
1630
1782
|
if (requestedCmdDef) return openCommandInTab(cmdString);
|
|
1631
1783
|
|
|
@@ -1743,7 +1895,7 @@ export function runServiceCommand(
|
|
|
1743
1895
|
const proc = spawnStreaming(
|
|
1744
1896
|
cmdString,
|
|
1745
1897
|
[],
|
|
1746
|
-
{ ...process.env, ...
|
|
1898
|
+
{ ...process.env, ...runEnv },
|
|
1747
1899
|
onLine,
|
|
1748
1900
|
wrappedOnClose,
|
|
1749
1901
|
repoPath,
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
|
|
12
|
+
import { SECRET_ENV_KEY_RE, secretEnvRefError } from "../secrets/secret-env.js";
|
|
12
13
|
|
|
13
14
|
export const CURRENT_SCHEMA_VERSION = 1;
|
|
14
15
|
export const KNOWN_VERSIONS = [1] as const;
|
|
@@ -69,6 +70,16 @@ const logSource = z.union([
|
|
|
69
70
|
}),
|
|
70
71
|
]);
|
|
71
72
|
|
|
73
|
+
// secret_env (R16): ENV_NAME → "<vault>/<item>/<field>", resolved through the
|
|
74
|
+
// secrets provider at up/restart and passed only in the process env.
|
|
75
|
+
const secretEnvConfig = z.record(
|
|
76
|
+
z.string().regex(SECRET_ENV_KEY_RE, "must be a valid env var name"),
|
|
77
|
+
z.string().superRefine((ref, ctx) => {
|
|
78
|
+
const err = secretEnvRefError(ref);
|
|
79
|
+
if (err) ctx.addIssue({ code: z.ZodIssueCode.custom, message: err });
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
72
83
|
// ── Service management config ─────────────────────────────────────────────────
|
|
73
84
|
//
|
|
74
85
|
// Keys for commands/logs/health are environment names (local, dev, …).
|
|
@@ -105,6 +116,7 @@ const serviceTarget = z
|
|
|
105
116
|
const serviceManagementConfig = z.object({
|
|
106
117
|
runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
|
|
107
118
|
target: serviceTarget.optional(),
|
|
119
|
+
secret_env: secretEnvConfig.optional(),
|
|
108
120
|
depends_on: z.array(dependsOnEntry).optional(),
|
|
109
121
|
commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
|
|
110
122
|
logs: z.record(z.string(), logSource).optional(),
|
|
@@ -280,6 +292,9 @@ const composeConfig = z.object({
|
|
|
280
292
|
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
281
293
|
)
|
|
282
294
|
.optional(),
|
|
295
|
+
// Env vars resolved from the secrets provider into the docker compose
|
|
296
|
+
// process's env at up/restart. Never written to the override.
|
|
297
|
+
secret_env: secretEnvConfig.optional(),
|
|
283
298
|
});
|
|
284
299
|
|
|
285
300
|
// ── v1 manifest schema ────────────────────────────────────────────────────────
|
|
@@ -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
|
-
/**
|
|
33
|
-
|
|
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: {
|
|
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
|
-
{
|
|
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
|
}
|
|
@@ -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 {
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -99,6 +99,8 @@ import { waitForReady } from "../../shared/environment-manager/readiness.js";
|
|
|
99
99
|
import {
|
|
100
100
|
canonicalServiceAddress,
|
|
101
101
|
previewServiceCommand,
|
|
102
|
+
previewServiceSecretEnv,
|
|
103
|
+
prepareServiceSecretEnv,
|
|
102
104
|
resolveService,
|
|
103
105
|
type ServiceCommandPreview,
|
|
104
106
|
} from "../../shared/environment-manager/service-runner.js";
|
|
@@ -1453,6 +1455,20 @@ class DenCLI {
|
|
|
1453
1455
|
console.log(chalk.gray(` ${p.note}`));
|
|
1454
1456
|
if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
|
|
1455
1457
|
}
|
|
1458
|
+
// R16: key names only. A dry run never asks the secrets provider.
|
|
1459
|
+
for (const a of new Set(runs)) {
|
|
1460
|
+
const sec = previewServiceSecretEnv(a, command, workspace);
|
|
1461
|
+
if (!sec) continue;
|
|
1462
|
+
const parts = [
|
|
1463
|
+
...(sec.service.length ? [sec.service.join(", ")] : []),
|
|
1464
|
+
...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
|
|
1465
|
+
];
|
|
1466
|
+
console.log(
|
|
1467
|
+
chalk.gray(
|
|
1468
|
+
` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
|
|
1469
|
+
),
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1456
1472
|
if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
|
|
1457
1473
|
console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
|
|
1458
1474
|
return;
|
|
@@ -1590,7 +1606,22 @@ class DenCLI {
|
|
|
1590
1606
|
}
|
|
1591
1607
|
}
|
|
1592
1608
|
|
|
1609
|
+
// R16: resolve the service's secret_env before anything spawns. A
|
|
1610
|
+
// missing key fails this target; nothing starts with a partial set.
|
|
1611
|
+
const secrets = await prepareServiceSecretEnv(
|
|
1612
|
+
address,
|
|
1613
|
+
command,
|
|
1614
|
+
workspace,
|
|
1615
|
+
);
|
|
1616
|
+
|
|
1593
1617
|
const started = await new Promise<boolean>((resolve) => {
|
|
1618
|
+
if (!secrets.ok) {
|
|
1619
|
+
console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
|
|
1620
|
+
for (const l of secrets.message.split("\n"))
|
|
1621
|
+
console.log(chalk.gray(` ${l}`));
|
|
1622
|
+
resolve(false);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1594
1625
|
const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
|
|
1595
1626
|
let detachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1596
1627
|
let settled = false;
|
|
@@ -1672,6 +1703,7 @@ class DenCLI {
|
|
|
1672
1703
|
runtime,
|
|
1673
1704
|
workspace,
|
|
1674
1705
|
force: command === "down" ? force : false,
|
|
1706
|
+
secretEnv: secrets.secretEnv,
|
|
1675
1707
|
onLine: (line) => {
|
|
1676
1708
|
spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
|
|
1677
1709
|
const trimmed = line.trim();
|
|
@@ -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()) {
|
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/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
|