@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `stack ls | up <name…> [--build] | down <name…> | restart <name…> | health <name…> | ps <name> | logs <name> [args]`
2
+ * `stack ls | up <name…> [--build] | down <name…> [--force] | restart <name…> | health <name…> | ps <name> | logs <name> [args]`
3
3
  *
4
4
  * Runs repos' Docker Compose projects with no host ports, each as its own
5
5
  * project (`<stack>-<workspace>`), served by the edge at
@@ -20,7 +20,19 @@ import {
20
20
  stackRestartService,
21
21
  stackUp,
22
22
  } from "../../shared/stacks/runtime.js";
23
- import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
23
+ import {
24
+ loadStackContext,
25
+ rejoinRunningStacks,
26
+ stackStatuses,
27
+ } from "../../shared/stacks/status.js";
28
+ import {
29
+ assertStackClaim,
30
+ forgetStackOwners,
31
+ recordStackOwner,
32
+ releaseStackOwner,
33
+ stackRefcounted,
34
+ } from "../../shared/stacks/owners.js";
35
+ import { resolveStackNamespace } from "../../shared/stacks/namespace.js";
24
36
  import type { StackConfig } from "../../shared/stacks/types.js";
25
37
  import { STACK_HEALTH_UNKNOWN_EXIT } from "../../shared/constants.js";
26
38
 
