@monster-orange/den 0.2.7 → 0.3.0

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 (139) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +619 -3
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agent-actor.ts +38 -3
  22. package/src/shared/auth/headless-login.ts +1 -1
  23. package/src/shared/auth/sudo-vault.ts +93 -31
  24. package/src/shared/auth/token-providers/index.ts +7 -3
  25. package/src/shared/brand.ts +7 -0
  26. package/src/shared/config-error.ts +63 -0
  27. package/src/shared/constants.ts +3 -24
  28. package/src/shared/den-config.ts +185 -7
  29. package/src/shared/edge/config.ts +147 -21
  30. package/src/shared/edge/exec.ts +31 -0
  31. package/src/shared/edge/included.ts +169 -0
  32. package/src/shared/edge/render.ts +69 -14
  33. package/src/shared/edge/runtime.ts +128 -121
  34. package/src/shared/edge/status.ts +68 -8
  35. package/src/shared/edge/tls.ts +328 -0
  36. package/src/shared/edge/types.ts +25 -9
  37. package/src/shared/environment-manager/catalog.ts +52 -1
  38. package/src/shared/environment-manager/default-workspace.ts +26 -3
  39. package/src/shared/environment-manager/git-remote.ts +49 -0
  40. package/src/shared/environment-manager/health.ts +73 -38
  41. package/src/shared/environment-manager/scope.ts +168 -0
  42. package/src/shared/environment-manager/service-runner.ts +789 -43
  43. package/src/shared/environment-manager/up-targets.ts +132 -0
  44. package/src/shared/environment-manager/workspace-manager.ts +67 -25
  45. package/src/shared/environment-manager/workspace-org.ts +26 -11
  46. package/src/shared/health/rollup.ts +48 -0
  47. package/src/shared/manifest/service-links.ts +20 -8
  48. package/src/shared/open-url.ts +21 -2
  49. package/src/shared/org-sources.ts +328 -0
  50. package/src/shared/org.ts +53 -5
  51. package/src/shared/plugins/manifest.ts +406 -0
  52. package/src/shared/plugins/store.ts +358 -0
  53. package/src/shared/process/legacy-adopt.ts +285 -0
  54. package/src/shared/process/process-group.ts +202 -0
  55. package/src/shared/process/spawn-streaming.ts +7 -0
  56. package/src/shared/secrets/doppler.ts +1 -1
  57. package/src/shared/secrets/index.ts +279 -50
  58. package/src/shared/secrets/none.ts +56 -0
  59. package/src/shared/secrets/strategies.ts +202 -0
  60. package/src/shared/secrets/types.ts +1 -1
  61. package/src/shared/smoke/git-remote.ts +40 -0
  62. package/src/shared/stacks/manifest-commands.ts +1 -1
  63. package/src/shared/stacks/namespace.ts +178 -0
  64. package/src/shared/stacks/owners.ts +303 -0
  65. package/src/shared/stacks/render.ts +8 -3
  66. package/src/shared/stacks/runtime.ts +100 -16
  67. package/src/shared/stacks/status.ts +63 -12
  68. package/src/shared/strategies/registry.ts +89 -0
  69. package/src/shared/theme/contract.ts +538 -0
  70. package/src/shared/types/index.ts +24 -5
  71. package/src/ui/cli/clone-plan.ts +154 -23
  72. package/src/ui/cli/create.ts +24 -7
  73. package/src/ui/cli/doctor-command.ts +131 -0
  74. package/src/ui/cli/edge-command.ts +56 -10
  75. package/src/ui/cli/flag-parser.ts +62 -3
  76. package/src/ui/cli/health-display.ts +25 -0
  77. package/src/ui/cli/index.ts +649 -72
  78. package/src/ui/cli/notice.ts +20 -0
  79. package/src/ui/cli/org-command.ts +12 -2
  80. package/src/ui/cli/plugin-command.ts +849 -0
  81. package/src/ui/cli/stack-command.ts +63 -2
  82. package/src/ui/cli/test-runner.ts +11 -9
  83. package/src/ui/cli/theme-command.ts +136 -0
  84. package/src/ui/cli/up-args.ts +15 -0
  85. package/src/ui/cli/web-command.ts +47 -23
  86. package/src/ui/mcp/api.ts +28 -2
  87. package/src/ui/mcp/scope.ts +298 -0
  88. package/src/ui/mcp/server.ts +70 -8
  89. package/src/ui/mcp/tools.ts +151 -24
  90. package/src/ui/server/appearance-prefs.ts +95 -0
  91. package/src/ui/server/audit.ts +90 -3
  92. package/src/ui/server/daemon-reaper.ts +29 -1
  93. package/src/ui/server/engine-stamp.ts +83 -0
  94. package/src/ui/server/github-service.ts +29 -45
  95. package/src/ui/server/graph-page.ts +20 -0
  96. package/src/ui/server/graph-prefs.ts +44 -1
  97. package/src/ui/server/http-errors.ts +293 -0
  98. package/src/ui/server/http-helpers.ts +22 -3
  99. package/src/ui/server/index.ts +590 -45
  100. package/src/ui/server/public/appearance.js +531 -0
  101. package/src/ui/server/public/edge-page.js +248 -0
  102. package/src/ui/server/public/edge.html +251 -0
  103. package/src/ui/server/public/environments.html +65 -83
  104. package/src/ui/server/public/error-page.js +247 -0
  105. package/src/ui/server/public/error.html +260 -0
  106. package/src/ui/server/public/graph-model.js +332 -14
  107. package/src/ui/server/public/graph.css +141 -47
  108. package/src/ui/server/public/graph.js +223 -44
  109. package/src/ui/server/public/header.js +396 -600
  110. package/src/ui/server/public/health-fit.js +248 -0
  111. package/src/ui/server/public/health-rollup.js +128 -0
  112. package/src/ui/server/public/i18n/es.js +287 -0
  113. package/src/ui/server/public/i18n/ja.js +276 -0
  114. package/src/ui/server/public/index.html +139 -438
  115. package/src/ui/server/public/local-dev.html +3468 -4044
  116. package/src/ui/server/public/logs.html +71 -208
  117. package/src/ui/server/public/panel.js +15 -3
  118. package/src/ui/server/public/release.html +175 -389
  119. package/src/ui/server/public/services.html +596 -770
  120. package/src/ui/server/public/settings-page.js +759 -0
  121. package/src/ui/server/public/settings-registry.js +521 -0
  122. package/src/ui/server/public/settings.html +590 -0
  123. package/src/ui/server/public/shell.js +3869 -0
  124. package/src/ui/server/public/topbar.js +1413 -0
  125. package/src/ui/server/public/version-label.js +12 -0
  126. package/src/ui/server/public/workspace-picker.js +74 -34
  127. package/src/ui/server/readonly-routes.ts +24 -4
  128. package/src/ui/server/release-service.ts +25 -3
  129. package/src/ui/server/services-list.ts +94 -17
  130. package/src/ui/server/settings-distribution.ts +74 -0
  131. package/src/ui/server/shell-prefs.ts +192 -0
  132. package/src/ui/server/theme-routes.ts +292 -0
  133. package/src/ui/server/theme-runtime.ts +407 -0
  134. package/src/ui/server/ui-assets.ts +16 -1
  135. package/src/ui/server/ui-language.ts +50 -0
  136. package/src/ui/server/workspace-routes.ts +55 -9
  137. package/themes/den/den-theme.json +72 -0
  138. package/themes/den/favicon.ico +0 -0
  139. package/themes/den/mark.svg +6 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Process groups that a detached `up` started (0.3.0-dev.37).
