@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,132 @@
1
+ /**
2
+ * What starting a repo runs (0.3.0-dev.35; backported to 0.2.8): the one
3
+ * decision `up <repo>` (its dependency closure) and `up -a` / `-A` share.
4
+ *
5
+ * - the repo's own `up` → the repo
6
+ * - none, but its services have one → those services: all of them, or,
7
+ * for a repo started only as a
8
+ * dependency, the ones its
9
+ * dependents name (`depends_on:
10
+ * [acme/api]`); every one when any
11
+ * dependent needs the repo as a
12
+ * whole
13
+ * - no up at all → skipped ("no local up command")
14
+ *
15
+ * A repo that can't be resolved in the workspace is passed through
16
+ * unchanged, so the runner reports it as before. Reads manifests from disk:
17
+ * call it once the start set's clones exist.
18
+ */
19
+ import { readManifest } from "../manifest/read-manifest.js";
20
+ import { depService } from "../manifest/deps.js";
21
+ import type { DerivedGraph } from "./derivation/types.js";
22
+ import {
23
+ getWorkspaceServices,
24
+ previewServiceCommand,
25
+ resolveService,
26
+ } from "./service-runner.js";
27
+
28
+ export interface UpTargets {
29
+ /** The start order, a repo replaced by its services where it has no up. */
30
+ order: string[];
31
+ /** Repos with no up at all (said and skipped, never fatal). */
32
+ skippedNoUp: string[];
33
+ /** repo → the service addresses that start in its place. */
34
+ expanded: Map<string, string[]>;
35
+ /**
36
+ * A list of dependency repos as the start set has them: an expanded repo
37
+ * becomes its services, a skipped one is dropped (nothing to wait on).
38
+ */
39
+ depsAs(deps: string[]): string[];
40
+ }
41
+
42
+ /** The repo part of an address ("acme/api" → "acme"). */
43
+ export const repoOfAddress = (address: string): string => address.split("/")[0];
44
+
45
+ /**
46
+ * @param order repos in start order
47
+ * @param opts.targets repos started for their own sake (the seed, or every
48
+ * `-a` candidate): all their services start
49
+ * @param opts.graph the derived graph: who depends on whom
50
+ */
51
+ export function resolveUpTargets(
52
+ order: string[],
53
+ workspace: string,
54
+ opts: { targets: ReadonlySet<string>; graph?: DerivedGraph },
55
+ ): UpTargets {
56
+ const services = getWorkspaceServices(workspace);
57
+ const expanded = new Map<string, string[]>();
58
+ const skippedNoUp: string[] = [];
59
+ const out: string[] = [];
60
+ const runs = (address: string): boolean =>
61
+ previewServiceCommand(address, "up", workspace).kind === "run";
62
+ for (const repo of order) {
63
+ const own = previewServiceCommand(repo, "up", workspace).kind;
64
+ if (
65
+ own === "run" ||
66
+ own === "via-repo" ||
67
+ !resolveService(repo, workspace)
68
+ ) {
69
+ out.push(repo);
70
+ continue;
71
+ }
72
+ const withUp = services
73
+ .filter((s) => s.repoName === repo && s.address !== repo)
74
+ .map((s) => s.address)
75
+ .filter(runs);
76
+ if (withUp.length === 0) {
77
+ skippedNoUp.push(repo);
78
+ continue;
79
+ }
80
+ const wanted = opts.targets.has(repo)
81
+ ? null
82
+ : namedByDependents(repo, order, workspace, opts.graph);
83
+ const start = wanted ? withUp.filter((a) => wanted.has(a)) : withUp;
84
+ const pick = start.length ? start : withUp;
85
+ expanded.set(repo, pick);
86
+ out.push(...pick);
87
+ }
88
+ const skipped = new Set(skippedNoUp);
89
+ return {
90
+ order: out,
91
+ skippedNoUp,
92
+ expanded,
93
+ depsAs: (deps) =>
94
+ deps.flatMap((d) => (skipped.has(d) ? [] : (expanded.get(d) ?? [d]))),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * The services of `repo` its dependents in `order` name, or null when one
100
+ * of them needs the repo as a whole (names it bare, or depends on it by a
101
+ * derived edge with no `depends_on` naming a service of it).
102
+ */
103
+ function namedByDependents(
104
+ repo: string,
105
+ order: string[],
106
+ workspace: string,
107
+ graph?: DerivedGraph,
108
+ ): Set<string> | null {
109
+ const named = new Set<string>();
110
+ const dependents = new Set(
111
+ (graph?.edges ?? [])
112
+ .filter((e) => e.to === repo && e.from !== repo)
113
+ .map((e) => e.from),
114
+ );
115
+ for (const dependent of order) {
116
+ if (dependent === repo) continue;
117
+ const resolved = resolveService(dependent, workspace);
118
+ const manifest = resolved ? readManifest(resolved.repoPath) : null;
119
+ let mine = 0;
120
+ for (const s of Object.values(manifest?.services ?? {}))
121
+ for (const dep of s?.depends_on ?? []) {
122
+ const d = depService(dep);
123
+ if (d === repo) return null;
124
+ if (repoOfAddress(d) === repo) {
125
+ named.add(d);
126
+ mine++;
127
+ }
128
+ }
129
+ if (dependents.has(dependent) && mine === 0) return null;
130
+ }
131
+ return named.size ? named : null;
132
+ }
@@ -22,6 +22,12 @@ import {
22
22
  } from "../process/spawn-streaming.js";
23
23
  import { buildServiceEnv } from "./service-env.js";
24
24
  import { assertSafeCloneDestination } from "./clone-guard.js";
25
+ import {
26
+ SAFE_SLUG_RE,
27
+ isGitRemote,
28
+ isSafeRemote,
29
+ redactRemote,
30
+ } from "./git-remote.js";
25
31
  import type { CommandDef } from "../types/index.js";
26
32
 
27
33
  // ── Terminal resolution (macOS) ──────────────────────────────────────────────
@@ -76,13 +82,45 @@ function resolveTerminalAppName(key: string): string | null {
76
82
 
77
83
  // ── Git helpers ───────────────────────────────────────────────────────────────
78
84
 
79
- function git(cmd: string, cwd?: string): void {
80
- execSync(`git -c credential.helper="!gh auth git-credential" ${cmd}`, {
85
+ const GH_CREDENTIAL = `-c credential.helper="!gh auth git-credential"`;
86
+
87
+ /**
88
+ * Run git with gh as an extra credential helper (a GitHub clone), or as
89
+ * plain git (`gh` false: a clone from a catalog `remote:`, which gets only
90
+ * the user's own credential helper, never gh's: SEC-0).
91
+ */
92
+ function git(cmd: string, cwd?: string, gh = true): void {
93
+ execSync(`git ${gh ? `${GH_CREDENTIAL} ` : ""}${cmd}`, {
81
94
  cwd,
82
95
  stdio: "inherit",
83
96
  });
84
97
  }
85
98
 
99
+ /** Set in a clone made from a `remote:`; its later fetches stay plain git. */
100
+ const PLAIN_MARK = "den.credential";
101
+
102
+ /** Whether a clone's fetches use gh: every clone but one Den made from a `remote:`. */
103
+ function usesGhCredential(repoPath: string): boolean {
104
+ try {
105
+ return (
106
+ execSync(`git config --local --get ${PLAIN_MARK}`, {
107
+ cwd: repoPath,
108
+ stdio: ["ignore", "pipe", "ignore"],
109
+ encoding: "utf-8",
110
+ }).trim() !== "git"
111
+ );
112
+ } catch {
113
+ return true; // unset: a GitHub clone, as every clone before ORG-1d
114
+ }
115
+ }
116
+
117
+ /** Clone `source` (a GitHub slug or a checked git URL) with its own credentials. */
118
+ function gitClone(args: string, source: string, dest: string): void {
119
+ const plain = isGitRemote(source);
120
+ git(`clone ${args}"${cloneUrlOf(source)}" "${dest}"`, undefined, !plain);
121
+ if (plain) git(`config --local ${PLAIN_MARK} git`, dest, false);
122
+ }
123
+
86
124
  function gitCapture(cmd: string, cwd?: string): string {
87
125
  return execSync(cmd, {
88
126
  cwd,
@@ -96,6 +134,16 @@ export function ghCloneUrl(ghSlug: string): string {
96
134
  return `https://github.com/${ghSlug}.git`;
97
135
  }
98
136
 
137
+ /** The URL a clone of `source` is made from: a GitHub slug's, or the remote itself. */
138
+ export function cloneUrlOf(source: string): string {
139
+ return isGitRemote(source) ? source : ghCloneUrl(source);
140
+ }
141
+
142
+ /** `source` for a log line: a slug as is, a remote with any user info masked. */
143
+ function shown(source: string): string {
144
+ return isGitRemote(source) ? redactRemote(source) : source;
145
+ }
146
+
99
147
  // ── Injection guards ──────────────────────────────────────────────────────────
100
148
  //
101
149
  // git() runs through a shell (execSync), so branch names and GitHub slugs
@@ -106,7 +154,7 @@ export function ghCloneUrl(ghSlug: string): string {
106
154
  // the single choke point for every clone path.
107
155
 
108
156
  export const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
109
- export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
157
+ export { SAFE_SLUG_RE };
110
158
 
111
159
  function assertSafeBranch(branch: string): void {
112
160
  if (!SAFE_BRANCH_RE.test(branch)) {
@@ -116,10 +164,12 @@ function assertSafeBranch(branch: string): void {
116
164
  }
117
165
  }
118
166
 
167
+ /** A GitHub slug, or (ORG-1d) a git URL from a catalog `remote:`. */
119
168
  function assertSafeSlug(ghSlug: string): void {
169
+ if (isGitRemote(ghSlug) && isSafeRemote(ghSlug)) return;
120
170
  if (!SAFE_SLUG_RE.test(ghSlug)) {
121
171
  throw new Error(
122
- `Invalid GitHub slug "${ghSlug}" — expected "owner/repo" using only letters, digits, ".", "_" and "-"`,
172
+ `Invalid GitHub slug "${redactRemote(ghSlug)}" — expected "owner/repo" using only letters, digits, ".", "_" and "-"`,
123
173
  );
124
174
  }
125
175
  }
@@ -427,17 +477,15 @@ export function ensureManagedServiceClone(
427
477
  if (!fs.existsSync(cloneDir)) {
428
478
  assertSafeCloneDestination(cloneDir, repoName);
429
479
  console.log(
430
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
480
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
431
481
  );
432
482
  fs.mkdirSync(path.join(DEN_SERVICES_DIR, repoName), {
433
483
  recursive: true,
434
484
  });
435
- git(
436
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
437
- );
485
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
438
486
  if (runSetup) runSetupIfDefined(cloneDir);
439
487
  } else {
440
- git("pull --ff-only", cloneDir);
488
+ git("pull --ff-only", cloneDir, usesGhCredential(cloneDir));
441
489
  }
442
490
 
443
491
  return cloneDir;
@@ -463,12 +511,10 @@ export function ensureManagedServiceCloneNewBranch(
463
511
  if (!fs.existsSync(cloneDir)) {
464
512
  assertSafeCloneDestination(cloneDir, repoName);
465
513
  console.log(
466
- ` ⬇ Cloning ${ghSlug}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
514
+ ` ⬇ Cloning ${shown(ghSlug)}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
467
515
  );
468
516
  fs.mkdirSync(path.dirname(cloneDir), { recursive: true });
469
- git(
470
- `clone --filter=blob:none --branch "${baseBranch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
471
- );
517
+ gitClone(`--filter=blob:none --branch "${baseBranch}" `, ghSlug, cloneDir);
472
518
  git(`checkout -b "${newBranch}"`, cloneDir);
473
519
  runSetupIfDefined(cloneDir);
474
520
  }
@@ -496,15 +542,13 @@ export function ensureDevManagedClone(
496
542
  if (!fs.existsSync(targetDir)) {
497
543
  const leafName = path.basename(targetDir);
498
544
  assertSafeCloneDestination(targetDir, leafName);
499
- console.log(` ⬇ Cloning ${ghSlug}@${branch} → ${targetDir}/`);
545
+ console.log(` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${targetDir}/`);
500
546
  fs.mkdirSync(path.dirname(targetDir), { recursive: true });
501
547
  try {
502
- git(
503
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${targetDir}"`,
504
- );
548
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, targetDir);
505
549
  } catch {
506
550
  // Branch may not exist — clone default branch and checkout/create the branch
507
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${targetDir}"`);
551
+ gitClone(`--filter=blob:none `, ghSlug, targetDir);
508
552
  try {
509
553
  git(`checkout "${branch}"`, targetDir);
510
554
  } catch {
@@ -538,17 +582,15 @@ export function ensureWorkspaceClone(
538
582
  if (!fs.existsSync(cloneDir)) {
539
583
  assertSafeCloneDestination(cloneDir, repoName);
540
584
  console.log(
541
- ` ⬇ Cloning ${ghSlug}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
585
+ ` ⬇ Cloning ${shown(ghSlug)}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
542
586
  );
543
587
  fs.mkdirSync(wsDir, { recursive: true });
544
588
 
545
589
  try {
546
- git(
547
- `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
548
- );
590
+ gitClone(`--filter=blob:none --branch "${branch}" `, ghSlug, cloneDir);
549
591
  } catch {
550
592
  // Branch doesn't exist yet — clone main and create the branch
551
- git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${cloneDir}"`);
593
+ gitClone(`--filter=blob:none `, ghSlug, cloneDir);
552
594
  git(`checkout -b "${branch}"`, cloneDir);
553
595
  console.log(` ✓ Created new branch ${branch} in ${repoName}`);
554
596
  }
@@ -660,7 +702,7 @@ export function pullRepo(repoPath: string): void {
660
702
  } catch {
661
703
  /* best-effort — a genuinely diverged clone still errors on the pull */
662
704
  }
663
- git("pull --ff-only", repoPath);
705
+ git("pull --ff-only", repoPath, usesGhCredential(repoPath));
664
706
  }
665
707
 
666
708
  /**
@@ -682,7 +724,7 @@ export function fetchAndFastForward(repoPath: string): {
682
724
  pulled: boolean;
683
725
  error?: string;
684
726
  } {
685
- const GH_CRED = `-c credential.helper="!gh auth git-credential"`;
727
+ const GH_CRED = usesGhCredential(repoPath) ? GH_CREDENTIAL : "";
686
728
  try {
687
729
  execSync(`git ${GH_CRED} fetch --quiet`, {
688
730
  cwd: repoPath,
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Which work org a workspace belongs to (ORG-1a, ORG-SPEC §2).
3
3
  *
4
- * An org is a key of `orgs:` or a GitHub owner. ORG-1a keeps today's model:
5
- * an org with no `sources` is the GitHub owner of the same name, and
4
+ * An org is a key of `orgs:` or a GitHub owner. An org with no `sources`
5
+ * and no `repos` is the GitHub owner of the same name (ORG-1a); ORG-1d's
6
+ * keys make it any name (org-sources.ts), and a repo's org is orgOf's.
6
7
  * `github_org` is the default org. A workspace's org resolves through this
7
8
  * chain, first match wins:
8
9
  *
@@ -25,6 +26,7 @@ import {
25
26
  import { readCatalog } from "./catalog.js";
26
27
  import { parseAddress, readWorkspacesFile } from "./workspaces.js";
27
28
  import { orgOf } from "../manifest/service-links.js";
29
+ import { githubOwnersOf, orgClaimingOwner } from "../org-sources.js";
28
30
  import { cachedRead } from "../read-cache.js";
29
31
  import type {
30
32
  ServiceCatalog,
@@ -71,12 +73,16 @@ export function readOrgContext(): OrgContext {
71
73
  };
72
74
  }
73
75
 
74
- /** `id` spelled as its `orgs:` key when one matches case-insensitively. */
76
+ /**
77
+ * `id` spelled as its `orgs:` key when one matches case-insensitively; a
78
+ * GitHub owner an ORG-1d source claims (`github_org: acme` with a source
79
+ * for acme) is that source's org.
80
+ */
75
81
  export function canonicalOrg(id: string, config: DenConfig): string {
76
82
  const key = Object.keys(config.orgs ?? {}).find(
77
83
  (k) => lower(k) === lower(id),
78
84
  );
79
- return key ?? id;
85
+ return key ?? orgClaimingOwner(id, config.orgs) ?? id;
80
86
  }
81
87
 
82
88
  /** The default org: `github_org`, or null when unset. */
@@ -104,7 +110,7 @@ function workspaceRepos(
104
110
  function knownOrgs(ctx: OrgContext): Set<string> {
105
111
  const ids = new Set(Object.keys(ctx.config.orgs ?? {}).map(lower));
106
112
  for (const repo of Object.keys(ctx.catalog)) {
107
- const o = orgOf(repo, ctx.catalog);
113
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
108
114
  if (o) ids.add(lower(o));
109
115
  }
110
116
  const d = ctx.config.github_org?.trim();
@@ -133,7 +139,7 @@ export function resolveWorkspaceOrg(
133
139
  const repos = workspaceRepos(ws, bp, ctx.catalog);
134
140
  const owners = new Map<string, string>(); // lowercased → first spelling
135
141
  for (const repo of repos) {
136
- const o = orgOf(repo, ctx.catalog);
142
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
137
143
  if (o && !owners.has(lower(o))) owners.set(lower(o), o);
138
144
  }
139
145
 
@@ -179,7 +185,7 @@ export function resolveWorkspaceOrg(
179
185
  (owners.size <= 1 || source === "workspace" || source === "blueprint")
180
186
  )
181
187
  for (const repo of repos) {
182
- const o = orgOf(repo, ctx.catalog);
188
+ const o = orgOf(repo, ctx.catalog, ctx.config.orgs);
183
189
  if (o && lower(o) !== lower(org))
184
190
  warnings.push(
185
191
  `workspace ${name}: repo ${repo} belongs to ${o}, not the workspace's org ${org}`,
@@ -209,16 +215,25 @@ export function resolveAllWorkspaceOrgs(ctx?: OrgContext): WorkspaceOrg[] {
209
215
 
210
216
  /**
211
217
  * The GitHub owner to clone and register a workspace's repos under: its
212
- * resolved org. With none at all (no org anywhere and no `github_org`) this
213
- * throws exactly as requireGithubOrg does, so an old config fails as before.
218
+ * resolved org, which for a legacy org is that GitHub owner. An org with
219
+ * ORG-1d `sources` gives its first GitHub source's owner, and one with no
220
+ * GitHub source throws (its repos clone from `remote:`; discovery is off).
221
+ * With no org at all (no org anywhere and no `github_org`) this throws
222
+ * exactly as requireGithubOrg does, so an old config fails as before.
214
223
  */
215
224
  export function githubOrgFor(workspace: string, ctx?: OrgContext): string {
216
225
  const hit = ctx
217
226
  ? resolveWorkspaceOrg(workspace, ctx)
218
227
  : (resolveAllWorkspaceOrgs().find((w) => w.workspace === workspace) ??
219
228
  resolveWorkspaceOrg(workspace, readOrgContext()));
220
- if (hit.org) return hit.org;
221
- return requireGithubOrg(ctx?.config);
229
+ if (!hit.org) return requireGithubOrg(ctx?.config);
230
+ const orgs = (ctx?.config ?? readDenConfig()).orgs;
231
+ const owner = githubOwnersOf(hit.org, orgs)[0];
232
+ if (owner) return owner;
233
+ throw new Error(
234
+ `org ${hit.org} has no GitHub source (orgs.${hit.org}.sources): ` +
235
+ `give workspace ${workspace}'s repos a remote: in the service catalog`,
236
+ );
222
237
  }
223
238
 
224
239
  export interface OrgEntry {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The repo health rollup, typed for TypeScript callers (`<cli> status`).
3
+ *
4
+ * The rule itself is one plain ES module, src/ui/server/public/health-rollup.js,
5
+ * because the browser runs it too (the header row and the graph): one
6
+ * function everywhere, so the row, its chip, the graph and `status` never
7
+ * disagree. See that file for the rule.
8
+ */
9
+ import * as rollup from "../../ui/server/public/health-rollup.js";
10
+
11
+ export type RepoState = "down" | "degraded" | "ok" | "unknown";
12
+
13
+ export interface HealthSignal {
14
+ service: string;
15
+ /** true up, false down or failing, null not checked (or not yet). */
16
+ ok: boolean | null | undefined;
17
+ isEntrypoint?: boolean;
18
+ /** Not answered yet this cycle: the repo stays unknown until it has. */
19
+ awaiting?: boolean;
20
+ }
21
+
22
+ export interface RepoRollup {
23
+ state: RepoState;
24
+ /** The entry service's name, or null when none can be told. */
25
+ entry: string | null;
26
+ /** Checked services that are down, by name. */
27
+ down: string[];
28
+ checked: number;
29
+ unchecked: number;
30
+ /** Checks that haven't answered yet; any holds the state at unknown. */
31
+ awaiting: number;
32
+ }
33
+
34
+ export const STATE_RANK: Record<RepoState, number> = rollup.STATE_RANK;
35
+
36
+ export const rollupRepo = rollup.rollupRepo as (
37
+ repo: string,
38
+ signals: HealthSignal[],
39
+ ) => RepoRollup;
40
+
41
+ export const describeRollup = rollup.describeRollup as (
42
+ r: RepoRollup,
43
+ ) => string;
44
+
45
+ export const compareRepos = rollup.compareRepos as (
46
+ a: { state: RepoState; repo: string; focused?: boolean },
47
+ b: { state: RepoState; repo: string; focused?: boolean },
48
+ ) => number;
@@ -12,6 +12,7 @@ import type {
12
12
  ServiceCatalog,
13
13
  ServiceManagementConfig,
14
14
  } from "../types/index.js";
15
+ import { orgOfRepo, type WorkOrgsConfig } from "../org-sources.js";
15
16
 
16
17
  export interface OpenLink {
17
18
  label: string;
@@ -63,11 +64,18 @@ export function linksFor(
63
64
  return out;
64
65
  }
65
66
 
66
- /** The GitHub owner a repo is catalogued under ("acme"), or "". */
67
- export function orgOf(repo: string, catalog: ServiceCatalog): string {
68
- const gh = catalog[repo]?.gh ?? "";
69
- const slash = gh.indexOf("/");
70
- return slash > 0 ? gh.slice(0, slash) : "";
67
+ /**
68
+ * The org a repo belongs to (ORG-1d): an org whose `repos` lists it, then an
69
+ * org with a source that claims its owner, then (legacy) its bare GitHub
70
+ * owner from `gh` ("acme"), else "". Without `orgs` (or with no ORG-1d keys
71
+ * in it) this is the repo's GitHub owner, as before.
72
+ */
73
+ export function orgOf(
74
+ repo: string,
75
+ catalog: ServiceCatalog,
76
+ orgs: WorkOrgsConfig = {},
77
+ ): string {
78
+ return orgOfRepo(repo, catalog, orgs);
71
79
  }
72
80
 
73
81
  export interface OrgDisplay {
@@ -75,14 +83,18 @@ export interface OrgDisplay {
75
83
  color?: string;
76
84
  }
77
85
 
78
- /** Every org in the catalog with its display name, configured ones first. */
86
+ /**
87
+ * Every org with its display name, configured ones first, then each
88
+ * catalogued repo's org. An owner a source claims is not an org of its own,
89
+ * so it is not listed.
90
+ */
79
91
  export function listOrgs(
80
92
  catalog: ServiceCatalog,
81
- configured: Record<string, OrgDisplay> = {},
93
+ configured: WorkOrgsConfig = {},
82
94
  ): Array<{ id: string; label: string; color?: string }> {
83
95
  const ids = new Set<string>(Object.keys(configured));
84
96
  for (const repo of Object.keys(catalog)) {
85
- const o = orgOf(repo, catalog);
97
+ const o = orgOf(repo, catalog, configured);
86
98
  if (o) ids.add(o);
87
99
  }
88
100
  return [...ids].map((id) => ({
@@ -6,7 +6,8 @@
6
6
  * Order, first answer wins:
7
7
  * 1. manifest `services.<svc>.urls.<env>`
8
8
  * 2. local only: the edge route for the service's compose container,
9
- * `https://<route>.<stack>.<edge domain>`; the stack-wide service (the
9
+ * `https://<route>.<stack>.<edge domain>` (`http://` and the edge's http
10
+ * port for `tls: none`, 0.3.0-dev.40); the stack-wide service (the
10
11
  * entrypoint, or one named after the stack) takes the `app` route, else
11
12
  * the first route
12
13
  * 3. the origin of the env's HTTP health URL
@@ -32,11 +33,22 @@ export function httpUrl(raw: unknown): string | null {
32
33
  }
33
34
  }
34
35
 
36
+ /**
37
+ * How the edge serves its routes: https on 443 (every edge.tls strategy but
38
+ * `none`), or http on its http port for `tls: none`. A non-default port is
39
+ * part of the URL.
40
+ */
41
+ export interface EdgeOrigin {
42
+ https: boolean;
43
+ port?: number | null;
44
+ }
45
+
35
46
  function edgeRouteUrl(
36
47
  manifest: DenManifest,
37
48
  subsystem: string,
38
49
  repoName: string,
39
50
  edgeDomain: string,
51
+ origin: EdgeOrigin,
40
52
  ): string | null {
41
53
  const routes = manifest.compose?.routes;
42
54
  if (!routes || !Object.keys(routes).length) return null;
@@ -51,7 +63,11 @@ function edgeRouteUrl(
51
63
  });
52
64
  if (!route && (manifest.entrypoint === subsystem || subsystem === stack))
53
65
  route = routes.app ? "app" : names[0];
54
- return route ? `https://${route}.${stack}.${edgeDomain}/` : null;
66
+ if (!route) return null;
67
+ const scheme = origin.https ? "https" : "http";
68
+ const dflt = origin.https ? 443 : 80;
69
+ const port = origin.port && origin.port !== dflt ? `:${origin.port}` : "";
70
+ return `${scheme}://${route}.${stack}.${edgeDomain}${port}/`;
55
71
  }
56
72
 
57
73
  export function openUrlFor(opts: {
@@ -63,6 +79,8 @@ export function openUrlFor(opts: {
63
79
  healthUrl?: string | null;
64
80
  /** Primary edge domain; local edge routes are skipped without it. */
65
81
  edgeDomain?: string | null;
82
+ /** The edge's scheme and port (default: https on 443). */
83
+ edgeOrigin?: EdgeOrigin;
66
84
  }): string | null {
67
85
  const svc = opts.manifest.services?.[opts.subsystem];
68
86
  const declared = httpUrl(
@@ -75,6 +93,7 @@ export function openUrlFor(opts: {
75
93
  opts.subsystem,
76
94
  opts.repoName,
77
95
  opts.edgeDomain,
96
+ opts.edgeOrigin ?? { https: true },
78
97
  );
79
98
  if (routed) return routed;
80
99
  }