@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
@@ -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
  }
@@ -7,12 +7,36 @@
7
7
  * The message names config keys (`orgs.initech.repos`), never values a
8
8
  * secret could hide in.
9
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
+
10
22
  export class ConfigError extends Error {
11
23
  readonly code = "config_invalid";
24
+ /** Every problem found (0.3.0-dev.40), in config order. */
25
+ readonly issues: ConfigIssue[];
12
26
 
13
- constructor(message: string) {
14
- super(message);
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
+ );
15
38
  this.name = "ConfigError";
39
+ this.issues = list;
16
40
  }
17
41
  }
18
42
 
@@ -28,6 +52,12 @@ export function configErrorBody(e: ConfigError): {
28
52
  ok: false;
29
53
  error: string;
30
54
  code: "config_invalid";
55
+ issues: Array<{ file: string; key: string; message: string }>;
31
56
  } {
32
- return { ok: false, error: e.message, code: e.code };
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
+ };
33
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
 
@@ -341,6 +341,33 @@ export interface UserConfig {
341
341
  density?: string;
342
342
  panelWidth?: number;
343
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 };
344
371
  }
345
372
 
346
373
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -403,12 +430,34 @@ export function mergeUserConfig(patch: Partial<UserConfig>): UserConfig {
403
430
  return next;
404
431
  }
405
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
+
406
455
  /**
407
456
  * The GitHub org managed repos clone from. Den has no default: an org that
408
457
  * needs one sets `github_org` in its config.
409
458
  */
410
459
  export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
411
- const org = config.github_org?.trim();
460
+ const org = configuredGithubOrg(config);
412
461
  if (!org)
413
462
  throw new Error(
414
463
  `github_org is not set: add \`github_org: <your-org>\` to ${DEN_CONFIG_PATH}`,
@@ -448,7 +497,19 @@ export function assertDenConfigValid(): void {
448
497
  }
449
498
  checked = { key, error };
450
499
  }
451
- if (checked.error) throw new ConfigError(checked.error.message);
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;
452
513
  }
453
514
 
454
515
  /**
@@ -463,7 +524,13 @@ export function readDenConfig(): DenConfig {
463
524
  const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
464
525
  // ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
465
526
  // value throws naming its key; configs without them are not touched.
466
- validateOrgSources(parsed?.orgs, product().configFile);
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
+ }
467
534
  // `environments` may be malformed (scalar/object) in a hand-edited file —
468
535
  // fall back to the default set rather than throwing on .filter below.
469
536
  const envs = Array.isArray(parsed?.environments)
@@ -476,7 +543,7 @@ export function readDenConfig(): DenConfig {
476
543
  production_envs: parsed?.production_envs,
477
544
  repos_root: parsed?.repos_root,
478
545
  github_org: parsed?.github_org,
479
- orgs: parsed?.orgs,
546
+ orgs,
480
547
  graph: parsed?.graph,
481
548
  org: parsed?.org,
482
549
  applications: parsed?.applications,
@@ -680,6 +747,11 @@ export interface ResolvedWorkspaceBlueprint {
680
747
  focus: boolean;
681
748
  /** `org:` (ORG-1a): the org this workspace belongs to, when the blueprint names one. */
682
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;
683
755
  }
684
756
 
685
757
  /** Which blueprint shape the distribution uses, and its workspaces. */
@@ -688,7 +760,32 @@ export interface WorkspaceBlueprints {
688
760
  workspaces: ResolvedWorkspaceBlueprint[];
689
761
  }
690
762
 
691
- 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
+ }
692
789
 
693
790
  function blueprintStrings(where: string, key: string, v: unknown): string[] {
694
791
  if (v === undefined) return [];
@@ -720,6 +817,10 @@ function resolveBlueprint(
720
817
  throw new Error(`${where}.focus must be true or false`);
721
818
  if (bp.org !== undefined && (typeof bp.org !== "string" || !bp.org.trim()))
722
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
+ );
723
824
  return {
724
825
  name,
725
826
  repos: blueprintStrings(where, "repos", bp.repos),
@@ -727,7 +828,42 @@ function resolveBlueprint(
727
828
  autostartOff: blueprintStrings(where, "autostart_off", bp.autostart_off),
728
829
  focus: bp.focus === true,
729
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;
730
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;
731
867
  }
732
868
 
733
869
  /**
@@ -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
+ }