@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -5,10 +5,13 @@
5
5
  * Claude Code's native installer puts `claude`.
6
6
  *
7
7
  * The search, first match wins:
8
+ * 0. workspace `<ws>.agents.runtimes.<id>.bin` in
9
+ * config/local/workspaces.yml (AG-0.5), for an agent of
10
+ * that workspace;
8
11
  * 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
9
12
  * (per machine, so never in the distribution's config):
10
- * an absolute path to an executable file; a bad one is
11
- * refused with its reason, never skipped.
13
+ * an absolute path to an executable file; a bad one (at
14
+ * either scope) is refused with its reason, never skipped.
12
15
  * 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
13
16
  * at server start, async, under a 3-second timeout; on
14
17
  * any failure it is left out (the current PATH remains).
@@ -18,6 +21,11 @@
18
21
  * /opt/homebrew/bin, /usr/local/bin and npm's global
19
22
  * bin, derived from npm_config_prefix and node's own
20
23
  * directory, never by running npm).
24
+ * 5. interactive-shell only when a runtime is still missing: the
25
+ * interactive login shell's PATH (`$SHELL -ilc`, where
26
+ * some people set PATH in .zshrc/.bashrc), asked once per
27
+ * process with stdin closed under the same 3-second
28
+ * timeout, and kept (ensureInteractivePath).
21
29
  *
22
30
  * Sessions and agents Den starts get the same directories on their PATH
23
31
  * (sessionPath), so the runtime's own tools are found too.
@@ -32,7 +40,13 @@ import { spawn } from "child_process";
32
40
  import * as fs from "fs";
33
41
  import * as path from "path";
34
42
 
35
- export type BinSource = "setting" | "login-shell" | "well-known" | "PATH";
43
+ export type BinSource =
44
+ | "workspace"
45
+ | "setting"
46
+ | "login-shell"
47
+ | "well-known"
48
+ | "PATH"
49
+ | "interactive-shell";
36
50
 
37
51
  /** The shell asked for the login PATH instead of $SHELL; "off" for none. */
38
52
  export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
