@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
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* to any workspace/catalog service is process-death-equivalent evidence and
|
|
16
16
|
* is removed directly. Records another root registered (all roots of a
|
|
17
17
|
* product share the file) are never touched, and an unstamped legacy record
|
|
18
|
-
* is not removed for being unresolvable.
|
|
18
|
+
* is not removed for being unresolvable. A record whose checkout is gone, or
|
|
19
|
+
* whose service moved to another checkout with nothing of it running
|
|
20
|
+
* (`staleReason`), is removed whatever the address's health says.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
@@ -49,6 +51,19 @@ export interface DaemonReaperDeps {
|
|
|
49
51
|
* and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
|
|
50
52
|
*/
|
|
51
53
|
rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
|
|
54
|
+
/**
|
|
55
|
+
* Why a record describes nothing that can be running whatever its health
|
|
56
|
+
* (service-runner's daemonStaleReason: its checkout is gone, or the
|
|
57
|
+
* service moved to another checkout and none of its containers run), or
|
|
58
|
+
* null. Injected; omitted, no record is dropped for it. `running` is the
|
|
59
|
+
* container listing, taken once per sweep by `runningContainers`.
|
|
60
|
+
*/
|
|
61
|
+
staleReason?: (
|
|
62
|
+
daemon: DaemonRecord,
|
|
63
|
+
running: Set<string> | null,
|
|
64
|
+
) => string | null;
|
|
65
|
+
/** The running container base names (null: unverifiable), once a sweep. */
|
|
66
|
+
runningContainers?: () => Promise<Set<string> | null>;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
export interface DaemonReapResult {
|
|
@@ -96,6 +111,11 @@ export async function reapDeadDaemons(
|
|
|
96
111
|
): Promise<DaemonReapResult> {
|
|
97
112
|
const removed: string[] = [];
|
|
98
113
|
const kept: string[] = [];
|
|
114
|
+
const running = deps.staleReason
|
|
115
|
+
? await (deps.runningContainers?.() ?? Promise.resolve(null)).catch(
|
|
116
|
+
() => null,
|
|
117
|
+
)
|
|
118
|
+
: null;
|
|
99
119
|
|
|
100
120
|
await Promise.allSettled(
|
|
101
121
|
deps.listDaemons().map(async (daemon) => {
|
|
@@ -105,6 +125,14 @@ export async function reapDeadDaemons(
|
|
|
105
125
|
kept.push(daemon.address);
|
|
106
126
|
return;
|
|
107
127
|
}
|
|
128
|
+
// Its checkout is gone, or the service has moved and nothing of
|
|
129
|
+
// the record's runs: whatever the address's health says (it is
|
|
130
|
+
// another checkout's), the record is stale.
|
|
131
|
+
if (deps.staleReason?.(daemon, running)) {
|
|
132
|
+
deps.unregisterDaemon(daemon.address);
|
|
133
|
+
removed.push(daemon.address);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
108
136
|
const service = resolveForDaemon(deps, daemon);
|
|
109
137
|
if (!service) {
|
|
110
138
|
// No longer in any owning workspace/catalog — nothing could restart
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MD-2 (0.3.0-dev.38): the engine a running web server loaded against the
|
|
3
|
+
* engine on disk. A pin that upgrades Den rewrites node_modules under a
|
|
4
|
+
* server that keeps the old code in memory while it serves the new pages
|
|
5
|
+
* from disk, so the new pages' routes 404. The server records the engine's
|
|
6
|
+
* version and build id at startup; page requests compare them with the
|
|
7
|
+
* engine's package.json now (cached for a few seconds).
|
|
8
|
+
*
|
|
9
|
+
* The build id is a short hash of that package.json: it changes with any
|
|
10
|
+
* re-pin, even one that keeps the version string (a git-sha pin).
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "crypto";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
export interface EngineStamp {
|
|
17
|
+
version: string | null;
|
|
18
|
+
buildId: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The engine's version and build id from its package.json, now. */
|
|
22
|
+
export function readEngineStamp(engineRoot: string): EngineStamp {
|
|
23
|
+
try {
|
|
24
|
+
const raw = fs.readFileSync(path.join(engineRoot, "package.json"));
|
|
25
|
+
const pkg = JSON.parse(raw.toString("utf-8")) as { version?: unknown };
|
|
26
|
+
return {
|
|
27
|
+
version: typeof pkg.version === "string" ? pkg.version : null,
|
|
28
|
+
buildId: createHash("sha256").update(raw).digest("hex").slice(0, 12),
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
return { version: null, buildId: null };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface EngineCheck {
|
|
36
|
+
loaded: EngineStamp;
|
|
37
|
+
onDisk: EngineStamp;
|
|
38
|
+
/** The engine on disk is not the one this server runs. */
|
|
39
|
+
stale: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How long a comparison is reused. */
|
|
43
|
+
export const ENGINE_CHECK_TTL_MS = 3_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The loaded stamp (read once, now) and a cached comparison with the disk.
|
|
47
|
+
* An unreadable package.json on disk is never "stale": nothing is known.
|
|
48
|
+
*/
|
|
49
|
+
export function engineWatch(
|
|
50
|
+
engineRoot: () => string,
|
|
51
|
+
opts: { ttlMs?: number; now?: () => number } = {},
|
|
52
|
+
): { loaded: EngineStamp; check(): EngineCheck } {
|
|
53
|
+
const ttl = opts.ttlMs ?? ENGINE_CHECK_TTL_MS;
|
|
54
|
+
const now = opts.now ?? Date.now;
|
|
55
|
+
const loaded = readEngineStamp(engineRoot());
|
|
56
|
+
let cache: { at: number; value: EngineCheck } | null = null;
|
|
57
|
+
return {
|
|
58
|
+
loaded,
|
|
59
|
+
check() {
|
|
60
|
+
const t = now();
|
|
61
|
+
if (cache && t - cache.at < ttl) return cache.value;
|
|
62
|
+
const onDisk = readEngineStamp(engineRoot());
|
|
63
|
+
const stale =
|
|
64
|
+
onDisk.buildId !== null &&
|
|
65
|
+
loaded.buildId !== null &&
|
|
66
|
+
onDisk.buildId !== loaded.buildId;
|
|
67
|
+
cache = { at: t, value: { loaded, onDisk, stale } };
|
|
68
|
+
return cache.value;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The restart message (pages, 404s, MCP): both versions and the command. */
|
|
74
|
+
export function engineStaleMessage(c: EngineCheck, cli: string): string {
|
|
75
|
+
const same = c.onDisk.version === c.loaded.version;
|
|
76
|
+
const v = (s: EngineStamp) =>
|
|
77
|
+
(s.version ?? "an unknown version") +
|
|
78
|
+
(same && s.buildId ? ` (build ${s.buildId})` : "");
|
|
79
|
+
return (
|
|
80
|
+
`Den was updated to ${v(c.onDisk)}. This server is still running ${v(c.loaded)}. ` +
|
|
81
|
+
`Restart it: ${cli} web restart (or your service restart).`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
import { execFileSync, execSync } from "child_process";
|
|
9
9
|
import * as path from "path";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
configuredGithubOrg,
|
|
12
|
+
isPlaceholderGithubOrg,
|
|
13
|
+
readDenConfig,
|
|
14
|
+
} from "../../shared/den-config.js";
|
|
11
15
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
12
16
|
import { githubOwnersOf } from "../../shared/org-sources.js";
|
|
13
17
|
import {
|
|
14
18
|
GH_API_TIMEOUT_MS,
|
|
15
|
-
GIT_REMOTE_TIMEOUT_MS,
|
|
16
19
|
BRANCH_CACHE_TTL_MS,
|
|
17
20
|
REPO_CACHE_TTL_MS,
|
|
18
21
|
} from "../../shared/constants.js";
|
|
@@ -40,6 +43,7 @@ export class GitHubService {
|
|
|
40
43
|
{ names: string[]; fetchedAt: number }
|
|
41
44
|
>();
|
|
42
45
|
private reposCache: { data: GitHubRepo[]; fetchedAt: number } | null = null;
|
|
46
|
+
private warnedNotConfigured = false;
|
|
43
47
|
|
|
44
48
|
/**
|
|
45
49
|
* List branches for a GitHub repo slug ("owner/repo").
|
|
@@ -96,11 +100,13 @@ export class GitHubService {
|
|
|
96
100
|
|
|
97
101
|
const orgs = this.resolveOrgs();
|
|
98
102
|
if (orgs.length === 0) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"
|
|
103
|
-
|
|
103
|
+
if (!this.warnedNotConfigured) {
|
|
104
|
+
this.warnedNotConfigured = true;
|
|
105
|
+
console.log(
|
|
106
|
+
"[github-service] GitHub repos not configured: set `github_org: <your-org>` in " +
|
|
107
|
+
`${product().configFile} to list them (no gh is run until then)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
104
110
|
return [];
|
|
105
111
|
}
|
|
106
112
|
const data = orgs.flatMap((org) => this.listOneOrg(org));
|
|
@@ -172,18 +178,16 @@ export class GitHubService {
|
|
|
172
178
|
|
|
173
179
|
/**
|
|
174
180
|
* Every GitHub org Den lists repos from: `github_org`, then the orgs in
|
|
175
|
-
* config
|
|
176
|
-
* repos can sit side by side). With
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* who pre-date `github_org` or had it commented out still get
|
|
180
|
-
* the right value as long as den itself was cloned from the
|
|
181
|
-
* same org as their services (the common case).
|
|
181
|
+
* the org config's `orgs:`, then every owner in the catalog (several
|
|
182
|
+
* orgs' repos can sit side by side). With `github_org` unset or still a
|
|
183
|
+
* placeholder (`your-github-org`), none: GitHub is not configured, and Den
|
|
184
|
+
* runs no gh for it.
|
|
182
185
|
*/
|
|
183
186
|
private resolveOrgs(): string[] {
|
|
184
187
|
const config = readDenConfig();
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
188
|
+
const primary = configuredGithubOrg(config);
|
|
189
|
+
if (!primary) return [];
|
|
190
|
+
const orgs = new Set<string>([primary]);
|
|
187
191
|
// A legacy `orgs:` key is a GitHub owner; an ORG-1d org lists its GitHub
|
|
188
192
|
// sources' owners, and one with no GitHub source adds none (discovery is
|
|
189
193
|
// hidden for it).
|
|
@@ -196,33 +200,8 @@ export class GitHubService {
|
|
|
196
200
|
}
|
|
197
201
|
// Org names reach a shell command: GitHub owners are letters, digits and
|
|
198
202
|
// hyphens, so anything else is dropped.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return fallback ? [fallback] : [];
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
private originOrg(): string | null {
|
|
206
|
-
// Fall back to the den repo's origin. Works for:
|
|
207
|
-
// SSH: git@github.com:ORG/den.git
|
|
208
|
-
// HTTPS: https://github.com/ORG/den.git
|
|
209
|
-
// https://github.com/ORG/den
|
|
210
|
-
try {
|
|
211
|
-
const url = execFileSync(
|
|
212
|
-
"git",
|
|
213
|
-
["-C", ROOT, "config", "--get", "remote.origin.url"],
|
|
214
|
-
{
|
|
215
|
-
encoding: "utf-8",
|
|
216
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
217
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
218
|
-
},
|
|
219
|
-
).trim();
|
|
220
|
-
const m = url.match(/[:/]([^/:]+)\/[^/]+?(?:\.git)?$/);
|
|
221
|
-
if (m) return m[1];
|
|
222
|
-
} catch {
|
|
223
|
-
/* no git remote available */
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return null;
|
|
203
|
+
return [...orgs].filter(
|
|
204
|
+
(o) => /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(o) && !isPlaceholderGithubOrg(o),
|
|
205
|
+
);
|
|
227
206
|
}
|
|
228
207
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Graph page (R22, 0.3.0-dev.32, Ward 2026-11-05): the workspace graph
|
|
3
|
+
* alone, full width, at /graph. It is served from local-dev.html (the
|
|
4
|
+
* Workspaces page, R24) with the shell's page id and the <title> swapped:
|
|
5
|
+
* the same graph, actions and details panel, no second copy of them. With
|
|
6
|
+
* page id "graph" the page hides the workspace list and draws only the
|
|
7
|
+
* graph.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SHELL = '<div id="den-shell" data-page="local-dev"';
|
|
11
|
+
const TITLE = "· Workspaces</title>";
|
|
12
|
+
|
|
13
|
+
/** local-dev.html → the Graph page. Throws when the markers are gone. */
|
|
14
|
+
export function graphPageHtml(localDev: string): string {
|
|
15
|
+
if (!localDev.includes(SHELL) || !localDev.includes(TITLE))
|
|
16
|
+
throw new Error("local-dev.html: the Graph page's markers are missing");
|
|
17
|
+
return localDev
|
|
18
|
+
.replace(SHELL, '<div id="den-shell" data-page="graph"')
|
|
19
|
+
.replace(TITLE, "· Graph</title>");
|
|
20
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Graph preferences: how the
|
|
2
|
+
* Graph preferences: how the graph is laid out for this engineer, and the
|
|
3
|
+
* Workspaces page's own view prefs (its list, its table's grouping).
|
|
3
4
|
* Stored in the user config as `graph:` and served by /api/settings, so the
|
|
4
5
|
* layout is remembered between sessions (the page mirrors it to localStorage
|
|
5
6
|
* so the first paint does not wait on the server).
|
|
@@ -14,6 +15,16 @@ export interface GraphPrefs {
|
|
|
14
15
|
density: "detailed" | "compact";
|
|
15
16
|
/** Details panel width in px. */
|
|
16
17
|
panelWidth: number;
|
|
18
|
+
/**
|
|
19
|
+
* The Workspaces page's workspace list: open, or collapsed to a 40px strip (R23,
|
|
20
|
+
* 0.3.0-dev.30, Ward QA 2026-11-05), so the graph gets the width.
|
|
21
|
+
*/
|
|
22
|
+
workspaces: "open" | "collapsed";
|
|
23
|
+
/**
|
|
24
|
+
* The Workspaces page's repo table (R24d, 0.3.0-dev.35): grouped by
|
|
25
|
+
* nothing, by application (as `<cli> apps`) or by org.
|
|
26
|
+
*/
|
|
27
|
+
groupBy: "none" | "app" | "org";
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
export const GRAPH_DEFAULTS: GraphPrefs = {
|
|
@@ -21,12 +32,16 @@ export const GRAPH_DEFAULTS: GraphPrefs = {
|
|
|
21
32
|
group: "org",
|
|
22
33
|
density: "detailed",
|
|
23
34
|
panelWidth: 440,
|
|
35
|
+
workspaces: "open",
|
|
36
|
+
groupBy: "none",
|
|
24
37
|
};
|
|
25
38
|
|
|
26
39
|
const CHOICES = {
|
|
27
40
|
direction: ["down", "right"],
|
|
28
41
|
group: ["org", "app"],
|
|
29
42
|
density: ["detailed", "compact"],
|
|
43
|
+
workspaces: ["open", "collapsed"],
|
|
44
|
+
groupBy: ["none", "app", "org"],
|
|
30
45
|
} as const;
|
|
31
46
|
|
|
32
47
|
const PANEL_MIN = 360;
|
|
@@ -87,3 +102,31 @@ export function mergeGraphPrefs(
|
|
|
87
102
|
}
|
|
88
103
|
return { ok: true, value: out };
|
|
89
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The stored block after a POSTed patch (0.3.0-dev.32, a consumer's nit):
|
|
108
|
+
* only the keys someone set are kept, and a key set to null is removed, so
|
|
109
|
+
* setting a pref and clearing it again leaves user.yml as it was. Nothing
|
|
110
|
+
* left: undefined (the caller drops `graph:`). Any bad field fails it.
|
|
111
|
+
*/
|
|
112
|
+
export function patchStoredGraph(
|
|
113
|
+
stored: unknown,
|
|
114
|
+
patch: unknown,
|
|
115
|
+
):
|
|
116
|
+
| { ok: true; value: Record<string, unknown> | undefined }
|
|
117
|
+
| { ok: false; error: string } {
|
|
118
|
+
if (!isObject(patch)) return { ok: false, error: "graph must be an object" };
|
|
119
|
+
const out: Record<string, unknown> = isObject(stored) ? { ...stored } : {};
|
|
120
|
+
for (const [key, v] of Object.entries(patch)) {
|
|
121
|
+
if (!(key in GRAPH_DEFAULTS))
|
|
122
|
+
return { ok: false, error: `unknown graph setting "${key}"` };
|
|
123
|
+
if (v === null) {
|
|
124
|
+
delete out[key];
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const c = check(key as keyof GraphPrefs, v);
|
|
128
|
+
if (!c.ok) return c;
|
|
129
|
+
out[key] = c.value;
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, value: Object.keys(out).length ? out : undefined };
|
|
132
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors people can see and act on (R26, 0.3.0-dev.38).
|
|
3
|
+
*
|
|
4
|
+
* R26a a page request (a browser navigation: GET/HEAD, Accept text/html,
|
|
5
|
+
* not /api/*) that fails gets the shell frame around an error page
|
|
6
|
+
* (public/error.html, drawn by error-page.js): 404 "Page not found"
|
|
7
|
+
* or 500 "Something went wrong", with the request id (422 "Config
|
|
8
|
+
* is invalid" for a ConfigError).
|
|
9
|
+
* R26b every /api/* error is JSON, one shape:
|
|
10
|
+
* { ok: false, error, code, requestId }. jsonApiErrors() makes that
|
|
11
|
+
* true for every route at the response, whatever the route sent
|
|
12
|
+
* (send400's text, a route's own { ok: false, error } body, …).
|
|
13
|
+
*
|
|
14
|
+
* Each request gets a short id (X-Request-Id on every response); the
|
|
15
|
+
* server log line of a failure carries it, so a person's "request a1b2c3d4"
|
|
16
|
+
* finds the stack trace.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from "crypto";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as http from "http";
|
|
21
|
+
import * as path from "path";
|
|
22
|
+
import { PLUGIN_FILE, PLUGIN_ID } from "../../shared/plugins/manifest.js";
|
|
23
|
+
import { PLUGINS_DIR, readLock } from "../../shared/plugins/store.js";
|
|
24
|
+
|
|
25
|
+
/** A short request id: 8 hex characters. */
|
|
26
|
+
export function newRequestId(): string {
|
|
27
|
+
return randomBytes(4).toString("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The stable `code` for an HTTP status. */
|
|
31
|
+
export function codeForStatus(status: number): string {
|
|
32
|
+
const codes: Record<number, string> = {
|
|
33
|
+
400: "bad_request",
|
|
34
|
+
401: "unauthorized",
|
|
35
|
+
403: "forbidden",
|
|
36
|
+
404: "not_found",
|
|
37
|
+
405: "method_not_allowed",
|
|
38
|
+
408: "timeout",
|
|
39
|
+
409: "conflict",
|
|
40
|
+
410: "gone",
|
|
41
|
+
413: "too_large",
|
|
42
|
+
415: "unsupported_media_type",
|
|
43
|
+
422: "invalid",
|
|
44
|
+
423: "locked",
|
|
45
|
+
429: "rate_limited",
|
|
46
|
+
500: "internal",
|
|
47
|
+
501: "not_implemented",
|
|
48
|
+
502: "bad_gateway",
|
|
49
|
+
503: "unavailable",
|
|
50
|
+
504: "timeout",
|
|
51
|
+
};
|
|
52
|
+
return codes[status] ?? (status >= 500 ? "internal" : `http_${status}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ApiErrorBody {
|
|
56
|
+
ok: false;
|
|
57
|
+
error: string;
|
|
58
|
+
code: string;
|
|
59
|
+
requestId: string;
|
|
60
|
+
[field: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The R26b body for an error response: `raw` is what the route sent. A JSON
|
|
65
|
+
* object keeps its fields (a route's own `runCmd`, `reason`, …) and gains
|
|
66
|
+
* ok, error, code and requestId; text becomes the message.
|
|
67
|
+
*/
|
|
68
|
+
export function apiErrorBody(
|
|
69
|
+
status: number,
|
|
70
|
+
raw: string,
|
|
71
|
+
requestId: string,
|
|
72
|
+
): ApiErrorBody {
|
|
73
|
+
const fallback = http.STATUS_CODES[status] ?? `HTTP ${status}`;
|
|
74
|
+
let obj: Record<string, unknown> | null = null;
|
|
75
|
+
const text = raw.trim();
|
|
76
|
+
if (text.startsWith("{")) {
|
|
77
|
+
try {
|
|
78
|
+
const v = JSON.parse(text) as unknown;
|
|
79
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
80
|
+
obj = v as Record<string, unknown>;
|
|
81
|
+
} catch {
|
|
82
|
+
/* text that looks like JSON: the message */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (obj) {
|
|
86
|
+
const error =
|
|
87
|
+
typeof obj.error === "string" && obj.error
|
|
88
|
+
? obj.error
|
|
89
|
+
: typeof obj.message === "string" && obj.message
|
|
90
|
+
? obj.message
|
|
91
|
+
: fallback;
|
|
92
|
+
return {
|
|
93
|
+
...obj,
|
|
94
|
+
ok: false,
|
|
95
|
+
error,
|
|
96
|
+
code:
|
|
97
|
+
typeof obj.code === "string" && obj.code
|
|
98
|
+
? obj.code
|
|
99
|
+
: codeForStatus(status),
|
|
100
|
+
requestId,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: text || fallback,
|
|
106
|
+
code: codeForStatus(status),
|
|
107
|
+
requestId,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Make every error status this response ends with a JSON R26b body. A 2xx
|
|
113
|
+
* or 3xx (an SSE stream among them) passes through untouched; an error's
|
|
114
|
+
* body is held until end() and rewritten. Call once per /api request,
|
|
115
|
+
* before the route runs.
|
|
116
|
+
*/
|
|
117
|
+
export function jsonApiErrors(
|
|
118
|
+
res: http.ServerResponse,
|
|
119
|
+
requestId: string,
|
|
120
|
+
): void {
|
|
121
|
+
const origWriteHead = res.writeHead.bind(res) as (
|
|
122
|
+
...a: unknown[]
|
|
123
|
+
) => http.ServerResponse;
|
|
124
|
+
const origWrite = res.write.bind(res) as (...a: unknown[]) => boolean;
|
|
125
|
+
const origEnd = res.end.bind(res) as (...a: unknown[]) => http.ServerResponse;
|
|
126
|
+
let held: { status: number; headers: http.OutgoingHttpHeaders } | null = null;
|
|
127
|
+
const chunks: Buffer[] = [];
|
|
128
|
+
const isError = (s: number): boolean => s >= 400;
|
|
129
|
+
|
|
130
|
+
const hold = (status: number, headers: unknown): void => {
|
|
131
|
+
const h: http.OutgoingHttpHeaders = {};
|
|
132
|
+
if (Array.isArray(headers)) {
|
|
133
|
+
for (let i = 0; i + 1 < headers.length; i += 2)
|
|
134
|
+
h[String(headers[i]).toLowerCase()] = headers[i + 1] as string;
|
|
135
|
+
} else if (headers && typeof headers === "object") {
|
|
136
|
+
for (const [k, v] of Object.entries(headers))
|
|
137
|
+
h[k.toLowerCase()] = v as string;
|
|
138
|
+
}
|
|
139
|
+
held = { status, headers: h };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
(res as unknown as { writeHead: unknown }).writeHead = (
|
|
143
|
+
status: number,
|
|
144
|
+
...rest: unknown[]
|
|
145
|
+
) => {
|
|
146
|
+
if (!isError(status)) return origWriteHead(status, ...rest);
|
|
147
|
+
const headers =
|
|
148
|
+
typeof rest[0] === "string" ? rest[1] : (rest[0] as unknown);
|
|
149
|
+
hold(status, headers);
|
|
150
|
+
return res;
|
|
151
|
+
};
|
|
152
|
+
(res as unknown as { write: unknown }).write = (
|
|
153
|
+
chunk: unknown,
|
|
154
|
+
...rest: unknown[]
|
|
155
|
+
) => {
|
|
156
|
+
if (!held && !res.headersSent && isError(res.statusCode))
|
|
157
|
+
hold(res.statusCode, {});
|
|
158
|
+
if (!held) return origWrite(chunk, ...rest);
|
|
159
|
+
if (chunk != null && typeof chunk !== "function")
|
|
160
|
+
chunks.push(
|
|
161
|
+
Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf-8"),
|
|
162
|
+
);
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
(res as unknown as { end: unknown }).end = (...args: unknown[]) => {
|
|
166
|
+
if (!held && !res.headersSent && isError(res.statusCode))
|
|
167
|
+
hold(res.statusCode, {});
|
|
168
|
+
if (!held) return origEnd(...args);
|
|
169
|
+
const chunk = args[0];
|
|
170
|
+
if (chunk != null && typeof chunk !== "function")
|
|
171
|
+
chunks.push(
|
|
172
|
+
Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf-8"),
|
|
173
|
+
);
|
|
174
|
+
const h = held as { status: number; headers: http.OutgoingHttpHeaders };
|
|
175
|
+
held = null;
|
|
176
|
+
const body = JSON.stringify(
|
|
177
|
+
apiErrorBody(
|
|
178
|
+
h.status,
|
|
179
|
+
Buffer.concat(chunks).toString("utf-8"),
|
|
180
|
+
requestId,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
const headers: http.OutgoingHttpHeaders = { ...h.headers };
|
|
184
|
+
delete headers["content-length"];
|
|
185
|
+
headers["content-type"] = "application/json";
|
|
186
|
+
headers["cache-control"] = "no-store";
|
|
187
|
+
origWriteHead(h.status, headers);
|
|
188
|
+
return origEnd(body);
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A browser navigation to a page: what gets the shell's error page. */
|
|
193
|
+
export function wantsHtmlPage(
|
|
194
|
+
req: http.IncomingMessage,
|
|
195
|
+
pathname: string,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
198
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) return false;
|
|
199
|
+
return String(req.headers.accept ?? "").includes("text/html");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What failed, for a person and the log, never with query values: the
|
|
204
|
+
* path, with id-like segments (numbers, long hex, uuids) as `:id`.
|
|
205
|
+
*/
|
|
206
|
+
export function routeTemplate(pathname: string): string {
|
|
207
|
+
return (
|
|
208
|
+
pathname
|
|
209
|
+
.split("/")
|
|
210
|
+
.map((seg) =>
|
|
211
|
+
/^\d+$/.test(seg) ||
|
|
212
|
+
/^[0-9a-f]{12,}$/i.test(seg) ||
|
|
213
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(seg)
|
|
214
|
+
? ":id"
|
|
215
|
+
: seg,
|
|
216
|
+
)
|
|
217
|
+
.join("/") || "/"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface PluginRoute {
|
|
222
|
+
id: string;
|
|
223
|
+
/** Its name from its manifest, when it is installed. */
|
|
224
|
+
name: string | null;
|
|
225
|
+
installed: boolean;
|
|
226
|
+
/** In plugins.lock.json (added, but its files are missing). */
|
|
227
|
+
locked: boolean;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The plugin a page path belongs to: plugin pages live under
|
|
232
|
+
* /plugins/<id>/ (PL-SPEC §4: served from plugins/<id>/). Null for any
|
|
233
|
+
* other path.
|
|
234
|
+
*/
|
|
235
|
+
export function pluginForPath(
|
|
236
|
+
pathname: string,
|
|
237
|
+
distRoot: string,
|
|
238
|
+
): PluginRoute | null {
|
|
239
|
+
const m = pathname.match(/^\/plugins\/([^/]+)(?:\/|$)/);
|
|
240
|
+
if (!m || !PLUGIN_ID.test(m[1])) return null;
|
|
241
|
+
const id = m[1];
|
|
242
|
+
const dir = path.join(distRoot, PLUGINS_DIR, id);
|
|
243
|
+
let name: string | null = null;
|
|
244
|
+
let installed = false;
|
|
245
|
+
try {
|
|
246
|
+
const manifest = JSON.parse(
|
|
247
|
+
fs.readFileSync(path.join(dir, PLUGIN_FILE), "utf-8"),
|
|
248
|
+
) as { name?: unknown };
|
|
249
|
+
installed = true;
|
|
250
|
+
if (typeof manifest.name === "string") name = manifest.name;
|
|
251
|
+
} catch {
|
|
252
|
+
installed = fs.existsSync(path.join(dir, "den-theme.json"));
|
|
253
|
+
}
|
|
254
|
+
let locked = false;
|
|
255
|
+
try {
|
|
256
|
+
locked = Object.prototype.hasOwnProperty.call(readLock(distRoot), id);
|
|
257
|
+
} catch {
|
|
258
|
+
/* no lock */
|
|
259
|
+
}
|
|
260
|
+
return { id, name, installed, locked };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What error.html is told: everything error-page.js draws from. */
|
|
264
|
+
export interface ErrorPageData {
|
|
265
|
+
/** 422: the config fails validation (a ConfigError). */
|
|
266
|
+
status: 404 | 422 | 500;
|
|
267
|
+
/** 404 and 422: the path asked for (no query). */
|
|
268
|
+
path?: string;
|
|
269
|
+
/** 422: the ConfigError's message (it names config keys, never values). */
|
|
270
|
+
message?: string;
|
|
271
|
+
/** 422: each problem: its file, key path and message (0.3.0-dev.40). */
|
|
272
|
+
issues?: Array<{ file: string; key: string; message: string }>;
|
|
273
|
+
/** 422: the distribution's CLI, for "`<cli> validate`". */
|
|
274
|
+
cli?: string;
|
|
275
|
+
/** 500: what failed. */
|
|
276
|
+
method?: string;
|
|
277
|
+
route?: string;
|
|
278
|
+
requestId: string;
|
|
279
|
+
time: string;
|
|
280
|
+
version: string | null;
|
|
281
|
+
plugin?: PluginRoute | null;
|
|
282
|
+
/** MD-2: the restart message, when the engine on disk is newer. */
|
|
283
|
+
engine?: { loaded: string | null; onDisk: string | null; message: string };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* error.html with its data in a JSON script (a `<` in any value can't
|
|
288
|
+
* close the script: it is escaped).
|
|
289
|
+
*/
|
|
290
|
+
export function fillErrorPage(html: string, data: ErrorPageData): string {
|
|
291
|
+
const json = JSON.stringify(data).replace(/</g, "\\u003c");
|
|
292
|
+
return html.replace("@@DEN_ERROR@@", () => json);
|
|
293
|
+
}
|
|
@@ -65,21 +65,40 @@ export function sendJson(
|
|
|
65
65
|
res.end(body);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A last fill for every served page, set by the server: the appearance
|
|
70
|
+
* placeholders the pre-paint script reads (@@DEN_MODE@@, @@DEN_DEFAULT_MODE@@).
|
|
71
|
+
*/
|
|
72
|
+
let htmlFill: ((html: string) => string) | null = null;
|
|
73
|
+
export function setHtmlFill(fn: ((html: string) => string) | null): void {
|
|
74
|
+
htmlFill = fn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function serveFile(
|
|
78
|
+
res: http.ServerResponse,
|
|
79
|
+
filePath: string,
|
|
80
|
+
/** A page served from another's file (the Graph page): its raw text in, out. */
|
|
81
|
+
transform?: (html: string) => string,
|
|
82
|
+
/** The status to answer with (an error page's: R26a). */
|
|
83
|
+
status = 200,
|
|
84
|
+
): void {
|
|
69
85
|
try {
|
|
70
86
|
let data: Buffer | string = fs.readFileSync(filePath);
|
|
87
|
+
if (transform) data = transform(data.toString("utf-8"));
|
|
71
88
|
// Pages are static and product-neutral: their product names are
|
|
72
89
|
// placeholders (__DEN_CLI__, …) filled from the running profile.
|
|
73
90
|
if (filePath.endsWith(".html") || filePath.endsWith(".js"))
|
|
74
91
|
data = fillProductTokens(data.toString("utf-8"));
|
|
75
|
-
if (filePath.endsWith(".html"))
|
|
92
|
+
if (filePath.endsWith(".html")) {
|
|
76
93
|
data = addDenUiMarker(brandHtmlTitle(String(data), readBrand()));
|
|
94
|
+
if (htmlFill) data = htmlFill(data);
|
|
95
|
+
}
|
|
77
96
|
const headers: Record<string, string> = { "Content-Type": mime(filePath) };
|
|
78
97
|
// App code must never be stale (no ETag/Last-Modified validators are sent,
|
|
79
98
|
// so any heuristic caching would have no way to revalidate).
|
|
80
99
|
if (filePath.endsWith(".html") || filePath.endsWith(".js"))
|
|
81
100
|
headers["Cache-Control"] = "no-store";
|
|
82
|
-
res.writeHead(
|
|
101
|
+
res.writeHead(status, headers);
|
|
83
102
|
res.end(data);
|
|
84
103
|
} catch {
|
|
85
104
|
res.writeHead(404, { "Content-Type": "text/plain" });
|