@monster-orange/den 0.2.8 → 0.4.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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  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 +12 -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/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -243,6 +243,11 @@ export interface DenConfig {
243
243
  * only; `actions` opts in to named actions. See readMcpConfig.
244
244
  */
245
245
  mcp?: McpConfig;
246
+ /**
247
+ * Agents: `max_concurrent` caps the sessions running at once in this
248
+ * distribution (default 4). See readAgentsConfig.
249
+ */
250
+ agents?: AgentsConfig;
246
251
  /**
247
252
  * The web UI: `port` (read by edge/config.ts readWebPort) and `service`,
248
253
  * the launchd service `<cli> service` installs. See readWebServiceConfig.
@@ -341,6 +346,33 @@ export interface UserConfig {
341
346
  density?: string;
342
347
  panelWidth?: number;
343
348
  };
349
+ /**
350
+ * Web UI appearance: theme id and mode (system | dark | light), set from
351
+ * the header. See src/ui/server/appearance-prefs.ts.
352
+ */
353
+ appearance?: { theme?: string; mode?: string };
354
+ /**
355
+ * The web UI's language (R19): en (the default, never stored), ja or es.
356
+ * See src/ui/server/ui-language.ts.
357
+ */
358
+ language?: string;
359
+ /**
360
+ * The rail's pins and collapse, and (R21 Navigation) the pages it hides
361
+ * and their order (src/ui/server/shell-prefs.ts).
362
+ */
363
+ shell?: {
364
+ pins?: string[];
365
+ collapsed?: boolean;
366
+ hidden?: string[];
367
+ order?: string[];
368
+ };
369
+ /**
370
+ * What the web UI and `<cli> status` show: `scope` is "all" (every active
371
+ * workspace, the default) or one active workspace's name. A view filter
372
+ * only: the focused workspace stays the default target for commands. See
373
+ * src/shared/environment-manager/scope.ts.
374
+ */
375
+ view?: { scope?: string };
344
376
  }
345
377
 
346
378
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -403,12 +435,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
403
435
  return next;
404
436
  }
405
437
 
438
+ /**
439
+ * A scaffold or docs placeholder rather than a real org: `your-github-org`,
440
+ * `your-org`, `<your-org>`, `<slug>`, `example-org`. Den treats it as unset.
441
+ */
442
+ export function isPlaceholderGithubOrg(org: string): boolean {
443
+ return /^(your[-_]?(github[-_]?)?org|<.*>|example[-_]?org)$/i.test(
444
+ org.trim(),
445
+ );
446
+ }
447
+
448
+ /**
449
+ * The configured `github_org`, or null when it is unset or still a
450
+ * placeholder ("not configured": Den runs no gh for it).
451
+ */
452
+ export function configuredGithubOrg(
453
+ config: DenConfig = readDenConfig(),
454
+ ): string | null {
455
+ const org =
456
+ typeof config.github_org === "string" ? config.github_org.trim() : "";
457
+ return org && !isPlaceholderGithubOrg(org) ? org : null;
458
+ }
459
+
406
460
  /**
407
461
  * The GitHub org managed repos clone from. Den has no default: an org that
408
462
  * needs one sets `github_org` in its config.
409
463
  */
410
464
  export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
411
- const org = config.github_org?.trim();
465
+ const org = configuredGithubOrg(config);
412
466
  if (!org)
