@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,51 +1,56 @@
1
1
  /**
2
2
  * Secrets-backed persistence for the local-dev sudo password.
3
3
  *
4
- * Goes through the configured secrets strategy (Doppler, 1Password or env —
5
- * see src/shared/secrets), addressing the named ref `sudo` (default
6
- * OP_API_KEYS_VAULT / OP_DEV_KEYS_ITEM / OP_DEN_SUDO_FIELD; an org
7
- * moves it with `secrets.refs.sudo` in its config) — flat key
8
- * DEN_LOCAL_ADMIN_PWD for key/value backends.
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.
9
9
  *
10
10
  * All read helpers fail *soft*: if the backend isn't installed, the user isn't
11
- * signed in, or the value is missing, they return null/false so the server can
12
- * fall back to the manual unlock banner without surfacing CLI noise.
11
+ * signed in, the ref is not configured or the value is missing, they return
12
+ * null/false so the server can fall back to the manual unlock without
13
+ * surfacing CLI noise.
13
14
  */
14
15
 
15
- import {
16
- OP_API_KEYS_VAULT,
17
- OP_DEV_KEYS_ITEM,
18
- OP_DEN_SUDO_FIELD,
19
- } from "../constants.js";
20
16
  import {
21
17
  getSecretsProvider,
22
18
  namedSecretRef,
19
+ notConfiguredMessage,
20
+ readSecretsConfig,
21
+ secretsStatus,
22
+ SecretsNotConfiguredError,
23
23
  type SecretRef,
24
+ type SecretsProviderName,
24
25
  } from "../secrets/index.js";
26
+ import { product } from "../product.js";
25
27
 
26
- /** The built-in default for the `sudo` named ref. */
27
- export const SUDO_SECRET_REF: SecretRef = {
28
- vault: OP_API_KEYS_VAULT,
29
- item: OP_DEV_KEYS_ITEM,
30
- field: OP_DEN_SUDO_FIELD,
31
- };
28
+ /** Name of the sudo ref under `secrets.refs`. */
29
+ export const SUDO_REF_NAME = "sudo";
32
30
 
