@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
+ * Orgs are Den's own concept; sources are bindings (ORG-1d, ORG-SPEC §1).
3
+ *
4
+ * An org is a key of `orgs:`. It may name the git hosts its repos live on
5
+ * (`sources: [{host: github, owner: acme}, {host: gitlab, group: globex}]`)
6
+ * and list repos outright (`repos: [widget]`). A repo's org resolves:
7
+ *
8
+ * 1. an org whose `repos` lists it
9
+ * 2. an org with a source that claims the repo's owner
10
+ * 3. (legacy) the repo's bare GitHub owner, from `gh: owner/repo`
11
+ *
12
+ * An org that sets neither `sources` nor `repos` is a legacy org: the GitHub
13
+ * owner of its own name (ORG-1a). So a config with no new keys resolves
14
+ * exactly as before, and only the new keys can raise an error (C1).
15
+ *
16
+ * A source claims an owner; it never brings credentials of its own here.
17
+ * GitHub sources clone through the user's gh, a repo with no source clones
18
+ * from its catalog `remote:` through plain git (SEC-0).
19
+ */
20
+ import { ConfigError, type ConfigIssue } from "./config-error.js";
21
+ import type { ServiceCatalog, ServiceCatalogEntry } from "./types/index.js";
22
+
23
+ /** `orgs.<id>.sources[i]`: a git host and the owner (or group) it claims. */
24
+ export interface OrgSourceConfig {
25
+ /** `github` (built in), or a git-host id such as `gitlab` (ORG-1e). */
26
+ host: string;
27
+ owner?: string;
28
+ group?: string;
29
+ }
30
+
31
+ /** One `orgs.<id>` entry. */
32
+ export interface WorkOrgConfig {
33
+ label?: string;
34
+ color?: string;
35
+ /** ORG-1d: the git hosts and owners this org's repos come from. */
36
+ sources?: OrgSourceConfig[];
37
+ /** ORG-1d: repos that belong to this org whatever their owner. */
38
+ repos?: string[];
39
+ }
40
+
41
+ export type WorkOrgsConfig = Record<string, WorkOrgConfig>;
42
+
43
+ /** Where a repo lives: a host id (`github`, else the remote's hostname) and owner path. */
44
+ export interface RepoOwner {
45
+ host: string;
46
+ hostname: string;
47
+ owner: string;
48
+ }
49
+
50
+ const lower = (s: string): string => s.toLowerCase();
51
+ const HOST_RE = /^[a-z][a-z0-9-]*$/;
52
+ const GH_OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
53
+ const GROUP_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
54
+
55
+ /** True when the entry uses the ORG-1d keys (its sources are then exactly `sources`). */
56
+ export function isSourcedOrg(entry: WorkOrgConfig | undefined): boolean {
57
+ return (
58
+ !!entry &&
59
+ typeof entry === "object" &&
60
+ ("sources" in entry || "repos" in entry)
61
+ );
62
+ }
63
+
64
+ const sourceOwner = (s: OrgSourceConfig): string =>
65
+ (s.owner ?? s.group ?? "").trim();
66
+
67
+ /**
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.
74
+ */
75
+ export function validateOrgSources(orgs: unknown, file: string): void {
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
+ };
85
+ const entries = Object.entries(orgs as Record<string, unknown>);
86
+ const repoAt = new Map<string, string>();
87
+ const ownerAt = new Map<string, string>();
88
+ for (const [id, raw] of entries) {
89
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
90
+ const e = raw as Record<string, unknown>;
91
+ const at = `orgs.${id}`;
92
+ if ("sources" in e) {
93
+ if (!Array.isArray(e.sources))
94
+ bad(
95
+ `${at}.sources`,
96
+ `${at}.sources must be a list of { host, owner } or { host, group }`,
97
+ );
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> }`,
105
+ );
106
+ const src = s as Record<string, unknown>;
107
+ const unknown = Object.keys(src).find(
108
+ (key) => !["host", "owner", "group"].includes(key),
109
+ );
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
+ });
145
+ }
146
+ if ("repos" in e) {
147
+ if (
148
+ !Array.isArray(e.repos) ||
149
+ e.repos.some((r) => typeof r !== "string" || !r.trim())
150
+ )
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
+ }
162
+ }
163
+ }
164
+ // A legacy org is the GitHub owner of its own name: a source elsewhere that
165
+ // claims that owner would split it silently.
166
+ for (const [id, raw] of entries) {
167
+ if (isSourcedOrg(raw as WorkOrgConfig)) continue;
168
+ const prior = ownerAt.get(`github:${lower(id)}`);
169
+ if (prior)
170
+ bad(
171
+ `orgs.${id}`,
172
+ `github owner ${id} is claimed by both ${prior} and orgs.${id} ` +
173
+ `(an org with no sources: is the GitHub owner of its own name)`,
174
+ );
175
+ }
176
+ if (issues.length) throw new ConfigError(issues);
177
+ }
178
+
179
+ /** `gh: owner/repo`'s owner, as spelled, or "" (the legacy orgOf). */
180
+ export function ghOwnerOf(entry: ServiceCatalogEntry | undefined): string {
181
+ const gh = entry?.gh ?? "";
182
+ const slash = gh.indexOf("/");
183
+ return slash > 0 ? gh.slice(0, slash) : "";
184
+ }
185
+
186
+ /** Host, hostname and owner path of a git URL; null for a local path or an unparsable one. */
187
+ export function parseRemote(remote: string): RepoOwner | null {
188
+ const r = remote.trim();
189
+ let hostname = "";
190
+ let p = "";
191
+ const scp = /^[A-Za-z0-9._-]+@([A-Za-z0-9.-]+):(.+)$/.exec(r);
192
+ if (scp) {
193
+ hostname = scp[1];
194
+ p = scp[2];
195
+ } else {
196
+ try {
197
+ const u = new URL(r);
198
+ if (u.protocol === "file:") return null;
199
+ hostname = u.hostname;
200
+ p = u.pathname;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+ const parts = p
206
+ .replace(/\.git$/, "")
207
+ .split("/")
208
+ .filter(Boolean);
209
+ if (!hostname || parts.length < 2) return null;
210
+ const host = lower(hostname) === "github.com" ? "github" : lower(hostname);
211
+ return {
212
+ host,
213
+ hostname: lower(hostname),
214
+ owner: parts.slice(0, -1).join("/"),
215
+ };
216
+ }
217
+
218
+ /** Where a catalogued repo lives: `gh` first (GitHub shorthand), else its `remote`. */
219
+ export function repoOwnerOf(
220
+ entry: ServiceCatalogEntry | undefined,
221
+ ): RepoOwner | null {
222
+ const gh = ghOwnerOf(entry);
223
+ if (gh) return { host: "github", hostname: "github.com", owner: gh };
224
+ return entry?.remote ? parseRemote(entry.remote) : null;
225
+ }
226
+
227
+ /**
228
+ * Whether `src` claims `owner`. A GitHub source claims a github.com owner;
229
+ * another host's source claims the owner (or a subgroup of it) on a remote
230
+ * whose hostname is `<host>.com` or starts with `<host>.`.
231
+ */
232
+ export function sourceClaims(src: OrgSourceConfig, owner: RepoOwner): boolean {
233
+ const want = lower(sourceOwner(src));
234
+ if (!want) return false;
235
+ const have = lower(owner.owner);
236
+ if (src.host === "github") return owner.host === "github" && have === want;
237
+ const onHost =
238
+ owner.hostname === `${src.host}.com` ||
239
+ owner.hostname.startsWith(`${src.host}.`);
240
+ return onHost && (have === want || have.startsWith(`${want}/`));
241
+ }
242
+
243
+ /** The org that lists `repo` in its `repos`, and the org whose source claims its owner. */
244
+ function claimsOf(
245
+ repo: string,
246
+ catalog: ServiceCatalog,
247
+ orgs: WorkOrgsConfig,
248
+ ): { listed?: string; claimed?: { org: string; source: OrgSourceConfig } } {
249
+ const out: ReturnType<typeof claimsOf> = {};
250
+ const owner = repoOwnerOf(catalog[repo]);
251
+ for (const [id, e] of Object.entries(orgs)) {
252
+ if (!isSourcedOrg(e)) continue;
253
+ if (!out.listed && Array.isArray(e.repos) && e.repos.includes(repo))
254
+ out.listed = id;
255
+ if (!out.claimed && owner && Array.isArray(e.sources)) {
256
+ const src = e.sources.find((s) => sourceClaims(s, owner));
257
+ if (src) out.claimed = { org: id, source: src };
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /** The org `repo` belongs to (see the module comment), or "" when none. */
264
+ export function orgOfRepo(
265
+ repo: string,
266
+ catalog: ServiceCatalog,
267
+ orgs: WorkOrgsConfig = {},
268
+ ): string {
269
+ const { listed, claimed } = claimsOf(repo, catalog, orgs);
270
+ if (listed) return listed;
271
+ if (claimed) return claimed.org;
272
+ const gh = ghOwnerOf(catalog[repo]);
273
+ if (gh) return gh;
274
+ const remote = repoOwnerOf(catalog[repo]);
275
+ return remote?.host === "github" ? remote.owner : "";
276
+ }
277
+
278
+ /**
279
+ * The source `repo` clones through: one of its own org's sources that claims
280
+ * its owner. Null when it has none: a legacy repo (GitHub by its `gh`), or a
281
+ * repo of an org with no matching source (it clones from `remote:`).
282
+ */
283
+ export function repoSource(
284
+ repo: string,
285
+ catalog: ServiceCatalog,
286
+ orgs: WorkOrgsConfig = {},
287
+ ): OrgSourceConfig | null {
288
+ const { listed, claimed } = claimsOf(repo, catalog, orgs);
289
+ if (listed) {
290
+ const owner = repoOwnerOf(catalog[repo]);
291
+ const srcs = orgs[listed]?.sources ?? [];
292
+ return (owner && srcs.find((s) => sourceClaims(s, owner))) || null;
293
+ }
294
+ return claimed?.source ?? null;
295
+ }
296
+
297
+ /** The org's GitHub owners: its own name for a legacy org, else its GitHub sources'. */
298
+ export function githubOwnersOf(
299
+ org: string,
300
+ orgs: WorkOrgsConfig = {},
301
+ ): string[] {
302
+ const key = Object.keys(orgs).find((k) => lower(k) === lower(org));
303
+ const e = key === undefined ? undefined : orgs[key];
304
+ if (!isSourcedOrg(e)) return [org];
305
+ return (e?.sources ?? [])
306
+ .filter((s) => s.host === "github")
307
+ .map(sourceOwner)
308
+ .filter(Boolean);
309
+ }
310
+
311
+ /** True when the org reaches GitHub (discovery, branches, releases work for it). */
312
+ export function orgHasGithub(org: string, orgs: WorkOrgsConfig = {}): boolean {
313
+ return githubOwnersOf(org, orgs).length > 0;
314
+ }
315
+
316
+ /** The ORG-1d org whose GitHub source claims GitHub owner `owner`, if any. */
317
+ export function orgClaimingOwner(
318
+ owner: string,
319
+ orgs: WorkOrgsConfig = {},
320
+ ): string | undefined {
321
+ const o: RepoOwner = { host: "github", hostname: "github.com", owner };
322
+ return Object.entries(orgs).find(
323
+ ([, e]) =>
324
+ isSourcedOrg(e) &&
325
+ Array.isArray(e.sources) &&
326
+ e.sources.some((s) => sourceClaims(s, o)),
327
+ )?.[0];
328
+ }
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;