413
467
  throw new Error(
414
468
  `github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
@@ -448,7 +502,19 @@ export function assertDenConfigValid(): void {
448
502
  }
449
503
  checked = { key, error };
450
504
  }
451
- if (checked.error) throw new ConfigError(checked.error.message);
505
+ if (checked.error) throw new ConfigError(checked.error.issues);
506
+ }
507
+
508
+ let orgsWaived = false;
509
+
510
+ /**
511
+ * For `down` only (0.3.0-dev.40): from now on in this process, a config
512
+ * whose `orgs:` fails validation reads as one with no org data instead of
513
+ * throwing, so stopping is never stranded by it. Every other key is read as
514
+ * usual; a file that does not parse still throws.
515
+ */
516
+ export function waiveInvalidOrgs(): void {
517
+ orgsWaived = true;
452
518
  }
453
519
 
454
520
  /**
@@ -463,7 +529,13 @@ export function readDenConfig(): DenConfig {
463
529
  const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
464
530
  // ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
465
531
  // value throws naming its key; configs without them are not touched.
466
- validateOrgSources(parsed?.orgs, product().configFile);
532
+ let orgs = parsed?.orgs;
533
+ try {
534
+ validateOrgSources(orgs, product().configFile);
535
+ } catch (e) {
536
+ if (!orgsWaived || !isConfigError(e)) throw e;
537
+ orgs = undefined;
538
+ }
467
539
  // `environments` may be malformed (scalar/object) in a hand-edited file —
468
540
  // fall back to the default set rather than throwing on .filter below.
469
541
  const envs = Array.isArray(parsed?.environments)
@@ -476,7 +548,7 @@ export function readDenConfig(): DenConfig {
476
548
  production_envs: parsed?.production_envs,
477
549
  repos_root: parsed?.repos_root,
478
550
  github_org: parsed?.github_org,
479
- orgs: parsed?.orgs,
551
+ orgs,
480
552
  graph: parsed?.graph,
481
553
  org: parsed?.org,
482
554
  applications: parsed?.applications,
@@ -485,6 +557,7 @@ export function readDenConfig(): DenConfig {
485
557
  env_health_urls: parsed?.env_health_urls,
486
558
  security: parsed?.security,
487
559
  mcp: parsed?.mcp,
560
+ agents: parsed?.agents,
488
561
  web: parsed?.web,
489
562
  test_accounts: parsed?.test_accounts,
490
563
  defaults: parsed?.defaults,
@@ -662,6 +735,116 @@ export function readMcpConfig(
662
735
  };
663
736
  }
664
737
 
738
+ // ── Agents (`<cli> sessions`, `<cli> agents`) ─────────────────────────────────
739
+
740
+ /** `agents:` in the org config. */
741
+ export interface AgentsConfig {
742
+ /** Sessions that may run at once (default 4). */
743
+ max_concurrent?: number;
744
+ /**
745
+ * Persona packs (AG-2a): directories of persona files, relative to the
746
+ * distribution root. Den's default pack is always read first; a pack's
747
+ * persona replaces one of the same id.
748
+ */
749
+ persona_packs?: string[];
750
+ /**
751
+ * Any other key is an agent id: `agents.<id>.grants` widens that agent's
752
+ * MCP scope to other workspaces (AG spec §5b). Config only: an agent
753
+ * never grants itself one.
754
+ */
755
+ [agentId: string]: unknown;
756
+ }
757
+
758
+ export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
759
+
760
+ /** The keys of `agents:` that are not an agent id. */
761
+ export const AGENTS_RESERVED_KEYS = ["max_concurrent", "persona_packs"];
762
+
763
+ /** One agent's own block, `agents.<id>`. */
764
+ export interface AgentGrantsConfig {
765
+ /** Workspaces beyond its own that its MCP serves. */
766
+ grants: string[];
767
+ }
768
+
769
+ /** `agents:` with its defaults applied. */
770
+ export interface ResolvedAgentsConfig {
771
+ maxConcurrent: number;
772
+ /** As configured (relative to the distribution root). */
773
+ personaPacks: string[];
774
+ /** `agents.<id>` blocks, by agent id. */
775
+ agents: Record<string, AgentGrantsConfig>;
776
+ }
777
+
778
+ /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
779
+ const AGENT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
780
+
781
+ const isMapping = (v: unknown): v is Record<string, unknown> =>
782
+ v !== null && typeof v === "object" && !Array.isArray(v);
783
+
784
+ /**
785
+ * The effective agents config. Anything malformed throws, naming the key:
786
+ * a typo must not lift the cap, drop a pack or widen a grant.
787
+ */
788
+ export function readAgentsConfig(
789
+ config: DenConfig = readDenConfig(),
790
+ ): ResolvedAgentsConfig {
791
+ const raw = (config.agents ?? {}) as unknown;
792
+ if (!isMapping(raw))
793
+ throw new Error(`agents: must be a mapping (got ${JSON.stringify(raw)})`);
794
+ const out: ResolvedAgentsConfig = {
795
+ maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
796
+ personaPacks: [],
797
+ agents: {},
798
+ };
799
+ const max = raw.max_concurrent;
800
+ if (max !== undefined) {
801
+ if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
802
+ throw new Error(
803
+ `agents.max_concurrent: must be a whole number of at least 1 (got ${JSON.stringify(max)})`,
804
+ );
805
+ out.maxConcurrent = max;
806
+ }
807
+ const packs = raw.persona_packs;
808
+ if (packs !== undefined && packs !== null) {
809
+ if (!Array.isArray(packs))
810
+ throw new Error(
811
+ `agents.persona_packs: must be a list of paths (got ${JSON.stringify(packs)})`,
812
+ );
813
+ packs.forEach((p, i) => {
814
+ if (typeof p !== "string" || !p.trim())
815
+ throw new Error(
816
+ `agents.persona_packs[${i}]: must be a non-empty path (got ${JSON.stringify(p)})`,
817
+ );
818
+ });
819
+ out.personaPacks = packs as string[];
820
+ }
821
+ for (const [id, block] of Object.entries(raw)) {
822
+ if (AGENTS_RESERVED_KEYS.includes(id)) continue;
823
+ const where = `agents.${id}`;
824
+ if (!AGENT_KEY_RE.test(id) || !isMapping(block))
825
+ throw new Error(
826
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
827
+ `or an agent id mapping to { grants: [...] })`,
828
+ );
829
+ for (const k of Object.keys(block))
830
+ if (k !== "grants")
831
+ throw new Error(`${where}.${k}: unknown key (an agent takes grants)`);
832
+ const grants = block.grants ?? [];
833
+ if (!Array.isArray(grants))
834
+ throw new Error(
835
+ `${where}.grants: must be a list of workspace names (got ${JSON.stringify(grants)})`,
836
+ );
837
+ grants.forEach((g, i) => {
838
+ if (typeof g !== "string" || !AGENT_KEY_RE.test(g))
839
+ throw new Error(
840
+ `${where}.grants[${i}]: ${JSON.stringify(g)} is not a workspace name`,
841
+ );
842
+ });
843
+ out.agents[id] = { grants: [...new Set(grants as string[])] };
844
+ }
845
+ return out;
846
+ }
847
+
665
848
  // ── Web UI service (`<cli> service`) ──────────────────────────────────────────
666
849
 
667
850
  /** `web:` in the org config. */
@@ -680,6 +863,11 @@ export interface ResolvedWorkspaceBlueprint {
680
863
  focus: boolean;
681
864
  /** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
682
865
  org?: string;
866
+ /**
867
+ * `stack_namespace:`: the Compose project suffix this workspace's stacks
868
+ * run under, in place of its own name (see stacks/namespace.ts).
869
+ */
870
+ stackNamespace?: string;
683
871
  }
684
872
 
685
873
  /** Which blueprint shape the distribution uses, and its workspaces. */
@@ -688,7 +876,32 @@ export interface WorkspaceBlueprints {
688
876
  workspaces: ResolvedWorkspaceBlueprint[];
689
877
  }
690
878
 
691
- const BLUEPRINT_KEYS = ["branch", "repos", "autostart_off", "org"];
879
+ const BLUEPRINT_KEYS = [
880
+ "branch",
881
+ "repos",
882
+ "autostart_off",
883
+ "org",
884
+ "stack_namespace",
885
+ ];
886
+
887
+ /**
888
+ * A stack namespace (and a Compose project suffix): lowercase letters,
889
+ * digits and dashes, not starting or ending with a dash.
890
+ */
891
+ export const STACK_NAMESPACE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
892
+
893
+ /** `v` as a stack namespace, or undefined when unset; throws naming `key`. */
894
+ export function checkStackNamespace(
895
+ key: string,
896
+ v: unknown,
897
+ ): string | undefined {
898
+ if (v === undefined || v === null) return undefined;
899
+ if (typeof v !== "string" || !STACK_NAMESPACE_RE.test(v))
900
+ throw new Error(
901
+ `${key} must be lowercase letters, digits and dashes (got ${JSON.stringify(v)})`,
902
+ );
903
+ return v;
904
+ }
692
905
 
693
906
  function blueprintStrings(where: string, key: string, v: unknown): string[] {
694
907
  if (v === undefined) return [];
@@ -720,6 +933,10 @@ function resolveBlueprint(
720
933
  throw new Error(`${where}.focus must be true or false`);
721
934
  if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
722
935
  throw new Error(`${where}.org must be an org id (a key of orgs:)`);
936
+ const stackNamespace = checkStackNamespace(
937
+ `${where}.stack_namespace`,
938
+ bp.stack_namespace,
939
+ );
723
940
  return {
724
941
  name,
725
942
  repos: blueprintStrings(where, "repos", bp.repos),
@@ -727,9 +944,44 @@ function resolveBlueprint(
727
944
  autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
728
945
  focus: bp.focus === true,
729
946
  ...(typeof bp.org === "string" ? { org: bp.org.trim() } : {}),
947
+ ...(stackNamespace ? { stackNamespace } : {}),
730
948
  };
731
949
  }
732
950
 
951
+ /**
952
+ * Each blueprint workspace's `stack_namespace`, by workspace name (the
953
+ * single `defaults.workspace` is `default`). Reads only that key, so a
954
+ * blueprint malformed elsewhere still gives its namespaces: the stacks'
955
+ * Compose projects never fall back to another name because of an unrelated
956
+ * error. A bad `stack_namespace` itself throws, naming the key.
957
+ */
958
+ export function readBlueprintStackNamespaces(
959
+ config: DenConfig = readDenConfig(),
960
+ ): Record<string, string> {
961
+ const out: Record<string, string> = {};
962
+ const defaults: unknown = config.defaults;
963
+ if (!defaults || typeof defaults !== "object" || Array.isArray(defaults))
964
+ return out;
965
+ const { workspace, workspaces } = defaults as Record<string, unknown>;
966
+ const take = (where: string, name: string, raw: unknown) => {
967
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
968
+ const ns = checkStackNamespace(
969
+ `${where}.stack_namespace`,
970
+ (raw as Record<string, unknown>).stack_namespace,
971
+ );
972
+ if (ns) out[name] = ns;
973
+ };
974
+ if (
975
+ workspaces &&
976
+ typeof workspaces === "object" &&
977
+ !Array.isArray(workspaces)
978
+ )
979
+ for (const [name, raw] of Object.entries(workspaces))
980
+ take(`defaults.workspaces.${name}`, name, raw);
981
+ else if (workspace != null) take("defaults.workspace", "default", workspace);
982
+ return out;
983
+ }
984
+
733
985
  /**
734
986
  * The distribution's workspace blueprint, validated: `defaults.workspace`
735
987
  * (one workspace, `default`) or `defaults.workspaces` (named workspaces).
@@ -12,9 +12,17 @@
12
12
  * publish: true # false: another proxy fronts the edge
13
13
  * http_port: 80
14
14
  * https_port: 443
15
+ * tls: none # http only; or (see tls.ts):
16
+ * # tls: { strategy: mkcert } # own local CA, per machine
17
+ * # tls: { strategy: mkcert, ca: ~/path/to/ca } # an existing CA; never minted
18
+ * # tls: { strategy: files, include: edge/dynamic, mounts: {...} }
19
+ * # # included config holds the certs; optional ca: the CA that
20
+ * # # signed them, trusted by health checks
21
+ * # legacy (still read, mapped to tls, deprecated):
22
+ * # caroot: <ca> → tls: { strategy: mkcert, ca: <ca> }
23
+ * # issue_cert: false → tls: { strategy: files }
24
+ * # neither, no tls → mkcert with a local CA, and a warning
15
25
  * # opt-ins (all off by default):
16
- * caroot: ~/path/to/mkcert-ca # use an existing CA; never mint one
17
- * issue_cert: false # included config holds the certs
18
26
  * https_redirect: false # included config redirects per name
19
27
  * docker_labels: { network: apps-network }
20
28
  * include: edge/dynamic # dynamic-config files, repo-relative
@@ -35,7 +43,8 @@ import { cachedRead } from "../read-cache.js";
35
43
  import { readManifestStacks } from "../stacks/config.js";
36
44
  import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
37
45
  import { assertDomain } from "./render.js";
38
- import type { EdgeConfig } from "./types.js";
46
+ import { tlsStrategyIds } from "./tls.js";
47
+ import type { EdgeConfig, EdgeTls } from "./types.js";
39
48
 
40
49
  import { product } from "../product.js";
41
50
  export const DEFAULT_TRAEFIK_IMAGE = "traefik:v3.7.17";
@@ -51,6 +60,7 @@ interface RawEdge {
51
60
  publish?: boolean;
52
61
  http_port?: number;
53
62
  https_port?: number;
63
+ tls?: unknown;
54
64
  caroot?: string;
55
65
  issue_cert?: boolean;
56
66
  https_redirect?: boolean;
@@ -120,6 +130,108 @@ function mounts(v: RawEdge["mounts"], root: string): Record<string, string> {
120
130
  return out;
121
131
  }
122
132
 
133
+ const TLS_KEYS: Record<string, string[]> = {
134
+ none: ["strategy"],
135
+ mkcert: ["strategy", "ca"],
136
+ files: ["strategy", "ca", "include", "mounts"],
137
+ };
138
+
139
+ interface ResolvedTls {
140
+ tls: EdgeTls;
141
+ from: EdgeConfig["tlsFrom"];
142
+ /** `files`' own include and mounts (raw, resolved by the caller). */
143
+ include?: string;
144
+ mounts?: Record<string, string>;
145
+ }
146
+
147
+ /**
148
+ * `edge.tls` (user.yml's replaces the team's), or the legacy keys mapped to
149
+ * it (EXT-1 rule 6): `issue_cert: false` → files, `caroot` → mkcert with
150
+ * `ca`, neither → mkcert with a local CA (implicit, warned).
151
+ */
152
+ function readTls(team: RawEdge, user: RawEdge, root: string): ResolvedTls {
153
+ const raw = user.tls ?? team.tls;
154
+ const caroot = user.caroot ?? team.caroot;
155
+ const issue = user.issue_cert ?? team.issue_cert;
156
+ const ids = tlsStrategyIds();
157
+ const builtIns = ids.join(", ");
158
+ if (raw === undefined) {
159
+ const ca = caroot ? resolvePath(caroot, root) : null;
160
+ return {
161
+ tls:
162
+ issue === false
163
+ ? { strategy: "files", ca }
164
+ : { strategy: "mkcert", ca },
165
+ from: caroot === undefined && issue === undefined ? "implicit" : "legacy",
166
+ };
167
+ }
168
+ const legacy = [
169
+ ...(caroot !== undefined ? ["caroot"] : []),
170
+ ...(issue !== undefined ? ["issue_cert"] : []),
171
+ ];
172
+ if (legacy.length)
173
+ throw new Error(
174
+ `edge.tls replaces edge.${legacy.join(" and edge.")}; remove ${legacy.length > 1 ? "them" : "it"} ` +
175
+ "(caroot is tls.ca, issue_cert: false is tls.strategy: files).",
176
+ );
177
+ const obj: Record<string, unknown> =
178
+ typeof raw === "string"
179
+ ? { strategy: raw }
180
+ : raw && typeof raw === "object" && !Array.isArray(raw)
181
+ ? (raw as Record<string, unknown>)
182
+ : {};
183
+ const id = obj.strategy;
184
+ if (typeof id !== "string" || !id)
185
+ throw new Error(
186
+ `edge.tls must be a strategy id or { strategy: <id>, ... }; the built-ins are ${builtIns}`,
187
+ );
188
+ if (!ids.includes(id))
189
+ throw new Error(
190
+ `edge.tls.strategy "${id}" is not a known TLS strategy; the built-ins are ${builtIns}`,
191
+ );
192
+ const allowed = TLS_KEYS[id] ?? ["strategy"];
193
+ for (const k of Object.keys(obj))
194
+ if (!allowed.includes(k))
195
+ throw new Error(
196
+ `edge.tls.${k} does not apply to strategy ${id} (it takes ${allowed.join(", ")})`,
197
+ );
198
+ let ca: string | null = null;
199
+ if (obj.ca !== undefined) {
200
+ if (typeof obj.ca !== "string" || !obj.ca.trim())
201
+ throw new Error(
202
+ "edge.tls.ca must be the path to an existing CA directory",
203
+ );
204
+ ca = resolvePath(obj.ca, root);
205
+ }
206
+ if (
207
+ obj.include !== undefined &&
208
+ (typeof obj.include !== "string" || !obj.include)
209
+ )
210
+ throw new Error(
211
+ "edge.tls.include must be a directory of dynamic-config files",
212
+ );
213
+ if (
214
+ obj.mounts !== undefined &&
215
+ (!obj.mounts || typeof obj.mounts !== "object" || Array.isArray(obj.mounts))
216
+ )
217
+ throw new Error("edge.tls.mounts must map host paths to container paths");
218
+ const tls: EdgeTls =
219
+ id === "none"
220
+ ? { strategy: "none" }
221
+ : { strategy: id as "mkcert" | "files", ca };
222
+ return {
223
+ tls,
224
+ from: "explicit",
225
+ include: obj.include as string | undefined,
226
+ mounts: obj.mounts as Record<string, string> | undefined,
227
+ };
228
+ }
229
+
230
+ /** True when the org config has an `edge:` block (an edge is configured). */
231
+ export function hasEdgeBlock(root: string = getDistRoot()): boolean {
232
+ return layers(root).team.edge !== undefined;
233
+ }
234
+
123
235
  /** The web UI's route name: the brand name as a slug ("Den" → "den"). */
124
236
  function webRouteName(team: Record<string, unknown>): string {
125
237
  const name = (team.brand as { name?: string } | undefined)?.name?.trim();
@@ -188,6 +300,22 @@ function computeEdgeConfig(root: string): EdgeConfig {
188
300
  throw new Error("edge.aliases must be a list of domains");
189
301
  aliases.forEach(assertDomain);
190
302
  const publish = user.publish ?? team.publish ?? true;
303
+ const tls = readTls(team, user, root);
304
+ if (tls.include !== undefined && team.include !== undefined)
305
+ throw new Error(
306
+ "edge.include and edge.tls.include are both set; keep one (tls.include for files' certs)",
307
+ );
308
+ const include = tls.include ?? team.include;
309
+ // The state dir the config names (or the XDG default), DEN_EDGE_DIR aside.
310
+ const configuredStateDir = expandHome(
311
+ user.state_dir ??
312
+ team.state_dir ??
313
+ path.join(
314
+ process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"),
315
+ team.org,
316
+ "edge",
317
+ ),
318
+ );
191
319
  return {
192
320
  org: team.org,
193
321
  domain: team.domain,
@@ -214,26 +342,24 @@ function computeEdgeConfig(root: string): EdgeConfig {
214
342
  ),
215
343
  }
216
344
  : null,
217
- stateDir: expandHome(
218
- process.env.DEN_EDGE_DIR ??
219
- user.state_dir ??
220
- team.state_dir ??
221
- path.join(
222
- process.env.XDG_DATA_HOME ||
223
- path.join(os.homedir(), ".local", "share"),
224
- team.org,
225
- "edge",
226
- ),
227
- ),
228
- caroot:
229
- (user.caroot ?? team.caroot)
230
- ? resolvePath((user.caroot ?? team.caroot)!, root)
231
- : null,
232
- issueCert: (user.issue_cert ?? team.issue_cert) !== false,
345
+ stateDir:
346
+ process.env.DEN_EDGE_DIR !== undefined
347
+ ? expandHome(process.env.DEN_EDGE_DIR)
348
+ : configuredStateDir,
349
+ // Set only when DEN_EDGE_DIR overrides it: the dir the config names.
350
+ ...(process.env.DEN_EDGE_DIR !== undefined &&
351
+ expandHome(process.env.DEN_EDGE_DIR) !== configuredStateDir
352
+ ? { defaultStateDir: configuredStateDir }
353
+ : {}),
354
+ tls: tls.tls,
355
+ tlsFrom: tls.from,
233
356
  httpsRedirect: (user.https_redirect ?? team.https_redirect) !== false,
234
357
  dockerLabels: dockerLabels(user.docker_labels ?? team.docker_labels),
235
- include: team.include ? resolvePath(team.include, root) : null,
236
- mounts: mounts({ ...(team.mounts ?? {}), ...(user.mounts ?? {}) }, root),
358
+ include: include ? resolvePath(include, root) : null,
359
+ mounts: mounts(
360
+ { ...(team.mounts ?? {}), ...(user.mounts ?? {}), ...(tls.mounts ?? {}) },
361
+ root,
362
+ ),
237
363
  networks: [
238
364
  ...new Set([
239
365
  ...list(team.networks, "networks"),
@@ -0,0 +1,31 @@
1
+ /** Running the edge's CLIs (docker, mkcert): one place, one error shape. */
2
+ import { spawnSync } from "child_process";
3
+
4
+ export function sh(
5
+ cmd: string,
6
+ args: string[],
7
+ opts: { env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
8
+ ) {
9
+ const r = spawnSync(cmd, args, {
10
+ encoding: "utf-8",
11
+ env: { ...process.env, ...opts.env },
12
+ stdio: opts.inherit ? "inherit" : "pipe",
13
+ });
14
+ if (r.error) throw new Error(`${cmd} is not installed (${r.error.message})`);
15
+ return {
16
+ code: r.status ?? 1,
17
+ out: (r.stdout ?? "").trim(),
18
+ err: (r.stderr ?? "").trim(),
19
+ };
20
+ }
21
+
22
+ export function must(
23
+ cmd: string,
24
+ args: string[],
25
+ opts: { env?: NodeJS.ProcessEnv } = {},
26
+ ): string {
27
+ const r = sh(cmd, args, opts);
28
+ if (r.code !== 0)
29
+ throw new Error(`${cmd} ${args.join(" ")} failed:\n${r.err || r.out}`);
30
+ return r.out;
31
+ }