@@ -52,7 +66,7 @@ export interface RuntimeFind {
52
66
 
53
67
  export interface SearchDir {
54
68
  dir: string;
55
- source: Exclude<BinSource, "setting">;
69
+ source: Exclude<BinSource, "setting" | "workspace">;
56
70
  }
57
71
 
58
72
  export interface Located {
@@ -96,6 +110,8 @@ const END = "__DEN_PATH_END__";
96
110
  export interface LoginPathOptions {
97
111
  env?: NodeJS.ProcessEnv;
98
112
  timeoutMs?: number;
113
+ /** The shell's flags: "-lc" (a login shell), "-ilc" (interactive too). */
114
+ flags?: "-lc" | "-ilc";
99
115
  }
100
116
 
101
117
  /**
@@ -127,10 +143,14 @@ export function resolveLoginPath(
127
143
  };
128
144
  let child: ReturnType<typeof spawn>;
129
145
  try {
130
- child = spawn(shell, ["-lc", `printf '${BEGIN}%s${END}' "$PATH"`], {
131
- env,
132
- stdio: ["ignore", "pipe", "ignore"],
133
- });
146
+ child = spawn(
147
+ shell,
148
+ [o.flags ?? "-lc", `printf '${BEGIN}%s${END}' "$PATH"`],
149
+ {
150
+ env,
151
+ stdio: ["ignore", "pipe", "ignore"],
152
+ },
153
+ );
134
154
  } catch {
135
155
  resolve(undefined);
136
156
  return;
@@ -167,6 +187,38 @@ export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
167
187
  if (p) setLoginPath(p);
168
188
  }
169
189
 
190
+ // ── the interactive shell's PATH (the fallback) ─────────────────────────────
191
+
192
+ let interactiveDirs: string[] | null = null;
193
+ let interactiveAsked: Promise<void> | null = null;
194
+
195
+ /**
196
+ * Ask the interactive login shell (`$SHELL -ilc`) for its PATH, once per
197
+ * process (stdin closed, the same timeout), and keep its directories as
198
+ * the last place a runtime is looked for. Only for a runtime still missing
199
+ * after the login PATH and the install directories; a second call waits
200
+ * on (or returns) the first. Never throws.
201
+ */
202
+ export function ensureInteractivePath(
203
+ o: Omit<LoginPathOptions, "flags"> = {},
204
+ ): Promise<void> {
205
+ interactiveAsked ??= resolveLoginPath({ ...o, flags: "-ilc" }).then((p) => {
206
+ if (p) interactiveDirs = absoluteDirs(p);
207
+ });
208
+ return interactiveAsked;
209
+ }
210
+
211
+ /** Whether the interactive shell was asked already (the answer is kept). */
212
+ export function interactivePathAsked(): boolean {
213
+ return interactiveAsked !== null;
214
+ }
215
+
216
+ /** Forget the interactive shell's PATH and that it was asked (tests). */
217
+ export function resetInteractivePath(): void {
218
+ interactiveDirs = null;
219
+ interactiveAsked = null;
220
+ }
221
+
170
222
  // ── the directories ─────────────────────────────────────────────────────────
171
223
 
172
224
  /** A PATH-style list's absolute entries, in order, once each. */
@@ -213,7 +265,10 @@ export function wellKnownDirs(
213
265
  : [];
214
266
  }
215
267
 
216
- /** Where a runtime is looked for, in order: login shell, PATH, well-known. */
268
+ /**
269
+ * Where a runtime is looked for, in order: login shell, PATH, well-known,
270
+ * then (once asked) the interactive shell's PATH.
271
+ */
217
272
  export function searchDirs(
218
273
  find: RuntimeFind | undefined,
219
274
  env: NodeJS.ProcessEnv,
@@ -226,6 +281,7 @@ export function searchDirs(
226
281
  add(loginDirs ?? [], "login-shell");
227
282
  add(absoluteDirs(env.PATH ?? ""), "PATH");
228
283
  add(wellKnownDirs(find, env), "well-known");
284
+ add(interactiveDirs ?? [], "interactive-shell");
229
285
  return out;
230
286
  }
231
287
 
@@ -248,8 +304,8 @@ export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
248
304
  export function checkBinSetting(
249
305
  id: string,
250
306
  value: unknown,
307
+ key: string = binSettingKey(id),
251
308
  ): { path: string } | { why: string } {
252
- const key = binSettingKey(id);
253
309
  if (
254
310
  value &&
255
311
  typeof value === "object" &&
@@ -268,10 +324,18 @@ export function checkBinSetting(
268
324
  return { path: p };
269
325
  }
270
326
 
327
+ /** A path someone set: where (global or the workspace's) and its key. */
328
+ export interface BinSetting {
329
+ value: unknown;
330
+ source: "setting" | "workspace";
331
+ /** The key it is under, for a refusal ("agents.runtimes.codex.bin"). */
332
+ key?: string;
333
+ }
334
+
271
335
  /**
272
336
  * Find runtime `id`'s CLI: the setting when there is one (refused with its
273
337
  * reason when bad), else the first of `find.bins` in searchDirs. Runs
274
- * nothing.
338
+ * nothing. `setting` is a value (global, as before) or a BinSetting.
275
339
  */
276
340
  export function locateRuntime(
277
341
  id: string,
@@ -279,11 +343,17 @@ export function locateRuntime(
279
343
  env: NodeJS.ProcessEnv,
280
344
  setting?: unknown,
281
345
  ): Located {
282
- if (setting !== undefined && setting !== null) {
283
- const c = checkBinSetting(id, setting);
346
+ const set: BinSetting | null =
347
+ setting === undefined || setting === null
348
+ ? null
349
+ : isBinSetting(setting)
350
+ ? setting
351
+ : { value: setting, source: "setting" };
352
+ if (set && set.value !== undefined && set.value !== null) {
353
+ const c = checkBinSetting(id, set.value, set.key);
284
354
  return "path" in c
285
- ? { available: true, path: c.path, source: "setting", searched: [] }
286
- : { available: false, why: c.why, source: "setting", searched: [] };
355
+ ? { available: true, path: c.path, source: set.source, searched: [] }
356
+ : { available: false, why: c.why, source: set.source, searched: [] };
287
357
  }
288
358
  const dirs = searchDirs(find, env);
289
359
  for (const { dir, source } of dirs)
@@ -304,6 +374,17 @@ export function locateRuntime(
304
374
  };
305
375
  }
306
376
 
377
+ function isBinSetting(v: unknown): v is BinSetting {
378
+ return (
379
+ !!v &&
380
+ typeof v === "object" &&
381
+ "source" in v &&
382
+ "value" in v &&
383
+ ((v as BinSetting).source === "setting" ||
384
+ (v as BinSetting).source === "workspace")
385
+ );
386
+ }
387
+
307
388
  /**
308
389
  * The PATH a session Den starts gets: the login shell's, the caller's,
309
390
  * then each runtime's well-known directories that exist, and `extra` (a
@@ -410,6 +491,52 @@ export function runtimeVersion(
410
491
  return p;
411
492
  }
412
493
 
494
+ /**
495
+ * Run `<bin> <args>` (stdin closed, under a timeout, never cached) and
496
+ * return what it printed and whether it exited 0: to check a path someone
497
+ * sets is the runtime it claims to be. Never throws.
498
+ */
499
+ export function versionOutput(
500
+ bin: string,
501
+ args: readonly string[],
502
+ env: NodeJS.ProcessEnv,
503
+ timeoutMs: number = VERSION_TIMEOUT_MS,
504
+ ): Promise<{ ok: boolean; out: string }> {
505
+ return new Promise((resolve) => {
506
+ let out = "";
507
+ let done = false;
508
+ const finish = (ok: boolean) => {
509
+ if (done) return;
510
+ done = true;
511
+ clearTimeout(timer);
512
+ resolve({ ok, out });
513
+ };
514
+ let child: ReturnType<typeof spawn>;
515
+ try {
516
+ child = spawn(bin, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
517
+ } catch {
518
+ resolve({ ok: false, out: "" });
519
+ return;
520
+ }
521
+ const timer = setTimeout(() => {
522
+ try {
523
+ child.kill("SIGKILL");
524
+ } catch {
525
+ /* gone */
526
+ }
527
+ finish(false);
528
+ }, timeoutMs);
529
+ timer.unref?.();
530
+ const take = (d: Buffer) => {
531
+ if (out.length < 4096) out += d.toString("utf-8");
532
+ };
533
+ child.stdout?.on("data", take);
534
+ child.stderr?.on("data", take);
535
+ child.on("error", () => finish(false));
536
+ child.on("close", (code) => finish(code === 0));
537
+ });
538
+ }
539
+
413
540
  /** Forget every version asked (tests). */
414
541
  export function clearVersionCache(): void {
415
542
  versions.clear();
@@ -13,8 +13,10 @@ import {
13
13
  getStrategy,
14
14
  registerStrategy,
15
15
  strategyIds,
16
+ unregisterStrategy,
16
17
  type StrategyEntry,
17
18
  } from "../strategies/registry.js";
19
+ import type { CatalogEntry } from "./catalog.js";
18
20
  import type { Persona } from "./personas.js";
19
21
  import type { BinSource, RuntimeFind } from "./runtime-search.js";
20
22
 
@@ -39,7 +41,8 @@ export interface RuntimeDetect {
39
41
  export interface DetectOptions {
40
42
  /**
41
43
  * `agents.runtimes.<id>.bin` as configured in user.yml (undefined:
42
- * read it there; null: none).
44
+ * read it there; null: none), or a BinSetting (runtime-search.ts) that
45
+ * says where it was set (the workspace's, or global).
43
46
  */
44
47
  bin?: unknown;
45
48
  }
@@ -107,6 +110,15 @@ export interface Runtime {
107
110
  id: string;
108
111
  /** Its name for people ("Claude Code"), in the Start agent form. */
109
112
  label?: string;
113
+ /** Who makes it ("Anthropic"): a catalog runtime's vendor (AG-0.5). */
114
+ vendor?: string;
115
+ /** Its catalog entry (catalog.ts), when it is one. */
116
+ catalog?: CatalogEntry;
117
+ /**
118
+ * Whether it runs with Den's tools (its MCP config and guards). false:
119
+ * it runs as its own CLI, tagged "Den tools: coming" (AG-0.5).
120
+ */
121
+ denTools?: boolean;
110
122
  /**
111
123
  * How its CLI is found and its version asked (AG-0.1): the search's
112
124
  * binary names and install directories, per runtime, so a catalog can
@@ -143,6 +155,11 @@ export class LaunchError extends Error {
143
155
  }
144
156
  }
145
157
 
158
+ /** Remove a runtime (a configured catalog entry that is gone). */
159
+ export function unregisterRuntime(id: string): void {
160
+ unregisterStrategy(RUNTIME_KIND, id);
161
+ }
162
+
146
163
  /** Register a runtime as strategy kind `runtime` (doctor lists it). */
147
164
  export function registerRuntime(rt: Runtime): void {
148
165
  const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {
@@ -1,7 +1,12 @@
1
1
  /**
2
2
  * Den's built-in runtimes, registered as strategy kind `runtime`: import
3
- * this module to have them. den-claude first: it is the default.
3
+ * this module to have them. den-claude first: it is the default. Then the
4
+ * rest of the runtime catalog (AG-0.5: codex, gemini, grok), then generic.
5
+ * Entries a distribution or user.yml adds are registered by
6
+ * syncConfiguredRuntimes, after the built-ins.
4
7
  */
8
+ import { BUILTIN_CATALOG, type CatalogEntry } from "./catalog.js";
9
+ import { catalogRuntime } from "./catalog-runtime.js";
5
10
  import { denClaude } from "./den-claude.js";
6
11
  import { generic } from "./generic.js";
7
12
  import {
@@ -9,6 +14,7 @@ import {
9
14
  getRuntime,
10
15
  registerRuntime,
11
16
  runtimeIds,
17
+ unregisterRuntime,
12
18
  type Runtime,
13
19
  } from "./runtime.js";
14
20
  import { sessionPath, type RuntimeFind } from "./runtime-search.js";
@@ -16,8 +22,24 @@ import { ACTOR_TOKEN_ENV } from "../actor-token.js";
16
22
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
17
23
 
18
24
  registerRuntime(denClaude);
25
+ for (const e of BUILTIN_CATALOG)
26
+ if (e.id !== denClaude.id) registerRuntime(catalogRuntime(e));
19
27
  registerRuntime(generic);
20
28
 
29
+ let configured: string[] = [];
30
+
31
+ /**
32
+ * Register the catalog entries a distribution and user.yml add (already
33
+ * checked: catalog.ts validateCatalog), replacing the last set: an entry
34
+ * taken out of the config is no runtime any more.
35
+ */
36
+ export function syncConfiguredRuntimes(entries: readonly CatalogEntry[]): void {
37
+ const ids = entries.map((e) => e.id);
38
+ for (const id of configured) if (!ids.includes(id)) unregisterRuntime(id);
39
+ for (const e of entries) registerRuntime(catalogRuntime(e));
40
+ configured = ids;
41
+ }
42
+
21
43
  export {
22
44
  allScrubEnv,
23
45
  DEFAULT_RUNTIME_ID,
@@ -2,10 +2,13 @@
2
2
  * Secrets-backed persistence for the local-dev sudo password.
3
3
  *
4
4
  * Goes through the configured secrets strategy (see src/shared/secrets),
5
- * addressing the named ref `sudo`. There is no built-in location: a
6
- * distribution that wants the saved password sets `secrets.refs.sudo` in its
7
- * config. Without it, the vault path is "not configured" and only the
8
- * interactive unlock is offered; nothing is spawned.
5
+ * addressing the named ref `sudo`. A distribution may set
6
+ * `secrets.refs.sudo` in its config; that ref always wins. Without one, the
7
+ * vault path is "not configured" until the person saves the password
8
+ * (LK-4): when a backend that keeps secrets (1Password, Doppler) is
9
+ * available, saving creates the item and writes the ref to their own
10
+ * user.yml, because the password is the person's, not the distribution's.
11
+ * Nothing is spawned until then.
9
12
  *
10
13
  * All read helpers fail *soft*: if the backend isn't installed, the user isn't
11
14
  * signed in, the ref is not configured or the value is missing, they return
@@ -21,22 +24,64 @@ import {
21
24
  secretsStatus,
22
25
  SecretsNotConfiguredError,
23
26
  type SecretRef,
27
+ type SecretsConfig,
24
28
  type SecretsProviderName,
25
29
  } from "../secrets/index.js";
26
- import { product } from "../product.js";
30
+ import {
31
+ readDenConfig,
32
+ readUserConfig,
33
+ writeUserConfig,
34
+ } from "../den-config.js";
35
+ import { envKey, product } from "../product.js";
27
36
 
28
37
  /** Name of the sudo ref under `secrets.refs`. */
29
38
  export const SUDO_REF_NAME = "sudo";
30
39
 
31
- /** The configured sudo ref, or null when `secrets.refs.sudo` is not set. */
40
+ /**
41
+ * The configured sudo ref, or null when `secrets.refs.sudo` is not set. The
42
+ * distribution's own ref wins over one in user.yml (the merged config lets
43
+ * user.yml override refs in general; not this one).
44
+ */
32
45
  export function sudoRef(): SecretRef | null {
33
46
  try {
34
- return namedSecretRef(SUDO_REF_NAME, readSecretsConfig());
47
+ const cfg = readSecretsConfig();
48
+ let team: SecretsConfig["refs"];
49
+ try {
50
+ team = readDenConfig().secrets?.refs;
51
+ } catch {
52
+ team = undefined;
53
+ }
54
+ const fromTeam = team?.[SUDO_REF_NAME]
55
+ ? namedSecretRef(SUDO_REF_NAME, {
56
+ ...cfg,
57
+ refs: { [SUDO_REF_NAME]: team[SUDO_REF_NAME] },
58
+ })
59
+ : null;
60
+ return fromTeam ?? namedSecretRef(SUDO_REF_NAME, cfg);
35
61
  } catch {
36
62
  return null;
37
63
  }
38
64
  }
39
65
 
66
+ /**
67
+ * LK-4: where a password saved with no `secrets.refs.sudo` goes, for a
68
+ * backend that keeps it: a new 1Password item in the person's default
69
+ * vault, or a Doppler key. Null for `env` (process memory only: nothing
70
+ * survives a restart) and `none`.
71
+ */
72
+ export function newSudoRef(
73
+ backend: SecretsProviderName,
74
+ ): Partial<SecretRef> | null {
75
+ switch (backend) {
76
+ case "1password":
77
+ return { item: `${product().title} admin password`, field: "password" };
78
+ case "doppler":
79
+ return { key: envKey("LOCAL_ADMIN_PWD") };
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
40
85
  /** True when the sudo ref is configured AND its backend is ready. */
41
86
  export async function isSecretsBackendReady(): Promise<boolean> {
42
87
  if (!sudoRef()) return false;
@@ -58,12 +103,64 @@ export async function hasSudoInVault(): Promise<boolean> {
58
103
  return (await readSudoFromVault()) !== null;
59
104
  }
60
105
 
61
- /** Save (or overwrite) the sudo password. Throws on backend errors. */
62
- export async function saveSudoToVault(password: string): Promise<void> {
106
+ /**
107
+ * A backend error, without the command line it ran: a CLI's failure message
108
+ * can quote its arguments, and a password can be one of them.
109
+ */
110
+ function withoutSecret(err: unknown, password: string): Error {
111
+ const msg = String((err as Error)?.message ?? err)
112
+ .split("\n")
113
+ .filter((l) => !/^Command failed:/.test(l))
114
+ .join(" ")
115
+ .split(password)
116
+ .join("***")
117
+ .trim();
118
+ return new Error(msg || "the secrets backend refused it");
119
+ }
120
+
121
+ /**
122
+ * Save (or overwrite) the sudo password. With no `secrets.refs.sudo` and a
123
+ * backend that keeps secrets (LK-4), it creates the item and then writes
124
+ * the ref to user.yml (`ref: "created"`); a failed save writes nothing.
125
+ * Throws on backend errors, never with the password in the message.
126
+ */
127
+ export async function saveSudoToVault(
128
+ password: string,
129
+ ): Promise<{ ref: "configured" | "created" }> {
63
130
  const ref = sudoRef();
64
- if (!ref)
131
+ if (ref) {
132
+ try {
133
+ await getSecretsProvider().set(ref, password);
134
+ } catch (err) {
135
+ throw withoutSecret(err, password);
136
+ }
137
+ return { ref: "configured" };
138
+ }
139
+ const st = secretsStatus(readSecretsConfig(), process.env);
140
+ // Only a backend someone chose (an unset provider is never used here).
141
+ const fresh =
142
+ !st.implicit && st.detect.available ? newSudoRef(st.backend) : null;
143
+ const made = fresh
144
+ ? namedSecretRef(
145
+ SUDO_REF_NAME,
146
+ { refs: { [SUDO_REF_NAME]: fresh } },
147
+ st.backend,
148
+ )
149
+ : null;
150
+ if (!fresh || !made)
65
151
  throw new SecretsNotConfiguredError(notConfiguredMessage(SUDO_REF_NAME));
66
- await getSecretsProvider().set(ref, password);
152
+ try {
153
+ await getSecretsProvider().set(made, password);
154
+ } catch (err) {
155
+ throw withoutSecret(err, password);
156
+ }
157
+ const user = readUserConfig();
158
+ const secrets = user.secrets ?? {};
159
+ writeUserConfig({
160
+ ...user,
161
+ secrets: { ...secrets, refs: { ...secrets.refs, [SUDO_REF_NAME]: fresh } },
162
+ });
163
+ return { ref: "created" };
67
164
  }
68
165
 
69
166
  /** What the UI's unlock form may offer: `/api/sudo-password/vault-status`. */
@@ -74,6 +171,12 @@ export interface SudoVaultStatus {
74
171
  label: string;
75
172
  /** `secrets.refs.sudo` is set for this backend. */
76
173
  configured: boolean;
174
+ /**
175
+ * Saving works without editing YAML: the ref is configured, or (LK-4) it
176
+ * isn't and the backend keeps secrets, so saving creates the item and
177
+ * writes the ref to user.yml.
178
+ */
179
+ canSave: boolean;
77
180
  /**
78
181
  * The backend can work here, whether or not a sudo ref is configured
79
182
  * (`configured` says that). With no ref: detect() only (a PATH check,
@@ -106,6 +209,7 @@ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
106
209
  backend: st.backend,
107
210
  label: st.label,
108
211
  configured: !!ref,
212
+ canSave: backendAvailable && (!!ref || !!newSudoRef(st.backend)),
109
213
  backendAvailable,
110
214
  ...(reason ? { reason } : {}),
111
215
  configFile: product().configFile,
@@ -125,6 +229,22 @@ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
125
229
  return out(true, await hasSudoInVault());
126
230
  }
127
231
 
232
+ /**
233
+ * Where a saved password would be kept, for Settings' "saved in <label>"
234
+ * (LK-0): read from the config alone, nothing spawned (a page view never
235
+ * wakes the vault).
236
+ */
237
+ export function sudoVaultWhere(): { configured: boolean; label: string } {
238
+ try {
239
+ const st = secretsStatus(readSecretsConfig(), process.env, {
240
+ detect: false,
241
+ });
242
+ return { configured: !!sudoRef(), label: st.label };
243
+ } catch {
244
+ return { configured: false, label: "" };
245
+ }
246
+ }
247
+
128
248
  /** Name of the active backend, for UI copy ("Save to Doppler"). */
129
249
  export function secretsBackendLabel(): string {
130
250
  return secretsStatus().label;