@monster-orange/den 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -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
+ }
@@ -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;
@@ -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
  }
@@ -17,7 +17,7 @@
17
17
  * GitHub sources clone through the user's gh, a repo with no source clones
18
18
  * from its catalog `remote:` through plain git (SEC-0).
19
19
  */
20
- import { ConfigError } from "./config-error.js";
20
+ import { ConfigError, type ConfigIssue } from "./config-error.js";
21
21
  import type { ServiceCatalog, ServiceCatalogEntry } from "./types/index.js";
22
22
 
23
23
  /** `orgs.<id>.sources[i]`: a git host and the owner (or group) it claims. */
@@ -65,13 +65,23 @@ const sourceOwner = (s: OrgSourceConfig): string =>
65
65
  (s.owner ?? s.group ?? "").trim();
66
66
 
67
67
  /**
68
- * Check the ORG-1d keys of `orgs:` and throw on the first problem, naming the
69
- * key. Entries without them are not looked at (C1). A repo listed by two
70
- * orgs, or an owner claimed by two orgs' sources (or by a source and a
71
- * legacy org of that name), names both.
68
+ * Check the ORG-1d keys of `orgs:` and throw a ConfigError listing every
69
+ * problem (0.3.0-dev.40; it threw on the first), each naming its key. A
70
+ * problem with a source skips the rest of that source; one with `sources`
71
+ * or `repos` as a whole skips that key. Entries without them are not
72
+ * looked at (C1). A repo listed by two orgs, or an owner claimed by two
73
+ * orgs' sources (or by a source and a legacy org of that name), names both.
72
74
  */