@@ -104,6 +116,19 @@ export async function cmdStack(argv: string[]): Promise<void> {
104
116
  const ctx = loadStackContext();
105
117
  const { edge, stacks } = ctx;
106
118
  const names = args.filter((a) => !a.startsWith("-"));
119
+ // workspaces.yml overriding a different blueprint stack_namespace.
120
+ if (ctx.denWorkspace && ctx.namespaceSource === "workspace")
121
+ for (const w of resolveStackNamespace(ctx.denWorkspace).warnings)
122
+ console.error(chalk.yellow(`⚠ ${w}`));
123
+ /** Each stack's other live owners; throws on a checkout mismatch. */
124
+ const claims = (targets: string[]): Map<string, string[]> => {
125
+ const out = new Map<string, string[]>();
126
+ if (!stackRefcounted(ctx)) return out;
127
+ for (const n of targets)
128
+ if (stacks[n])
129
+ out.set(n, assertStackClaim(ctx, stacks[n], ctx.denWorkspace));
130
+ return out;
131
+ };
107
132
  const pick = () => {
108
133
  if (!names.length)
109
134
  throw new Error(
@@ -131,13 +156,25 @@ export async function cmdStack(argv: string[]): Promise<void> {
131
156
  // Re-render the edge so its cert covers every stack, then start the stacks.
132
157
  const order = startOrder(pick(), stacks);
133
158
  const secrets = await resolveStacksSecretEnv(order.map((n) => stacks[n]));
159
+ // A shared namespace: refused before anything starts when another
160
+ // workspace runs one of these projects from a different checkout.
161
+ const shared = claims(order);
134
162
  await edgeUp(edge);
163
+ rejoinRunningStacks();
135
164
  for (const n of order) {
136
165
  console.log(chalk.cyan(`\n▶ ${n} (${projectName(n, ctx.workspace)})`));
166
+ if (shared.get(n)?.length)
167
+ console.log(
168
+ chalk.gray(
169
+ ` shared with [${shared.get(n)!.join(", ")}] (stack namespace ${ctx.workspace})`,
170
+ ),
171
+ );
137
172
  stackUp(ctx, stacks[n], {
138
173
  build: args.includes("--build"),
139
174
  secretEnv: secrets.get(n),
140
175
  });
176
+ if (stackRefcounted(ctx))
177
+ recordStackOwner(ctx, stacks[n], ctx.denWorkspace);
141
178
  for (const route of Object.keys(stacks[n].routes))
142
179
  console.log(chalk.green(` ✓ ${routeUrl(`${route}.${n}`, edge)}`));
143
180
  }
@@ -158,14 +195,24 @@ export async function cmdStack(argv: string[]): Promise<void> {
158
195
  }
159
196
  return;
160
197
  }
198
+ const shared = claims(pick());
161
199
  await edgeUp(edge);
200
+ rejoinRunningStacks();
162
201
  for (const n of pick()) {
163
202
  console.log(chalk.cyan(`↻ ${n}`));
203
+ if (shared.get(n)?.length)
204
+ console.log(
205
+ chalk.yellow(
206
+ ` Warning: restarting a shared stack — this interrupts [${shared.get(n)!.join(", ")}]`,
207
+ ),
208
+ );
164
209
  stackDown(ctx, stacks[n]);
165
210
  stackUp(ctx, stacks[n], {
166
211
  build: args.includes("--build"),
167
212
  secretEnv: secrets.get(n),
168
213
  });
214
+ if (stackRefcounted(ctx))
215
+ recordStackOwner(ctx, stacks[n], ctx.denWorkspace);
169
216
  }
170
217
  return;
171
218
  }
@@ -173,8 +220,22 @@ export async function cmdStack(argv: string[]): Promise<void> {
173
220
  for (const n of startOrder(pick(), stacks)
174
221
  .reverse()
175
222
  .filter((n) => names.includes(n))) {
223
+ // A shared namespace: the last live owner stops it; any other
224
+ // detaches. `--force` stops it whoever else holds it.
225
+ if (stackRefcounted(ctx) && !args.includes("--force")) {
226
+ const r = releaseStackOwner(ctx, stacks[n], ctx.denWorkspace);
227
+ if (!r.stop) {
228
+ console.log(
229
+ chalk.cyan(
230
+ `▼ ${n}: detached workspace "${ctx.denWorkspace}" — ${projectName(n, ctx.workspace)} still running for [${r.remaining.join(", ")}]`,
231
+ ),
232
+ );
233
+ continue;
234
+ }
235
+ }
176
236
  console.log(chalk.cyan(`▼ ${n}`));
177
237
  stackDown(ctx, stacks[n]);
238
+ forgetStackOwners(ctx, stacks[n]);
178
239
  }
179
240
  return;
180
241
  case "ps":
@@ -24,6 +24,12 @@ import {
24
24
  tsxCommand,
25
25
  type EngineCommand,
26
26
  } from "../../shared/engine.js";
27
+ import { product } from "../../shared/product.js";
28
+
29
+ /** Remediation when test sessions cannot be created (no vendor assumed). */
30
+ const SETUP_SECRETS_HINT = ` make ${product().cli}-setup-secrets - Set up the selected secrets.provider`;
31
+ const CI_CREDENTIALS_HINT =
32
+ " CI: E2E_<ROLE>_EMAIL / E2E_<ROLE>_PASSWORD env vars";
27
33
 
28
34
  /** After Playwright smoke runs: fail if storage fixture handoff was left behind. */
29
35
  function smokePostRunTeardownVerifyStep(env: NodeJS.ProcessEnv): CommandStep {
@@ -913,13 +919,11 @@ export class TestRunner {
913
919
  if (shouldSetup) {
914
920
  console.log(
915
921
  chalk.cyan(
916
- "\n📝 Sessions are created automatically — ensure 1Password is signed in:\n",
922
+ "\n📝 Sessions are created automatically — ensure the secrets backend is ready:\n",
917
923
  ),
918
924
  );
919
- console.log(chalk.cyan(" eval $(op signin) - Sign in to 1Password"));
920
- console.log(
921
- chalk.cyan(" make auth-configure - One-time admin setup\n"),
922
- );
925
+ console.log(chalk.cyan(SETUP_SECRETS_HINT));
926
+ console.log(chalk.cyan(`${CI_CREDENTIALS_HINT}\n`));
923
927
  return false;
924
928
  } else {
925
929
  console.log(chalk.red("Cannot run tests without authentication\n"));
@@ -934,10 +938,8 @@ export class TestRunner {
934
938
  "\n⚠️ Authentication failed — sessions are created fresh each run\n",
935
939
  ),
936
940
  );
937
- console.log(chalk.cyan(" eval $(op signin) - Sign in to 1Password"));
938
- console.log(
939
- chalk.cyan(" make auth-configure - One-time admin setup\n"),
940
- );
941
+ console.log(chalk.cyan(SETUP_SECRETS_HINT));
942
+ console.log(chalk.cyan(`${CI_CREDENTIALS_HINT}\n`));
941
943
  return false;
942
944
  }
943
945
 
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `theme validate | add | list | remove | new`
3
+ *
4
+ * Sugar over `plugin *` (PL-2a): the same commands, filtered to kind theme,
5
+ * with the same output and exit codes. Only `new` is theme-specific.
6
+ *
7
+ * A theme is a den-theme.json (den.theme/v1) with its files, in any git repo.
8
+ * `add` fetches one at an exact ref, validates it, and vendors it into the
9
+ * distribution (`plugins/<id>/`) with a lock entry (`plugins.lock.json`: kinds,
10
+ * source, ref, commit, content hash), so it is pinned the way Den is, works offline,
11
+ * and the whole team gets the same files from the distribution's own repo.
12
+ * Den's default theme ships inside Den (themes/den in the package).
13
+ */
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import chalk from "chalk";
17
+ import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
18
+ import { product } from "../../shared/product.js";
19
+ import { readBrand } from "../../shared/brand.js";
20
+ import { THEME_FILE } from "../../shared/theme/contract.js";
21
+ import { PLUGIN_ID, PLUGIN_ID_RULE } from "../../shared/plugins/manifest.js";
22
+ import {
23
+ PLUGINS_DIR,
24
+ hashFiles,
25
+ parsePluginSource,
26
+ pluginDirFor,
27
+ type LockEntry,
28
+ type PluginSource,
29
+ } from "../../shared/plugins/store.js";
30
+ import { runPluginCommand, type PluginDeps } from "./plugin-command.js";
31
+
32
+ // The plugin store is where themes live (PL-2a); these names stay for
33
+ // theme-runtime and older callers.
34
+ export {
35
+ LOCK_FILE,
36
+ PLUGINS_DIR,
37
+ installedThemeIds,
38
+ readLock,
39
+ refKind,
40
+ type GitRunner,
41
+ type LockEntry,
42
+ } from "../../shared/plugins/store.js";
43
+ export { validationSummary } from "./plugin-command.js";
44
+
45
+ export type ThemeSource = PluginSource;
46
+ export const parseThemeSource = parsePluginSource;
47
+ export const hashTheme = hashFiles;
48
+ export const themeDirFor = pluginDirFor;
49
+
50
+ /** The lock's theme entries (the lock is shared with other plugin kinds). */
51
+ export function themesIn(lock: Record<string, LockEntry>): LockEntry[] {
52
+ return Object.values(lock).filter((e) => e.kinds?.includes("theme"));
53
+ }
54
+
55
+ export type ThemeDeps = PluginDeps;
56
+
57
+ /**
58
+ * Run `theme <sub>`; returns the exit code. add, list, validate and remove
59
+ * are `plugin <sub>` filtered to kind theme; new is the theme scaffold.
60
+ */
61
+ export async function cmdTheme(
62
+ args: string[],
63
+ deps: ThemeDeps = {},
64
+ ): Promise<number> {
65
+ const log = deps.log ?? ((l: string) => console.log(l));
66
+ const distRoot = deps.distRoot ?? getDistRoot();
67
+ const [sub, ...rest] = args;
68
+ const cli = product().cli;
69
+
70
+ switch (sub) {
71
+ case "validate":
72
+ case "add":
73
+ case "list":
74
+ case "remove":
75
+ case undefined:
76
+ return runPluginCommand(args, "theme", deps);
77
+
78
+ case "new": {
79
+ // A local theme to edit: a copy of the built-in one, in plugins/<id>/,
80
+ // committed with the distribution so the whole team gets it.
81
+ const id = rest[0];
82
+ if (!id || !PLUGIN_ID.test(id) || id === "den") {
83
+ log(
84
+ chalk.red(
85
+ `Usage: ${cli} theme new <id> (${PLUGIN_ID_RULE}; not "den")`,
86
+ ),
87
+ );
88
+ return 1;
89
+ }
90
+ const dest = themeDirFor(distRoot, id);
91
+ if (fs.existsSync(dest)) {
92
+ log(
93
+ chalk.red(`✗ ${PLUGINS_DIR}/${id}/ already exists; nothing changed`),
94
+ );
95
+ return 1;
96
+ }
97
+ const from = path.join(
98
+ deps.engineRoot ?? getEngineRoot(),
99
+ "themes",
100
+ "den",
101
+ );
102
+ // Not Den's tab icon: a new theme's tab shows its own mark.
103
+ const denIcon = path.join(from, "favicon.ico");
104
+ fs.cpSync(from, dest, {
105
+ recursive: true,
106
+ filter: (src) => src !== denIcon,
107
+ });
108
+ const file = path.join(dest, THEME_FILE);
109
+ const m = JSON.parse(fs.readFileSync(file, "utf-8")) as Record<
110
+ string,
111
+ unknown
112
+ >;
113
+ delete m.favicon;
114
+ const name = id
115
+ .split("-")
116
+ .map((w) => w[0].toUpperCase() + w.slice(1))
117
+ .join(" ");
118
+ Object.assign(m, { id, name, version: "0.1.0" });
119
+ delete m.homepage;
120
+ // The author is the theme's maintainer (plugin rule 1): the team.
121
+ m.author = readBrand().name;
122
+ fs.writeFileSync(file, `${JSON.stringify(m, null, 2)}\n`);
123
+ log(
124
+ ` ${id} → ${PLUGINS_DIR}/${id}/ (a copy of den: edit ${THEME_FILE} and mark.svg)`,
125
+ );
126
+ log(` ${cli} theme validate checks it; commit it and your team has it.`);
127
+ return 0;
128
+ }
129
+
130
+ default:
131
+ log(
132
+ `Usage: ${cli} theme [list] | validate [dir] | new <id> | add <repo>[/<subpath>]@<ref> | remove <id>`,
133
+ );
134
+ return 1;
135
+ }
136
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `up -w <ws>` with no repo, no `-a` and no service repo under the cwd
3
+ * (0.2.8-dev.1). `-w` only picks the workspace; it does not say what in it
4
+ * to start, so the plan used to come back "No services specified and no
5
+ * invocation target resolvable". It is a usage error naming the two forms
6
+ * that do: a repo, or `-a` for the whole workspace.
7
+ */
8
+ export function upNeedsTarget(cli: string, ws: string, plan: boolean): string {
9
+ const name = /^[A-Za-z0-9._-]{1,64}$/.test(ws) ? ws : "<workspace>";
10
+ const p = plan ? " --plan" : "";
11
+ return (
12
+ `up -w ${name} needs what to start: \`${cli} up -a${p} -w ${name}\` ` +
13
+ `for the whole workspace, or \`${cli} up <repo>${p} -w ${name}\` for one repo and its dependencies.`
14
+ );
15
+ }
@@ -4,11 +4,12 @@ import * as fs from "fs";
4
4
  import * as http from "http";
5
5
  import * as path from "path";
6
6
  import { pathToFileURL } from "url";
7
+ import { CLI_SPAWN_TIMEOUT_MS, DEFAULT_PORT } from "../../shared/constants.js";
7
8
  import {
8
- CLI_SPAWN_TIMEOUT_MS,
9
- DEFAULT_PORT,
10
- OP_ACCOUNT_DEFAULT,
11
- } from "../../shared/constants.js";
9
+ readSecretsConfig,
10
+ secretsProcessEnv,
11
+ type SecretsConfig,
12
+ } from "../../shared/secrets/index.js";
12
13
  import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
13
14
  import { hasDenUiMarker } from "../../shared/ui-marker.js";
14
15
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
@@ -269,26 +270,43 @@ async function getHttpText(
269
270
  });
270
271
  }
271
272
 
273
+ /**
274
+ * The API is up: /api/server-info answers whatever the config holds
275
+ * (0.3.0-dev.42: /api/health answers 422 under an invalid config, so
276
+ * `web up` reported a running server as not ready). A server too old for
277
+ * server-info is asked /api/health, as before.
278
+ */
272
279
  async function isDenWebApiRunning(port: number): Promise<boolean> {
273
- const response = await getHttpText(port, "/api/health");
274
- if (!response || response.statusCode !== 200) return false;
275
- try {
276
- return Array.isArray(JSON.parse(response.body));
277
- } catch {
278
- return false;
279
- }
280
+ const json = async (p: string): Promise<unknown> => {
281
+ const response = await getHttpText(port, p);
282
+ if (!response || response.statusCode !== 200) return undefined;
283
+ try {
284
+ return JSON.parse(response.body) as unknown;
285
+ } catch {
286
+ return undefined;
287
+ }
288
+ };
289
+ const info = await json("/api/server-info");
290
+ if (
291
+ info &&
292
+ typeof info === "object" &&
293
+ typeof (info as { pid?: unknown }).pid === "number"
294
+ )
295
+ return true;
296
+ return Array.isArray(await json("/api/health"));
280
297
  }
281
298
 
282
299
  async function isDenWebUiReady(port: number): Promise<boolean> {
283
300
  const response = await getHttpText(port, "/index.html");
284
301
  return (
285
- response?.statusCode === 200 &&
302
+ // 422: the "Config is invalid" page, the shell serving as it should.
303
+ (response?.statusCode === 200 || response?.statusCode === 422) &&
286
304
  /<html[\s>]/i.test(response.body) &&
287
305
  hasDenUiMarker(response.body)
288
306
  );
289
307
  }
290
308
 
291
- async function isDenWebRunning(port: number): Promise<boolean> {
309
+ export async function isDenWebRunning(port: number): Promise<boolean> {
292
310
  return (await isDenWebApiRunning(port)) && (await isDenWebUiReady(port));
293
311
  }
294
312
 
@@ -350,6 +368,20 @@ export function resolveServerEntrypoint(engine: string = getEngineRoot()): {
350
368
  };
351
369
  }
352
370
 
371
+ /**
372
+ * The env the web server daemon starts with: the caller's, its port, and
373
+ * whatever the selected secrets strategy gives (only 1password gives one,
374
+ * OP_ACCOUNT, from the distribution's `secrets.onepassword.account` or the
375
+ * caller's own OP_ACCOUNT). Den adds no account of its own.
376
+ */
377
+ export function webServerEnv(
378
+ port: number,
379
+ base: NodeJS.ProcessEnv = process.env,
380
+ cfg: SecretsConfig = readSecretsConfig(),
381
+ ): NodeJS.ProcessEnv {
382
+ return { ...base, PORT: String(port), ...secretsProcessEnv(cfg, base) };
383
+ }
384
+
353
385
  function startDenWebServer(options: WebCommandOptions): StartedServer {
354
386
  const root = getDistRoot();
355
387
  const entrypoint = resolveServerEntrypoint();
@@ -369,11 +401,7 @@ function startDenWebServer(options: WebCommandOptions): StartedServer {
369
401
  {
370
402
  cwd: root,
371
403
  detached: true,
372
- env: {
373
- ...process.env,
374
- PORT: String(options.port),
375
- OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
376
- },
404
+ env: webServerEnv(options.port),
377
405
  stdio: ["ignore", logFd, logFd],
378
406
  },
379
407
  );
@@ -435,11 +463,7 @@ export function spawnDetachedWebRestart(port: number): number | undefined {
435
463
  {
436
464
  cwd: root,
437
465
  detached: true,
438
- env: {
439
- ...process.env,
440
- PORT: String(port),
441
- OP_ACCOUNT: process.env.OP_ACCOUNT ?? OP_ACCOUNT_DEFAULT,
442
- },
466
+ env: webServerEnv(port),
443
467
  stdio: ["ignore", logFd, logFd],
444
468
  },
445
469
  );
package/src/ui/mcp/api.ts CHANGED
@@ -85,6 +85,28 @@ export class DenHttpError extends Error {
85
85
  }
86
86
  }
87
87
 
88
+ /**
89
+ * An error body as a line: the server's R26b JSON ({ ok: false, error,
90
+ * code, requestId }) as its message and request id, anything else as is.
91
+ */
92
+ export function errorText(body: string): string {
93
+ try {
94
+ const j = JSON.parse(body) as {
95
+ error?: unknown;
96
+ code?: unknown;
97
+ requestId?: unknown;
98
+ };
99
+ if (j && typeof j.error === "string")
100
+ return (
101
+ j.error +
102
+ (typeof j.requestId === "string" ? ` (request ${j.requestId})` : "")
103
+ );
104
+ } catch {
105
+ /* not JSON */
106
+ }
107
+ return body;
108
+ }
109
+
88
110
  /** Read routes: GET only. */
89
111
  const READ_ROUTES = new Set([
90
112
  "/api/health",
@@ -268,8 +290,12 @@ export function createDenApi(opts: {
268
290
  signal,
269
291
  });
270
292
  if (!res.ok) {
271
- const text = (await res.text()).slice(0, 2000);
272
- throw new DenHttpError(method, path, res.status, text);
293
+ throw new DenHttpError(
294
+ method,
295
+ path,
296
+ res.status,
297
+ errorText((await res.text()).slice(0, 2000)),
298
+ );
273
299
  }
274
300
  return res;
275
301
  } catch (e) {