@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
@@ -5,12 +5,22 @@
5
5
  *
6
6
  * On a fresh machine (no workspace set up yet, a blueprint in the config)
7
7
  * the plan is what `<cli> workspace seed` and then setup would do, for every
8
- * blueprint workspace. Otherwise it is the focused workspace's repos, the
9
- * ones setup works on.
8
+ * blueprint workspace (`-w` narrows it). Otherwise it is the focused
9
+ * workspace's repos, the ones setup works on, or the `-w` / `--all-active`
10
+ * ones; repos only other workspaces hold are named in a hint.
10
11
  */
11
12
  import * as fs from "fs";
12
13
  import * as path from "path";
13
- import { readCatalog } from "../../shared/environment-manager/catalog.js";
14
+ import {
15
+ readCatalog,
16
+ resolveRepoCloneSource,
17
+ } from "../../shared/environment-manager/catalog.js";
18
+ import {
19
+ isGitRemote,
20
+ redactRemote,
21
+ } from "../../shared/environment-manager/git-remote.js";
22
+ import { readDenConfig } from "../../shared/den-config.js";
23
+ import type { WorkOrgsConfig } from "../../shared/org-sources.js";
14
24
  import {
15
25
  blueprintWorkspaces,
16
26
  catalogFill,
@@ -32,6 +42,7 @@ import {
32
42
  } from "../../shared/environment-manager/workspace-org.js";
33
43
  import { orgOf } from "../../shared/manifest/service-links.js";
34
44
  import type { ServiceCatalog } from "../../shared/types/index.js";
45
+ import { plural } from "../../shared/plural.js";
35
46
 
36
47
  export type ClonePlanState =
37
48
  | "cloned"
@@ -64,6 +75,39 @@ export interface ClonePlan {
64
75
  /** True when planned from the blueprint (nothing seeded yet). */
65
76
  fromBlueprint: boolean;
66
77
  workspaces: ClonePlanWorkspace[];
78
+ /** Repos held only by workspaces outside the plan, per workspace. */
79
+ outside?: { workspace: string; repos: string[] }[];
80
+ }
81
+
82
+ /** Which workspaces `setup --plan` plans (default: the focused one). */
83
+ export interface PlanSelection {
84
+ /** `-w <ws>`, repeatable. */
85
+ workspaces?: readonly string[];
86
+ /** `--all-active`: every active workspace (plus any `-w` one). */
87
+ allActive?: boolean;
88
+ }
89
+
90
+ /**
91
+ * The branch a checkout has out, read from its HEAD file (no git process):
92
+ * the branch name, `detached <sha7>`, or null when it can't be read.
93
+ */
94
+ export function checkoutBranch(dir: string): string | null {
95
+ try {
96
+ let gitDir = path.join(dir, ".git");
97
+ if (fs.statSync(gitDir).isFile()) {
98
+ // A worktree or submodule: .git names the real git dir.
99
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, "utf-8"));
100
+ if (!m) return null;
101
+ gitDir = path.resolve(dir, m[1].trim());
102
+ }
103
+ const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf-8").trim();
104
+ const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
105
+ if (ref) return ref[1];
106
+ if (/^[0-9a-f]{40,64}$/.test(head)) return `detached ${head.slice(0, 7)}`;
107
+ return null;
108
+ } catch {
109
+ return null;
110
+ }
67
111
  }
68
112
 
69
113
  const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
@@ -72,22 +116,33 @@ function cloneDir(repo: string, branch: string): string {
72
116
  return path.join(DEN_SERVICES_DIR, repo, branch);
73
117
  }
74
118
 
