@monster-orange/den 0.2.8 → 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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  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/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. 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
+ }
@@ -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) {
@@ -103,6 +103,27 @@ function printable(v: string): string {
103
103
  return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
104
104
  }
105
105
 
106
+ /**
107
+ * A path echoed back in an error: one printable line, never cut mid-name
108
+ * (0.3.0-dev.40, a consumer's 0.2.8 nit: printable's 80-character cap cut
109
+ * the filename). Up to `max` characters it is whole; a longer one keeps its
110
+ * start and its last segment whole, eliding the middle with "…".
111
+ */
112
+ export function printablePath(v: string, max = 160): string {
113
+ const s = v.replace(/[^\x20-\x7e]/g, "?");
114
+ if (s.length <= max) return s;
115
+ const cut = s.lastIndexOf("/");
116
+ const tail = cut >= 0 ? s.slice(cut) : s;
117
+ // The last segment is kept whole whatever its length; the room left is
118
+ // shared between the path's start and the directories just before it.
119
+ const room = max - tail.length - 1;
120
+ if (room <= 0) return `…${tail}`;
121
+ const head = s.slice(0, Math.ceil(room / 2));
122
+ const end = s.length - tail.length;
123
+ const more = s.slice(end - (room - head.length), end);
124
+ return `${head}…${more}${tail}`;
125
+ }
126
+
106
127
  /** The refusal for a call that names `other`. */
107
128
  export function otherWorkspace(
108
129
  tool: string,
@@ -272,6 +293,6 @@ export async function assertPathInScope(
272
293
  .map((r) => path.resolve(r));
273
294
  if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
274
295
  throw new ScopeRefusal(
275
- `${tool}: ${printable(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
296
+ `${tool}: ${printablePath(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
276
297
  );
277
298
  }
@@ -601,13 +601,17 @@ export function versionBefore(a: string, b: string): boolean {
601
601
  return false;
602
602
  }
603
603
 
604
- /** The running web server's version, or undefined when it reports none. */
605
- async function webServerVersion(api: DenApi): Promise<string | undefined> {
604
+ /** The running web server's /api/server-info, or null when it has none. */
605
+ async function readServerInfo(
606
+ api: DenApi,
607
+ ): Promise<Record<string, unknown> | null> {
606
608
  try {
607
- const info = (await api.get("/api/server-info")) as { version?: unknown };
608
- return typeof info?.version === "string" ? info.version : undefined;
609
+ const info = (await api.get("/api/server-info")) as unknown;
610
+ return info && typeof info === "object"
611
+ ? (info as Record<string, unknown>)
612
+ : null;
609
613
  } catch (e) {
610
- if (e instanceof DenHttpError) return undefined;
614
+ if (e instanceof DenHttpError) return null;
611
615
  throw e;
612
616
  }
613
617
  }
@@ -621,11 +625,13 @@ async function notFoundMessage(
621
625
  e: DenHttpError,
622
626
  tool: string,
623
627
  ctx: ToolContext,
628
+ info: () => Promise<Record<string, unknown> | null>,
624
629
  ): Promise<string> {
625
630
  const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
626
631
  const since = ROUTE_SINCE[e.path];
627
632
  if (!since) return plain;
628
- const version = await webServerVersion(ctx.api);
633
+ const v = (await info())?.version;
634
+ const version = typeof v === "string" ? v : undefined;
629
635
  if (version !== undefined && !versionBefore(version, since)) return plain;
630
636
  return (
631
637
  `web server is Den ${version ?? `< ${since}`}; ` +
@@ -664,6 +670,30 @@ async function reportRefusal(
664
670
  }
665
671
  }
666
672
 
673
+ /**
674
+ * MD-2: when the web server runs an older engine than the one on disk (a
675
+ * pin upgraded Den under it), every failed call says so, naming both
676
+ * versions and the restart. Null when the engines agree or it can't tell.
677
+ */
678
+ function staleEngineMessage(
679
+ tool: string,
680
+ ctx: ToolContext,
681
+ info: Record<string, unknown> | null,
682
+ ): string | null {
683
+ if (info?.engineStale !== true) return null;
684
+ const ver = (x: unknown) =>
685
+ x &&
686
+ typeof x === "object" &&
687
+ typeof (x as { version?: unknown }).version === "string"
688
+ ? (x as { version: string }).version
689
+ : "unknown";
690
+ return (
691
+ `Error: ${tool}: Den was updated to ${ver(info.onDisk)}, but the web server ` +
692
+ `is still running ${ver(info.loaded)}. Restart it: ${ctx.cli} web restart ` +
693
+ `(or your service restart).`
694
+ );
695
+ }
696
+
667
697
  /**
668
698
  * A scoped server's arguments: the workspace exists (checked once per
669
699
  * server), and a tool that takes a workspace gets the scoped one; naming
@@ -709,8 +739,21 @@ export async function callTool(
709
739
  text: redactSecrets(JSON.stringify(e.out, null, 2)),
710
740
  isError: true,
711
741
  };
742
+ // server-info is read lazily, once, and only after a call failed.
743
+ let infoRead: Promise<Record<string, unknown> | null> | null = null;
744
+ const info = () => (infoRead ??= readServerInfo(ctx.api).catch(() => null));
745
+ if (e instanceof DenHttpError) {
746
+ const stale = staleEngineMessage(name, ctx, await info());
747
+ if (stale)
748
+ return {
749
+ text: redactSecrets(
750
+ `${stale} (${e.method} ${e.path} answered ${e.status}: ${e.body})`,
751
+ ),
752
+ isError: true,
753
+ };
754
+ }
712
755
  if (e instanceof DenHttpError && e.status === 404) {
713
- const text = await notFoundMessage(e, name, ctx).catch(
756
+ const text = await notFoundMessage(e, name, ctx, info).catch(
714
757
  (inner: Error) => `Error: ${inner.message}`,
715
758
  );
716
759
  return { text: redactSecrets(text), isError: true };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Appearance preferences: the theme and mode this engineer chose. Stored in
3
+ * the user config as `appearance:` and served by /api/settings, like the
4
+ * graph prefs. A pure UI preference: changing it writes this block of
5
+ * config/local/user.yml and nothing else (no edge, services or restart).
6
+ */
7
+
8
+ export type ModePref = "system" | "dark" | "light";
9
+
10
+ export interface AppearancePrefs {
11
+ /**
12
+ * A theme id, or null to follow the org default (the org config's
13
+ * `brand.theme`, else den). A named theme is pinned even when it is the
14
+ * org default today: you keep it when the org changes `brand.theme`.
15
+ */
16
+ theme: string | null;
17
+ /** system follows prefers-color-scheme. */
18
+ mode: ModePref;
19
+ }
20
+
21
+ export const APPEARANCE_DEFAULTS: AppearancePrefs = {
22
+ theme: null,
23
+ mode: "system",
24
+ };
25
+
26
+ export const MODES: readonly ModePref[] = ["system", "dark", "light"];
27
+ const THEME_ID = /^[a-z][a-z0-9-]{1,39}$/;
28
+
29
+ const isObject = (v: unknown): v is Record<string, unknown> =>
30
+ typeof v === "object" && v !== null && !Array.isArray(v);
31
+
32
+ /**
33
+ * The stored block, normalised. A stored theme id is kept even when that
34
+ * theme is not installed now (resolution warns and falls back), so a theme
35
+ * that is briefly missing does not lose the preference.
36
+ */
37
+ export function appearancePrefs(stored: unknown): AppearancePrefs {
38
+ const out: AppearancePrefs = { ...APPEARANCE_DEFAULTS };
39
+ if (!isObject(stored)) return out;
40
+ if (typeof stored.theme === "string" && THEME_ID.test(stored.theme))
41
+ out.theme = stored.theme;
42
+ if (MODES.includes(stored.mode as ModePref))
43
+ out.mode = stored.mode as ModePref;
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Merge a POSTed (partial) block. `theme` must name an installed theme that
49
+ * validates; null or "" clears it ("Org default": follow the org); any bad
50
+ * field fails the whole patch.
51
+ *
52
+ * A theme picked by name is stored by name, even one that is the org
53
+ * default now (0.3.0-dev.11): that is a pin, and the menu shows it as one.
54
+ * Only "Org default" follows the org when it changes `brand.theme`.
55
+ */
56
+ export function mergeAppearancePrefs(
57
+ current: AppearancePrefs,
58
+ patch: unknown,
59
+ isUsableTheme: (id: string) => boolean,
60
+ ): { ok: true; value: AppearancePrefs } | { ok: false; error: string } {
61
+ if (!isObject(patch))
62
+ return { ok: false, error: "appearance must be an object" };
63
+ const out: AppearancePrefs = { ...current };
64
+ for (const [key, v] of Object.entries(patch)) {
65
+ if (key === "mode") {
66
+ // null clears it to the default (0.3.0-dev.32), as theme's does.
67
+ if (v === null) out.mode = APPEARANCE_DEFAULTS.mode;
68
+ else if (!MODES.includes(v as ModePref))
69
+ return {
70
+ ok: false,
71
+ error: `appearance.mode must be one of ${MODES.map((m) => `"${m}"`).join(", ")}`,
72
+ };
73
+ else out.mode = v as ModePref;
74
+ } else if (key === "theme") {
75
+ if (v === null || v === "") out.theme = null;
76
+ else if (typeof v !== "string" || !THEME_ID.test(v) || !isUsableTheme(v))
77
+ return {
78
+ ok: false,
79
+ error: `appearance.theme: no valid installed theme "${String(v)}"`,
80
+ };
81
+ else out.theme = v;
82
+ } else return { ok: false, error: `unknown appearance setting "${key}"` };
83
+ }
84
+ return { ok: true, value: out };
85
+ }
86
+
87
+ /** What user.yml stores: only what differs from the defaults. */
88
+ export function storedAppearance(
89
+ p: AppearancePrefs,
90
+ ): { theme?: string; mode?: ModePref } | undefined {
91
+ const out: { theme?: string; mode?: ModePref } = {};
92
+ if (p.theme) out.theme = p.theme;
93
+ if (p.mode !== APPEARANCE_DEFAULTS.mode) out.mode = p.mode;
94
+ return Object.keys(out).length ? out : undefined;
95
+ }