@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.
- package/CHANGELOG.md +1655 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +619 -3
- 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/agent-actor.ts +38 -3
- 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 +63 -0
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +185 -7
- 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/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +26 -3
- package/src/shared/environment-manager/git-remote.ts +49 -0
- 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/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +328 -0
- 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 +24 -5
- package/src/ui/cli/clone-plan.ts +154 -23
- 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 +62 -3
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +649 -72
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/org-command.ts +12 -2
- 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/up-args.ts +15 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +298 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +151 -24
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +90 -3
- 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 +29 -45
- 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 +590 -45
- 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 +15 -3
- 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 +24 -4
- package/src/ui/server/release-service.ts +25 -3
- 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 +55 -9
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
package/src/ui/server/index.ts
CHANGED
|
@@ -50,10 +50,18 @@ import {
|
|
|
50
50
|
getWorkspaceByName,
|
|
51
51
|
isWorkspaceActive,
|
|
52
52
|
listWorkspaceServices,
|
|
53
|
+
readWorkspacesFile,
|
|
53
54
|
resolveDepAddress,
|
|
54
55
|
parseAddress,
|
|
55
56
|
readManifestAnyBranch,
|
|
56
57
|
} from "../../shared/environment-manager/workspaces.js";
|
|
58
|
+
import {
|
|
59
|
+
parseScope,
|
|
60
|
+
scopeKey,
|
|
61
|
+
scopePayload,
|
|
62
|
+
scopeView,
|
|
63
|
+
storedView,
|
|
64
|
+
} from "../../shared/environment-manager/scope.js";
|
|
57
65
|
import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
|
|
58
66
|
import {
|
|
59
67
|
resolveHistorySource,
|
|
@@ -81,6 +89,8 @@ import {
|
|
|
81
89
|
loadDaemonsFromFile,
|
|
82
90
|
pruneDeadDaemonsAsync,
|
|
83
91
|
daemonRootOf,
|
|
92
|
+
daemonStaleReason,
|
|
93
|
+
runningContainerBasesAsync,
|
|
84
94
|
isInteractiveServiceCommand,
|
|
85
95
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
86
96
|
shouldDetachServiceCommand,
|
|
@@ -96,6 +106,7 @@ import {
|
|
|
96
106
|
getServiceLogPath,
|
|
97
107
|
} from "../../shared/process/spawn-streaming.js";
|
|
98
108
|
import {
|
|
109
|
+
assertDenConfigValid,
|
|
99
110
|
readDenConfig,
|
|
100
111
|
isProductionEnv,
|
|
101
112
|
isLocalEnabled,
|
|
@@ -106,8 +117,17 @@ import {
|
|
|
106
117
|
writeUserReleaseConfig,
|
|
107
118
|
type ReleaseConfig,
|
|
108
119
|
} from "../../shared/den-config.js";
|
|
109
|
-
import {
|
|
110
|
-
|
|
120
|
+
import {
|
|
121
|
+
CONFIG_ERROR_STATUS,
|
|
122
|
+
configErrorBody,
|
|
123
|
+
isConfigError,
|
|
124
|
+
} from "../../shared/config-error.js";
|
|
125
|
+
import { readOrgView } from "../../shared/org.js";
|
|
126
|
+
import {
|
|
127
|
+
engineVersion,
|
|
128
|
+
getDistRoot,
|
|
129
|
+
getEngineRoot,
|
|
130
|
+
} from "../../shared/paths.js";
|
|
111
131
|
import { enginePath } from "../../shared/engine.js";
|
|
112
132
|
import {
|
|
113
133
|
releaseService,
|
|
@@ -131,11 +151,16 @@ import {
|
|
|
131
151
|
type Placement,
|
|
132
152
|
} from "../../shared/manifest/placement.js";
|
|
133
153
|
import {
|
|
134
|
-
isOpSignedIn,
|
|
135
|
-
hasSudoInVault,
|
|
136
154
|
readSudoFromVault,
|
|
137
155
|
saveSudoToVault,
|
|
156
|
+
sudoRef,
|
|
157
|
+
sudoVaultStatus,
|
|
158
|
+
SUDO_REF_NAME,
|
|
138
159
|
} from "../../shared/auth/sudo-vault.js";
|
|
160
|
+
import {
|
|
161
|
+
notConfiguredMessage,
|
|
162
|
+
secretsConfigWarnings,
|
|
163
|
+
} from "../../shared/secrets/index.js";
|
|
139
164
|
import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
|
|
140
165
|
import { actorLabel } from "../../shared/agent-actor.js";
|
|
141
166
|
import {
|
|
@@ -143,11 +168,32 @@ import {
|
|
|
143
168
|
actorSuffix,
|
|
144
169
|
recordAction,
|
|
145
170
|
recordAgentRefusal,
|
|
171
|
+
repoAtPath,
|
|
146
172
|
setAuditSink,
|
|
147
173
|
} from "./audit.js";
|
|
148
174
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
149
175
|
import { serveUiAsset } from "./ui-assets.js";
|
|
150
|
-
import { graphPrefs,
|
|
176
|
+
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
177
|
+
import { graphPageHtml } from "./graph-page.js";
|
|
178
|
+
import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
|
|
179
|
+
import { distributionSettings, userWebPort } from "./settings-distribution.js";
|
|
180
|
+
import {
|
|
181
|
+
appearancePrefs,
|
|
182
|
+
mergeAppearancePrefs,
|
|
183
|
+
storedAppearance,
|
|
184
|
+
} from "./appearance-prefs.js";
|
|
185
|
+
import {
|
|
186
|
+
activeTheme,
|
|
187
|
+
handleThemeRoutes,
|
|
188
|
+
type ThemeRouteDeps,
|
|
189
|
+
} from "./theme-routes.js";
|
|
190
|
+
import { findTheme } from "./theme-runtime.js";
|
|
191
|
+
import {
|
|
192
|
+
UI_LANGUAGES,
|
|
193
|
+
isUiLanguage,
|
|
194
|
+
localizePage,
|
|
195
|
+
uiLanguage,
|
|
196
|
+
} from "./ui-language.js";
|
|
151
197
|
import { singleFlight } from "./single-flight.js";
|
|
152
198
|
import { graphOrder } from "./graph-order.js";
|
|
153
199
|
import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
|
|
@@ -189,6 +235,13 @@ interface HealthResult {
|
|
|
189
235
|
/** Where it runs for this env, and where a remote one is (R13). */
|
|
190
236
|
runs: Placement["runs"];
|
|
191
237
|
target: Placement["target"];
|
|
238
|
+
/** From the focused workspace: the header sorts these first. */
|
|
239
|
+
focused?: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* The workspaces in view that define this service (focus first). More
|
|
242
|
+
* than one means a shared service: it is listed once, with its owners.
|
|
243
|
+
*/
|
|
244
|
+
workspaces?: string[];
|
|
192
245
|
}
|
|
193
246
|
|
|
194
247
|
/**
|
|
@@ -247,9 +300,12 @@ const SERVICES_CACHE_TTL_MS = 2_000;
|
|
|
247
300
|
const READ_CACHE_TTL_MS = 5_000;
|
|
248
301
|
enableReadCache(READ_CACHE_TTL_MS);
|
|
249
302
|
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
250
|
-
/**
|
|
303
|
+
/**
|
|
304
|
+
* A workspace-scoped run covers a different service set than the default
|
|
305
|
+
* one, and the default one covers whatever the view scope has in view.
|
|
306
|
+
*/
|
|
251
307
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
252
|
-
return `${env}|${workspace ??
|
|
308
|
+
return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
|
|
253
309
|
}
|
|
254
310
|
import {
|
|
255
311
|
LOG_SWEEP_INTERVAL_MS,
|
|
@@ -381,6 +437,98 @@ function serverLog(msg: string, kind: string = "info", actor?: string): void {
|
|
|
381
437
|
if (!SILENT) process.stdout.write(msg + "\n");
|
|
382
438
|
}
|
|
383
439
|
|
|
440
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
441
|
+
// The active theme (user's, else the org config's `theme:`, else den). A
|
|
442
|
+
// theme switch is a write of user.yml `appearance:` and a new /theme.css:
|
|
443
|
+
// nothing else (no edge, no services, no restart).
|
|
444
|
+
const themeDeps: ThemeRouteDeps = {
|
|
445
|
+
roots: () => ({ distRoot: getDistRoot(), engineRoot: getEngineRoot() }),
|
|
446
|
+
prefs: () => appearancePrefs(readUserConfig().appearance),
|
|
447
|
+
distributionTheme: () => readBrand().theme,
|
|
448
|
+
warn: (line) => serverLog(line, "warn"),
|
|
449
|
+
denFavicon: path.join(PUBLIC_DIR, "favicon.ico"),
|
|
450
|
+
};
|
|
451
|
+
// The pre-paint script in each page reads the user's mode and the theme's
|
|
452
|
+
// default mode from these placeholders, so the first paint is right.
|
|
453
|
+
// The UI language (R19): a page in another language than English loads
|
|
454
|
+
// its catalog before shell.js, so the first paint is in it.
|
|
455
|
+
setHtmlFill((html) => {
|
|
456
|
+
let mode = "system";
|
|
457
|
+
let def = "dark";
|
|
458
|
+
let lang = uiLanguage(undefined);
|
|
459
|
+
try {
|
|
460
|
+
const user = readUserConfig();
|
|
461
|
+
lang = uiLanguage(user.language);
|
|
462
|
+
mode = appearancePrefs(user.appearance).mode;
|
|
463
|
+
def = activeTheme(themeDeps)?.theme.default_mode ?? "dark";
|
|
464
|
+
} catch {
|
|
465
|
+
/* defaults */
|
|
466
|
+
}
|
|
467
|
+
let out = localizePage(html, lang)
|
|
468
|
+
.split("@@DEN_MODE@@")
|
|
469
|
+
.join(mode)
|
|
470
|
+
.split("@@DEN_DEFAULT_MODE@@")
|
|
471
|
+
.join(def);
|
|
472
|
+
// MD-2: every page says when the engine on disk is not the one running.
|
|
473
|
+
const engine = engineCheck();
|
|
474
|
+
if (engine.stale)
|
|
475
|
+
out = out.replace(
|
|
476
|
+
'<div id="den-shell"',
|
|
477
|
+
() =>
|
|
478
|
+
`<div id="den-shell" data-engine-stale="${escAttr(JSON.stringify(engineBanner(engine)))}"`,
|
|
479
|
+
);
|
|
480
|
+
return out;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ── MD-2: the engine this server loaded, against the one on disk ─────────────
|
|
484
|
+
const ENGINE = engineWatch(getEngineRoot);
|
|
485
|
+
function engineCheck(): EngineCheck {
|
|
486
|
+
return ENGINE.check();
|
|
487
|
+
}
|
|
488
|
+
/** What the shell's banner and the error pages say on a mismatch. */
|
|
489
|
+
function engineBanner(c: EngineCheck): {
|
|
490
|
+
loaded: string | null;
|
|
491
|
+
onDisk: string | null;
|
|
492
|
+
message: string;
|
|
493
|
+
} {
|
|
494
|
+
return {
|
|
495
|
+
loaded: c.loaded.version,
|
|
496
|
+
onDisk: c.onDisk.version,
|
|
497
|
+
message: engineStaleMessage(c, product().cli),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function escAttr(v: string): string {
|
|
501
|
+
return v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* R26a: the shell's error page for a failed page request (404 or 500).
|
|
506
|
+
* HEAD gets the status and headers only.
|
|
507
|
+
*/
|
|
508
|
+
function sendErrorPage(
|
|
509
|
+
req: http.IncomingMessage,
|
|
510
|
+
res: http.ServerResponse,
|
|
511
|
+
data: Omit<ErrorPageData, "time" | "version" | "engine">,
|
|
512
|
+
): void {
|
|
513
|
+
const engine = engineCheck();
|
|
514
|
+
const full: ErrorPageData = {
|
|
515
|
+
...data,
|
|
516
|
+
time: new Date().toISOString(),
|
|
517
|
+
version: engine.loaded.version,
|
|
518
|
+
...(engine.stale ? { engine: engineBanner(engine) } : {}),
|
|
519
|
+
};
|
|
520
|
+
if (res.headersSent) {
|
|
521
|
+
res.end();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
serveFile(
|
|
525
|
+
res,
|
|
526
|
+
path.join(PUBLIC_DIR, "error.html"),
|
|
527
|
+
(html) => fillErrorPage(html, full),
|
|
528
|
+
data.status,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
384
532
|
// Every audited action (exec, run, sync) lands in the log as a lifecycle
|
|
385
533
|
// line; an agent's carries its label so the /logs page can mark it.
|
|
386
534
|
setAuditSink((line, actor) =>
|
|
@@ -417,6 +565,46 @@ const STATIC_EXTS = new Set([
|
|
|
417
565
|
".woff2",
|
|
418
566
|
]);
|
|
419
567
|
|
|
568
|
+
/** Every workspace's checkouts (active or not), for repoAtPath. */
|
|
569
|
+
function allWorkspaceCheckouts(): ResolvedService[] {
|
|
570
|
+
const catalog = readCatalog();
|
|
571
|
+
return Object.entries(readWorkspacesFile().workspaces).flatMap(
|
|
572
|
+
([name, config]) => listWorkspaceServices(config, catalog, name),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* API routes that answer whatever the config holds: the identity probe the
|
|
578
|
+
* CLI and MCP use to find this server, its own log (where a config error is
|
|
579
|
+
* also written), the changelog, and restarting it. And (0.3.0-dev.40, R26a's
|
|
580
|
+
* 422 page) what the shell and the Settings and Logs pages need, so the
|
|
581
|
+
* rail stays usable to reach the fix: the UI prefs (theme, mode, language,
|
|
582
|
+
* the rail's pins) and editor settings (/api/settings: user.yml, and the
|
|
583
|
+
* org config's Distribution rows read without validation), the installed
|
|
584
|
+
* themes, the view scope and a workspace's editor (workspaces.yml), and
|
|
585
|
+
* whether a sudo password is held. None of them reads org data.
|
|
586
|
+
*/
|
|
587
|
+
const CONFIG_FREE_ROUTES = new Set([
|
|
588
|
+
"/api/server-info",
|
|
589
|
+
"/api/server-log",
|
|
590
|
+
"/api/changelog",
|
|
591
|
+
"/api/web/restart",
|
|
592
|
+
"/api/settings",
|
|
593
|
+
"/api/themes",
|
|
594
|
+
"/api/scope",
|
|
595
|
+
"/api/workspaces/settings",
|
|
596
|
+
"/api/sudo-password/status",
|
|
597
|
+
// The env picker on every page (falls back to local; a consumer's dev.40 finding).
|
|
598
|
+
"/api/environments",
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Pages that load whatever the config holds (0.3.0-dev.40): the rail's way
|
|
603
|
+
* to the fix (Settings, Logs). Any other page navigation with a config that
|
|
604
|
+
* fails validation gets the 422 "Config is invalid" page.
|
|
605
|
+
*/
|
|
606
|
+
const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
|
|
607
|
+
|
|
420
608
|
// ── Sudo password store ───────────────────────────────────────────────────────
|
|
421
609
|
import {
|
|
422
610
|
setSudoPassword,
|
|
@@ -435,8 +623,23 @@ import {
|
|
|
435
623
|
serveFile,
|
|
436
624
|
send400,
|
|
437
625
|
send404,
|
|
626
|
+
setHtmlFill,
|
|
438
627
|
} from "./http-helpers.js";
|
|
439
628
|
import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
|
|
629
|
+
import {
|
|
630
|
+
engineStaleMessage,
|
|
631
|
+
engineWatch,
|
|
632
|
+
type EngineCheck,
|
|
633
|
+
} from "./engine-stamp.js";
|
|
634
|
+
import {
|
|
635
|
+
fillErrorPage,
|
|
636
|
+
jsonApiErrors,
|
|
637
|
+
newRequestId,
|
|
638
|
+
pluginForPath,
|
|
639
|
+
routeTemplate,
|
|
640
|
+
wantsHtmlPage,
|
|
641
|
+
type ErrorPageData,
|
|
642
|
+
} from "./http-errors.js";
|
|
440
643
|
import {
|
|
441
644
|
readOrgContext,
|
|
442
645
|
resolveAllWorkspaceOrgs,
|
|
@@ -456,14 +659,17 @@ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
|
456
659
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
457
660
|
import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
|
|
458
661
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
662
|
+
import { edgeHttps } from "../../shared/edge/render.js";
|
|
459
663
|
import {
|
|
460
664
|
linksFor,
|
|
461
665
|
openUrlFor,
|
|
462
666
|
rehost,
|
|
667
|
+
type EdgeOrigin,
|
|
463
668
|
type OpenLink,
|
|
464
669
|
} from "../../shared/open-url.js";
|
|
465
670
|
|
|
466
671
|
import { product } from "../../shared/product.js";
|
|
672
|
+
import { readBrand } from "../../shared/brand.js";
|
|
467
673
|
/** config `web.port` (user > team), falling back to the built-in default. */
|
|
468
674
|
function readWebPortSafe(): number {
|
|
469
675
|
try {
|
|
@@ -577,6 +783,12 @@ async function handleRequest(
|
|
|
577
783
|
): Promise<void> {
|
|
578
784
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
579
785
|
const pathname = url.pathname;
|
|
786
|
+
// R26: one id per request, on every response and on its failure's log line.
|
|
787
|
+
const requestId = requestIdOf(req);
|
|
788
|
+
if (!res.headersSent) res.setHeader("X-Request-Id", requestId);
|
|
789
|
+
// R26b: every /api error answers JSON, whatever the route sends.
|
|
790
|
+
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
791
|
+
jsonApiErrors(res, requestId);
|
|
580
792
|
// A mutating request may change what /api/services would list (exec and
|
|
581
793
|
// sync stream over GET, for EventSource).
|
|
582
794
|
if (
|
|
@@ -602,14 +814,45 @@ async function handleRequest(
|
|
|
602
814
|
res.on("finish", () => {
|
|
603
815
|
const ms = dim(`${Date.now() - start}ms`);
|
|
604
816
|
serverLog(
|
|
605
|
-
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}
|
|
817
|
+
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
|
|
818
|
+
(res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
|
|
606
819
|
"request",
|
|
607
820
|
);
|
|
608
821
|
});
|
|
609
822
|
}
|
|
610
823
|
}
|
|
611
824
|
|
|
825
|
+
// ── Config validation ──────────────────────────────────────────────────────
|
|
826
|
+
// A config that fails validation answers every API route that reads it as
|
|
827
|
+
// the ConfigError the handler below maps to 422 JSON, before any route's own
|
|
828
|
+
// catch can turn it into a 400, a 500 or an empty list. Pages still load.
|
|
829
|
+
if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
|
|
830
|
+
assertDenConfigValid();
|
|
831
|
+
// A page navigation, likewise: the shell frame with the 422 page, not a
|
|
832
|
+
// page whose every call then fails (R26a). Static files never get here
|
|
833
|
+
// as a navigation (wantsHtmlPage needs Accept: text/html).
|
|
834
|
+
if (
|
|
835
|
+
wantsHtmlPage(req, pathname) &&
|
|
836
|
+
!CONFIG_FREE_PAGES.has(pathname) &&
|
|
837
|
+
(path.extname(pathname) === ".html" ||
|
|
838
|
+
!STATIC_EXTS.has(path.extname(pathname)))
|
|
839
|
+
)
|
|
840
|
+
assertDenConfigValid();
|
|
841
|
+
|
|
612
842
|
// ── Static UI ──────────────────────────────────────────────────────────────
|
|
843
|
+
// The Settings page (R21). Local Dev's old settings panel link
|
|
844
|
+
// (/local-dev?settings=1) lands on its category there.
|
|
845
|
+
if (pathname === "/settings" || pathname === "/settings.html") {
|
|
846
|
+
return serveFile(res, path.join(PUBLIC_DIR, "settings.html"));
|
|
847
|
+
}
|
|
848
|
+
if (
|
|
849
|
+
(pathname === "/local-dev" || pathname === "/local-dev.html") &&
|
|
850
|
+
url.searchParams.has("settings")
|
|
851
|
+
) {
|
|
852
|
+
res.writeHead(302, { Location: "/settings#editor" });
|
|
853
|
+
res.end();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
613
856
|
// Local Dev is the default page when local environment is enabled.
|
|
614
857
|
if (
|
|
615
858
|
pathname === "/" ||
|
|
@@ -624,6 +867,20 @@ async function handleRequest(
|
|
|
624
867
|
return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
|
|
625
868
|
}
|
|
626
869
|
|
|
870
|
+
// The Graph page (R22): Local Dev's page in its full-width graph view.
|
|
871
|
+
if (pathname === "/graph" || pathname === "/graph.html") {
|
|
872
|
+
if (!isLocalEnabled()) {
|
|
873
|
+
res.writeHead(302, { Location: "/tests" });
|
|
874
|
+
res.end();
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
return serveFile(
|
|
878
|
+
res,
|
|
879
|
+
path.join(PUBLIC_DIR, "local-dev.html"),
|
|
880
|
+
graphPageHtml,
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
|
|
627
884
|
if (pathname === "/tests" || pathname === "/index.html") {
|
|
628
885
|
return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
|
|
629
886
|
}
|
|
@@ -639,6 +896,11 @@ async function handleRequest(
|
|
|
639
896
|
return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
|
|
640
897
|
}
|
|
641
898
|
|
|
899
|
+
// The Edge page (PL-SPEC §10): read-only, from /api/edge.
|
|
900
|
+
if (pathname === "/edge" || pathname === "/edge.html") {
|
|
901
|
+
return serveFile(res, path.join(PUBLIC_DIR, "edge.html"));
|
|
902
|
+
}
|
|
903
|
+
|
|
642
904
|
if (pathname === "/logs") {
|
|
643
905
|
return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
|
|
644
906
|
}
|
|
@@ -647,6 +909,9 @@ async function handleRequest(
|
|
|
647
909
|
return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
|
|
648
910
|
}
|
|
649
911
|
|
|
912
|
+
// The active theme as CSS, its mark and fonts, and the theme list.
|
|
913
|
+
if (handleThemeRoutes(req, res, pathname, themeDeps)) return;
|
|
914
|
+
|
|
650
915
|
// The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
|
|
651
916
|
if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
|
|
652
917
|
|
|
@@ -690,6 +955,9 @@ async function handleRequest(
|
|
|
690
955
|
|
|
691
956
|
// ── API: environments ──────────────────────────────────────────────────────
|
|
692
957
|
// Orgs the catalog's repos belong to, with config/den.yml display names.
|
|
958
|
+
// ORG-1d: a repo's org is an org listing it, else one whose source claims
|
|
959
|
+
// its owner, else its GitHub owner; a claimed owner is not listed. Service
|
|
960
|
+
// rows keep `org` as the repo's GitHub owner (C3).
|
|
693
961
|
// Each entry also says whether it is the default org (`github_org`) and
|
|
694
962
|
// which workspaces on this machine belong to it (ORG-1a). Nothing else
|
|
695
963
|
// changes: same entries, order and fields (ORG-SPEC C3).
|
|
@@ -704,7 +972,7 @@ async function handleRequest(
|
|
|
704
972
|
);
|
|
705
973
|
if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
|
|
706
974
|
const repos = Object.fromEntries(
|
|
707
|
-
Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
|
|
975
|
+
Object.keys(catalog).map((r) => [r, orgOf(r, catalog, ctx.config.orgs)]),
|
|
708
976
|
);
|
|
709
977
|
return sendJson(res, { orgs, repos });
|
|
710
978
|
}
|
|
@@ -809,6 +1077,17 @@ async function handleRequest(
|
|
|
809
1077
|
} catch {
|
|
810
1078
|
return send400(res, "invalid JSON");
|
|
811
1079
|
}
|
|
1080
|
+
// A call that named a path (manifest_validate) is recorded under the
|
|
1081
|
+
// catalog repo that path is inside, across every workspace's checkouts.
|
|
1082
|
+
if (typeof report.repo !== "string" && report.path !== undefined) {
|
|
1083
|
+
let repo: string | null = null;
|
|
1084
|
+
try {
|
|
1085
|
+
repo = repoAtPath(report.path, allWorkspaceCheckouts());
|
|
1086
|
+
} catch {
|
|
1087
|
+
/* unresolvable workspaces: recorded under the tool only */
|
|
1088
|
+
}
|
|
1089
|
+
if (repo) report = { ...report, repo };
|
|
1090
|
+
}
|
|
812
1091
|
const entry = recordAgentRefusal(actor, report);
|
|
813
1092
|
return sendJson(res, { recorded: true, ts: entry.ts });
|
|
814
1093
|
}
|
|
@@ -886,9 +1165,11 @@ async function handleRequest(
|
|
|
886
1165
|
|
|
887
1166
|
// ── API: health check ─────────────────────────────────────────────────────
|
|
888
1167
|
/**
|
|
889
|
-
* Workspaces a health run covers: one named workspace, else
|
|
890
|
-
* (focus first). Health is
|
|
891
|
-
*
|
|
1168
|
+
* Workspaces a health run covers: one named workspace, else the view
|
|
1169
|
+
* scope's (every active one by default, focus first). Health is
|
|
1170
|
+
* machine-level: a service running under a non-focused active workspace
|
|
1171
|
+
* is still up, and its card must say so. The pages and `status` read the
|
|
1172
|
+
* same scope (src/shared/environment-manager/scope.ts).
|
|
892
1173
|
*/
|
|
893
1174
|
function healthWorkspaces(
|
|
894
1175
|
workspaceName: string | null,
|
|
@@ -897,12 +1178,7 @@ async function handleRequest(
|
|
|
897
1178
|
const named = getWorkspaceByName(workspaceName);
|
|
898
1179
|
return named ? [named] : [];
|
|
899
1180
|
}
|
|
900
|
-
|
|
901
|
-
const names = [
|
|
902
|
-
...(focus ? [focus] : []),
|
|
903
|
-
...getActiveWorkspaces().filter((n) => n !== focus),
|
|
904
|
-
];
|
|
905
|
-
return names.flatMap((n) => getWorkspaceByName(n) ?? []);
|
|
1181
|
+
return scopeView().workspaces.filter((w) => w.config);
|
|
906
1182
|
}
|
|
907
1183
|
|
|
908
1184
|
/**
|
|
@@ -929,11 +1205,16 @@ async function handleRequest(
|
|
|
929
1205
|
string,
|
|
930
1206
|
{ svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
|
|
931
1207
|
>();
|
|
1208
|
+
const owners = new Map<string, string[]>();
|
|
932
1209
|
for (const ws of workspaces)
|
|
933
|
-
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
|
|
1210
|
+
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name)) {
|
|
934
1211
|
// Modules and libraries have no runtime, so no health dot.
|
|
935
|
-
if (!
|
|
1212
|
+
if (!hasRuntime(repoKind(svc.repoPath))) continue;
|
|
1213
|
+
if (!byAddress.has(svc.address))
|
|
936
1214
|
byAddress.set(svc.address, { svc, ws });
|
|
1215
|
+
const o = owners.get(svc.address) ?? [];
|
|
1216
|
+
if (!o.includes(ws.name)) owners.set(svc.address, [...o, ws.name]);
|
|
1217
|
+
}
|
|
937
1218
|
let services = [...byAddress.values()];
|
|
938
1219
|
if (addressFilter) {
|
|
939
1220
|
services = services.filter((s) => s.svc.address === addressFilter);
|
|
@@ -962,11 +1243,19 @@ async function handleRequest(
|
|
|
962
1243
|
compose: ComposeHealthTarget | null;
|
|
963
1244
|
/** A `type: container` check: answered from the shared listing. */
|
|
964
1245
|
container: ContainerHealthTarget | null;
|
|
965
|
-
meta: Pick<HealthResult, "runs" | "target">;
|
|
1246
|
+
meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
|
|
966
1247
|
}> = [];
|
|
1248
|
+
const focusName = getFocusedWorkspaceName();
|
|
967
1249
|
let edgeDomain: string | null = null;
|
|
1250
|
+
// The links follow the edge's TLS: http on its http port for `tls: none`
|
|
1251
|
+
// (0.3.0-dev.40, DOC-1: the panel's Opens said https).
|
|
1252
|
+
let edgeOrigin: EdgeOrigin | undefined;
|
|
968
1253
|
try {
|
|
969
|
-
|
|
1254
|
+
const edge = readEdgeConfig();
|
|
1255
|
+
edgeDomain = edge.domain;
|
|
1256
|
+
edgeOrigin = edgeHttps(edge)
|
|
1257
|
+
? { https: true, port: edge.ports?.https ?? 443 }
|
|
1258
|
+
: { https: false, port: edge.ports?.http ?? 80 };
|
|
970
1259
|
} catch {
|
|
971
1260
|
/* no edge configured: local links come from manifests only */
|
|
972
1261
|
}
|
|
@@ -996,7 +1285,12 @@ async function handleRequest(
|
|
|
996
1285
|
org: orgOf(resolved.repoName, catalog),
|
|
997
1286
|
compose: null,
|
|
998
1287
|
container: null,
|
|
999
|
-
meta: {
|
|
1288
|
+
meta: {
|
|
1289
|
+
runs: "none",
|
|
1290
|
+
target: null,
|
|
1291
|
+
focused: ws.name === focusName,
|
|
1292
|
+
workspaces: owners.get(resolved.address) ?? [ws.name],
|
|
1293
|
+
},
|
|
1000
1294
|
});
|
|
1001
1295
|
continue;
|
|
1002
1296
|
}
|
|
@@ -1074,6 +1368,7 @@ async function handleRequest(
|
|
|
1074
1368
|
env,
|
|
1075
1369
|
healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
|
|
1076
1370
|
edgeDomain,
|
|
1371
|
+
edgeOrigin,
|
|
1077
1372
|
}),
|
|
1078
1373
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1079
1374
|
org: orgOf(resolved.repoName, catalog),
|
|
@@ -1081,7 +1376,12 @@ async function handleRequest(
|
|
|
1081
1376
|
compose,
|
|
1082
1377
|
container:
|
|
1083
1378
|
healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
|
|
1084
|
-
meta: {
|
|
1379
|
+
meta: {
|
|
1380
|
+
runs: plan.placement.runs,
|
|
1381
|
+
target: plan.placement.target,
|
|
1382
|
+
focused: ws.name === focusName,
|
|
1383
|
+
workspaces: owners.get(resolved.address) ?? [ws.name],
|
|
1384
|
+
},
|
|
1085
1385
|
});
|
|
1086
1386
|
}
|
|
1087
1387
|
|
|
@@ -1318,7 +1618,8 @@ async function handleRequest(
|
|
|
1318
1618
|
return sendJson(
|
|
1319
1619
|
res,
|
|
1320
1620
|
await servicesCache.get(
|
|
1321
|
-
|
|
1621
|
+
// No workspace named: what the view scope has in view.
|
|
1622
|
+
`${env}|${wsOverride ?? `*:${scopeKey(scopeView())}`}`,
|
|
1322
1623
|
() => buildServicesList(env, wsOverride),
|
|
1323
1624
|
url.searchParams.get("refresh") === "1",
|
|
1324
1625
|
),
|
|
@@ -2392,10 +2693,10 @@ async function handleRequest(
|
|
|
2392
2693
|
// `org` resolved (id defaults to edge.org) for the header's org label.
|
|
2393
2694
|
const config = readDenConfig();
|
|
2394
2695
|
// `default_org` (ORG-1a) is the default work org, `github_org`; `org`
|
|
2395
|
-
// stays the instance org (
|
|
2696
|
+
// stays the instance org (readOrgView).
|
|
2396
2697
|
return sendJson(res, {
|
|
2397
2698
|
...config,
|
|
2398
|
-
org:
|
|
2699
|
+
org: readOrgView(),
|
|
2399
2700
|
default_org: config.github_org?.trim() || null,
|
|
2400
2701
|
});
|
|
2401
2702
|
}
|
|
@@ -2403,11 +2704,17 @@ async function handleRequest(
|
|
|
2403
2704
|
// ── API: server info + staleness (drives the header "stale server" pill) ────
|
|
2404
2705
|
if (pathname === "/api/server-info" && req.method === "GET") {
|
|
2405
2706
|
const { stale, scanned } = computeStale();
|
|
2707
|
+
const engine = engineCheck();
|
|
2406
2708
|
return sendJson(res, {
|
|
2407
2709
|
pid: process.pid,
|
|
2408
2710
|
port: PORT,
|
|
2409
|
-
// Den
|
|
2410
|
-
|
|
2711
|
+
// The Den this server runs (its routes): the MCP server compares it
|
|
2712
|
+
// with the routes it needs. loaded and onDisk (MD-2): the engine at
|
|
2713
|
+
// startup and on disk now; engineStale when they differ.
|
|
2714
|
+
version: ENGINE.loaded.version ?? engineVersion() ?? null,
|
|
2715
|
+
loaded: engine.loaded,
|
|
2716
|
+
onDisk: engine.onDisk,
|
|
2717
|
+
engineStale: engine.stale,
|
|
2411
2718
|
// Whose server this is: `<cli> web up|down`, `mcp connect` and
|
|
2412
2719
|
// `service install` compare both with their own and never act on
|
|
2413
2720
|
// another distribution's server (see cli/server-identity.ts).
|
|
@@ -2807,8 +3114,9 @@ async function handleRequest(
|
|
|
2807
3114
|
// ── API: sudo password ────────────────────────────────────────────────────
|
|
2808
3115
|
// POST /api/sudo-password body: { password: string, saveToVault?: boolean }
|
|
2809
3116
|
// GET /api/sudo-password/status → { set: boolean }
|
|
2810
|
-
// GET /api/sudo-password/vault-status → {
|
|
2811
|
-
//
|
|
3117
|
+
// GET /api/sudo-password/vault-status → { backend, label, configured,
|
|
3118
|
+
// backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
3119
|
+
// POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
|
|
2812
3120
|
if (pathname === "/api/sudo-password") {
|
|
2813
3121
|
if (req.method !== "POST") {
|
|
2814
3122
|
res.writeHead(405);
|
|
@@ -2860,11 +3168,10 @@ async function handleRequest(
|
|
|
2860
3168
|
}
|
|
2861
3169
|
|
|
2862
3170
|
// Advertises vault capability to the UI so it knows whether to show the
|
|
2863
|
-
// "Save to
|
|
3171
|
+
// "Save to <backend>" checkbox and whether auto-load is worth trying. With
|
|
3172
|
+
// no secrets.refs.sudo it says "not configured" and spawns nothing.
|
|
2864
3173
|
if (pathname === "/api/sudo-password/vault-status") {
|
|
2865
|
-
|
|
2866
|
-
const hasStored = opAvailable ? await hasSudoInVault() : false;
|
|
2867
|
-
return sendJson(res, { opAvailable, hasStored });
|
|
3174
|
+
return sendJson(res, await sudoVaultStatus());
|
|
2868
3175
|
}
|
|
2869
3176
|
|
|
2870
3177
|
// Auto-unlock path: UI calls this when status.set is false. We read from
|
|
@@ -2877,6 +3184,13 @@ async function handleRequest(
|
|
|
2877
3184
|
res.end();
|
|
2878
3185
|
return;
|
|
2879
3186
|
}
|
|
3187
|
+
if (!sudoRef()) {
|
|
3188
|
+
return sendJson(res, {
|
|
3189
|
+
ok: false,
|
|
3190
|
+
reason: "not-configured",
|
|
3191
|
+
message: notConfiguredMessage(SUDO_REF_NAME),
|
|
3192
|
+
});
|
|
3193
|
+
}
|
|
2880
3194
|
const password = await readSudoFromVault();
|
|
2881
3195
|
if (!password) {
|
|
2882
3196
|
return sendJson(res, { ok: false, reason: "not-found" });
|
|
@@ -2898,9 +3212,43 @@ async function handleRequest(
|
|
|
2898
3212
|
}
|
|
2899
3213
|
|
|
2900
3214
|
// ── API: user settings ────────────────────────────────────────────────────
|
|
2901
|
-
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages
|
|
2902
|
-
//
|
|
3215
|
+
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages,
|
|
3216
|
+
// log_window_lines, graph, appearance, shell, distribution,
|
|
3217
|
+
// user_web_port, local_dev, cli_tabs, language, ui_languages }
|
|
3218
|
+
// POST /api/settings body: { editor?, terminal?, editor_overrides?, graph?, appearance?, shell?,
|
|
3219
|
+
// language? }
|
|
2903
3220
|
// graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
|
|
3221
|
+
// appearance: { theme: id | null, mode: system|dark|light } (appearance-prefs.ts)
|
|
3222
|
+
// shell: { pins, collapsed, hidden, order, dock, dockHeight } (shell-prefs.ts)
|
|
3223
|
+
// language: the UI's language, en | ja | es (ui-language.ts); null or
|
|
3224
|
+
// "en" clears it (English is the default)
|
|
3225
|
+
// It writes config/local/user.yml only. `distribution` is read-only: what
|
|
3226
|
+
// the org config (tracked) sets, for the Settings page's Distribution rows.
|
|
3227
|
+
// Every key POST accepts has a row in the Settings page's registry
|
|
3228
|
+
// (settings-registry.js; settings-inventory.test.ts checks).
|
|
3229
|
+
// The view scope: which active workspaces the pages, the health row and
|
|
3230
|
+
// `status` show. A view preference only (user.yml `view`): it changes no
|
|
3231
|
+
// edge route, container or service, and never the focus.
|
|
3232
|
+
if (pathname === "/api/scope") {
|
|
3233
|
+
if (req.method === "GET") return sendJson(res, scopePayload(scopeView()));
|
|
3234
|
+
if (req.method === "POST") {
|
|
3235
|
+
let body: { scope?: unknown };
|
|
3236
|
+
try {
|
|
3237
|
+
body = JSON.parse(await readBody(req)) as { scope?: unknown };
|
|
3238
|
+
} catch {
|
|
3239
|
+
return send400(res, "invalid JSON");
|
|
3240
|
+
}
|
|
3241
|
+
const parsed = parseScope(body.scope, getActiveWorkspaces());
|
|
3242
|
+
if (!parsed.ok) return send400(res, parsed.error);
|
|
3243
|
+
const user = readUserConfig();
|
|
3244
|
+
const view = storedView(parsed.value);
|
|
3245
|
+
if (view) user.view = view;
|
|
3246
|
+
else delete user.view;
|
|
3247
|
+
writeUserConfig(user);
|
|
3248
|
+
return sendJson(res, scopePayload(scopeView(parsed.value)));
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
2904
3252
|
if (pathname === "/api/settings") {
|
|
2905
3253
|
if (req.method === "GET") {
|
|
2906
3254
|
const user = readUserConfig();
|
|
@@ -2915,6 +3263,16 @@ async function handleRequest(
|
|
|
2915
3263
|
languages: SUPPORTED_LANGUAGES,
|
|
2916
3264
|
log_window_lines: user.log_window_lines ?? 20_000,
|
|
2917
3265
|
graph: graphPrefs(user.graph),
|
|
3266
|
+
appearance: appearancePrefs(user.appearance),
|
|
3267
|
+
shell: shellPrefs(user.shell),
|
|
3268
|
+
distribution: distributionSettings(getDistRoot()),
|
|
3269
|
+
// Set from the command line (`<cli> config`), shown read-only.
|
|
3270
|
+
user_web_port: userWebPort(user as Record<string, unknown>),
|
|
3271
|
+
local_dev: user.local_dev !== false,
|
|
3272
|
+
cli_tabs: user.cli_tabs === true,
|
|
3273
|
+
// The UI's language (R19), and the ones it has catalogs for.
|
|
3274
|
+
language: uiLanguage(user.language),
|
|
3275
|
+
ui_languages: UI_LANGUAGES,
|
|
2918
3276
|
});
|
|
2919
3277
|
}
|
|
2920
3278
|
if (req.method === "POST") {
|
|
@@ -2924,12 +3282,57 @@ async function handleRequest(
|
|
|
2924
3282
|
terminal?: string | null;
|
|
2925
3283
|
editor_overrides?: Record<string, string> | null;
|
|
2926
3284
|
graph?: unknown;
|
|
3285
|
+
appearance?: unknown;
|
|
3286
|
+
shell?: unknown;
|
|
3287
|
+
language?: unknown;
|
|
2927
3288
|
};
|
|
2928
3289
|
const user = readUserConfig();
|
|
2929
|
-
|
|
2930
|
-
|
|
3290
|
+
// null clears (0.3.0-dev.32, a consumer's nit): a block or a key
|
|
3291
|
+
// set to null is removed from user.yml, and a block left empty
|
|
3292
|
+
// goes too, so setting a pref and clearing it again leaves the
|
|
3293
|
+
// file byte-identical.
|
|
3294
|
+
// The rail's pins and collapse (R14): a view preference only.
|
|
3295
|
+
if (body.shell === null) delete user.shell;
|
|
3296
|
+
else if (body.shell !== undefined) {
|
|
3297
|
+
const sh = mergeShellPrefs(shellPrefs(user.shell), body.shell);
|
|
3298
|
+
if (!sh.ok) return send400(res, sh.error);
|
|
3299
|
+
const stored = storedShell(sh.value);
|
|
3300
|
+
if (stored) user.shell = stored;
|
|
3301
|
+
else delete user.shell;
|
|
3302
|
+
}
|
|
3303
|
+
if (body.graph === null) delete user.graph;
|
|
3304
|
+
else if (body.graph !== undefined) {
|
|
3305
|
+
const g = patchStoredGraph(user.graph, body.graph);
|
|
2931
3306
|
if (!g.ok) return send400(res, g.error);
|
|
2932
|
-
user.graph = g.value;
|
|
3307
|
+
if (g.value) user.graph = g.value as UserConfig["graph"];
|
|
3308
|
+
else delete user.graph;
|
|
3309
|
+
}
|
|
3310
|
+
// A UI preference only: the page swaps /theme.css and data-mode.
|
|
3311
|
+
if (body.appearance === null) delete user.appearance;
|
|
3312
|
+
else if (body.appearance !== undefined) {
|
|
3313
|
+
const a = mergeAppearancePrefs(
|
|
3314
|
+
appearancePrefs(user.appearance),
|
|
3315
|
+
body.appearance,
|
|
3316
|
+
(id) => !!findTheme(themeDeps.roots(), id)?.ok,
|
|
3317
|
+
);
|
|
3318
|
+
if (!a.ok) return send400(res, a.error);
|
|
3319
|
+
const stored = storedAppearance(a.value);
|
|
3320
|
+
if (stored) user.appearance = stored;
|
|
3321
|
+
else delete user.appearance;
|
|
3322
|
+
}
|
|
3323
|
+
// The UI's language (R19): stored only when it isn't English.
|
|
3324
|
+
if (body.language !== undefined) {
|
|
3325
|
+
const clear =
|
|
3326
|
+
body.language === null ||
|
|
3327
|
+
body.language === "" ||
|
|
3328
|
+
body.language === "en";
|
|
3329
|
+
if (!clear && !isUiLanguage(body.language))
|
|
3330
|
+
return send400(
|
|
3331
|
+
res,
|
|
3332
|
+
`language must be one of ${UI_LANGUAGES.map((l) => `"${l}"`).join(", ")}`,
|
|
3333
|
+
);
|
|
3334
|
+
if (clear) delete user.language;
|
|
3335
|
+
else user.language = body.language as string;
|
|
2933
3336
|
}
|
|
2934
3337
|
if (body.editor === null || body.editor === "") {
|
|
2935
3338
|
delete user.editor;
|
|
@@ -2975,6 +3378,9 @@ async function handleRequest(
|
|
|
2975
3378
|
terminal: user.terminal ?? null,
|
|
2976
3379
|
editor_overrides: user.editor_overrides ?? {},
|
|
2977
3380
|
graph: graphPrefs(user.graph),
|
|
3381
|
+
appearance: appearancePrefs(user.appearance),
|
|
3382
|
+
shell: shellPrefs(user.shell),
|
|
3383
|
+
language: uiLanguage(user.language),
|
|
2978
3384
|
});
|
|
2979
3385
|
} catch (e) {
|
|
2980
3386
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
@@ -3142,22 +3548,157 @@ async function handleRequest(
|
|
|
3142
3548
|
}
|
|
3143
3549
|
}
|
|
3144
3550
|
|
|
3551
|
+
// A route that throws, for the error frame's tests and screenshots only
|
|
3552
|
+
// (DEN_DEBUG_ROUTES=1): a page and an API call.
|
|
3553
|
+
if (
|
|
3554
|
+
process.env.DEN_DEBUG_ROUTES === "1" &&
|
|
3555
|
+
(pathname === "/_den/throw" || pathname === "/api/_den/throw")
|
|
3556
|
+
) {
|
|
3557
|
+
throw new Error("a debug route threw (DEN_DEBUG_ROUTES)");
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3145
3560
|
// ── 404 ────────────────────────────────────────────────────────────────────
|
|
3561
|
+
sendNotFound(req, res, pathname, requestId);
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
/**
|
|
3565
|
+
* No route answered. /api: the R26b JSON (an `engine_stale` code naming
|
|
3566
|
+
* both versions when the engine on disk is newer: its routes are the ones
|
|
3567
|
+
* that 404 here). A page: the shell's 404 page (R26a). Anything else (an
|
|
3568
|
+
* asset): plain text, as before.
|
|
3569
|
+
*/
|
|
3570
|
+
function sendNotFound(
|
|
3571
|
+
req: http.IncomingMessage,
|
|
3572
|
+
res: http.ServerResponse,
|
|
3573
|
+
pathname: string,
|
|
3574
|
+
requestId: string,
|
|
3575
|
+
): void {
|
|
3576
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3577
|
+
const engine = engineCheck();
|
|
3578
|
+
if (engine.stale)
|
|
3579
|
+
return sendJson(
|
|
3580
|
+
res,
|
|
3581
|
+
{
|
|
3582
|
+
ok: false,
|
|
3583
|
+
error: engineStaleMessage(engine, product().cli),
|
|
3584
|
+
code: "engine_stale",
|
|
3585
|
+
loaded: engine.loaded.version,
|
|
3586
|
+
onDisk: engine.onDisk.version,
|
|
3587
|
+
},
|
|
3588
|
+
404,
|
|
3589
|
+
);
|
|
3590
|
+
return sendJson(
|
|
3591
|
+
res,
|
|
3592
|
+
{
|
|
3593
|
+
ok: false,
|
|
3594
|
+
error: `No such API route: ${req.method ?? "GET"} ${pathname}`,
|
|
3595
|
+
code: "not_found",
|
|
3596
|
+
},
|
|
3597
|
+
404,
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
if (wantsHtmlPage(req, pathname))
|
|
3601
|
+
return sendErrorPage(req, res, {
|
|
3602
|
+
status: 404,
|
|
3603
|
+
path: pathname,
|
|
3604
|
+
requestId,
|
|
3605
|
+
plugin: pluginForPath(pathname, ROOT),
|
|
3606
|
+
});
|
|
3146
3607
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
3147
3608
|
res.end("Not found");
|
|
3148
3609
|
}
|
|
3149
3610
|
|
|
3611
|
+
/** The request's id: assigned once (the handler and its catch share it). */
|
|
3612
|
+
const REQUEST_IDS = new WeakMap<http.IncomingMessage, string>();
|
|
3613
|
+
function requestIdOf(req: http.IncomingMessage): string {
|
|
3614
|
+
let id = REQUEST_IDS.get(req);
|
|
3615
|
+
if (!id) {
|
|
3616
|
+
id = newRequestId();
|
|
3617
|
+
REQUEST_IDS.set(req, id);
|
|
3618
|
+
}
|
|
3619
|
+
return id;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3150
3622
|
const server = http.createServer((req, res) => {
|
|
3151
3623
|
// An async handler with no catch turns any route throw into an unhandled
|
|
3152
3624
|
// rejection — which CRASHES the whole server on modern Node (freezing every
|
|
3153
3625
|
// health dot) and leaves the request hanging. Fail the one request instead.
|
|
3154
|
-
handleRequest(req, res).catch((err) => {
|
|
3155
|
-
|
|
3626
|
+
handleRequest(req, res).catch((err: unknown) => {
|
|
3627
|
+
// R26: the log has the stack under the request id; the browser gets the
|
|
3628
|
+
// id, never the stack. The route is its template: no query values.
|
|
3629
|
+
const requestId = requestIdOf(req);
|
|
3630
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
3631
|
+
const route = routeTemplate(pathname);
|
|
3632
|
+
const method = req.method ?? "GET";
|
|
3633
|
+
// A config that fails validation is the user's to fix, not a crash: the
|
|
3634
|
+
// /api frame answers 422 config_invalid (jsonApiErrors adds requestId), a
|
|
3635
|
+
// page gets the error page saying so, anything else the message as text.
|
|
3636
|
+
if (isConfigError(err)) {
|
|
3637
|
+
serverLog(
|
|
3638
|
+
` ⚠ ${method} ${route} (req ${requestId}): ${err.message}`,
|
|
3639
|
+
"error",
|
|
3640
|
+
);
|
|
3641
|
+
try {
|
|
3642
|
+
if (res.headersSent) {
|
|
3643
|
+
res.end();
|
|
3644
|
+
return;
|
|
3645
|
+
}
|
|
3646
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3647
|
+
sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
if (wantsHtmlPage(req, pathname)) {
|
|
3651
|
+
sendErrorPage(req, res, {
|
|
3652
|
+
status: CONFIG_ERROR_STATUS,
|
|
3653
|
+
path: pathname,
|
|
3654
|
+
message: err.message,
|
|
3655
|
+
issues: configErrorBody(err).issues,
|
|
3656
|
+
cli: product().cli,
|
|
3657
|
+
requestId,
|
|
3658
|
+
});
|
|
3659
|
+
return;
|
|
3660
|
+
}
|
|
3661
|
+
res.writeHead(CONFIG_ERROR_STATUS, { "Content-Type": "text/plain" });
|
|
3662
|
+
res.end(`${err.message} (request ${requestId})`);
|
|
3663
|
+
} catch {
|
|
3664
|
+
/* socket already gone */
|
|
3665
|
+
}
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
const stack =
|
|
3669
|
+
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
3670
|
+
serverLog(
|
|
3671
|
+
` ✗ ${method} ${route} failed (req ${requestId}): ${stack}`,
|
|
3672
|
+
"error",
|
|
3673
|
+
);
|
|
3674
|
+
if (SILENT)
|
|
3675
|
+
console.error(
|
|
3676
|
+
`[server] ${method} ${route} failed (req ${requestId}):`,
|
|
3677
|
+
err,
|
|
3678
|
+
);
|
|
3156
3679
|
try {
|
|
3157
|
-
if (
|
|
3158
|
-
res.
|
|
3680
|
+
if (res.headersSent) {
|
|
3681
|
+
res.end();
|
|
3682
|
+
return;
|
|
3683
|
+
}
|
|
3684
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3685
|
+
sendJson(
|
|
3686
|
+
res,
|
|
3687
|
+
{
|
|
3688
|
+
ok: false,
|
|
3689
|
+
error: `${method} ${route} failed on the server`,
|
|
3690
|
+
code: "internal",
|
|
3691
|
+
},
|
|
3692
|
+
500,
|
|
3693
|
+
);
|
|
3694
|
+
return;
|
|
3695
|
+
}
|
|
3696
|
+
if (wantsHtmlPage(req, pathname)) {
|
|
3697
|
+
sendErrorPage(req, res, { status: 500, method, route, requestId });
|
|
3698
|
+
return;
|
|
3159
3699
|
}
|
|
3160
|
-
res.
|
|
3700
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
3701
|
+
res.end(`internal server error (request ${requestId})`);
|
|
3161
3702
|
} catch {
|
|
3162
3703
|
/* socket already gone */
|
|
3163
3704
|
}
|
|
@@ -3215,6 +3756,8 @@ void (async () => {
|
|
|
3215
3756
|
delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
3216
3757
|
retryDelayMs: DAEMON_RETRY_DELAY_MS,
|
|
3217
3758
|
rootOf: daemonRootOf,
|
|
3759
|
+
staleReason: daemonStaleReason,
|
|
3760
|
+
runningContainers: runningContainerBasesAsync,
|
|
3218
3761
|
});
|
|
3219
3762
|
if (reaped.removed.length > 0)
|
|
3220
3763
|
console.error(
|
|
@@ -3292,6 +3835,8 @@ server.listen(PORT, BIND_HOST, () => {
|
|
|
3292
3835
|
console.log();
|
|
3293
3836
|
console.log(` Web UI → http://${host}:${PORT}`);
|
|
3294
3837
|
console.log();
|
|
3838
|
+
// Once per start: a secrets config that leaves Den's own features off.
|
|
3839
|
+
for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
|
|
3295
3840
|
});
|
|
3296
3841
|
|
|
3297
3842
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|