75
- /** The slug setup and up would clone `repo` from, or null without an org. */
76
- function slugFor(
119
+ /**
120
+ * What setup and up would clone `repo` from (a GitHub slug, or an ORG-1d
121
+ * `remote:`), or null without an org. `owner` gives the slug's owner for a
122
+ * repo with neither `gh` nor `remote`.
123
+ */
124
+ function sourceFor(
77
125
  repo: string,
78
- workspace: string,
79
126
  catalog: ServiceCatalog,
80
127
  ctx: OrgContext,
128
+ owner: () => string,
81
129
  ): string | null {
82
- const gh = catalog[repo]?.gh?.trim();
83
- if (gh) return gh;
84
130
  try {
85
- return `${githubOrgFor(workspace, ctx)}/${repo}`;
131
+ return resolveRepoCloneSource(repo, catalog, owner, ctx.config.orgs);
86
132
  } catch {
87
133
  return null;
88
134
  }
89
135
  }
90
136
 
137
+ function slugFor(
138
+ repo: string,
139
+ workspace: string,
140
+ catalog: ServiceCatalog,
141
+ ctx: OrgContext,
142
+ ): string | null {
143
+ return sourceFor(repo, catalog, ctx, () => githubOrgFor(workspace, ctx));
144
+ }
145
+
91
146
  function managedRow(
92
147
  repo: string,
93
148
  branch: string,
@@ -96,12 +151,13 @@ function managedRow(
96
151
  ): ClonePlanRow {
97
152
  const dir = cloneDir(repo, branch);
98
153
  const cloned = fs.existsSync(dir);
154
+ const remote = slug !== null && isGitRemote(slug);
99
155
  return {
100
156
  repo,
101
157
  mode: "managed",
102
158
  branch,
103
- owner: slug ? ownerOf(slug) : null,
104
- url: slug ? ghCloneUrl(slug) : null,
159
+ owner: !slug ? null : remote ? null : ownerOf(slug),
160
+ url: !slug ? null : remote ? redactRemote(slug) : ghCloneUrl(slug),
105
161
  state: cloned
106
162
  ? "cloned"
107
163
  : !slug
@@ -135,7 +191,9 @@ export function workspaceClonePlan(
135
191
  rows.push({
136
192
  repo: repoName,
137
193
  mode: "dev-managed",
138
- branch,
194
+ // Den doesn't set a checkout's branch: say what it has out.
195
+ branch:
196
+ (entry.path && checkoutBranch(entry.path)) || "(branch unknown)",
139
197
  owner: owner || null,
140
198
  url: null,
141
199
  state: "checkout",
@@ -175,28 +233,88 @@ function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
175
233
  managedRow(
176
234
  repo,
177
235
  bp.branch,
178
- ctx.catalog[repo]?.gh?.trim() || fill.get(repo) || null,
236
+ sourceFor(repo, ctx.catalog, ctx, () => {
237
+ const slug = fill.get(repo);
238
+ if (!slug) throw new Error(`no org for ${repo}`);
239
+ return ownerOf(slug);
240
+ }),
179
241
  true, // seeding writes every blueprint repo into the catalog
180
242
  ),
181
243
  ),
182
244
  }));
183
245
  }
184
246
 
185
- /** `<cli> setup --plan`: the plan for `names` (every repo when empty). */
186
- export function buildSetupPlan(names: readonly string[] = []): ClonePlan {
247
+ /**
248
+ * `<cli> setup --plan`: the plan for `names` (every repo when empty) in the
249
+ * selected workspaces. Throws on a workspace that does not exist.
250
+ */
251
+ export function buildSetupPlan(
252
+ names: readonly string[] = [],
253
+ sel: PlanSelection = {},
254
+ ): ClonePlan {
187
255
  const ctx = readOrgContext();
188
- if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0)
189
- return { fromBlueprint: true, workspaces: blueprintClonePlan(ctx) };
190
- const ws = getActiveWorkspace();
191
- if (!ws) return { fromBlueprint: false, workspaces: [] };
256
+ const want = [...new Set(sel.workspaces ?? [])];
257
+ if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0) {
258
+ const all = blueprintClonePlan(ctx);
259
+ const unknown = want.filter((n) => !all.some((w) => w.name === n));
260
+ if (unknown.length)
261
+ throw new Error(
262
+ `no blueprint workspace named ${unknown.join(", ")} (the blueprint has ${all.map((w) => w.name).join(", ")})`,
263
+ );
264
+ return {
265
+ fromBlueprint: true,
266
+ workspaces: want.length ? all.filter((w) => want.includes(w.name)) : all,
267
+ };
268
+ }
269
+ const focused = getActiveWorkspace()?.name;
270
+ const planned = [
271
+ ...new Set(
272
+ sel.allActive
273
+ ? [...ctx.file.active, ...want]
274
+ : want.length
275
+ ? want
276
+ : focused
277
+ ? [focused]
278
+ : [],
279
+ ),
280
+ ];
281
+ const unknown = planned.filter((n) => !ctx.file.workspaces[n]);
282
+ if (unknown.length)
283
+ throw new Error(
284
+ `no workspace named ${unknown.join(", ")} (workspaces: ${Object.keys(ctx.file.workspaces).join(", ") || "none"})`,
285
+ );
286
+ const workspaces = planned.map((n) =>
287
+ workspaceClonePlan(n, names.length ? names : undefined, ctx),
288
+ );
192
289
  return {
193
290
  fromBlueprint: false,
194
- workspaces: [
195
- workspaceClonePlan(ws.name, names.length ? names : undefined, ctx),
196
- ],
291
+ workspaces,
292
+ outside: outsideRepos(ctx, planned, workspaces),
197
293
  };
198
294
  }
199
295
 
296
+ /** Repos that only workspaces outside `planned` hold, per workspace. */
297
+ function outsideRepos(
298
+ ctx: OrgContext,
299
+ planned: readonly string[],
300
+ workspaces: readonly ClonePlanWorkspace[],
301
+ ): { workspace: string; repos: string[] }[] {
302
+ const inPlan = new Set(workspaces.flatMap((w) => w.rows.map((r) => r.repo)));
303
+ const out: { workspace: string; repos: string[] }[] = [];
304
+ for (const [name, ws] of Object.entries(ctx.file.workspaces)) {
305
+ if (planned.includes(name)) continue;
306
+ const repos = [
307
+ ...new Set(
308
+ Object.keys(ws?.services ?? {}).map(
309
+ (a) => parseAddress(a, ctx.catalog).repoName,
310
+ ),
311
+ ),
312
+ ].filter((r) => !inPlan.has(r));
313
+ if (repos.length) out.push({ workspace: name, repos });
314
+ }
315
+ return out;
316
+ }
317
+
200
318
  const STATE_TEXT: Record<ClonePlanState, string> = {
201
319
  cloned: "cloned",
202
320
  "would-clone": "would clone",
@@ -210,6 +328,7 @@ export function renderClonePlan(
210
328
  plan: ClonePlan,
211
329
  cli: string,
212
330
  catalog: ServiceCatalog = readCatalog(),
331
+ orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
213
332
  ): string[] {
214
333
  const out: string[] = [];
215
334
  if (plan.fromBlueprint)
@@ -230,7 +349,7 @@ export function renderClonePlan(
230
349
  ? ` ${r.path}`
231
350
  : ""
232
351
  : "";
233
- const owner = r.owner ?? orgOf(r.repo, catalog) ?? "";
352
+ const owner = r.owner ?? orgOf(r.repo, catalog, orgs) ?? "";
234
353
  out.push(
235
354
  ` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
236
355
  `${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
@@ -238,6 +357,18 @@ export function renderClonePlan(
238
357
  }
239
358
  for (const w of ws.warnings) out.push(` ⚠ ${w}`);
240
359
  }
360
+ const outside = plan.outside ?? [];
361
+ if (outside.length) {
362
+ const n = new Set(outside.flatMap((o) => o.repos)).size;
363
+ out.push(
364
+ "",
365
+ `Not in this plan: ${plural(n, "repo")} in other workspaces: ` +
366
+ outside
367
+ .map((o) => `${o.workspace} (${o.repos.join(", ")})`)
368
+ .join(", ") +
369
+ `. Plan them with -w <workspace> or --all-active.`,
370
+ );
371
+ }
241
372
  out.push("", "Plan only: nothing was cloned, seeded or written.");
242
373
  return out;
243
374
  }
@@ -146,17 +146,26 @@ export function renderDistribution(
146
146
  ...bannerLines(title).map((l) => ` - "${l}"`),
147
147
  "",
148
148
  "# The GitHub org (or user) that owns the repos Den clones and lists.",
149
- "github_org: your-github-org",
149
+ "# Unset, Den lists no GitHub repos (and never calls gh for them). E.g.",
150
+ "# github_org: example-org",
150
151
  "",
151
- "# Secrets backend: env reads plain environment variables; doppler and",
152
- "# 1password are the others.",
152
+ "# Secrets backend: none (this distribution needs no secrets store), or",
153
+ "# env (plain environment variables), doppler or 1password. A backend's",
154
+ "# refs say where Den's own features find their secrets, e.g.",
155
+ `# refs: { sudo: { key: ${prefix}_LOCAL_ADMIN_PWD } }`,
153
156
  "secrets:",
154
- " provider: env",
157
+ " provider: none",
155
158
  "",
156
- "# This instance's HTTPS edge: https://<service>.<domain>.",
159
+ "# This instance's edge: <service>.<domain>, one Traefik for its routes.",
157
160
  "edge:",
158
161
  ` org: ${name}`,
159
162
  ` domain: ${name}.localhost`,
163
+ " # TLS: none serves http only. For https, pick a strategy:",
164
+ " # tls: { strategy: mkcert } # a local CA, per machine",
165
+ " # tls: { strategy: mkcert, ca: ~/path/to/ca } # an existing mkcert CA",
166
+ " # tls: { strategy: files, include: edge/dynamic, mounts: { <host>: <container> } }",
167
+ " # # your own certs, in included Traefik config",
168
+ " tls: none",
160
169
  "",
161
170
  "environments: [local]",
162
171
  "",
@@ -344,13 +353,21 @@ export function runCreate(argv: string[], cwd: string): number {
344
353
  denVersion: runningDenVersion(),
345
354
  });
346
355
  const cli = args.cli ?? args.name;
347
- const rel = path.relative(cwd, args.dir) || ".";
356
+ // Relative only when the new directory is under the caller's cwd; a
357
+ // path that climbs out (../../..) is harder to read than the absolute one.
358
+ const rel = path.relative(cwd, args.dir);
359
+ const cdPath =
360
+ rel === ""
361
+ ? "."
362
+ : rel.startsWith("..") || path.isAbsolute(rel)
363
+ ? args.dir
364
+ : rel;
348
365
  console.log(
349
366
  `Created ${args.name} in ${args.dir} (${plural(written.length, "file")}).`,
350
367
  );
351
368
  console.log("");
352
369
  console.log("Next:");
353
- console.log(` cd ${rel}`);
370
+ console.log(` cd ${cdPath}`);
354
371
  console.log(" npm install");
355
372
  console.log(` ./bin/${cli} --help`);
356
373
  return 0;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `doctor`: every strategy kind in the registry (secrets, tls), the
3
+ * strategy this distribution selects for it, whether nothing chose it
4
+ * (implicit), and its detect() result with the reason.
5
+ *
6
+ * Read-only: detect() is a PATH check, so doctor never spawns a backend
7
+ * CLI, never prompts, and never prints a secret value. Exit 1 when a
8
+ * strategy that will be used is unavailable or its config is invalid.
9
+ */
10
+ import chalk from "chalk";
11
+ import { readBrand } from "../../shared/brand.js";
12
+ import { hasEdgeBlock, readEdgeConfig } from "../../shared/edge/config.js";
13
+ import { edgeTlsWarnings, tlsStrategyFor } from "../../shared/edge/tls.js";
14
+ import {
15
+ readSecretsConfig,
16
+ secretsStatus,
17
+ } from "../../shared/secrets/index.js";
18
+ import { strategyKinds } from "../../shared/strategies/registry.js";
19
+
20
+ export interface DoctorRow {
21
+ kind: string;
22
+ /** The selected strategy id ("" when the config could not be read). */
23
+ selected: string;
24
+ implicit: boolean;
25
+ available: boolean;
26
+ reason?: string;
27
+ /** True when it will be used and cannot work: doctor exits 1. */
28
+ problem: boolean;
29
+ warnings: string[];
30
+ }
31
+
32
+ type Selector = (env: NodeJS.ProcessEnv) => DoctorRow;
33
+
34
+ const SELECTORS: Record<string, Selector> = {
35
+ secrets(env) {
36
+ const st = secretsStatus(readSecretsConfig(), env);
37
+ return {
38
+ kind: "secrets",
39
+ selected: st.backend,
40
+ implicit: st.implicit,
41
+ available: st.detect.available,
42
+ reason: st.detect.reason,
43
+ // The implicit default is used only when a secret is asked for.
44
+ problem: !st.detect.available && !st.implicit,
45
+ warnings: [],
46
+ };
47
+ },
48
+ tls(env) {
49
+ if (!hasEdgeBlock())
50
+ return {
51
+ kind: "tls",
52
+ selected: "none",
53
+ implicit: false,
54
+ available: true,
55
+ reason: "no edge: block",
56
+ problem: false,
57
+ warnings: [],
58
+ };
59
+ try {
60
+ const cfg = readEdgeConfig();
61
+ const d = tlsStrategyFor(cfg).detect(env);
62
+ return {
63
+ kind: "tls",
64
+ selected: cfg.tls.strategy,
65
+ implicit: cfg.tlsFrom === "implicit",
66
+ available: d.available,
67
+ reason: d.reason,
68
+ problem: !d.available,
69
+ warnings: edgeTlsWarnings(cfg),
70
+ };
71
+ } catch (err) {
72
+ return {
73
+ kind: "tls",
74
+ selected: "",
75
+ implicit: false,
76
+ available: false,
77
+ reason: (err as Error).message,
78
+ problem: true,
79
+ warnings: [],
80
+ };
81
+ }
82
+ },
83
+ };
84
+
85
+ /**
86
+ * One row per registered kind (secrets, then tls, then any other kind in
87
+ * registration order). Never spawns, never throws.
88
+ */
89
+ export function doctorRows(env: NodeJS.ProcessEnv = process.env): DoctorRow[] {
90
+ const kinds = strategyKinds();
91
+ const ordered = [
92
+ ...Object.keys(SELECTORS).filter((k) => kinds.includes(k)),
93
+ ...kinds.filter((k) => !(k in SELECTORS)),
94
+ ];
95
+ return ordered.map(
96
+ (kind) =>
97
+ SELECTORS[kind]?.(env) ?? {
98
+ kind,
99
+ selected: "",
100
+ implicit: false,
101
+ available: false,
102
+ reason: "no selector for this kind",
103
+ problem: false,
104
+ warnings: [],
105
+ },
106
+ );
107
+ }
108
+
109
+ export function formatDoctor(rows: DoctorRow[], title: string): string[] {
110
+ const lines = [chalk.bold(`${title} doctor: strategies`), ""];
111
+ const width = Math.max(...rows.map((r) => r.kind.length));
112
+ for (const r of rows) {
113
+ const sel = r.selected
114
+ ? `${r.selected}${r.implicit ? " (implicit)" : ""}`
115
+ : "(invalid config)";
116
+ const state = r.available
117
+ ? chalk.green("✓ available")
118
+ : (r.problem ? chalk.red : chalk.yellow)("✗ unavailable");
119
+ lines.push(
120
+ ` ${r.kind.padEnd(width)} ${sel.padEnd(20)} ${state}${r.reason ? `: ${r.reason}` : ""}`,
121
+ );
122
+ for (const w of r.warnings) lines.push(chalk.yellow(` ⚠ ${w}`));
123
+ }
124
+ return lines;
125
+ }
126
+
127
+ export function cmdDoctor(_args: string[]): number {
128
+ const rows = doctorRows();
129
+ for (const l of formatDoctor(rows, readBrand().name)) console.log(l);
130
+ return rows.some((r) => r.problem) ? 1 : 0;
131
+ }
@@ -1,7 +1,11 @@
1
1
  /**
2
- * `edge up | down | status | trust | untrust | routes`
2
+ * `edge up [--take-over] | down | status | trust | untrust | routes`
3
3
  *
4
- * This org's HTTPS edge: own CA + cert, routes at `<name>.<edge.domain>`.
4
+ * This org's edge: routes at `<name>.<edge.domain>`, TLS as `edge.tls` says
5
+ * (none, mkcert or files). `trust` / `untrust` exist only under mkcert.
6
+ * `routes` lists Den's own routes, then the routers in the included
7
+ * dynamic files, each with its source file, then the stacks' routers
8
+ * (`stack-*.yml`, 0.3.0-dev.32), each marked "stack" with its file.
5
9
  */
6
10
  import chalk from "chalk";
7
11
  import { readBrand } from "../../shared/brand.js";
@@ -15,18 +19,37 @@ import {
15
19
  trustCa,
16
20
  untrustCa,
17
21
  } from "../../shared/edge/runtime.js";
18
- import { edgeStatus } from "../../shared/edge/status.js";
22
+ import {
23
+ includedRouters,
24
+ stackRouters,
25
+ routerUrls,
26
+ type IncludedRouter,
27
+ } from "../../shared/edge/included.js";
28
+ import { edgeRoutes, edgeStatus } from "../../shared/edge/status.js";
29
+ import { tlsYaml } from "../../shared/edge/tls.js";
30
+ import { rejoinRunningStacks } from "../../shared/stacks/status.js";
31
+
32
+ /** An included router's URLs (the hosts its rule names), else its name. */
33
+ function includedUrl(r: IncludedRouter): string {
34
+ return r.hosts.length ? routerUrls(r).join(", ") : r.name;
35
+ }
19
36
 
20
37
  export async function cmdEdge(args: string[]): Promise<void> {
21
38
  const sub = args[0] ?? "status";
22
39
  const cfg = readEdgeConfig();
23
40
  const n = edgeNames(cfg.org);
24
41
  const published = cfg.ports
25
- ? `host :${cfg.ports.http} / :${cfg.ports.https}`
42
+ ? cfg.tls.strategy === "none"
43
+ ? `host :${cfg.ports.http} (http only)`
44
+ : `host :${cfg.ports.http} / :${cfg.ports.https}`
26
45
  : `not published (reach it on Docker network ${n.network})`;
27
46
  switch (sub) {
28
47
  case "up": {
29
- const { certs } = await edgeUp(cfg);
48
+ // --take-over: the only way to replace an edge another state dir
49
+ // owns (0.3.0-dev.40); never implied.
50
+ const { certs } = await edgeUp(cfg, {
51
+ takeOver: args.includes("--take-over"),
52
+ });
30
53
  console.log(
31
54
  chalk.green(
32
55
  `✓ ${n.container} up — ${edgeDomains(cfg)
@@ -37,7 +60,7 @@ export async function cmdEdge(args: string[]): Promise<void> {
37
60
  console.log(` ports ${published}`);
38
61
  if (certs.issued)
39
62
  console.log(` cert issued for ${certs.names.join(", ")}`);
40
- if (caTrusted(cfg) === false)
63
+ if (cfg.tls.strategy === "mkcert" && caTrusted(cfg) === false)
41
64
  console.log(
42
65
  chalk.yellow(
43
66
  " CA not trusted yet — run `edge trust` (asks for your password once)",
@@ -45,6 +68,8 @@ export async function cmdEdge(args: string[]): Promise<void> {
45
68
  );
46
69
  for (const name of ["edge", ...Object.keys(cfg.routes)])
47
70
  console.log(` ${routeUrl(name, cfg)}`);
71
+ // Running stacks' routes stay up: the edge rejoins their networks.
72
+ rejoinRunningStacks();
48
73
  return;
49
74
  }
50
75
  case "down":
@@ -57,10 +82,25 @@ export async function cmdEdge(args: string[]): Promise<void> {
57
82
  case "untrust":
58
83
  process.exitCode = untrustCa(cfg);
59
84
  return;
60
- case "routes":
61
- for (const r of edgeStatus(cfg).routes)
85
+ case "routes": {
86
+ for (const r of edgeRoutes(cfg))
62
87
  console.log(`${r.url.padEnd(52)} → ${r.upstream}`);
88
+ // Routers in the included dynamic files: Traefik serves them too.
89
+ const inc = includedRouters(cfg);
90
+ for (const r of inc.routers)
91
+ console.log(
92
+ `${includedUrl(r).padEnd(52)} → ${r.upstream || "?"} (${r.file})`,
93
+ );
94
+ // Routers the stacks wrote (stack up): neither Den's nor included.
95
+ const stk = stackRouters(cfg);
96
+ for (const r of stk.routers)
97
+ console.log(
98
+ `${includedUrl(r).padEnd(52)} → ${r.upstream || "?"} (stack: ${r.file})`,
99
+ );
100
+ for (const e of [...inc.errors, ...stk.errors])
101
+ console.log(chalk.yellow(`${e.file}: not read (${e.error})`));
63
102
  return;
103
+ }
64
104
  case "status": {
65
105
  const st = edgeStatus(cfg);
66
106
  console.log(chalk.bold(`${readBrand().name} edge — ${st.org}`));
@@ -68,14 +108,20 @@ export async function cmdEdge(args: string[]): Promise<void> {
68
108
  console.log(` ports ${published}`);
69
109
  console.log(` domains ${st.domains.join(", ")} (+ every subdomain)`);
70
110
  console.log(
71
- ` CA ${caDir(cfg)} — ${st.caTrusted === null ? "unknown" : st.caTrusted ? "trusted" : "NOT trusted"}`,
111
+ ` tls ${tlsYaml(cfg.tls).replace(/^tls: /, "")}${cfg.tlsFrom === "implicit" ? " (implicit: edge.tls is not set)" : ""}`,
72
112
  );
113
+ if (cfg.tls.strategy !== "none")
114
+ console.log(
115
+ // Fronted (edge.publish: false): the proxy in front owns
116
+ // browser trust (BOOT-1), so this machine's trust is moot.
117
+ ` CA ${caDir(cfg)} — ${!st.ports ? "not needed: fronted" : st.caTrusted === null ? "unknown" : st.caTrusted ? "trusted" : "NOT trusted"}`,
118
+ );
73
119
  if (st.certIssued) console.log(` cert ${st.certNames.join(", ")}`);
74
120
  return;
75
121
  }
76
122
  default:
77
123
  throw new Error(
78
- `Unknown edge command "${sub}". Use: up | down | status | trust | untrust | routes`,
124
+ `Unknown edge command "${sub}". Use: up [--take-over] | down | status | trust | untrust | routes`,
79
125
  );
80
126
  }
81
127
  }