@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.
- package/CHANGELOG.md +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- 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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
|
61
|
+
export function buildScopeMenuModel(scope, data, t = enT) {
|
|
33
62
|
const active = activeNames(data);
|
|
34
|
-
const
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
title:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
@@ -164,6 +170,11 @@ export async function handleReadonlyRoutes(
|
|
|
164
170
|
|
|
165
171
|
if (pathname === "/api/edge") {
|
|
166
172
|
try {
|
|
173
|
+
if (url.searchParams.get("probe") === "1") {
|
|
174
|
+
deps.edgeConfig();
|
|
175
|
+
sendJson(res, { configured: true });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
167
178
|
sendJson(res, { configured: true, ...deps.edge() });
|
|
168
179
|
} catch (e) {
|
|
169
180
|
// No edge configured is a state, not a server error.
|
|
@@ -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
|
|
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 {
|
|
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
|
|
98
|
+
const inView: { name: string; config: WorkspaceConfig }[] = wsOverride
|
|
91
99
|
? wsFile?.workspaces[wsOverride]
|
|
92
|
-
? { name: wsOverride, config: wsFile.workspaces[wsOverride] }
|
|
93
|
-
:
|
|
94
|
-
:
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell preferences (0.3.0-dev.17): what this engineer pinned to the rail
|
|
3
|
+
* and whether the rail is collapsed; since 0.3.0-dev.19 (R21 Navigation)
|
|
4
|
+
* also the rail pages they hid and the order they put them in. Stored in the user config as `shell:`
|
|
5
|
+
* and served by /api/settings, like `graph:` and `appearance:`. A view
|
|
6
|
+
* preference only: pinning changes no edge route, container or service.
|
|
7
|
+
* The shell mirrors it to localStorage so the first paint does not wait on
|
|
8
|
+
* the server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface ShellPrefs {
|
|
12
|
+
/** Pinned repos or services (`repo` or `repo/service`), in pin order. */
|
|
13
|
+
pins: string[];
|
|
14
|
+
/** The rail shows icons only. */
|
|
15
|
+
collapsed: boolean;
|
|
16
|
+
/** NAV page ids the rail leaves out (Settings is never one). */
|
|
17
|
+
hidden: string[];
|
|
18
|
+
/** NAV page ids in the engineer's order; pages not listed keep theirs. */
|
|
19
|
+
order: string[];
|
|
20
|
+
/**
|
|
21
|
+
* The runs dock is open (R16a, 0.3.0-dev.30, Ward QA 2026-11-05): it
|
|
22
|
+
* starts collapsed to its bar; opening it is the engineer's choice and
|
|
23
|
+
* remembered, never forced by a run starting.
|
|
24
|
+
*/
|
|
25
|
+
dock: boolean;
|
|
26
|
+
/** The open dock's output height in px; 0 is the default (170px). */
|
|
27
|
+
dockHeight: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const SHELL_DEFAULTS: ShellPrefs = {
|
|
31
|
+
pins: [],
|
|
32
|
+
collapsed: false,
|
|
33
|
+
hidden: [],
|
|
34
|
+
order: [],
|
|
35
|
+
dock: false,
|
|
36
|
+
dockHeight: 0,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The dock height's bounds (the page also caps it at half the window). */
|
|
40
|
+
export const DOCK_MIN = 60;
|
|
41
|
+
export const DOCK_MAX = 2000;
|
|
42
|
+
|
|
43
|
+
function checkDockHeight(
|
|
44
|
+
v: unknown,
|
|
45
|
+
): { ok: true; value: number } | { ok: false; error: string } {
|
|
46
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
47
|
+
return { ok: false, error: dockHeightError() };
|
|
48
|
+
const h = Math.round(v);
|
|
49
|
+
if (h !== 0 && (h < DOCK_MIN || h > DOCK_MAX))
|
|
50
|
+
return { ok: false, error: dockHeightError() };
|
|
51
|
+
return { ok: true, value: h };
|
|
52
|
+
}
|
|
53
|
+
function dockHeightError(): string {
|
|
54
|
+
return `shell.dockHeight must be 0 (the default) or a number from ${DOCK_MIN} to ${DOCK_MAX}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** How many page ids `hidden` and `order` keep. */
|
|
58
|
+
export const MAX_NAV_IDS = 64;
|
|
59
|
+
/** A NAV id, as shell.js's registerNav takes it. */
|
|
60
|
+
const NAV_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
61
|
+
|
|
62
|
+
/** How many pins the rail keeps. */
|
|
63
|
+
export const MAX_PINS = 24;
|
|
64
|
+
const PIN =
|
|
65
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}(\/[A-Za-z0-9][A-Za-z0-9._-]{0,99})?$/;
|
|
66
|
+
|
|
67
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
68
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
69
|
+
|
|
70
|
+
function checkPins(
|
|
71
|
+
v: unknown,
|
|
72
|
+
): { ok: true; value: string[] } | { ok: false; error: string } {
|
|
73
|
+
if (!Array.isArray(v))
|
|
74
|
+
return { ok: false, error: "shell.pins must be a list" };
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
for (const p of v) {
|
|
77
|
+
if (typeof p !== "string" || !PIN.test(p))
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: `shell.pins: "${String(p)}" is not a repo or repo/service name`,
|
|
81
|
+
};
|
|
82
|
+
if (!out.includes(p)) out.push(p);
|
|
83
|
+
}
|
|
84
|
+
if (out.length > MAX_PINS)
|
|
85
|
+
return { ok: false, error: `shell.pins: at most ${MAX_PINS}` };
|
|
86
|
+
return { ok: true, value: out };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkNavIds(
|
|
90
|
+
key: "hidden" | "order",
|
|
91
|
+
v: unknown,
|
|
92
|
+
): { ok: true; value: string[] } | { ok: false; error: string } {
|
|
93
|
+
if (!Array.isArray(v))
|
|
94
|
+
return { ok: false, error: `shell.${key} must be a list` };
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
for (const id of v) {
|
|
97
|
+
if (typeof id !== "string" || !NAV_ID.test(id))
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `shell.${key}: "${String(id)}" is not a page id`,
|
|
101
|
+
};
|
|
102
|
+
if (!out.includes(id)) out.push(id);
|
|
103
|
+
}
|
|
104
|
+
if (out.length > MAX_NAV_IDS)
|
|
105
|
+
return { ok: false, error: `shell.${key}: at most ${MAX_NAV_IDS}` };
|
|
106
|
+
return { ok: true, value: out };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The stored block, normalised: a missing or hand-broken field is its default. */
|
|
110
|
+
export function shellPrefs(stored: unknown): ShellPrefs {
|
|
111
|
+
const out: ShellPrefs = {
|
|
112
|
+
pins: [],
|
|
113
|
+
collapsed: false,
|
|
114
|
+
hidden: [],
|
|
115
|
+
order: [],
|
|
116
|
+
dock: false,
|
|
117
|
+
dockHeight: 0,
|
|
118
|
+
};
|
|
119
|
+
if (!isObject(stored)) return out;
|
|
120
|
+
if (typeof stored.dock === "boolean") out.dock = stored.dock;
|
|
121
|
+
if (stored.dockHeight !== undefined) {
|
|
122
|
+
const h = checkDockHeight(stored.dockHeight);
|
|
123
|
+
if (h.ok) out.dockHeight = h.value;
|
|
124
|
+
}
|
|
125
|
+
const pins = checkPins(stored.pins);
|
|
126
|
+
if (pins.ok) out.pins = pins.value;
|
|
127
|
+
if (typeof stored.collapsed === "boolean") out.collapsed = stored.collapsed;
|
|
128
|
+
for (const key of ["hidden", "order"] as const) {
|
|
129
|
+
const ids = checkNavIds(key, stored[key]);
|
|
130
|
+
if (ids.ok) out[key] = ids.value;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Merge a POSTed (partial) block over the current prefs; any bad field fails it. */
|
|
136
|
+
export function mergeShellPrefs(
|
|
137
|
+
current: ShellPrefs,
|
|
138
|
+
patch: unknown,
|
|
139
|
+
): { ok: true; value: ShellPrefs } | { ok: false; error: string } {
|
|
140
|
+
if (!isObject(patch)) return { ok: false, error: "shell must be an object" };
|
|
141
|
+
const out: ShellPrefs = {
|
|
142
|
+
...current,
|
|
143
|
+
pins: [...current.pins],
|
|
144
|
+
hidden: [...current.hidden],
|
|
145
|
+
order: [...current.order],
|
|
146
|
+
};
|
|
147
|
+
for (const key of Object.keys(patch)) {
|
|
148
|
+
// null clears a pref back to its default (0.3.0-dev.32): the stored
|
|
149
|
+
// block drops it, so set-then-clear leaves user.yml as it was.
|
|
150
|
+
if (patch[key] === null && key in SHELL_DEFAULTS) {
|
|
151
|
+
const d = SHELL_DEFAULTS[key as keyof ShellPrefs];
|
|
152
|
+
(out as unknown as Record<string, unknown>)[key] = Array.isArray(d)
|
|
153
|
+
? []
|
|
154
|
+
: d;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (key === "pins") {
|
|
158
|
+
const p = checkPins(patch.pins);
|
|
159
|
+
if (!p.ok) return p;
|
|
160
|
+
out.pins = p.value;
|
|
161
|
+
} else if (key === "collapsed") {
|
|
162
|
+
if (typeof patch.collapsed !== "boolean")
|
|
163
|
+
return { ok: false, error: "shell.collapsed must be true or false" };
|
|
164
|
+
out.collapsed = patch.collapsed;
|
|
165
|
+
} else if (key === "dock") {
|
|
166
|
+
if (typeof patch.dock !== "boolean")
|
|
167
|
+
return { ok: false, error: "shell.dock must be true or false" };
|
|
168
|
+
out.dock = patch.dock;
|
|
169
|
+
} else if (key === "dockHeight") {
|
|
170
|
+
const h = checkDockHeight(patch.dockHeight);
|
|
171
|
+
if (!h.ok) return h;
|
|
172
|
+
out.dockHeight = h.value;
|
|
173
|
+
} else if (key === "hidden" || key === "order") {
|
|
174
|
+
const ids = checkNavIds(key, patch[key]);
|
|
175
|
+
if (!ids.ok) return ids;
|
|
176
|
+
out[key] = ids.value;
|
|
177
|
+
} else return { ok: false, error: `shell.${key} is not a setting` };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, value: out };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The block to store: none for the defaults, so user.yml stays lean. */
|
|
183
|
+
export function storedShell(p: ShellPrefs): Partial<ShellPrefs> | undefined {
|
|
184
|
+
const out: Partial<ShellPrefs> = {};
|
|
185
|
+
if (p.pins.length) out.pins = p.pins;
|
|
186
|
+
if (p.collapsed) out.collapsed = true;
|
|
187
|
+
if (p.hidden.length) out.hidden = p.hidden;
|
|
188
|
+
if (p.order.length) out.order = p.order;
|
|
189
|
+
if (p.dock) out.dock = true;
|
|
190
|
+
if (p.dockHeight) out.dockHeight = p.dockHeight;
|
|
191
|
+
return Object.keys(out).length ? out : undefined;
|
|
192
|
+
}
|