3
+ *
4
+ * A detached service is spawned as the leader of its own process group, so
5
+ * its pid is the group id. `up` records that pgid together with the leader's
6
+ * start time, and `down` signals the whole group: SIGTERM, a short grace
7
+ * period, then SIGKILL.
8
+ *
9
+ * A recorded pid can be reused once its process exits, so nothing is
10
+ * signalled until the group is shown to still be the one `up` started:
11
+ *
12
+ * - the leader (pid == pgid) is alive: its start time must equal the
13
+ * recorded one, and it must still lead that group. Anything else is a
14
+ * different process that happens to hold the pid, and it is left alone.
15
+ * - the leader has exited but members still carry the pgid: the group is
16
+ * still ours. A process group id is not handed out again while any
17
+ * process is in that group, so live members of it are ones the leader
18
+ * started.
19
+ * - nothing carries the pgid: the group has exited.
20
+ */
21
+
22
+ import { execFile, execFileSync } from "child_process";
23
+
24
+ export interface RecordedProcessGroup {
25
+ /** The group id: the detached leader's pid. */
26
+ pgid: number;
27
+ /** The leader's start time as `ps -o lstart=` prints it. */
28
+ startedAt: string;
29
+ }
30
+
31
+ export type GroupState =
32
+ | { state: "ours"; members: number[] }
33
+ | { state: "gone" }
34
+ | { state: "reused"; detail: string };
35
+
36
+ export type StopGroupOutcome =
37
+ | { outcome: "stopped"; signal: "SIGTERM" | "SIGKILL"; members: number[] }
38
+ | { outcome: "gone" }
39
+ | { outcome: "reused"; detail: string }
40
+ | { outcome: "survived"; members: number[] }
41
+ | { outcome: "error"; detail: string };
42
+
43
+ interface PsRow {
44
+ pid: number;
45
+ pgid: number;
46
+ zombie: boolean;
47
+ lstart: string;
48
+ }
49
+
50
+ const PS = "ps";
51
+ // lstart is printed in the C locale's fixed format, whatever the user's.
52
+ const PS_ENV = { ...process.env, LC_ALL: "C", LANG: "C" };
53
+
54
+ function parsePs(out: string): PsRow[] {
55
+ const rows: PsRow[] = [];
56
+ for (const line of out.split("\n")) {
57
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
58
+ if (!m) continue;
59
+ rows.push({
60
+ pid: Number(m[1]),
61
+ pgid: Number(m[2]),
62
+ zombie: m[3].startsWith("Z"),
63
+ lstart: m[4].trim().replace(/\s+/g, " "),
64
+ });
65
+ }
66
+ return rows;
67
+ }
68
+
69
+ const PS_COLUMNS = ["-o", "pid=,pgid=,stat=,lstart="];
70
+ const PS_ARGS = ["-A", ...PS_COLUMNS];
71
+
72
+ function psRowsSync(args: string[]): PsRow[] | null {
73
+ try {
74
+ return parsePs(
75
+ execFileSync(PS, args, {
76
+ env: PS_ENV,
77
+ encoding: "utf-8",
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ timeout: 5000,
80
+ }),
81
+ );
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function psRows(): Promise<PsRow[] | null> {
88
+ return new Promise((resolve) => {
89
+ execFile(
90
+ PS,
91
+ PS_ARGS,
92
+ { env: PS_ENV, encoding: "utf-8", timeout: 5000 },
93
+ (err, stdout) => resolve(err ? null : parsePs(stdout)),
94
+ );
95
+ });
96
+ }
97
+
98
+ /**
99
+ * The record `up` keeps for a detached leader it just spawned, or null when
100
+ * the process cannot be read (it already exited, or `ps` is unavailable).
101
+ */
102
+ export function recordProcessGroup(pid: number): RecordedProcessGroup | null {
103
+ const row = psRowsSync(["-p", String(pid), ...PS_COLUMNS])?.find(
104
+ (r) => r.pid === pid && !r.zombie,
105
+ );
106
+ if (!row || row.pgid !== pid) return null;
107
+ return { pgid: pid, startedAt: row.lstart };
108
+ }
109
+
110
+ /** A recorded group's state now, synchronously; null when `ps` cannot be run. */
111
+ export function processGroupStateSync(
112
+ rec: RecordedProcessGroup,
113
+ ): GroupState | null {
114
+ const rows = psRowsSync(PS_ARGS);
115
+ return rows ? classifyGroup(rec, rows) : null;
116
+ }
117
+
118
+ /** Whether a recorded group is still the one `up` started (see the header). */
119
+ export function classifyGroup(
120
+ rec: RecordedProcessGroup,
121
+ rows: PsRow[],
122
+ ): GroupState {
123
+ const live = rows.filter((r) => !r.zombie);
124
+ const leader = live.find((r) => r.pid === rec.pgid);
125
+ if (leader && (leader.lstart !== rec.startedAt || leader.pgid !== rec.pgid))
126
+ return {
127
+ state: "reused",
128
+ detail: `pid ${rec.pgid} now belongs to another process (started ${leader.lstart}; the recorded one started ${rec.startedAt})`,
129
+ };
130
+ const members = live.filter((r) => r.pgid === rec.pgid).map((r) => r.pid);
131
+ return members.length > 0 ? { state: "ours", members } : { state: "gone" };
132
+ }
133
+
134
+ /** The recorded group's state now; null when `ps` cannot be run. */
135
+ export async function processGroupState(
136
+ rec: RecordedProcessGroup,
137
+ ): Promise<GroupState | null> {
138
+ const rows = await psRows();
139
+ return rows ? classifyGroup(rec, rows) : null;
140
+ }
141
+
142
+ function signalGroup(
143
+ pgid: number,
144
+ signal: NodeJS.Signals,
145
+ ): "sent" | "gone" | string {
146
+ try {
147
+ process.kill(-pgid, signal);
148
+ return "sent";
149
+ } catch (e) {
150
+ const code = (e as NodeJS.ErrnoException).code;
151
+ if (code === "ESRCH") return "gone";
152
+ return `${signal} to process group ${pgid} failed: ${code ?? (e as Error).message}`;
153
+ }
154
+ }
155
+
156
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
157
+
158
+ async function waitUntilGone(
159
+ rec: RecordedProcessGroup,
160
+ ms: number,
161
+ ): Promise<GroupState | null> {
162
+ const deadline = Date.now() + ms;
163
+ let state = await processGroupState(rec);
164
+ while (state?.state === "ours" && Date.now() < deadline) {
165
+ await sleep(100);
166
+ state = await processGroupState(rec);
167
+ }
168
+ return state;
169
+ }
170
+
171
+ /**
172
+ * Stop a recorded process group: SIGTERM, up to `graceMs` for it to exit,
173
+ * then SIGKILL. The group is re-verified before each signal, so a pid that
174
+ * was reused in between is never signalled.
175
+ */
176
+ export async function stopProcessGroup(
177
+ rec: RecordedProcessGroup,
178
+ graceMs = 3000,
179
+ ): Promise<StopGroupOutcome> {
180
+ const before = await processGroupState(rec);
181
+ if (!before) return { outcome: "error", detail: "could not run ps" };
182
+ if (before.state === "gone") return { outcome: "gone" };
183
+ if (before.state === "reused")
184
+ return { outcome: "reused", detail: before.detail };
185
+
186
+ const term = signalGroup(rec.pgid, "SIGTERM");
187
+ if (term === "gone") return { outcome: "gone" };
188
+ if (term !== "sent") return { outcome: "error", detail: term };
189
+
190
+ const afterTerm = await waitUntilGone(rec, graceMs);
191
+ if (!afterTerm) return { outcome: "error", detail: "could not run ps" };
192
+ if (afterTerm.state !== "ours")
193
+ return { outcome: "stopped", signal: "SIGTERM", members: before.members };
194
+
195
+ const kill = signalGroup(rec.pgid, "SIGKILL");
196
+ if (kill !== "sent" && kill !== "gone")
197
+ return { outcome: "error", detail: kill };
198
+ const afterKill = await waitUntilGone(rec, 2000);
199
+ if (afterKill?.state === "ours")
200
+ return { outcome: "survived", members: afterKill.members };
201
+ return { outcome: "stopped", signal: "SIGKILL", members: before.members };
202
+ }
@@ -74,6 +74,7 @@ export function spawnDaemon(
74
74
  onClose: (exitCode: number) => void,
75
75
  cwd?: string,
76
76
  onServiceExit?: (exitCode: number) => void,
77
+ onSpawn?: (pid: number) => void,
77
78
  ): ReturnType<typeof spawn> {
78
79
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
79
80
 
@@ -112,6 +113,9 @@ export function spawnDaemon(
112
113
  onLine(`Error: ${err.message}`);
113
114
  onServiceExit?.(1);
114
115
  });
116
+ // The service leads its own process group (detached), so its pid is the
117
+ // group id `down` signals later.
118
+ if (onSpawn && svcProc.pid) onSpawn(svcProc.pid);
115
119
  svcProc.unref(); // den's event loop can exit independently
116
120
  fs.closeSync(logFd); // parent closes its copy; child keeps its own fd
117
121
 
@@ -148,6 +152,7 @@ export function spawnDaemon(
148
152
  * @param cwd Working directory (defaults to process.cwd())
149
153
  * @param detached Put the child in its own process group (default false)
150
154
  * @param logPath If provided together with detached=true, run as a daemon writing to this path
155
+ * @param onSpawn Daemon mode: called with the service's pid (its process group id) once spawned
151
156
  */
152
157
  export function spawnStreaming(
153
158
  command: string,
@@ -159,6 +164,7 @@ export function spawnStreaming(
159
164
  detached?: boolean,
160
165
  logPath?: string,
161
166
  onServiceExit?: (exitCode: number) => void,
167
+ onSpawn?: (pid: number) => void,
162
168
  ): ReturnType<typeof spawn> {
163
169
  // Daemon mode: service runs independently from den — survives restarts.
164
170
  if (detached && logPath) {
@@ -170,6 +176,7 @@ export function spawnStreaming(
170
176
  onClose,
171
177
  cwd,
172
178
  onServiceExit,
179
+ onSpawn,
173
180
  );
174
181
  }
175
182
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Doppler strategy — reads/writes flat keys with the `doppler` CLI.
3
- * Selected with `secrets.provider: doppler` (the default).
3
+ * Selected with `secrets.provider: doppler`.
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,
@@ -2,30 +2,40 @@
2
2
  * Secrets strategy selection.
3
3
  *
4
4
  * Resolution order for the backend:
5
- * 1. DEN_SECRETS_PROVIDER env var (doppler | 1password | env)
5
+ * 1. DEN_SECRETS_PROVIDER env var (none | doppler | 1password | env)
6
6
  * 2. `secrets.provider` in config/local/user.yml (per-engineer)
7
- * 3. `secrets.provider` in config/den.yml (team default)
8
- * 4. "doppler"
7
+ * 3. `secrets.provider` in the org config (team default)
8
+ * 4. "doppler" — implicit and deprecated: a warning says to set it.
9
9
  *
10
- * Adding a backend = implement SecretsProvider and register it in FACTORIES.
10
+ * Every backend is a strategy registered in the strategy registry
11
+ * (./strategies.ts); a new one registers there, like a plugin.
12
+ *
13
+ * Den assumes nothing about where a distribution keeps its secrets: no
14
+ * vault, item, field or account is built in. The engine's own named secrets
15
+ * (`sudo`, `journeys_agent_key`) exist only when `secrets.refs.<name>` says
16
+ * where they are; otherwise the feature reports "not configured".
11
17
  */
12
- import { OP_ACCOUNT_DEFAULT, OP_CLI_TIMEOUT_MS } from "../constants.js";
13
18
  import { readDenConfig, readUserConfig } from "../den-config.js";
14
- import { DopplerProvider } from "./doppler.js";
15
- import { EnvProvider } from "./env.js";
16
- import { OnePasswordProvider } from "./onepassword.js";
19
+ import type { DetectResult } from "../strategies/registry.js";
20
+ import { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
21
+ import { secretsStrategy, type SecretsStrategy } from "./strategies.js";
17
22
  import type {
23
+ PreflightResult,
18
24
  SecretRef,
19
25
  SecretsProvider,
20
26
  SecretsProviderName,
21
27
  } from "./types.js";
22
28
 
23
- import { envKey } from "../product.js";
29
+ import { envKey, product } from "../product.js";
24
30
  export * from "./types.js";
25
31
  export { flatKeyFor, toEnvName, refPath } from "./keys.js";
26
32
  export { DopplerProvider } from "./doppler.js";
27
33
  export { EnvProvider } from "./env.js";
34
+ export { NoneProvider } from "./none.js";
28
35
  export { OnePasswordProvider } from "./onepassword.js";
36
+ export { notConfiguredMessage, SecretsNotConfiguredError } from "./none.js";
37
+ export { secretsStrategy, secretsStrategyIds } from "./strategies.js";
38
+ export type { SecretsStrategy, SecretsSetupContext } from "./strategies.js";
29
39
  export * from "./secret-env.js";
30
40
 
31
41
  export interface SecretsConfig {
@@ -42,17 +52,19 @@ export interface SecretsConfig {
42
52
  /** `"vault/item/field": FLAT_KEY` overrides for doppler/env. */
43
53
  keys?: Record<string, string>;
44
54
  /**
45
- * Where the engine's own named secrets live, overriding the built-in
46
- * defaults field by field (see {@link namedSecretRef}). Names in use:
47
- * `sudo` (the saved local-dev sudo password) and `journeys_agent_key`
48
- * (the journeys pillar's agent API key).
55
+ * Where the engine's own named secrets live (see {@link namedSecretRef}).
56
+ * There are no built-in defaults: a name with no entry here is "not
57
+ * configured" and its feature stays off. Names in use: `sudo` (the saved
58
+ * local-dev sudo password) and `journeys_agent_key` (the journeys
59
+ * pillar's agent API key).
49
60
  *
50
61
  * refs:
51
- * journeys_agent_key: { vault: Shared, item: CI Keys }
62
+ * sudo: { vault: Private, item: Dev Keys, field: ACME_LOCAL_ADMIN_PWD }
63
+ * journeys_agent_key: { key: CURSOR_API_KEY } # doppler / env
52
64
  */
53
65
  refs?: Record<string, Partial<SecretRef>>;
54
66
  /**
55
- * The per-engineer developer keys `make den-setup-1password` collects
67
+ * The per-engineer developer keys `make <cli>-setup-1password` collects
56
68
  * into the developer-keys item (see scripts/setup-1password.ts). Default:
57
69
  * only the keys the engine itself uses.
58
70
  */
@@ -70,6 +82,10 @@ export interface DeveloperKeyDef {
70
82
  }
71
83
 
72
84
  export interface DeveloperKeysConfig {
85
+ /** The 1Password vault holding the developer-keys item (no default). */
86
+ vault?: string;
87
+ /** The developer-keys item (no default). */
88
+ item?: string;
73
89
  /**
74
90
  * A dotenv file (relative to the den root, e.g. an app checkout's
75
91
  * `.env`) whose values are offered as defaults.
@@ -79,26 +95,21 @@ export interface DeveloperKeysConfig {
79
95
  }
80
96
 
81
97
  export const SECRETS_PROVIDERS: readonly SecretsProviderName[] = [
98
+ "none",
82
99
  "doppler",
83
100
  "1password",
84
101
  "env",
85
102
  ];
86
103
 
87
- const FACTORIES: Record<
88
- SecretsProviderName,
89
- (c: SecretsConfig) => SecretsProvider
90
- > = {
91
- doppler: (c) => new DopplerProvider({ ...c.doppler, keys: c.keys }),
92
- "1password": (c) =>
93
- new OnePasswordProvider({
94
- account: c.onepassword?.account ?? OP_ACCOUNT_DEFAULT,
95
- timeoutMs: OP_CLI_TIMEOUT_MS,
96
- }),
97
- env: (c) => new EnvProvider(c.keys),
98
- };
104
+ /** The backend an unset `secrets.provider` falls back to (deprecated). */
105
+ export const IMPLICIT_SECRETS_PROVIDER: SecretsProviderName = "doppler";
99
106
 
100
107
  function isProviderName(v: unknown): v is SecretsProviderName {
101
- return typeof v === "string" && (SECRETS_PROVIDERS as string[]).includes(v);
108
+ return (
109
+ typeof v === "string" &&
110
+ (SECRETS_PROVIDERS as string[]).includes(v) &&
111
+ secretsStrategy(v) !== undefined
112
+ );
102
113
  }
103
114
 
104
115
  /** Effective secrets config (team file, then per-engineer override). */
@@ -125,33 +136,48 @@ export function readSecretsConfig(): SecretsConfig {
125
136
  };
126
137
  }
127
138
 
139
+ /** Whether `ref` gives backend `name` enough to address a secret. */
140
+ export function refIsComplete(
141
+ name: SecretsProviderName,
142
+ ref: Partial<SecretRef> | undefined,
143
+ ): boolean {
144
+ if (!ref || typeof ref !== "object") return false;
145
+ return secretsStrategy(name)?.capabilities.refComplete(ref) ?? false;
146
+ }
147
+
128
148
  /**
129
- * The engine-named secret `name`: `defaults` with any configured
130
- * `secrets.refs.<name>` fields laid over them. The engine never hard-wires
131
- * an org's vault or item names; the defaults are neutral placeholders.
149
+ * The engine-named secret `name`, exactly as `secrets.refs.<name>` gives it,
150
+ * or null when the distribution has not configured it (or configured too
151
+ * little for the selected backend). There are no built-in defaults: Den
152
+ * never assumes a vault, item, field or key of its own.
132
153
  */
133
154
  export function namedSecretRef(
134
155
  name: string,
135
- defaults: SecretRef,
136
156
  cfg: SecretsConfig = readSecretsConfig(),
137
- ): SecretRef {
138
- const override = cfg.refs?.[name] ?? {};
139
- const pick = (v: unknown, d: string): string =>
140
- typeof v === "string" && v.trim() ? v.trim() : d;
157
+ provider: SecretsProviderName = resolveProviderName(cfg),
158
+ ): SecretRef | null {
159
+ const raw = cfg.refs?.[name];
160
+ if (!refIsComplete(provider, raw)) return null;
161
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
141
162
  const ref: SecretRef = {
142
- vault: pick(override.vault, defaults.vault),
143
- item: pick(override.item, defaults.item),
144
- field: pick(override.field, defaults.field),
163
+ vault: str(raw!.vault),
164
+ item: str(raw!.item),
165
+ field: str(raw!.field) || str(raw!.key),
145
166
  };
146
- const key = override.key ?? defaults.key;
147
- if (key) ref.key = key;
167
+ if (str(raw!.key)) ref.key = str(raw!.key);
148
168
  return ref;
149
169
  }
150
170
 
151
- export function resolveProviderName(
171
+ export interface ResolvedProvider {
172
+ name: SecretsProviderName;
173
+ /** True when nothing chose it: the deprecated implicit doppler default. */
174
+ implicit: boolean;
175
+ }
176
+
177
+ export function resolveProvider(
152
178
  cfg: SecretsConfig,
153
179
  env: NodeJS.ProcessEnv = process.env,
154
- ): SecretsProviderName {
180
+ ): ResolvedProvider {
155
181
  const fromEnv = env.DEN_SECRETS_PROVIDER;
156
182
  if (fromEnv) {
157
183
  if (!isProviderName(fromEnv)) {
@@ -159,40 +185,243 @@ export function resolveProviderName(
159
185
  `${envKey("SECRETS_PROVIDER")}="${fromEnv}" is not one of ${SECRETS_PROVIDERS.join(", ")}`,
160
186
  );
161
187
  }
162
- return fromEnv;
188
+ return { name: fromEnv, implicit: false };
163
189
  }
164
190
  if (cfg.provider !== undefined && !isProviderName(cfg.provider)) {
165
191
  throw new Error(
166
192
  `secrets.provider "${String(cfg.provider)}" is not one of ${SECRETS_PROVIDERS.join(", ")}`,
167
193
  );
168
194
  }
169
- return cfg.provider ?? "doppler";
195
+ return cfg.provider
196
+ ? { name: cfg.provider, implicit: false }
197
+ : { name: IMPLICIT_SECRETS_PROVIDER, implicit: true };
198
+ }
199
+
200
+ export function resolveProviderName(
201
+ cfg: SecretsConfig,
202
+ env: NodeJS.ProcessEnv = process.env,
203
+ ): SecretsProviderName {
204
+ return resolveProvider(cfg, env).name;
205
+ }
206
+
207
+ /** The registered strategy for `name` (always registered for a valid name). */
208
+ export function strategyFor(name: SecretsProviderName): SecretsStrategy {
209
+ const s = secretsStrategy(name);
210
+ if (!s) throw new Error(`secrets strategy "${name}" is not registered`);
211
+ return s;
170
212
  }
171
213
 
172
214
  export function createSecretsProvider(
173
215
  name: SecretsProviderName,
174
216
  cfg: SecretsConfig = {},
175
217
  ): SecretsProvider {
176
- return FACTORIES[name](cfg);
218
+ return strategyFor(name).capabilities.create(cfg);
219
+ }
220
+
221
+ /**
222
+ * The provider behind detect(): while the strategy reports its backend
223
+ * unavailable (its CLI is not on PATH), nothing is spawned. Reads report
224
+ * null / not ready, and get, set and preflight give detect()'s reason
225
+ * instead of a spawn error.
226
+ */
227
+ class DetectedProvider implements SecretsProvider {
228
+ constructor(
229
+ private readonly inner: SecretsProvider,
230
+ private readonly strategy: SecretsStrategy,
231
+ private readonly implicit = false,
232
+ ) {}
233
+ get name(): SecretsProviderName {
234
+ return this.inner.name;
235
+ }
236
+ private unavailable(): string | null {
237
+ // The implicit default is resolved lazily: only a secret actually being
238
+ // read or written gets here, and only then does it warn (once).
239
+ if (this.implicit) warnImplicitOnce();
240
+ const d = this.strategy.detect();
241
+ return d.available ? null : `not available: ${d.reason ?? "unavailable"}`;
242
+ }
243
+ async isReady(): Promise<boolean> {
244
+ return this.unavailable() ? false : this.inner.isReady();
245
+ }
246
+ async get(ref: SecretRef): Promise<string> {
247
+ const why = this.unavailable();
248
+ if (why) throw new Error(`${this.name} secrets backend ${why}`);
249
+ return this.inner.get(ref);
250
+ }
251
+ async tryGet(ref: SecretRef): Promise<string | null> {
252
+ return this.unavailable() ? null : this.inner.tryGet(ref);
253
+ }
254
+ async set(ref: SecretRef, value: string): Promise<void> {
255
+ const why = this.unavailable();
256
+ if (why) throw new Error(`${this.name} secrets backend ${why}`);
257
+ return this.inner.set(ref, value);
258
+ }
259
+ async preflight(): Promise<PreflightResult> {
260
+ // Nothing chose a backend, so nothing requires one to be ready.
261
+ if (this.implicit) return { ok: true };
262
+ const why = this.unavailable();
263
+ if (why)
264
+ return {
265
+ ok: false,
266
+ message:
267
+ `${this.strategy.capabilities.label} ${why}.\n` +
268
+ `Install it, or set secrets.provider in ${product().configFile} (none | doppler | 1password | env).`,
269
+ };
270
+ return this.inner.preflight();
271
+ }
272
+ runPrefix(): string[] {
273
+ // Never wrap a command in an implicit backend.
274
+ if (this.implicit) return [];
275
+ return this.unavailable() ? [] : this.inner.runPrefix();
276
+ }
277
+ }
278
+
279
+ let warnedImplicit = false;
280
+ function warnImplicitOnce(): void {
281
+ if (warnedImplicit) return;
282
+ warnedImplicit = true;
283
+ console.warn(`⚠️ ${IMPLICIT_PROVIDER_WARNING}`);
177
284
  }
178
285
 
179
286
  let cached: SecretsProvider | null = null;
180
287
  let cachedKey = "";
181
288
 
182
- /** The configured backend. Re-resolves when DEN_SECRETS_PROVIDER changes. */
289
+ /**
290
+ * The configured backend, behind its strategy's detect(). Re-resolves when
291
+ * DEN_SECRETS_PROVIDER or the config changes.
292
+ */
183
293
  export function getSecretsProvider(): SecretsProvider {
184
294
  const cfg = readSecretsConfig();
185
- const name = resolveProviderName(cfg);
186
- const key = `${name}|${JSON.stringify(cfg)}`;
295
+ const { name, implicit } = resolveProvider(cfg);
296
+ const key = `${name}|${implicit}|${JSON.stringify(cfg)}`;
187
297
  if (!cached || key !== cachedKey) {
188
- cached = createSecretsProvider(name, cfg);
298
+ cached = new DetectedProvider(
299
+ createSecretsProvider(name, cfg),
300
+ strategyFor(name),
301
+ implicit,
302
+ );
189
303
  cachedKey = key;
190
304
  }
191
305
  return cached;
192
306
  }
193
307
 
308
+ export interface SecretsStatus {
309
+ /** The selected strategy id. */
310
+ backend: SecretsProviderName;
311
+ /** Name people see ("1Password"). */
312
+ label: string;
313
+ /** True when secrets.provider is unset (the deprecated doppler default). */
314
+ implicit: boolean;
315
+ /** detect() of the selected strategy. */
316
+ detect: DetectResult;
317
+ }
318
+
319
+ /** The selected strategy and its detect() result. Never spawns, never throws. */
320
+ export function secretsStatus(
321
+ cfg: SecretsConfig = readSecretsConfig(),
322
+ env: NodeJS.ProcessEnv = process.env,
323
+ opts: { detect?: boolean } = {},
324
+ ): SecretsStatus {
325
+ let r: ResolvedProvider;
326
+ try {
327
+ r = resolveProvider(cfg, env);
328
+ } catch (err) {
329
+ return {
330
+ backend: "none",
331
+ label: "none",
332
+ implicit: false,
333
+ detect: { available: false, reason: (err as Error).message },
334
+ };
335
+ }
336
+ const s = strategyFor(r.name);
337
+ return {
338
+ backend: r.name,
339
+ label: s.capabilities.label,
340
+ implicit: r.implicit,
341
+ // detect:false: report the choice without probing (nothing needs it).
342
+ detect:
343
+ opts.detect === false
344
+ ? { available: false, reason: "not checked: nothing needs it" }
345
+ : s.detect(env),
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Env vars the selected strategy gives the processes Den starts (the web
351
+ * server). Only 1password gives any (OP_ACCOUNT, from the distribution's
352
+ * config or the engineer's shell); every other backend gives none.
353
+ */
354
+ export function secretsProcessEnv(
355
+ cfg: SecretsConfig = readSecretsConfig(),
356
+ env: NodeJS.ProcessEnv = process.env,
357
+ ): Record<string, string> {
358
+ let name: SecretsProviderName;
359
+ try {
360
+ name = resolveProviderName(cfg, env);
361
+ } catch {
362
+ return {};
363
+ }
364
+ return strategyFor(name).capabilities.env?.(cfg, env) ?? {};
365
+ }
366
+
367
+ /** The engine's own named refs, for the legacy warning. */
368
+ export const ENGINE_SECRET_REFS = ["sudo", "journeys_agent_key"] as const;
369
+
370
+ export const IMPLICIT_PROVIDER_WARNING =
371
+ "secrets.provider is not set; the implicit doppler default is deprecated and will be removed in a later minor. " +
372
+ "Set it explicitly: doppler, 1password, env or none.";
373
+
374
+ /**
375
+ * Config warnings about secrets, for the server's startup log and
376
+ * `validate`: a 1password distribution with no refs (the old built-in
377
+ * vault/item/field are gone). The implicit doppler default warns lazily,
378
+ * the first time it resolves a secret (see getSecretsProvider), so a
379
+ * distribution that needs no secrets never hears about it.
380
+ */
381
+ export function secretsConfigWarnings(
382
+ cfg: SecretsConfig = readSecretsConfig(),
383
+ env: NodeJS.ProcessEnv = process.env,
384
+ ): string[] {
385
+ let r: ResolvedProvider;
386
+ try {
387
+ r = resolveProvider(cfg, env);
388
+ } catch (err) {
389
+ return [(err as Error).message];
390
+ }
391
+ const out: string[] = [];
392
+ if (
393
+ r.name === "1password" &&
394
+ !ENGINE_SECRET_REFS.some((n) => refIsComplete(r.name, cfg.refs?.[n]))
395
+ ) {
396
+ out.push(
397
+ "secrets.provider is 1password but secrets.refs is empty: Den no longer assumes a default vault, item or field. " +
398
+ `Add the refs to ${product().configFile}, e.g.\n` +
399
+ " secrets:\n" +
400
+ " refs:\n" +
401
+ " sudo: { vault: <vault>, item: <item>, field: <field> }\n" +
402
+ " journeys_agent_key: { vault: <vault>, item: <item>, field: CURSOR_API_KEY }\n" +
403
+ "The vault-backed features (saved sudo password, journeys agent key) stay off until then.",
404
+ );
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /**
410
+ * Throw "not configured" for engine ref `name`, or return it. For features
411
+ * that cannot run without it.
412
+ */
413
+ export function requireNamedSecretRef(
414
+ name: string,
415
+ cfg: SecretsConfig = readSecretsConfig(),
416
+ ): SecretRef {
417
+ const ref = namedSecretRef(name, cfg);
418
+ if (!ref) throw new SecretsNotConfiguredError(notConfiguredMessage(name));
419
+ return ref;
420
+ }
421
+
194
422
  /** Test hook. */
195
423
  export function resetSecretsProviderCache(): void {
196
424
  cached = null;
197
425
  cachedKey = "";
426
+ warnedImplicit = false;
198
427
  }