73
75
  export function validateOrgSources(orgs: unknown, file: string): void {
74
76
  if (!orgs || typeof orgs !== "object" || Array.isArray(orgs)) return;
77
+ const issues: ConfigIssue[] = [];
78
+ // `text` is the message as it reads after "<file>: ".
79
+ const bad = (key: string, text: string): void => {
80
+ const message = text.startsWith(key)
81
+ ? text.slice(key.length).replace(/^:?\s*/, "")
82
+ : text;
83
+ issues.push({ file, key, message, text });
84
+ };
75
85
  const entries = Object.entries(orgs as Record<string, unknown>);
76
86
  const repoAt = new Map<string, string>();
77
87
  const ownerAt = new Map<string, string>();
@@ -81,67 +91,74 @@ export function validateOrgSources(orgs: unknown, file: string): void {
81
91
  const at = `orgs.${id}`;
82
92
  if ("sources" in e) {
83
93
  if (!Array.isArray(e.sources))
84
- throw new ConfigError(
85
- `${file}: ${at}.sources must be a list of { host, owner } or { host, group }`,
94
+ bad(
95
+ `${at}.sources`,
96
+ `${at}.sources must be a list of { host, owner } or { host, group }`,
86
97
  );
87
- e.sources.forEach((s: unknown, i: number) => {
88
- const k = `${at}.sources[${i}]`;
89
- if (!s || typeof s !== "object" || Array.isArray(s))
90
- throw new ConfigError(
91
- `${file}: ${k} must be a map: { host: github, owner: <owner> }`,
92
- );
93
- const src = s as Record<string, unknown>;
94
- for (const key of Object.keys(src))
95
- if (!["host", "owner", "group"].includes(key))
96
- throw new ConfigError(
97
- `${file}: ${k}: unknown key "${key}" (known: host, owner, group)`,
98
+ else
99
+ e.sources.forEach((s: unknown, i: number) => {
100
+ const k = `${at}.sources[${i}]`;
101
+ if (!s || typeof s !== "object" || Array.isArray(s))
102
+ return bad(
103
+ k,
104
+ `${k} must be a map: { host: github, owner: <owner> }`,
98
105
  );
99
- if (typeof src.host !== "string" || !HOST_RE.test(src.host))
100
- throw new ConfigError(
101
- `${file}: ${k}.host must be a git host id such as github or gitlab`,
102
- );
103
- const hasOwner = src.owner !== undefined;
104
- const hasGroup = src.group !== undefined;
105
- if (hasOwner === hasGroup)
106
- throw new ConfigError(
107
- `${file}: ${k} needs exactly one of owner or group`,
106
+ const src = s as Record<string, unknown>;
107
+ const unknown = Object.keys(src).find(
108
+ (key) => !["host", "owner", "group"].includes(key),
108
109
  );
109
- const key = hasOwner ? "owner" : "group";
110
- const v = src[key];
111
- const ok =
112
- typeof v === "string" &&
113
- (src.host === "github" ? GH_OWNER_RE : GROUP_RE).test(v.trim());
114
- if (!ok)
115
- throw new ConfigError(
116
- src.host === "github"
117
- ? `${file}: ${k}.${key} must be a GitHub owner name (letters, digits and hyphens)`
118
- : `${file}: ${k}.${key} must be an owner or group path`,
119
- );
120
- const claim = `${src.host}:${lower((v as string).trim())}`;
121
- const prior = ownerAt.get(claim);
122
- if (prior && !prior.startsWith(`${at}.`))
123
- throw new ConfigError(
124
- `${file}: ${src.host} owner ${(v as string).trim()} is claimed by both ${prior} and ${k}`,
125
- );
126
- if (!prior) ownerAt.set(claim, k);
127
- });
110
+ if (unknown !== undefined)
111
+ return bad(
112
+ k,
113
+ `${k}: unknown key "${unknown}" (known: host, owner, group)`,
114
+ );
115
+ if (typeof src.host !== "string" || !HOST_RE.test(src.host))
116
+ return bad(
117
+ `${k}.host`,
118
+ `${k}.host must be a git host id such as github or gitlab`,
119
+ );
120
+ const hasOwner = src.owner !== undefined;
121
+ const hasGroup = src.group !== undefined;
122
+ if (hasOwner === hasGroup)
123
+ return bad(k, `${k} needs exactly one of owner or group`);
124
+ const key = hasOwner ? "owner" : "group";
125
+ const v = src[key];
126
+ const ok =
127
+ typeof v === "string" &&
128
+ (src.host === "github" ? GH_OWNER_RE : GROUP_RE).test(v.trim());
129
+ if (!ok)
130
+ return bad(
131
+ `${k}.${key}`,
132
+ src.host === "github"
133
+ ? `${k}.${key} must be a GitHub owner name (letters, digits and hyphens)`
134
+ : `${k}.${key} must be an owner or group path`,
135
+ );
136
+ const claim = `${src.host}:${lower((v as string).trim())}`;
137
+ const prior = ownerAt.get(claim);
138
+ if (prior && !prior.startsWith(`${at}.`))
139
+ return bad(
140
+ k,
141
+ `${src.host} owner ${(v as string).trim()} is claimed by both ${prior} and ${k}`,
142
+ );
143
+ if (!prior) ownerAt.set(claim, k);
144
+ });
128
145
  }
129
146
  if ("repos" in e) {
130
147
  if (
131
148
  !Array.isArray(e.repos) ||
132
149
  e.repos.some((r) => typeof r !== "string" || !r.trim())
133
150
  )
134
- throw new ConfigError(
135
- `${file}: ${at}.repos must be a list of repo names`,
136
- );
137
- for (const r of e.repos as string[]) {
138
- const prior = repoAt.get(r.trim());
139
- if (prior && prior !== `${at}.repos`)
140
- throw new ConfigError(
141
- `${file}: repo ${r.trim()} is claimed by both ${prior} and ${at}.repos`,
142
- );
143
- repoAt.set(r.trim(), `${at}.repos`);
144
- }
151
+ bad(`${at}.repos`, `${at}.repos must be a list of repo names`);
152
+ else
153
+ for (const r of e.repos as string[]) {
154
+ const prior = repoAt.get(r.trim());
155
+ if (prior && prior !== `${at}.repos`)
156
+ bad(
157
+ `${at}.repos`,
158
+ `repo ${r.trim()} is claimed by both ${prior} and ${at}.repos`,
159
+ );
160
+ else repoAt.set(r.trim(), `${at}.repos`);
161
+ }
145
162
  }
146
163
  }
147
164
  // A legacy org is the GitHub owner of its own name: a source elsewhere that
@@ -150,11 +167,13 @@ export function validateOrgSources(orgs: unknown, file: string): void {
150
167
  if (isSourcedOrg(raw as WorkOrgConfig)) continue;
151
168
  const prior = ownerAt.get(`github:${lower(id)}`);
152
169
  if (prior)
153
- throw new ConfigError(
154
- `${file}: github owner ${id} is claimed by both ${prior} and orgs.${id} ` +
170
+ bad(
171
+ `orgs.${id}`,
172
+ `github owner ${id} is claimed by both ${prior} and orgs.${id} ` +
155
173
  `(an org with no sources: is the GitHub owner of its own name)`,
156
174
  );
157
175
  }
176
+ if (issues.length) throw new ConfigError(issues);
158
177
  }
159
178
 
160
179
  /** `gh: owner/repo`'s owner, as spelled, or "" (the legacy orgOf). */
package/src/shared/org.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * org:
6
6
  * id: globex # defaults to edge.org
7
- * title: Globex # defaults to the id
7
+ * title: Globex # defaults to the github_org's `orgs:` label, then the id
8
8
  */
9
9
  import * as fs from "fs";
10
10
  import * as path from "path";
@@ -31,12 +31,60 @@ export function resolveOrg(
31
31
  return { id, title: parsed?.org?.title?.trim() || id };
32
32
  }
33
33
 
34
+ function readParsed(root: string): unknown {
35
+ return yaml.parse(
36
+ fs.readFileSync(path.join(root, product().configFile), "utf-8"),
37
+ );
38
+ }
39
+
34
40
  export function readOrg(root: string = getDistRoot()): Org | undefined {
35
41
  try {
36
- return resolveOrg(
37
- yaml.parse(
38
- fs.readFileSync(path.join(root, product().configFile), "utf-8"),
39
- ),
42
+ return resolveOrg(readParsed(root) as Parameters<typeof resolveOrg>[0]);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ /** Where the shell's org label came from (0.3.0-dev.18, additive). */
49
+ export type OrgTitleSource = "org.title" | "orgs" | "id";
50
+
51
+ /** The org as the shell labels it: `title` plus where it came from. */
52
+ export interface OrgView extends Org {
53
+ title_source: OrgTitleSource;
54
+ }
55
+
56
+ /**
57
+ * Pure: the org's display label for the top bar. `org.title` first, then
58
+ * the `orgs:` label of the GitHub org (`github_org`, the label the
59
+ * Environments page shows), then the id. A distribution with only
60
+ * `edge.org: <id>` no longer shows its bare id when it has a label.
61
+ */
62
+ export function resolveOrgView(
63
+ parsed:
64
+ | {
65
+ org?: OrgConfig;
66
+ edge?: { org?: string };
67
+ github_org?: string;
68
+ orgs?: Record<string, { label?: string } | null | undefined>;
69
+ }
70
+ | null
71
+ | undefined,
72
+ ): OrgView | undefined {
73
+ const org = resolveOrg(parsed);
74
+ if (!org) return undefined;
75
+ if (parsed?.org?.title?.trim()) return { ...org, title_source: "org.title" };
76
+ const gh =
77
+ typeof parsed?.github_org === "string" ? parsed.github_org.trim() : "";
78
+ const label = gh ? parsed?.orgs?.[gh]?.label : undefined;
79
+ if (typeof label === "string" && label.trim())
80
+ return { ...org, title: label.trim(), title_source: "orgs" };
81
+ return { ...org, title_source: "id" };
82
+ }
83
+
84
+ export function readOrgView(root: string = getDistRoot()): OrgView | undefined {
85
+ try {
86
+ return resolveOrgView(
87
+ readParsed(root) as Parameters<typeof resolveOrgView>[0],
40
88
  );
41
89
  } catch {
42
90
  return undefined;