@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,12 @@
1
+ /**
2
+ * The header version pill's text: the running Den's own version (its
3
+ * package.json, from /api/server-info), prerelease suffix and all
4
+ * ("v0.3.0-dev.5"). The changelog's newest heading names the release being
5
+ * built ("0.3.0"), not this build, so it is only the fallback.
6
+ */
7
+ export function versionLabel(serverVersion, changelog) {
8
+ const v =
9
+ (typeof serverVersion === "string" && serverVersion.trim()) ||
10
+ changelog?.[0]?.version;
11
+ return v ? `v${v}` : null;
12
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Workspace pill / picker models.
2
+ * The Workspace ▾ menu's model.
3
3
  *
4
4
  * Pure and DOM-free (like perf-report.js) so the shape of the header's
5
- * workspace menu is unit-testable under Jest's node environment; header.js
5
+ * Workspace menu is unit-testable under Jest's node environment; header.js
6
6
  * owns everything that touches the DOM.
7
7
  *
8
8
  * Input is always the `/api/workspaces` payload:
@@ -11,6 +11,28 @@
11
11
  * single string, which both helpers still accept.
12
12
  */
13
13
 
14
+ /**
15
+ * The menu's en strings, for where there is no shell (a unit test). In a
16
+ * page, header.js passes the shell's t (R19), which has the same en text
17
+ * (i18n.test.ts holds them equal).
18
+ */
19
+ export const EN_FALLBACK = {
20
+ "ws.all": "All active",
21
+ "ws.allN": "All active ({n})",
22
+ "ws.allTip":
23
+ "Every active workspace; a service several define is listed once",
24
+ "ws.noneLabel": "none",
25
+ "ws.viewTitle":
26
+ "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
27
+ "ws.viewTitleFocus":
28
+ "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and focus ({focus}) stays the target for commands.",
29
+ };
30
+ function enT(key, vars) {
31
+ return String(EN_FALLBACK[key] ?? key).replace(/\{(\w+)\}/g, (m, k) =>
32
+ vars && vars[k] != null ? String(vars[k]) : m,
33
+ );
34
+ }
35
+
14
36
  /** Active workspace names, normalised to an array. */
15
37
  function activeNames(data) {
16
38
  const active = data?.active;
@@ -26,44 +48,62 @@ function focusName(data) {
26
48
  }
27
49
 
28
50
  /**
29
- * Label + tooltip for the header pill.
30
- * Falls back to "default" so the pill never renders empty.
51
+ * The top bar's Workspace ▾ menu (R4, 0.3.0-dev.18), from the `/api/scope`
52
+ * and `/api/workspaces` payloads. It is the view scope: what every page,
53
+ * the health row and `status` show. Choosing an entry never moves the
54
+ * focus (the default target for commands); the focused workspace is
55
+ * marked, and `focusable` names the workspace in view when it is not the
56
+ * focus, for the menu's separate "Set <ws> as focus" item.
57
+ *
58
+ * "All active" is offered with two or more active workspaces; with one,
59
+ * it and that workspace are the same view.
31
60
  */
32
- export function buildWorkspacePillModel(data) {
61
+ export function buildScopeMenuModel(scope, data, t = enT) {
33
62
  const active = activeNames(data);
34
- const label = focusName(data) ?? "default";
63
+ const focus = scope?.focus ?? focusName(data);
64
+ const multi = active.length > 1;
65
+ const selected =
66
+ scope?.scope && scope.scope !== "all" && active.includes(scope.scope)
67
+ ? scope.scope
68
+ : "all";
69
+ const inView = Array.isArray(scope?.inView) ? scope.inView : active;
70
+ const one = multi ? (selected === "all" ? null : selected) : active[0];
71
+ const items = [
72
+ ...(multi
73
+ ? [
74
+ {
75
+ value: "all",
76
+ label: t("ws.allN", { n: active.length }),
77
+ checked: selected === "all",
78
+ focused: false,
79
+ title: t("ws.allTip"),
80
+ },
81
+ ]
82
+ : []),
83
+ ...active.map((n) => ({
84
+ value: n,
85
+ label: n,
86
+ checked: n === one,
87
+ focused: n === focus,
88
+ })),
89
+ ];
35
90
  return {
36
- label,
37
- // Only worth spelling out when more than one workspace is active —
38
- // otherwise the pill text already says everything.
39
- title:
40
- active.length > 1
41
- ? `Active: ${active.join(", ")} · Focus: ${label}`
42
- : "Switch workspace",
91
+ label: active.length === 0 ? t("ws.noneLabel") : (one ?? t("ws.all")),
92
+ items,
93
+ focusable: one && one !== focus ? one : null,
94
+ title: t(focus ? "ws.viewTitleFocus" : "ws.viewTitle", {
95
+ ws: inView.join(", ") || t("ws.noneLabel"),
96
+ focus,
97
+ }),
43
98
  };
44
99
  }
45
100
 
46
101
  /**
47
- * Menu model for the pill's dropdown: every known workspace, active ones
48
- * first (they are the likely targets), each side alphabetical so the list
49
- * order is stable across polls.
102
+ * " · a, b" after a service that several workspaces in view define (it is
103
+ * listed once); "" when one does.
50
104
  */
51
- export function buildWorkspaceMenuModel(data) {
52
- const active = new Set(activeNames(data));
53
- const focus = focusName(data);
54
- // Union of the catalog and the active set: a workspace listed as active but
55
- // missing from `workspaces` still has to be selectable (and visible).
56
- const names = new Set([...Object.keys(data?.workspaces ?? {}), ...active]);
57
-
58
- const items = [...names].map((name) => ({
59
- name,
60
- active: active.has(name),
61
- focused: name === focus,
62
- }));
63
- items.sort(
64
- (a, b) =>
65
- Number(b.active) - Number(a.active) || a.name.localeCompare(b.name),
66
- );
67
-
68
- return { focus, items };
105
+ export function ownersLabel(owners) {
106
+ return Array.isArray(owners) && owners.length > 1
107
+ ? ` · ${owners.join(", ")}`
108
+ : "";
69
109
  }
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * /api/stacks what `stack ls` + `stack health` show
5
5
  * ?workspace=<den workspace> ?health=0 skips docker
6
- * /api/edge what `edge status` shows: up/down, routes, cert names
6
+ * /api/edge what `edge status` shows: up/down, routes, cert names,
7
+ * the included files' routers, the TLS strategy
8
+ * ?probe=1 only { configured } (the rail's Edge
9
+ * page asks on every load: no Docker, no keychain)
7
10
  * /api/security/plan the security CLI's own dry run for ?env&mode&service:
8
11
  * target discovery and the owned-host check, nothing run
9
12
  * /api/activity the audit trail for one service (?address=repo/svc) or
@@ -46,6 +49,8 @@ export interface ReadonlyRouteDeps {
46
49
  stacks: StackStatus[];
47
50
  };
48
51
  edge: () => EdgeStatus;
52
+ /** The edge block reads (no Docker asked); throws with the reason if not. */
53
+ edgeConfig: () => void;
49
54
  actions: () => AuditEntry[];
50
55
  securityPlan: (args: SecurityPlanArgs) => Promise<SecurityPlanResult>;
51
56
  }
@@ -62,6 +67,7 @@ export const defaultReadonlyRouteDeps: ReadonlyRouteDeps = {
62
67
  };
63
68
  },
64
69
  edge: () => edgeStatus(readEdgeConfig()),
70
+ edgeConfig: () => void readEdgeConfig(),
65
71
  actions: recentActions,
66
72
  securityPlan: async ({ env, mode, service }) => {
67
73
  // The CLI's own entrypoint in --dry-run: the same discovery, mode
@@ -96,13 +102,22 @@ export function activityFor(
96
102
  // and for each of its services.
97
103
  const repoLevel = (e: AuditEntry, repo: string): boolean =>
98
104
  e.verb === "exec" && e.detail.startsWith(repo + " ");
105
+ // A refused non-lifecycle call (logs_tail, logs_search, …) is listed under
106
+ // each service or repo it named.
107
+ const named = (e: AuditEntry, hit: (a: string) => boolean): boolean =>
108
+ (e.addresses ?? []).some(hit);
99
109
  const matches = (e: AuditEntry): boolean => {
100
- if (target.address)
110
+ if (target.address) {
111
+ const address = target.address;
112
+ const repo = address.split("/")[0];
101
113
  return (
102
- (e.verb === "exec" && e.detail.startsWith(target.address + " ")) ||
103
- repoLevel(e, target.address.split("/")[0])
114
+ (e.verb === "exec" && e.detail.startsWith(address + " ")) ||
115
+ repoLevel(e, repo) ||
116
+ named(e, (a) => a === address || a === repo)
104
117
  );
118
+ }
105
119
  const repo = target.repo ?? "";
120
+ if (named(e, (a) => a === repo || a.startsWith(repo + "/"))) return true;
106
121
  if (e.verb === "exec")
107
122
  return e.detail.startsWith(repo + "/") || repoLevel(e, repo);
108
123
  if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
@@ -155,6 +170,11 @@ export async function handleReadonlyRoutes(
155
170
 
156
171
  if (pathname === "/api/edge") {
157
172
  try {
173
+ if (url.searchParams.get("probe") === "1") {
174
+ deps.edgeConfig();
175
+ sendJson(res, { configured: true });
176
+ return true;
177
+ }
158
178
  sendJson(res, { configured: true, ...deps.edge() });
159
179
  } catch (e) {
160
180
  // No edge configured is a state, not a server error.
@@ -19,6 +19,7 @@ import { execFile } from "child_process";
19
19
  import { promisify } from "util";
20
20
  import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
21
21
  import {
22
+ readDenConfig,
22
23
  readExplicitReleaseOrg,
23
24
  readReleaseConfig,
24
25
  releaseOwnerFor,
@@ -29,6 +30,11 @@ import {
29
30
  } from "../../shared/den-config.js";
30
31
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
31
32
  import { orgOf } from "../../shared/manifest/service-links.js";
33
+ import {
34
+ ghOwnerOf,
35
+ isSourcedOrg,
36
+ orgHasGithub,
37
+ } from "../../shared/org-sources.js";
32
38
  import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
33
39
 
34
40
  import { product } from "../../shared/product.js";
@@ -258,10 +264,10 @@ export class ReleaseService {
258
264
  const known = new Set(cfg.repos.map((r) => r.name));
259
265
  return {
260
266
  ...cfg,
261
- repos: [
267
+ repos: releasableOnGithub([
262
268
  ...cfg.repos,
263
269
  ...manifestReleaseRepos().filter((r) => !known.has(r.name)),
264
- ],
270
+ ]),
265
271
  };
266
272
  }
267
273
 
@@ -274,7 +280,8 @@ export class ReleaseService {
274
280
  private ownerFor(repo: string, cfg: ReleaseConfig): string {
275
281
  return releaseOwnerFor({
276
282
  explicitOrg: readExplicitReleaseOrg(),
277
- catalogOwner: orgOf(repo, readCatalog()),
283
+ // The repo's GitHub owner, never its ORG-1d org id (which needn't be one).
284
+ catalogOwner: ghOwnerOf(readCatalog()[repo]),
278
285
  githubOrg: cfg.org,
279
286
  });
280
287
  }
@@ -1122,6 +1129,21 @@ export async function readVersionSha(
1122
1129
  }
1123
1130
  }
1124
1131
 
1132
+ /**
1133
+ * Release repos less those of an org with no GitHub source (ORG-1d): their
1134
+ * release flow needs GitHub, so the Release page hides them rather than
1135
+ * failing. With no ORG-1d keys every repo stays.
1136
+ */
1137
+ export function releasableOnGithub(repos: ReleaseRepo[]): ReleaseRepo[] {
1138
+ const orgs = readDenConfig().orgs ?? {};
1139
+ if (!Object.values(orgs).some(isSourcedOrg)) return repos;
1140
+ const catalog = readCatalog();
1141
+ return repos.filter((r) => {
1142
+ const org = orgOf(r.name, catalog, orgs);
1143
+ return !org || orgHasGithub(org, orgs);
1144
+ });
1145
+ }
1146
+
1125
1147
  /** Workspace repos whose manifest has a `release:` block, as release repos. */
1126
1148
  export function manifestReleaseRepos(): ReleaseRepo[] {
1127
1149
  const catalog = readCatalog();
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * The /api/services payload: every service of the legacy-discovered repos and
3
- * of the requested (or active) workspace, with commands, branch, deps and
4
- * graph rank.
3
+ * of the requested workspace (or, with none named, of every workspace the
4
+ * view scope has in view), with commands, branch, deps and graph rank. A
5
+ * service two workspaces in view share is listed once, with `inWorkspaces`
6
+ * naming both (focus first); its deps and autostart come from the first.
5
7
  *
6
8
  * Built off the request path's critical section: the per-repo branch lookups
7
9
  * (`git rev-parse`, ~40ms each) run as async child processes in parallel
@@ -15,20 +17,26 @@ import type { Environment } from "../../shared/types/index.js";
15
17
  import { discoverRepos } from "../../pillars/smoke/discover.js";
16
18
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
17
19
  import {
18
- getActiveWorkspace,
19
20
  listWorkspaceServices,
20
21
  noAutostartRepos,
21
22
  readWorkspacesFile,
22
23
  resolveDepAddress,
23
24
  } from "../../shared/environment-manager/workspaces.js";
24
25
  import { graphOrder } from "./graph-order.js";
25
- import { listDaemons } from "../../shared/environment-manager/service-runner.js";
26
+ import { scopeView } from "../../shared/environment-manager/scope.js";
27
+ import type { WorkspaceConfig } from "../../shared/types/index.js";
28
+ import {
29
+ listDaemons,
30
+ resolveService,
31
+ } from "../../shared/environment-manager/service-runner.js";
26
32
  import { loadManifest } from "../../shared/manifest/read-manifest.js";
27
33
  import { servicePlacement } from "../../shared/manifest/placement.js";
28
34
  import { depService } from "../../shared/manifest/deps.js";
29
35
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
30
36
  import { readDenConfig } from "../../shared/den-config.js";
31
37
  import { GIT_REMOTE_TIMEOUT_MS } from "../../shared/constants.js";
38
+ import { effectiveLocalCommands } from "../../shared/stacks/manifest-commands.js";
39
+ import { resolveUpTargets } from "../../shared/environment-manager/up-targets.js";
32
40
 
33
41
  /** `repoPath`'s checked-out branch, or null (not a checkout, git failed). */
34
42
  export function currentBranch(repoPath: string): Promise<string | null> {
@@ -87,11 +95,19 @@ export async function buildServicesList(
87
95
  // Catalog entries (workspace-aware)
88
96
  const catalog = readCatalog();
89
97
  const wsFile = wsOverride ? readWorkspacesFile() : null;
90
- const ws = wsOverride
98
+ const inView: { name: string; config: WorkspaceConfig }[] = wsOverride
91
99
  ? wsFile?.workspaces[wsOverride]
92
- ? { name: wsOverride, config: wsFile.workspaces[wsOverride] }
93
- : null
94
- : getActiveWorkspace();
100
+ ? [{ name: wsOverride, config: wsFile.workspaces[wsOverride] }]
101
+ : []
102
+ : scopeView().workspaces.filter((w) => w.config);
103
+ // Every workspace in view that defines an address, focus first.
104
+ const ownersOf = new Map<string, string[]>();
105
+ for (const w of inView)
106
+ for (const r of listWorkspaceServices(w.config, catalog, w.name)) {
107
+ const o = ownersOf.get(r.address) ?? [];
108
+ if (!o.includes(w.name)) ownersOf.set(r.address, [...o, w.name]);
109
+ }
110
+ const seen = new Set<string>();
95
111
 
96
112
  // One lookup per checkout, all in flight at once; filled in below.
97
113
  const branches = new Map<string, Promise<string | null>>();
@@ -101,19 +117,53 @@ export async function buildServicesList(
101
117
  return p;
102
118
  };
103
119
 
104
- const noAutostart = ws
105
- ? noAutostartRepos(ws.config, catalog)
106
- : new Set<string>();
120
+ const noAutostartBy = new Map(
121
+ inView.map((w) => [w.name, noAutostartRepos(w.config, catalog)] as const),
122
+ );
107
123
  const daemonOwners = new Map(
108
124
  listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
109
125
  );
126
+ // What starting a repo runs: the decision `up <repo>` makes
127
+ // (resolveUpTargets: its own up, a compose stack's, else its services'),
128
+ // so a repo row's Start/Restart offers exactly what `up` would start
129
+ // (0.3.0-dev.37). [] when it has no up at all. One per workspace + repo.
130
+ const upTargetsBy = new Map<string, string[]>();
131
+ const upTargetsOf = (ws: string, repo: string): string[] => {
132
+ const key = `${ws}\0${repo}`;
133
+ let t = upTargetsBy.get(key);
134
+ if (!t) {
135
+ try {
136
+ const up = resolveUpTargets([repo], ws, {
137
+ targets: new Set([repo]),
138
+ });
139
+ // As the table's rows name them: a bare repo is its entry service.
140
+ t = up.skippedNoUp.includes(repo)
141
+ ? []
142
+ : up.order.map((a) => {
143
+ const r = resolveService(a, ws);
144
+ return r ? `${r.repoName}/${r.subsystem}` : a;
145
+ });
146
+ } catch {
147
+ t = [];
148
+ }
149
+ upTargetsBy.set(key, t);
150
+ }
151
+ return t;
152
+ };
153
+
110
154
  // Modules and libraries have no runtime: no card, health or commands.
111
155
  // They show as contributors on the Applications tab instead.
112
156
  const pendingBranches: Promise<void>[] = [];
113
- const catalogEntries = ws
114
- ? listWorkspaceServices(ws.config, catalog, ws.name)
157
+ const catalogEntries = inView.flatMap((ws) => {
158
+ const noAutostart = noAutostartBy.get(ws.name) ?? new Set<string>();
159
+ return (
160
+ listWorkspaceServices(ws.config, catalog, ws.name)
115
161
  .filter((r) => hasRuntime(repoKind(r.repoPath)))
162
+ // A shared service is listed once: the first workspace in view
163
+ // (focus first) owns its entry.
164
+ .filter((r) => !seen.has(r.address) && !!seen.add(r.address))
116
165
  .flatMap((resolved) => {
166
+ const inWorkspaces = ownersOf.get(resolved.address) ?? [ws.name];
117
167
  // The manifest and the layers it was built from: repo or held,
118
168
  // plus override when manifests/<repo>.override.yml applied.
119
169
  const { manifest, layers: manifestLayers } = loadManifest(
@@ -141,6 +191,7 @@ export async function buildServicesList(
141
191
  isEntrypoint: false,
142
192
  autostart: !noAutostart.has(resolved.repoName),
143
193
  owners: daemonOwners.get(resolved.address) ?? [],
194
+ inWorkspaces,
144
195
  deps: [] as string[],
145
196
  cloned: fs.existsSync(resolved.repoPath),
146
197
  manifestLayers,
@@ -153,7 +204,23 @@ export async function buildServicesList(
153
204
  service: resolved.subsystem,
154
205
  address: resolved.address,
155
206
  repoPath: resolved.repoPath,
156
- commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
207
+ // Locally, a compose stack's synthesized up/down/restart count
208
+ // (what the runner runs), not just the declared ones.
209
+ commands: withDown(
210
+ Object.keys(
211
+ env === "local"
212
+ ? effectiveLocalCommands(
213
+ manifest,
214
+ resolved.subsystem,
215
+ resolved.repoName,
216
+ ws.name,
217
+ )
218
+ : (svc.commands?.[env] ?? {}),
219
+ ),
220
+ ),
221
+ ...(env === "local"
222
+ ? { upTargets: upTargetsOf(ws.name, resolved.repoName) }
223
+ : {}),
157
224
  logs: svc.logs?.[env] ?? null,
158
225
  mode: resolved.mode,
159
226
  branch: resolved.branch as string | null,
@@ -161,6 +228,7 @@ export async function buildServicesList(
161
228
  isEntrypoint: manifest.entrypoint === resolved.subsystem,
162
229
  autostart: !noAutostart.has(resolved.repoName),
163
230
  owners: daemonOwners.get(resolved.address) ?? [],
231
+ inWorkspaces,
164
232
  deps: (svc.depends_on ?? []).map((d) =>
165
233
  resolveDepAddress(depService(d), catalog, ws.config, ws.name),
166
234
  ),
@@ -176,13 +244,16 @@ export async function buildServicesList(
176
244
  );
177
245
  return [entry];
178
246
  })
179
- : [];
247
+ );
248
+ });
180
249
 
181
250
  // Merge: catalog entries take precedence over legacy for same repo.
182
251
  // When a workspace filter is active, also exclude legacy entries not in
183
252
  // the workspace (legacy discovery is not workspace-scoped).
184
253
  const catalogRepos = new Set(Object.keys(catalog));
185
- const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
254
+ const wsServiceKeys = inView.length
255
+ ? new Set(inView.flatMap((w) => Object.keys(w.config.services)))
256
+ : null;
186
257
  const merged = [
187
258
  ...legacyEntries.filter((e) => {
188
259
  if (catalogRepos.has(e.repo)) return false;
@@ -197,7 +268,13 @@ export async function buildServicesList(
197
268
  // health row and `den status` use. Added as a FIELD rather than by
198
269
  // re-sorting the array, because four pages consume this payload and some
199
270
  // rely on the current order.
200
- const graphRank = new Map(graphOrder(ws?.name).map((repo, i) => [repo, i]));
271
+ // With several workspaces in view: the focus's order, then repos only the
272
+ // others define, in their own order.
273
+ const order: string[] = [];
274
+ for (const w of inView.length ? inView : [null])
275
+ for (const repo of graphOrder(w?.name))
276
+ if (!order.includes(repo)) order.push(repo);
277
+ const graphRank = new Map(order.map((repo, i) => [repo, i]));
201
278
  const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
202
279
  await Promise.all(pendingBranches);
203
280
  return merged.map((e) => ({
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What the distribution's tracked config sets, for the Settings page's
3
+ * read-only Distribution rows (R21, 0.3.0-dev.19). Read straight from the
4
+ * org config file (product().configFile), never from config/local/user.yml,
5
+ * so a row shows the distribution's value even when you override it.
6
+ *
7
+ * Read-only by design: nothing in the Settings page writes the
8
+ * distribution's config (it is tracked in git). Your own override of the
9
+ * same thing (a theme pick, `web.port` in user.yml) is a separate User row.
10
+ */
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import * as yaml from "yaml";
14
+ import { product } from "../../shared/product.js";
15
+
16
+ export interface DistributionSettings {
17
+ /** The org config file, relative to the distribution root. */
18
+ file: string;
19
+ /** brand.name */
20
+ brand: string | null;
21
+ /** brand.theme: the theme drawn when you have chosen none. */
22
+ theme: string | null;
23
+ github_org: string | null;
24
+ /** orgs: id → label (or the id when it has none). */
25
+ orgs: Record<string, string>;
26
+ /** web.port in the org config (your user.yml may override it). */
27
+ web_port: number | null;
28
+ environments: string[] | null;
29
+ /** edge.domain */
30
+ domain: string | null;
31
+ }
32
+
33
+ /** Your own `web.port` override in config/local/user.yml, or null. */
34
+ export function userWebPort(user: Record<string, unknown>): number | null {
35
+ const p = (user.web as { port?: unknown } | undefined)?.port;
36
+ return typeof p === "number" && Number.isInteger(p) ? p : null;
37
+ }
38
+
39
+ const str = (v: unknown): string | null =>
40
+ typeof v === "string" && v.trim() ? v.trim() : null;
41
+
42
+ /** The distribution's values; an unreadable file reads as nothing set. */
43
+ export function distributionSettings(root: string): DistributionSettings {
44
+ const file = product().configFile;
45
+ let raw: Record<string, unknown> = {};
46
+ try {
47
+ raw =
48
+ (yaml.parse(fs.readFileSync(path.join(root, file), "utf-8")) as Record<
49
+ string,
50
+ unknown
51
+ > | null) ?? {};
52
+ } catch {
53
+ /* no or broken config: nothing set */
54
+ }
55
+ const brand = (raw.brand ?? {}) as Record<string, unknown>;
56
+ const orgs: Record<string, string> = {};
57
+ if (raw.orgs && typeof raw.orgs === "object" && !Array.isArray(raw.orgs))
58
+ for (const [id, v] of Object.entries(raw.orgs as Record<string, unknown>))
59
+ orgs[id] = str((v as { label?: unknown } | null)?.label) ?? id;
60
+ const port = (raw.web as { port?: unknown } | undefined)?.port;
61
+ const envs = Array.isArray(raw.environments)
62
+ ? raw.environments.filter((e): e is string => typeof e === "string")
63
+ : null;
64
+ return {
65
+ file,
66
+ brand: str(brand.name),
67
+ theme: str(brand.theme),
68
+ github_org: str(raw.github_org),
69
+ orgs,
70
+ web_port: typeof port === "number" && Number.isInteger(port) ? port : null,
71
+ environments: envs,
72
+ domain: str((raw.edge as { domain?: unknown } | undefined)?.domain),
73
+ };
74
+ }