33
- /** The effective sudo ref (`secrets.refs.sudo` over the default). */
34
- function sudoRef(): SecretRef {
35
- return namedSecretRef("sudo", SUDO_SECRET_REF);
31
+ /** The configured sudo ref, or null when `secrets.refs.sudo` is not set. */
32
+ export function sudoRef(): SecretRef | null {
33
+ try {
34
+ return namedSecretRef(SUDO_REF_NAME, readSecretsConfig());
35
+ } catch {
36
+ return null;
37
+ }
36
38
  }
37
39
 
38
- /** True when the secrets backend is installed AND authenticated. */
40
+ /** True when the sudo ref is configured AND its backend is ready. */
39
41
  export async function isSecretsBackendReady(): Promise<boolean> {
42
+ if (!sudoRef()) return false;
40
43
  return getSecretsProvider().isReady();
41
44
  }
42
45
 
43
46
  /** @deprecated use isSecretsBackendReady */
44
47
  export const isOpSignedIn = isSecretsBackendReady;
45
48
 
46
- /** Read the stored sudo password. Null when unavailable or empty. */
49
+ /** Read the stored sudo password. Null when unavailable, unconfigured or empty. */
47
50
  export async function readSudoFromVault(): Promise<string | null> {
48
- return getSecretsProvider().tryGet(sudoRef());
51
+ const ref = sudoRef();
52
+ if (!ref) return null;
53
+ return getSecretsProvider().tryGet(ref);
49
54
  }
50
55
 
51
56
  /** Whether a non-empty sudo password is stored (without returning it). */
@@ -55,15 +60,72 @@ export async function hasSudoInVault(): Promise<boolean> {
55
60
 
56
61
  /** Save (or overwrite) the sudo password. Throws on backend errors. */
57
62
  export async function saveSudoToVault(password: string): Promise<void> {
58
- await getSecretsProvider().set(sudoRef(), password);
63
+ const ref = sudoRef();
64
+ if (!ref)
65
+ throw new SecretsNotConfiguredError(notConfiguredMessage(SUDO_REF_NAME));
66
+ await getSecretsProvider().set(ref, password);
67
+ }
68
+
69
+ /** What the UI's unlock form may offer: `/api/sudo-password/vault-status`. */
70
+ export interface SudoVaultStatus {
71
+ /** The selected strategy id. */
72
+ backend: SecretsProviderName;
73
+ /** Name people see ("1Password"), for "Save to <label>". */
74
+ label: string;
75
+ /** `secrets.refs.sudo` is set for this backend. */
76
+ configured: boolean;
77
+ /**
78
+ * The backend can work here, whether or not a sudo ref is configured
79
+ * (`configured` says that). With no ref: detect() only (a PATH check,
80
+ * nothing spawned), and only for an explicitly chosen provider; an unset
81
+ * provider is never probed (it is not needed). With a ref: detect() and
82
+ * signed in.
83
+ */
84
+ backendAvailable: boolean;
85
+ /** Why it is not available (not configured, CLI not on PATH, signed out). */
86
+ reason?: string;
87
+ /** The distribution's org config file (e.g. config/acme.yml), for UI copy. */
88
+ configFile: string;
89
+ hasStored: boolean;
90
+ /** @deprecated alias of backendAvailable; removed in a later minor. */
91
+ opAvailable: boolean;
92
+ }
93
+
94
+ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
95
+ const cfg = readSecretsConfig();
96
+ const ref = sudoRef();
97
+ const pre = secretsStatus(cfg, process.env, { detect: false });
98
+ // Probe only what is needed or was chosen: a ref, or an explicit backend.
99
+ const probe = !!ref || (!pre.implicit && pre.backend !== "none");
100
+ const st = probe ? secretsStatus(cfg, process.env) : pre;
101
+ const out = (
102
+ backendAvailable: boolean,
103
+ hasStored: boolean,
104
+ reason?: string,
105
+ ): SudoVaultStatus => ({
106
+ backend: st.backend,
107
+ label: st.label,
108
+ configured: !!ref,
109
+ backendAvailable,
110
+ ...(reason ? { reason } : {}),
111
+ configFile: product().configFile,
112
+ hasStored,
113
+ opAvailable: backendAvailable,
114
+ });
115
+ if (!ref)
116
+ return out(
117
+ probe && st.detect.available,
118
+ false,
119
+ notConfiguredMessage(SUDO_REF_NAME),
120
+ );
121
+ if (!st.detect.available)
122
+ return out(false, false, `not available: ${st.detect.reason ?? ""}`.trim());
123
+ if (!(await getSecretsProvider().isReady()))
124
+ return out(false, false, `${st.label} is not signed in`);
125
+ return out(true, await hasSudoInVault());
59
126
  }
60
127
 
61
128
  /** Name of the active backend, for UI copy ("Save to Doppler"). */
62
129
  export function secretsBackendLabel(): string {
63
- const n = getSecretsProvider().name;
64
- return n === "1password"
65
- ? "1Password"
66
- : n === "doppler"
67
- ? "Doppler"
68
- : "environment";
130
+ return secretsStatus().label;
69
131
  }
@@ -115,9 +115,13 @@ const localJwtProvider: TokenProvider = {
115
115
  const l = cfg.auth?.localJwt;
116
116
  if (!l) throw new Error(`auth.localJwt is not set for ${cfg.name}`);
117
117
  const env = o.env ?? process.env;
118
+ // An explicit key the environment file names: not a default ref, so it
119
+ // resolves as it always has (env first, then the selected backend).
120
+ const fromEnv = env[l.secretKey];
121
+ const backend = fromEnv ? null : getSecretsProvider();
118
122
  const secret =
119
- env[l.secretKey] ??
120
- (await getSecretsProvider().tryGet({
123
+ fromEnv ??
124
+ (await backend!.tryGet({
121
125
  vault: "",
122
126
  item: "",
123
127
  field: l.secretKey,
@@ -125,7 +129,7 @@ const localJwtProvider: TokenProvider = {
125
129
  }));
126
130
  if (!secret) {
127
131
  throw new Error(
128
- `local-jwt signing secret ${l.secretKey} not found in env or the secrets backend`,
132
+ `local-jwt signing secret ${l.secretKey} not found in env or the ${backend?.name ?? "configured"} secrets backend (secrets.provider)`,
129
133
  );
130
134
  }
131
135
  const ttl = l.ttlSeconds ?? 3600;
@@ -8,6 +8,8 @@
8
8
  * name: Acme Dev # shown in titles and the banner
9
9
  * tagline: Acme local dev and E2E harness
10
10
  * banner: [ "...", "..." ] # optional ASCII art lines
11
+ * theme: acme # the web UI's default theme (an id in
12
+ * # plugins/<id>/, or den)
11
13
  */
12
14
  import * as fs from "fs";
13
15
  import * as path from "path";
@@ -19,6 +21,8 @@ export interface BrandConfig {
19
21
  name: string;
20
22
  tagline: string;
21
23
  banner?: string[];
24
+ /** The web UI's default theme id; the user's appearance.theme wins. */
25
+ theme?: string;
22
26
  }
23
27
 
24
28
  export function defaultBrand(): BrandConfig {
@@ -43,6 +47,9 @@ export function readBrand(root: string = getDistRoot()): BrandConfig {
43
47
  name: raw.name?.trim() || fallback.name,
44
48
  tagline: raw.tagline?.trim() || fallback.tagline,
45
49
  banner: Array.isArray(raw.banner) ? raw.banner.map(String) : undefined,
50
+ ...(typeof raw.theme === "string" && raw.theme.trim()
51
+ ? { theme: raw.theme.trim() }
52
+ : {}),
46
53
  };
47
54
  return cached;
48
55
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A distribution config that fails validation (ORG-1d's `orgs:` checks, for
3
+ * now). It is the user's to fix, not a crash: the CLI prints it and exits 1
4
+ * before doing anything, and the web server answers it as
5
+ * `{ ok: false, error, code: "config_invalid" }` with status 422.
6
+ *
7
+ * The message names config keys (`orgs.initech.repos`), never values a
8
+ * secret could hide in.
9
+ */
10
+ /**
11
+ * One validation problem: the config file (as the distribution names it),
12
+ * the key path (`orgs.acme.sources[0].owner`) and what is wrong with it.
13
+ * `text` is the whole line after "<file>: ".
14
+ */
15
+ export interface ConfigIssue {
16
+ file: string;
17
+ key: string;
18
+ message: string;
19
+ text: string;
20
+ }
21
+
22
+ export class ConfigError extends Error {
23
+ readonly code = "config_invalid";
24
+ /** Every problem found (0.3.0-dev.40), in config order. */
25
+ readonly issues: ConfigIssue[];
26
+
27
+ /**
28
+ * The message is one line per issue, "<file>: <text>". A plain string is
29
+ * one issue-less message.
30
+ */
31
+ constructor(issues: ConfigIssue[] | string) {
32
+ const list = typeof issues === "string" ? [] : issues;
33
+ super(
34
+ typeof issues === "string"
35
+ ? issues
36
+ : list.map((i) => `${i.file}: ${i.text}`).join("\n"),
37
+ );
38
+ this.name = "ConfigError";
39
+ this.issues = list;
40
+ }
41
+ }
42
+
43
+ export function isConfigError(e: unknown): e is ConfigError {
44
+ return e instanceof ConfigError;
45
+ }
46
+
47
+ /** The web server's status for a ConfigError: the request was fine, the config is not. */
48
+ export const CONFIG_ERROR_STATUS = 422;
49
+
50
+ /** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
51
+ export function configErrorBody(e: ConfigError): {
52
+ ok: false;
53
+ error: string;
54
+ code: "config_invalid";
55
+ issues: Array<{ file: string; key: string; message: string }>;
56
+ } {
57
+ return {
58
+ ok: false,
59
+ error: e.message,
60
+ code: e.code,
61
+ issues: e.issues.map(({ file, key, message }) => ({ file, key, message })),
62
+ };
63
+ }
@@ -5,17 +5,7 @@
5
5
  * If it's specific to one module (e.g. a Playwright config timeout), keep it local.
6
6
  */
7
7
 
8
- import { envKey } from "./product.js";
9
- // ── 1Password strategy (only used when secrets.provider: 1password) ──────────────────────────────────────────────────────────────
10
-
11
- /**
12
- * Default 1Password account domain used by every `op` invocation across den.
13
- * Kept in sync with the `OP_ACCOUNT ?= …` default in the Makefile. Scripts that
14
- * may run outside `make` (setup-1password, auth-configure)
15
- * set `process.env.OP_ACCOUNT ||= OP_ACCOUNT_DEFAULT` at entry so op picks up
16
- * the right account even when invoked directly via `npx tsx`.
17
- */
18
- export const OP_ACCOUNT_DEFAULT = "example.1password.com";
8
+ // ── Test accounts ─────────────────────────────────────────────────────────────
19
9
 
20
10
  /**
21
11
  * Default vault for shared test accounts. Deprecated: the vault is org data —
@@ -24,19 +14,8 @@ export const OP_ACCOUNT_DEFAULT = "example.1password.com";
24
14
  */
25
15
  export const OP_TEST_VAULT = "test-accounts";
26
16
 
27
- /** Vault containing per-engineer API keys (1Password's built-in personal vault). */
28
- export const OP_API_KEYS_VAULT = "Employee";
29
-
30
- /** Item within OP_API_KEYS_VAULT holding the developer's API keys. */
31
- export const OP_DEV_KEYS_ITEM = "Developer API Keys";
32
-
33
- /**
34
- * Field within OP_DEV_KEYS_ITEM where the user's local admin (sudo) password
35
- * is optionally stored. Populated when the user checks "Save to 1Password" on
36
- * the den local-dev unlock banner; read on server startup so subsequent
37
- * sessions unlock without prompting.
38
- */
39
- export const OP_DEN_SUDO_FIELD = `${envKey("LOCAL_ADMIN_PWD")}`;
17
+ // No 1Password account, vault, item or field is built in: a distribution
18
+ // names its own in `secrets` (src/shared/secrets).
40
19
 
41
20
  // ── Timeouts ─────────────────────────────────────────────────────────────────
42
21
 
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
16
16
  import { getDistRoot } from "./paths.js";
17
17
  import type { SecretsConfig } from "./secrets/index.js";
18
18
  import type { OrgConfig } from "./org.js";
19
+ import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
+ import { ConfigError, isConfigError } from "./config-error.js";
19
21
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
20
22
 
21
23
  import { product } from "./product.js";
@@ -194,11 +196,13 @@ export interface DenConfig {
194
196
  /** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
195
197
  github_org?: string;
196
198
  /**
197
- * Display names (and an optional colour) for the GitHub orgs repos belong
198
- * to, keyed by GitHub owner. Orgs are worked out from the catalog; this
199
- * only names them.
199
+ * Work orgs, keyed by org id: a display name and an optional colour. An
200
+ * entry with neither `sources` nor `repos` is the GitHub owner of its own
201
+ * name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
202
+ * git-host owners, and `repos: [...]` lists repos outright. See
203
+ * src/shared/org-sources.ts.
200
204
  */
201
- orgs?: Record<string, { label?: string; color?: string }>;
205
+ orgs?: WorkOrgsConfig;
202
206
  /** Derived-dependency-graph tuning (aliases + edge overrides). */
203
207
  graph?: GraphConfig;
204
208
  /**
@@ -337,6 +341,33 @@ export interface UserConfig {
337
341
  density?: string;
338
342
  panelWidth?: number;
339
343
  };
344
+ /**
345
+ * Web UI appearance: theme id and mode (system | dark | light), set from
346
+ * the header. See src/ui/server/appearance-prefs.ts.
347
+ */
348
+ appearance?: { theme?: string; mode?: string };
349
+ /**
350
+ * The web UI's language (R19): en (the default, never stored), ja or es.
351
+ * See src/ui/server/ui-language.ts.
352
+ */
353
+ language?: string;
354
+ /**
355
+ * The rail's pins and collapse, and (R21 Navigation) the pages it hides
356
+ * and their order (src/ui/server/shell-prefs.ts).
357
+ */
358
+ shell?: {
359
+ pins?: string[];
360
+ collapsed?: boolean;
361
+ hidden?: string[];
362
+ order?: string[];
363
+ };
364
+ /**
365
+ * What the web UI and `<cli> status` show: `scope` is "all" (every active
366
+ * workspace, the default) or one active workspace's name. A view filter
367
+ * only: the focused workspace stays the default target for commands. See
368
+ * src/shared/environment-manager/scope.ts.
369
+ */
370
+ view?: { scope?: string };
340
371
  }
341
372
 
342
373
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -399,12 +430,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
399
430
  return next;
400
431
  }
401
432
 
433
+ /**
434
+ * A scaffold or docs placeholder rather than a real org: `your-github-org`,
435
+ * `your-org`, `<your-org>`, `<slug>`, `example-org`. Den treats it as unset.
436
+ */
437
+ export function isPlaceholderGithubOrg(org: string): boolean {
438
+ return /^(your[-_]?(github[-_]?)?org|<.*>|example[-_]?org)$/i.test(
439
+ org.trim(),
440
+ );
441
+ }
442
+
443
+ /**
444
+ * The configured `github_org`, or null when it is unset or still a
445
+ * placeholder ("not configured": Den runs no gh for it).
446
+ */
447
+ export function configuredGithubOrg(
448
+ config: DenConfig = readDenConfig(),
449
+ ): string | null {
450
+ const org =
451
+ typeof config.github_org === "string" ? config.github_org.trim() : "";
452
+ return org && !isPlaceholderGithubOrg(org) ? org : null;
453
+ }
454
+
402
455
  /**
403
456
  * The GitHub org managed repos clone from. Den has no default: an org that
404
457
  * needs one sets `github_org` in its config.
405
458
  */
406
459
  export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
407
- const org = config.github_org?.trim();
460
+ const org = configuredGithubOrg(config);
408
461
  if (!org)
409
462
  throw new Error(
410
463
  `github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
@@ -412,6 +465,53 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
412
465
  return org;
413
466
  }
414
467
 
468
+ let checked: { key: string; error: ConfigError | null } | null = null;
469
+
470
+ /**
471
+ * Throw the config's validation error (a ConfigError naming its keys), if it
472
+ * has one, without reading anything else. The CLI runs this before any
473
+ * command that touches orgs, the catalog, clones or releases, and the web
474
+ * server before every config-reading route, so a config that fails
475
+ * validation stops them all, not only those that happen to read `orgs:`.
476
+ * A missing file, or one that does not parse, is left to readDenConfig.
477
+ * Memoised on the file's mtime and size: the server checks per request.
478
+ */
479
+ export function assertDenConfigValid(): void {
480
+ let key: string;
481
+ try {
482
+ const st = fs.statSync(DEN_CONFIG_PATH);
483
+ key = `${st.mtimeMs}:${st.size}`;
484
+ } catch {
485
+ return;
486
+ }
487
+ if (checked?.key !== key) {
488
+ let error: ConfigError | null = null;
489
+ try {
490
+ const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
491
+ orgs?: unknown;
492
+ } | null;
493
+ validateOrgSources(parsed?.orgs, product().configFile);
494
+ } catch (e) {
495
+ if (!isConfigError(e)) return;
496
+ error = e;
497
+ }
498
+ checked = { key, error };
499
+ }
500
+ if (checked.error) throw new ConfigError(checked.error.issues);
501
+ }
502
+
503
+ let orgsWaived = false;
504
+
505
+ /**
506
+ * For `down` only (0.3.0-dev.40): from now on in this process, a config
507
+ * whose `orgs:` fails validation reads as one with no org data instead of
508
+ * throwing, so stopping is never stranded by it. Every other key is read as
509
+ * usual; a file that does not parse still throws.
510
+ */
511
+ export function waiveInvalidOrgs(): void {
512
+ orgsWaived = true;
513
+ }
514
+
415
515
  /**
416
516
  * Read the effective den config, merging the shared deployment config with
417
517
  * per-engineer overrides from config/local/user.yml.
@@ -422,6 +522,15 @@ export function readDenConfig(): DenConfig {
422
522
  try {
423
523
  const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
424
524
  const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
525
+ // ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
526
+ // value throws naming its key; configs without them are not touched.
527
+ let orgs = parsed?.orgs;
528
+ try {
529
+ validateOrgSources(orgs, product().configFile);
530
+ } catch (e) {
531
+ if (!orgsWaived || !isConfigError(e)) throw e;
532
+ orgs = undefined;
533
+ }
425
534
  // `environments` may be malformed (scalar/object) in a hand-edited file —
426
535
  // fall back to the default set rather than throwing on .filter below.
427
536
  const envs = Array.isArray(parsed?.environments)
@@ -434,7 +543,7 @@ export function readDenConfig(): DenConfig {
434
543
  production_envs: parsed?.production_envs,
435
544
  repos_root: parsed?.repos_root,
436
545
  github_org: parsed?.github_org,
437
- orgs: parsed?.orgs,
546
+ orgs,
438
547
  graph: parsed?.graph,
439
548
  org: parsed?.org,
440
549
  applications: parsed?.applications,
@@ -638,6 +747,11 @@ export interface ResolvedWorkspaceBlueprint {
638
747
  focus: boolean;
639
748
  /** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
640
749
  org?: string;
750
+ /**
751
+ * `stack_namespace:`: the Compose project suffix this workspace's stacks
752
+ * run under, in place of its own name (see stacks/namespace.ts).
753
+ */
754
+ stackNamespace?: string;
641
755
  }
642
756
 
643
757
  /** Which blueprint shape the distribution uses, and its workspaces. */
@@ -646,7 +760,32 @@ export interface WorkspaceBlueprints {
646
760
  workspaces: ResolvedWorkspaceBlueprint[];
647
761
  }
648
762
 
649
- const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
763
+ const BLUEPRINT_KEYS = [
764
+ "branch",
765
+ "repos",
766
+ "autostart_off",
767
+ "org",
768
+ "stack_namespace",
769
+ ];
770
+
771
+ /**
772
+ * A stack namespace (and a Compose project suffix): lowercase letters,
773
+ * digits and dashes, not starting or ending with a dash.
774
+ */
775
+ export const STACK_NAMESPACE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
776
+
777
+ /** `v` as a stack namespace, or undefined when unset; throws naming `key`. */
778
+ export function checkStackNamespace(
779
+ key: string,
780
+ v: unknown,
781
+ ): string | undefined {
782
+ if (v === undefined || v === null) return undefined;
783
+ if (typeof v !== "string" || !STACK_NAMESPACE_RE.test(v))
784
+ throw new Error(
785
+ `${key} must be lowercase letters, digits and dashes (got ${JSON.stringify(v)})`,
786
+ );
787
+ return v;
788
+ }
650
789
 
651
790
  function blueprintStrings(where: string, key: string, v: unknown): string[] {
652
791
  if (v === undefined) return [];
@@ -678,6 +817,10 @@ function resolveBlueprint(
678
817
  throw new Error(`${where}.focus must be true or false`);
679
818
  if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
680
819
  throw new Error(`${where}.org must be an org id (a key of orgs:)`);
820
+ const stackNamespace = checkStackNamespace(
821
+ `${where}.stack_namespace`,
822
+ bp.stack_namespace,
823
+ );
681
824
  return {
682
825
  name,
683
826
  repos: blueprintStrings(where, "repos", bp.repos),
@@ -685,7 +828,42 @@ function resolveBlueprint(
685
828
  autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
686
829
  focus: bp.focus === true,
687
830
  ...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
831
+ ...(stackNamespace ? { stackNamespace } : {}),
832
+ };
833
+ }
834
+
835
+ /**
836
+ * Each blueprint workspace's `stack_namespace`, by workspace name (the
837
+ * single `defaults.workspace` is `default`). Reads only that key, so a
838
+ * blueprint malformed elsewhere still gives its namespaces: the stacks'
839
+ * Compose projects never fall back to another name because of an unrelated
840
+ * error. A bad `stack_namespace` itself throws, naming the key.
841
+ */
842
+ export function readBlueprintStackNamespaces(
843
+ config: DenConfig = readDenConfig(),
844
+ ): Record<string, string> {
845
+ const out: Record<string, string> = {};
846
+ const defaults: unknown = config.defaults;
847
+ if (!defaults || typeof defaults !== "object" || Array.isArray(defaults))
848
+ return out;
849
+ const { workspace, workspaces } = defaults as Record<string, unknown>;
850
+ const take = (where: string, name: string, raw: unknown) => {
851
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
852
+ const ns = checkStackNamespace(
853
+ `${where}.stack_namespace`,
854
+ (raw as Record<string, unknown>).stack_namespace,
855
+ );
856
+ if (ns) out[name] = ns;
688
857
  };
858
+ if (
859
+ workspaces &&
860
+ typeof workspaces === "object" &&
861
+ !Array.isArray(workspaces)
862
+ )
863
+ for (const [name, raw] of Object.entries(workspaces))
864
+ take(`defaults.workspaces.${name}`, name, raw);
865
+ else if (workspace != null) take("defaults.workspace", "default", workspace);
866
+ return out;
689
867
  }
690
868
 
691
869
  /**