@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
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The edge's TLS strategies (TLS-1a), registered through the generic
3
+ * strategy registry (src/shared/strategies/registry.ts) as kind "tls",
4
+ * exactly as a plugin would register one: `{ id, kind, detect(),
5
+ * capabilities }`.
6
+ *
7
+ * - `none`: http only. No https entrypoint, no cert work, no mkcert.
8
+ * - `mkcert`: the edge issues its own wildcard cert from a local CA, its
9
+ * own under the state dir (minted on first use) or `ca`, an existing one
10
+ * it never mints or uninstalls. The only strategy with `trust`/`untrust`.
11
+ * - `files`: the distribution's included dynamic config and mounts hold the
12
+ * certs; Den issues none. `ca` names the CA that signed them, so health
13
+ * checks trust it.
14
+ *
15
+ * `secret` (cert and key from the selected secrets strategy) and `acme` are
16
+ * later plugins; they register here the same way.
17
+ *
18
+ * Capabilities:
19
+ * - `https`: the edge has an https entrypoint;
20
+ * - `issuesCert`: Den issues the edge's own cert (the TLS store it renders);
21
+ * - `ensure(cfg)`: make sure the cert exists for certNames(cfg);
22
+ * - `trust(cfg)` / `untrust(cfg)`: mkcert only;
23
+ * - `status(cfg)`: the names on the cert and whether its CA is trusted.
24
+ */
25
+ import * as fs from "fs";
26
+ import * as path from "path";
27
+ import {
28
+ getStrategy,
29
+ onPath,
30
+ registerStrategy,
31
+ strategyIds,
32
+ type DetectResult,
33
+ type StrategyEntry,
34
+ } from "../strategies/registry.js";
35
+ import { must, sh } from "./exec.js";
36
+ import { certNames } from "./render.js";
37
+ import type { EdgeConfig, EdgeTls } from "./types.js";
38
+
39
+ export const TLS_KIND = "tls";
40
+
41
+ export type TlsEnsureResult =
42
+ | {
43
+ kind: "cert";
44
+ certFile: string;
45
+ keyFile: string;
46
+ issued: boolean;
47
+ names: string[];
48
+ }
49
+ | { kind: "dynamic" | "none"; issued: false; names: [] };
50
+
51
+ export interface TlsCapabilities {
52
+ label: string;
53
+ https: boolean;
54
+ issuesCert: boolean;
55
+ ensure(cfg: EdgeConfig): TlsEnsureResult;
56
+ trust?(cfg: EdgeConfig): number;
57
+ untrust?(cfg: EdgeConfig): number;
58
+ status(cfg: EdgeConfig): { names: string[]; trusted: boolean | null };
59
+ }
60
+
61
+ export type TlsStrategy = StrategyEntry<typeof TLS_KIND, TlsCapabilities>;
62
+
63
+ // ── The CA ───────────────────────────────────────────────────────────────────
64
+
65
+ /** The configured CA (`edge.tls.ca`, formerly `edge.caroot`), or null. */
66
+ export function tlsCa(cfg: EdgeConfig): string | null {
67
+ return cfg.tls.strategy === "none" ? null : cfg.tls.ca;
68
+ }
69
+
70
+ export function caDir(cfg: EdgeConfig): string {
71
+ return tlsCa(cfg) ?? path.join(cfg.stateDir, "ca");
72
+ }
73
+
74
+ /** True when the edge uses an existing CA (`ca`) rather than its own. */
75
+ export function sharedCa(cfg: EdgeConfig): boolean {
76
+ return tlsCa(cfg) !== null;
77
+ }
78
+
79
+ /**
80
+ * A shared CA must already exist with its key: mkcert silently creates a new
81
+ * CA in an empty CAROOT, and devices that trust the shared one would not
82
+ * trust that. The org's own CA may be created on first use.
83
+ */
84
+ export function assertCaUsable(cfg: EdgeConfig): void {
85
+ if (!sharedCa(cfg)) return;
86
+ const dir = caDir(cfg);
87
+ if (!fs.existsSync(dir))
88
+ throw new Error(
89
+ `edge.tls.ca ${dir} does not exist. The edge never creates a CA there; ` +
90
+ `point it at the existing CA (\`mkcert -CAROOT\`).`,
91
+ );
92
+ for (const f of ["rootCA.pem", "rootCA-key.pem"])
93
+ if (!fs.existsSync(path.join(dir, f)))
94
+ throw new Error(
95
+ `edge.tls.ca ${dir} has no ${f}. Refusing to run: mkcert would mint a ` +
96
+ `new CA that other devices don't trust.`,
97
+ );
98
+ }
99
+
100
+ /** Whether the CA is in the macOS system keychain (null: can't tell). */
101
+ export function caTrusted(cfg: EdgeConfig): boolean | null {
102
+ if (cfg.tls.strategy === "none") return null;
103
+ const pem = path.join(caDir(cfg), "rootCA.pem");
104
+ if (!fs.existsSync(pem)) return false;
105
+ if (process.platform !== "darwin") return null;
106
+ return sh("security", ["verify-cert", "-c", pem]).code === 0;
107
+ }
108
+
109
+ // ── mkcert ───────────────────────────────────────────────────────────────────
110
+
111
+ export const MKCERT_MISSING = "mkcert is not installed";
112
+
113
+ /** detect() first: a missing mkcert is a reason, never a spawn error. */
114
+ function requireMkcert(): void {
115
+ const d = mkcert.detect();
116
+ if (!d.available)
117
+ throw new Error(
118
+ `${d.reason}. Install it (https://github.com/FiloSottile/mkcert, e.g. ` +
119
+ "`brew install mkcert`), or set edge.tls to none or files.",
120
+ );
121
+ }
122
+
123
+ function mkcertEnv(cfg: EdgeConfig): NodeJS.ProcessEnv {
124
+ return { CAROOT: caDir(cfg) };
125
+ }
126
+
127
+ const NO_CERT = { issued: false, names: [] } as const;
128
+
129
+ const none: TlsStrategy = {
130
+ id: "none",
131
+ kind: TLS_KIND,
132
+ detect: () => ({ available: true, reason: "http only" }),
133
+ capabilities: {
134
+ label: "none (http only)",
135
+ https: false,
136
+ issuesCert: false,
137
+ ensure: () => ({ kind: "none", ...NO_CERT, names: [] }),
138
+ status: () => ({ names: [], trusted: null }),
139
+ },
140
+ };
141
+
142
+ const mkcert: TlsStrategy = {
143
+ id: "mkcert",
144
+ kind: TLS_KIND,
145
+ detect: (env: NodeJS.ProcessEnv = process.env): DetectResult =>
146
+ onPath("mkcert", env)
147
+ ? { available: true }
148
+ : { available: false, reason: MKCERT_MISSING },
149
+ capabilities: {
150
+ label: "mkcert",
151
+ https: true,
152
+ issuesCert: true,
153
+ /** Issue (or re-issue when names changed) the edge cert from the CA. */
154
+ ensure(cfg) {
155
+ const names = certNames(cfg);
156
+ assertCaUsable(cfg);
157
+ const dir = path.join(cfg.stateDir, "certs");
158
+ const certFile = path.join(dir, "edge.pem");
159
+ const keyFile = path.join(dir, "edge-key.pem");
160
+ const stamp = path.join(dir, "names.txt");
161
+ const want = names.join("\n");
162
+ if (
163
+ fs.existsSync(certFile) &&
164
+ fs.existsSync(stamp) &&
165
+ fs.readFileSync(stamp, "utf-8") === want
166
+ )
167
+ return { kind: "cert", certFile, keyFile, issued: false, names };
168
+ requireMkcert();
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ if (!sharedCa(cfg))
171
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
172
+ must("mkcert", ["-cert-file", certFile, "-key-file", keyFile, ...names], {
173
+ env: mkcertEnv(cfg),
174
+ });
175
+ fs.mkdirSync(path.dirname(stamp), { recursive: true });
176
+ fs.writeFileSync(stamp, want, "utf-8");
177
+ return { kind: "cert", certFile, keyFile, issued: true, names };
178
+ },
179
+ /** Install the CA into the system + browser trust stores (prompts). */
180
+ trust(cfg) {
181
+ assertCaUsable(cfg);
182
+ requireMkcert();
183
+ if (!sharedCa(cfg))
184
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
185
+ return sh("mkcert", ["-install"], { env: mkcertEnv(cfg), inherit: true })
186
+ .code;
187
+ },
188
+ untrust(cfg) {
189
+ if (sharedCa(cfg))
190
+ throw new Error(
191
+ `edge.tls.ca is a shared CA (${caDir(cfg)}); other tools and devices ` +
192
+ `rely on it being trusted, so the edge never uninstalls it.`,
193
+ );
194
+ requireMkcert();
195
+ return sh("mkcert", ["-uninstall"], {
196
+ env: mkcertEnv(cfg),
197
+ inherit: true,
198
+ }).code;
199
+ },
200
+ status: (cfg) => ({ names: certNames(cfg), trusted: caTrusted(cfg) }),
201
+ },
202
+ };
203
+
204
+ const files: TlsStrategy = {
205
+ id: "files",
206
+ kind: TLS_KIND,
207
+ detect: () => ({ available: true }),
208
+ capabilities: {
209
+ label: "files (included config)",
210
+ https: true,
211
+ issuesCert: false,
212
+ ensure: () => ({ kind: "dynamic", ...NO_CERT, names: [] }),
213
+ status: (cfg) => ({ names: [], trusted: caTrusted(cfg) }),
214
+ },
215
+ };
216
+
217
+ for (const s of [none, mkcert, files]) registerStrategy(s);
218
+
219
+ /** Every registered TLS strategy id (built-ins first). */
220
+ export function tlsStrategyIds(): string[] {
221
+ return strategyIds(TLS_KIND);
222
+ }
223
+
224
+ /** The registered TLS strategy `id`, or undefined. */
225
+ export function tlsStrategy(id: string): TlsStrategy | undefined {
226
+ return getStrategy<typeof TLS_KIND, TlsCapabilities>(TLS_KIND, id);
227
+ }
228
+
229
+ /** The strategy the edge config selects (always registered once parsed). */
230
+ export function tlsStrategyFor(cfg: EdgeConfig): TlsStrategy {
231
+ const s = tlsStrategy(cfg.tls.strategy);
232
+ if (!s)
233
+ throw new Error(`TLS strategy "${cfg.tls.strategy}" is not registered`);
234
+ return s;
235
+ }
236
+
237
+ /** True when the edge serves https (every strategy but none). */
238
+ export function servesHttps(cfg: EdgeConfig): boolean {
239
+ return cfg.tls.strategy !== "none";
240
+ }
241
+
242
+ /** True when Den issues the edge's own cert (mkcert). */
243
+ export function issuesCert(cfg: EdgeConfig): boolean {
244
+ return cfg.tls.strategy === "mkcert";
245
+ }
246
+
247
+ const TRUST_NEEDS_MKCERT =
248
+ "edge trust needs edge.tls.strategy: mkcert (or files with a ca)";
249
+
250
+ function trustRefusal(cfg: EdgeConfig, verb: "trust" | "untrust"): Error {
251
+ return new Error(
252
+ `${TRUST_NEEDS_MKCERT.replace("trust", verb)} (this edge's is ${cfg.tls.strategy}: ` +
253
+ (cfg.tls.strategy === "none"
254
+ ? "it serves http only, there is no CA to trust)."
255
+ : "its certs come from your included config; trust their CA yourself)."),
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Trust capabilities for the edge: mkcert's own, or, for `files` with an
261
+ * existing `ca` (the legacy caroot + issue_cert: false shape), mkcert's
262
+ * against that CA: `edge trust` installs it, as caroot always did, and
263
+ * untrust refuses it as a shared CA.
264
+ */
265
+ function trustCapabilities(cfg: EdgeConfig): TlsCapabilities | null {
266
+ const c = tlsStrategyFor(cfg).capabilities;
267
+ if (c.trust) return c;
268
+ if (cfg.tls.strategy === "files" && sharedCa(cfg)) return mkcert.capabilities;
269
+ return null;
270
+ }
271
+
272
+ /** `edge trust`: mkcert, or files with an existing ca. */
273
+ export function edgeTrust(cfg: EdgeConfig): number {
274
+ const t = trustCapabilities(cfg)?.trust;
275
+ if (!t) throw trustRefusal(cfg, "trust");
276
+ return t(cfg);
277
+ }
278
+
279
+ /** `edge untrust`: mkcert only, and never for a shared CA. */
280
+ export function edgeUntrust(cfg: EdgeConfig): number {
281
+ const u = trustCapabilities(cfg)?.untrust;
282
+ if (!u) throw trustRefusal(cfg, "untrust");
283
+ return u(cfg);
284
+ }
285
+
286
+ // ── Config warnings ──────────────────────────────────────────────────────────
287
+
288
+ export const IMPLICIT_TLS_WARNING =
289
+ "edge.tls is not set; defaulting to mkcert with a local CA. Set edge.tls explicitly.";
290
+
291
+ /** The explicit `edge.tls` line that says what `tls` says. */
292
+ export function tlsYaml(tls: EdgeTls): string {
293
+ if (tls.strategy === "none") return "tls: none";
294
+ const ca = tls.ca ? `, ca: ${tls.ca}` : "";
295
+ if (tls.strategy === "mkcert") return `tls: { strategy: mkcert${ca} }`;
296
+ return `tls: { strategy: files${ca}, include: <edge.include>, mounts: <edge.mounts> }`;
297
+ }
298
+
299
+ /** Config warnings about the edge's TLS, for `validate` and `doctor`. */
300
+ export function edgeTlsWarnings(cfg: EdgeConfig): string[] {
301
+ if (cfg.tlsFrom === "implicit") return [IMPLICIT_TLS_WARNING];
302
+ if (cfg.tlsFrom === "legacy")
303
+ return [
304
+ "edge.caroot and edge.issue_cert are deprecated; they still work. " +
305
+ `Say the same with edge.${tlsYaml(cfg.tls)}`,
306
+ ];
307
+ return [];
308
+ }
309
+
310
+ let warnedImplicit = false;
311
+
312
+ /**
313
+ * The implicit-mkcert warning, at most once per run: from `validate` and
314
+ * from the paths that would mint a cert (`edge up`, `stack up`).
315
+ */
316
+ export function warnImplicitTlsOnce(
317
+ cfg: EdgeConfig,
318
+ warn: (line: string) => void = (l) => console.warn(`⚠️ ${l}`),
319
+ ): void {
320
+ if (cfg.tlsFrom !== "implicit" || warnedImplicit) return;
321
+ warnedImplicit = true;
322
+ warn(IMPLICIT_TLS_WARNING);
323
+ }
324
+
325
+ /** Test seam: forget that the warning was given. */
326
+ export function __resetTlsWarningForTests(): void {
327
+ warnedImplicit = false;
328
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The org's HTTPS edge: one Traefik that terminates TLS with the org's own CA
3
- * (mkcert, separate CAROOT) and routes `<name>.<domain>` to local services.
2
+ * The org's edge: one Traefik that terminates TLS as `edge.tls` says (none,
3
+ * mkcert or files) and routes `<name>.<domain>` to local services.
4
4
  * File-provider routes, plus Docker labels when `docker_labels` opts in
5
5
  * (through a read-only socket proxy; the edge never mounts the socket).
6
6
  *
@@ -15,6 +15,11 @@ export interface EdgePorts {
15
15
  https: number;
16
16
  }
17
17
 
18
+ export type EdgeTls =
19
+ | { strategy: "none" }
20
+ | { strategy: "mkcert"; ca: string | null }
21
+ | { strategy: "files"; ca: string | null };
22
+
18
23
  export interface EdgeConfig {
19
24
  /** Org slug — names the edge container and its Docker network. */
20
25
  org: string;
@@ -35,14 +40,25 @@ export interface EdgeConfig {
35
40
  /** Where the org's CA, certs and generated config live. */
36
41
  stateDir: string;
37
42
  /**
38
- * An existing mkcert CA to use instead of the org's own under `stateDir`
39
- * (e.g. a CA other devices already trust). The edge refuses to run if its
40
- * key is missing, so mkcert can never mint a replacement, and it never
41
- * uninstalls a shared CA.
43
+ * The state dir the config names, when DEN_EDGE_DIR overrides it with
44
+ * another (0.3.0-dev.40): an unlabelled edge is adopted only from that one.
45
+ */
46
+ defaultStateDir?: string;
47
+ /**
48
+ * How the edge gets its certificates: the `edge.tls` strategy (see
49
+ * tls.ts). `none`: http only, no https entrypoint. `mkcert`: the edge
50
+ * issues its own wildcard cert from a local CA (its own under `stateDir`,
51
+ * or `ca`, an existing one it never mints or uninstalls). `files`: the
52
+ * included dynamic config and mounts hold the certs; `ca` is the CA that
53
+ * signed them, if Den should trust it for health checks.
54
+ */
55
+ tls: EdgeTls;
56
+ /**
57
+ * Where `tls` came from: `explicit` (edge.tls), `legacy` (edge.caroot /
58
+ * edge.issue_cert, mapped), or `implicit` (neither: mkcert with a local CA,
59
+ * deprecated, warned once per run).
42
60
  */
43
- caroot: string | null;
44
- /** Issue the edge's own wildcard cert. Off when included config holds the certs. */
45
- issueCert: boolean;
61
+ tlsFrom: "explicit" | "legacy" | "implicit";
46
62
  /** Catch-all HTTP→HTTPS redirect. Off when included config decides per name. */
47
63
  httpsRedirect: boolean;
48
64
  /**
@@ -14,7 +14,13 @@ import * as path from "path";
14
14
  import * as yaml from "yaml";
15
15
  import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
16
16
  import { getDistRoot } from "../paths.js";
17
- import { requireGithubOrg } from "../den-config.js";
17
+ import { readDenConfig, requireGithubOrg } from "../den-config.js";
18
+ import {
19
+ parseRemote,
20
+ repoSource,
21
+ type WorkOrgsConfig,
22
+ } from "../org-sources.js";
23
+ import { checkedRemote, isGitRemote } from "./git-remote.js";
18
24
 
19
25
  /** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
20
26
 
@@ -77,6 +83,51 @@ export function resolveRepoGhSlug(
77
83
  return `${owner()}/${repoName}`;
78
84
  }
79
85
 
86
+ /**
87
+ * What `repoName` clones from (ORG-1d): a GitHub slug or a git URL (see
88
+ * git-remote.ts). The repo's source decides: a GitHub source gives its slug
89
+ * (`gh`, else `<owner>/<repo>`). A repo with no source and a `remote:` gives
90
+ * that remote, through plain git. Anything else is the legacy GitHub slug
91
+ * (resolveRepoGhSlug), so a catalog without `remote:` clones as before.
92
+ */
93
+ export function resolveRepoCloneSource(
94
+ repoName: string,
95
+ catalog: ServiceCatalog = readCatalog(),
96
+ owner: () => string = () => requireGithubOrg(),
97
+ orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
98
+ ): string {
99
+ const entry = catalog[repoName];
100
+ const gh = entry?.gh?.trim();
101
+ const remote = entry?.remote?.trim();
102
+ const src = repoSource(repoName, catalog, orgs ?? {});
103
+ if (src?.host === "github") {
104
+ if (gh) return gh;
105
+ const fromRemote = remote ? parseRemote(remote) : null;
106
+ return fromRemote?.host === "github"
107
+ ? `${fromRemote.owner}/${repoName}`
108
+ : `${src.owner ?? ""}/${repoName}`;
109
+ }
110
+ if (remote) return checkedRemote(repoName, remote);
111
+ if (src)
112
+ throw new Error(
113
+ `${repoName}: its org's source is on ${src.host}, which Den clones only ` +
114
+ `from a remote: — set ${repoName}.remote in the service catalog`,
115
+ );
116
+ return resolveRepoGhSlug(repoName, catalog, owner);
117
+ }
118
+
119
+ /**
120
+ * Whether a clone source may be written back as the catalog's `gh` (the
121
+ * empty-slug repair): only a GitHub slug, and only for an entry with no
122
+ * `gh` and no `remote` of its own.
123
+ */
124
+ export function needsGhRepair(
125
+ entry: ServiceCatalogEntry | undefined,
126
+ source: string,
127
+ ): boolean {
128
+ return !isGitRemote(source) && !entry?.gh?.trim() && !entry?.remote?.trim();
129
+ }
130
+
80
131
  export function removeCatalogEntry(repoName: string): void {
81
132
  const catalog = readCatalog();
82
133
  delete catalog[repoName];
@@ -22,8 +22,10 @@ import {
22
22
  import {
23
23
  githubOrgFor,
24
24
  readOrgContext,
25
+ resolveWorkspaceOrg,
25
26
  type OrgContext,
26
27
  } from "./workspace-org.js";
28
+ import { orgHasGithub } from "../org-sources.js";
27
29
  import type {
28
30
  ServiceCatalog,
29
31
  WorkspaceConfig,
@@ -71,6 +73,16 @@ function blueprintServices(
71
73
  return services;
72
74
  }
73
75
 
76
+ /**
77
+ * The blueprint's `stack_namespace`, carried into the seeded workspace so
78
+ * its stacks keep that namespace on this machine ({} when it sets none).
79
+ */
80
+ function blueprintStackNamespace(
81
+ bp: ResolvedWorkspaceBlueprint,
82
+ ): Pick<WorkspaceConfig, "stack_namespace"> {
83
+ return bp.stackNamespace ? { stack_namespace: bp.stackNamespace } : {};
84
+ }
85
+
74
86
  /**
75
87
  * The clone slug seeding would give each repo the catalog has no `gh` for:
76
88
  * `<org>/<repo>` with its workspace's resolved org. A repo in several
@@ -83,11 +95,17 @@ export function catalogFill(
83
95
  ctx: OrgContext = readOrgContext(),
84
96
  ): Map<string, string> {
85
97
  const fill = new Map<string, string>();
86
- for (const ws of workspaces)
98
+ for (const ws of workspaces) {
99
+ // ORG-1d: an org with no GitHub source has no slug to give; its repos
100
+ // clone from their catalog `remote:`.
101
+ const org = resolveWorkspaceOrg(ws.name, ctx).org;
102
+ if (org && !orgHasGithub(org, ctx.config.orgs)) continue;
87
103
  for (const repo of ws.repos) {
88
- if (catalog[repo]?.gh || fill.has(repo)) continue;
104
+ if (catalog[repo]?.gh || catalog[repo]?.remote || fill.has(repo))
105
+ continue;
89
106
  fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
90
107
  }
108
+ }
91
109
  return fill;
92
110
  }
93
111
 
@@ -166,7 +184,8 @@ function sameEntry(a: unknown, b: unknown): boolean {
166
184
  /**
167
185
  * Whether seeding now would change nothing: every workspace the blueprint
168
186
  * seeds holds exactly what seeding writes (same repos, all managed on the
169
- * blueprint's branch, same autostart defaults), records the blueprint as
187
+ * blueprint's branch, same autostart defaults, its stack_namespace),
188
+ * records the blueprint as
170
189
  * applied, and (multi blueprint) is active. A workspace the user changed
171
190
  * since (a repo switched to a local checkout, one added or removed) is not
172
191
  * "already seeded": seeding would reset it.
@@ -188,6 +207,8 @@ export function blueprintAlreadySeeded(
188
207
  bp.repos.some((r, i) => applied[i] !== r)
189
208
  )
190
209
  return false;
210
+ if (bp.stackNamespace && ws.stack_namespace !== bp.stackNamespace)
211
+ return false;
191
212
  return sameEntry(ws.services ?? {}, blueprintServices(bp));
192
213
  });
193
214
  }
@@ -214,6 +235,7 @@ export function seedDefaultWorkspace(): string[] {
214
235
  ...(file.workspaces.default ?? {}),
215
236
  services: blueprintServices(bp),
216
237
  seeded_repos: [...repos],
238
+ ...blueprintStackNamespace(bp),
217
239
  };
218
240
  if (!file.active?.length) {
219
241
  file.active = ["default"];
@@ -240,6 +262,7 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
240
262
  ...(prev ?? {}),
241
263
  services: blueprintServices(bp),
242
264
  seeded_repos: [...bp.repos],
265
+ ...blueprintStackNamespace(bp),
243
266
  };
244
267
  }
245
268
  const names = bps.map((b) => b.name);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a managed clone is made from (ORG-1d): a GitHub slug (`owner/repo`,
3
+ * cloned from github.com with the user's gh as credential helper) or a git
4
+ * URL from the catalog's `remote:` (cloned through plain git and the user's
5
+ * own credential helper, never Den's or gh's: SEC-0).
6
+ *
7
+ * Clone commands run through a shell, so a remote must pass REMOTE_RE: a URL
8
+ * with a known transport, scp-style `user@host:path`, or an absolute path,
9
+ * and nothing a shell or git would read as a command (`ext::`, quotes, `$`,
10
+ * spaces, a leading `-`).
11
+ */
12
+
13
+ /** A GitHub slug, as the clone guards accept it. */
14
+ export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
15
+
16
+ const REMOTE_RE =
17
+ /^(?:(?:https?|ssh|git|file):\/\/[A-Za-z0-9._~@:%+/-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~+/-]+|\/[A-Za-z0-9._~@+/-]+)$/;
18
+
19
+ /** True when `spec` is a git URL rather than a GitHub slug. */
20
+ export function isGitRemote(spec: string): boolean {
21
+ return !SAFE_SLUG_RE.test(spec);
22
+ }
23
+
24
+ /** True when `remote` is a git URL Den will hand to git. */
25
+ export function isSafeRemote(remote: string): boolean {
26
+ return REMOTE_RE.test(remote) && !remote.includes("..");
27
+ }
28
+
29
+ /**
30
+ * `remote` for display: any user info (`https://user:token@host/…`) masked.
31
+ * Remotes can carry a token, so one is never printed as written.
32
+ */
33
+ export function redactRemote(remote: string): string {
34
+ return remote.replace(/^([a-z+]+:\/\/)[^@/]+@/i, "$1***@");
35
+ }
36
+
37
+ /**
38
+ * The checked remote for `repo`, or a throw naming its catalog key (and not
39
+ * its value, which may hold a token).
40
+ */
41
+ export function checkedRemote(repo: string, remote: string): string {
42
+ const r = remote.trim();
43
+ if (!isSafeRemote(r))
44
+ throw new Error(
45
+ `service catalog: ${repo}.remote is not a git URL Den can clone ` +
46
+ `(https://, ssh://, git://, file://, user@host:path or an absolute path)`,
47
+ );
48
+ return r;
49
+ }