@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
package/src/ui/server/index.ts
CHANGED
|
@@ -55,6 +55,13 @@ import {
|
|
|
55
55
|
parseAddress,
|
|
56
56
|
readManifestAnyBranch,
|
|
57
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";
|
|
58
65
|
import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
|
|
59
66
|
import {
|
|
60
67
|
resolveHistorySource,
|
|
@@ -82,6 +89,8 @@ import {
|
|
|
82
89
|
loadDaemonsFromFile,
|
|
83
90
|
pruneDeadDaemonsAsync,
|
|
84
91
|
daemonRootOf,
|
|
92
|
+
daemonStaleReason,
|
|
93
|
+
runningContainerBasesAsync,
|
|
85
94
|
isInteractiveServiceCommand,
|
|
86
95
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
87
96
|
shouldDetachServiceCommand,
|
|
@@ -113,8 +122,12 @@ import {
|
|
|
113
122
|
configErrorBody,
|
|
114
123
|
isConfigError,
|
|
115
124
|
} from "../../shared/config-error.js";
|
|
116
|
-
import {
|
|
117
|
-
import {
|
|
125
|
+
import { readOrgView } from "../../shared/org.js";
|
|
126
|
+
import {
|
|
127
|
+
engineVersion,
|
|
128
|
+
getDistRoot,
|
|
129
|
+
getEngineRoot,
|
|
130
|
+
} from "../../shared/paths.js";
|
|
118
131
|
import { enginePath } from "../../shared/engine.js";
|
|
119
132
|
import {
|
|
120
133
|
releaseService,
|
|
@@ -138,11 +151,16 @@ import {
|
|
|
138
151
|
type Placement,
|
|
139
152
|
} from "../../shared/manifest/placement.js";
|
|
140
153
|
import {
|
|
141
|
-
isOpSignedIn,
|
|
142
|
-
hasSudoInVault,
|
|
143
154
|
readSudoFromVault,
|
|
144
155
|
saveSudoToVault,
|
|
156
|
+
sudoRef,
|
|
157
|
+
sudoVaultStatus,
|
|
158
|
+
SUDO_REF_NAME,
|
|
145
159
|
} from "../../shared/auth/sudo-vault.js";
|
|
160
|
+
import {
|
|
161
|
+
notConfiguredMessage,
|
|
162
|
+
secretsConfigWarnings,
|
|
163
|
+
} from "../../shared/secrets/index.js";
|
|
146
164
|
import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
|
|
147
165
|
import { actorLabel } from "../../shared/agent-actor.js";
|
|
148
166
|
import {
|
|
@@ -155,7 +173,27 @@ import {
|
|
|
155
173
|
} from "./audit.js";
|
|
156
174
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
157
175
|
import { serveUiAsset } from "./ui-assets.js";
|
|
158
|
-
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";
|
|
159
197
|
import { singleFlight } from "./single-flight.js";
|
|
160
198
|
import { graphOrder } from "./graph-order.js";
|
|
161
199
|
import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
|
|
@@ -197,6 +235,13 @@ interface HealthResult {
|
|
|
197
235
|
/** Where it runs for this env, and where a remote one is (R13). */
|
|
198
236
|
runs: Placement["runs"];
|
|
199
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[];
|
|
200
245
|
}
|
|
201
246
|
|
|
202
247
|
/**
|
|
@@ -255,9 +300,12 @@ const SERVICES_CACHE_TTL_MS = 2_000;
|
|
|
255
300
|
const READ_CACHE_TTL_MS = 5_000;
|
|
256
301
|
enableReadCache(READ_CACHE_TTL_MS);
|
|
257
302
|
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
258
|
-
/**
|
|
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
|
+
*/
|
|
259
307
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
260
|
-
return `${env}|${workspace ??
|
|
308
|
+
return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
|
|
261
309
|
}
|
|
262
310
|
import {
|
|
263
311
|
LOG_SWEEP_INTERVAL_MS,
|
|
@@ -389,6 +437,98 @@ function serverLog(msg: string, kind: string = "info", actor?: string): void {
|
|
|
389
437
|
if (!SILENT) process.stdout.write(msg + "\n");
|
|
390
438
|
}
|
|
391
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
|
+
|
|
392
532
|
// Every audited action (exec, run, sync) lands in the log as a lifecycle
|
|
393
533
|
// line; an agent's carries its label so the /logs page can mark it.
|
|
394
534
|
setAuditSink((line, actor) =>
|
|
@@ -436,15 +576,35 @@ function allWorkspaceCheckouts(): ResolvedService[] {
|
|
|
436
576
|
/**
|
|
437
577
|
* API routes that answer whatever the config holds: the identity probe the
|
|
438
578
|
* CLI and MCP use to find this server, its own log (where a config error is
|
|
439
|
-
* also written), the changelog, and restarting it.
|
|
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.
|
|
440
586
|
*/
|
|
441
587
|
const CONFIG_FREE_ROUTES = new Set([
|
|
442
588
|
"/api/server-info",
|
|
443
589
|
"/api/server-log",
|
|
444
590
|
"/api/changelog",
|
|
445
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",
|
|
446
599
|
]);
|
|
447
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
|
+
|
|
448
608
|
// ── Sudo password store ───────────────────────────────────────────────────────
|
|
449
609
|
import {
|
|
450
610
|
setSudoPassword,
|
|
@@ -463,8 +623,23 @@ import {
|
|
|
463
623
|
serveFile,
|
|
464
624
|
send400,
|
|
465
625
|
send404,
|
|
626
|
+
setHtmlFill,
|
|
466
627
|
} from "./http-helpers.js";
|
|
467
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";
|
|
468
643
|
import {
|
|
469
644
|
readOrgContext,
|
|
470
645
|
resolveAllWorkspaceOrgs,
|
|
@@ -484,14 +659,17 @@ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
|
484
659
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
485
660
|
import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
|
|
486
661
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
662
|
+
import { edgeHttps } from "../../shared/edge/render.js";
|
|
487
663
|
import {
|
|
488
664
|
linksFor,
|
|
489
665
|
openUrlFor,
|
|
490
666
|
rehost,
|
|
667
|
+
type EdgeOrigin,
|
|
491
668
|
type OpenLink,
|
|
492
669
|
} from "../../shared/open-url.js";
|
|
493
670
|
|
|
494
671
|
import { product } from "../../shared/product.js";
|
|
672
|
+
import { readBrand } from "../../shared/brand.js";
|
|
495
673
|
/** config `web.port` (user > team), falling back to the built-in default. */
|
|
496
674
|
function readWebPortSafe(): number {
|
|
497
675
|
try {
|
|
@@ -605,6 +783,12 @@ async function handleRequest(
|
|
|
605
783
|
): Promise<void> {
|
|
606
784
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
607
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);
|
|
608
792
|
// A mutating request may change what /api/services would list (exec and
|
|
609
793
|
// sync stream over GET, for EventSource).
|
|
610
794
|
if (
|
|
@@ -630,7 +814,8 @@ async function handleRequest(
|
|
|
630
814
|
res.on("finish", () => {
|
|
631
815
|
const ms = dim(`${Date.now() - start}ms`);
|
|
632
816
|
serverLog(
|
|
633
|
-
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}
|
|
817
|
+
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
|
|
818
|
+
(res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
|
|
634
819
|
"request",
|
|
635
820
|
);
|
|
636
821
|
});
|
|
@@ -643,8 +828,31 @@ async function handleRequest(
|
|
|
643
828
|
// catch can turn it into a 400, a 500 or an empty list. Pages still load.
|
|
644
829
|
if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
|
|
645
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();
|
|
646
841
|
|
|
647
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
|
+
}
|
|
648
856
|
// Local Dev is the default page when local environment is enabled.
|
|
649
857
|
if (
|
|
650
858
|
pathname === "/" ||
|
|
@@ -659,6 +867,20 @@ async function handleRequest(
|
|
|
659
867
|
return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
|
|
660
868
|
}
|
|
661
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
|
+
|
|
662
884
|
if (pathname === "/tests" || pathname === "/index.html") {
|
|
663
885
|
return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
|
|
664
886
|
}
|
|
@@ -674,6 +896,11 @@ async function handleRequest(
|
|
|
674
896
|
return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
|
|
675
897
|
}
|
|
676
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
|
+
|
|
677
904
|
if (pathname === "/logs") {
|
|
678
905
|
return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
|
|
679
906
|
}
|
|
@@ -682,6 +909,9 @@ async function handleRequest(
|
|
|
682
909
|
return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
|
|
683
910
|
}
|
|
684
911
|
|
|
912
|
+
// The active theme as CSS, its mark and fonts, and the theme list.
|
|
913
|
+
if (handleThemeRoutes(req, res, pathname, themeDeps)) return;
|
|
914
|
+
|
|
685
915
|
// The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
|
|
686
916
|
if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
|
|
687
917
|
|
|
@@ -935,9 +1165,11 @@ async function handleRequest(
|
|
|
935
1165
|
|
|
936
1166
|
// ── API: health check ─────────────────────────────────────────────────────
|
|
937
1167
|
/**
|
|
938
|
-
* Workspaces a health run covers: one named workspace, else
|
|
939
|
-
* (focus first). Health is
|
|
940
|
-
*
|
|
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).
|
|
941
1173
|
*/
|
|
942
1174
|
function healthWorkspaces(
|
|
943
1175
|
workspaceName: string | null,
|
|
@@ -946,12 +1178,7 @@ async function handleRequest(
|
|
|
946
1178
|
const named = getWorkspaceByName(workspaceName);
|
|
947
1179
|
return named ? [named] : [];
|
|
948
1180
|
}
|
|
949
|
-
|
|
950
|
-
const names = [
|
|
951
|
-
...(focus ? [focus] : []),
|
|
952
|
-
...getActiveWorkspaces().filter((n) => n !== focus),
|
|
953
|
-
];
|
|
954
|
-
return names.flatMap((n) => getWorkspaceByName(n) ?? []);
|
|
1181
|
+
return scopeView().workspaces.filter((w) => w.config);
|
|
955
1182
|
}
|
|
956
1183
|
|
|
957
1184
|
/**
|
|
@@ -978,11 +1205,16 @@ async function handleRequest(
|
|
|
978
1205
|
string,
|
|
979
1206
|
{ svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
|
|
980
1207
|
>();
|
|
1208
|
+
const owners = new Map<string, string[]>();
|
|
981
1209
|
for (const ws of workspaces)
|
|
982
|
-
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
|
|
1210
|
+
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name)) {
|
|
983
1211
|
// Modules and libraries have no runtime, so no health dot.
|
|
984
|
-
if (!
|
|
1212
|
+
if (!hasRuntime(repoKind(svc.repoPath))) continue;
|
|
1213
|
+
if (!byAddress.has(svc.address))
|
|
985
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
|
+
}
|
|
986
1218
|
let services = [...byAddress.values()];
|
|
987
1219
|
if (addressFilter) {
|
|
988
1220
|
services = services.filter((s) => s.svc.address === addressFilter);
|
|
@@ -1011,11 +1243,19 @@ async function handleRequest(
|
|
|
1011
1243
|
compose: ComposeHealthTarget | null;
|
|
1012
1244
|
/** A `type: container` check: answered from the shared listing. */
|
|
1013
1245
|
container: ContainerHealthTarget | null;
|
|
1014
|
-
meta: Pick<HealthResult, "runs" | "target">;
|
|
1246
|
+
meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
|
|
1015
1247
|
}> = [];
|
|
1248
|
+
const focusName = getFocusedWorkspaceName();
|
|
1016
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;
|
|
1017
1253
|
try {
|
|
1018
|
-
|
|
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 };
|
|
1019
1259
|
} catch {
|
|
1020
1260
|
/* no edge configured: local links come from manifests only */
|
|
1021
1261
|
}
|
|
@@ -1045,7 +1285,12 @@ async function handleRequest(
|
|
|
1045
1285
|
org: orgOf(resolved.repoName, catalog),
|
|
1046
1286
|
compose: null,
|
|
1047
1287
|
container: null,
|
|
1048
|
-
meta: {
|
|
1288
|
+
meta: {
|
|
1289
|
+
runs: "none",
|
|
1290
|
+
target: null,
|
|
1291
|
+
focused: ws.name === focusName,
|
|
1292
|
+
workspaces: owners.get(resolved.address) ?? [ws.name],
|
|
1293
|
+
},
|
|
1049
1294
|
});
|
|
1050
1295
|
continue;
|
|
1051
1296
|
}
|
|
@@ -1123,6 +1368,7 @@ async function handleRequest(
|
|
|
1123
1368
|
env,
|
|
1124
1369
|
healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
|
|
1125
1370
|
edgeDomain,
|
|
1371
|
+
edgeOrigin,
|
|
1126
1372
|
}),
|
|
1127
1373
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1128
1374
|
org: orgOf(resolved.repoName, catalog),
|
|
@@ -1130,7 +1376,12 @@ async function handleRequest(
|
|
|
1130
1376
|
compose,
|
|
1131
1377
|
container:
|
|
1132
1378
|
healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
|
|
1133
|
-
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
|
+
},
|
|
1134
1385
|
});
|
|
1135
1386
|
}
|
|
1136
1387
|
|
|
@@ -1367,7 +1618,8 @@ async function handleRequest(
|
|
|
1367
1618
|
return sendJson(
|
|
1368
1619
|
res,
|
|
1369
1620
|
await servicesCache.get(
|
|
1370
|
-
|
|
1621
|
+
// No workspace named: what the view scope has in view.
|
|
1622
|
+
`${env}|${wsOverride ?? `*:${scopeKey(scopeView())}`}`,
|
|
1371
1623
|
() => buildServicesList(env, wsOverride),
|
|
1372
1624
|
url.searchParams.get("refresh") === "1",
|
|
1373
1625
|
),
|
|
@@ -2441,10 +2693,10 @@ async function handleRequest(
|
|
|
2441
2693
|
// `org` resolved (id defaults to edge.org) for the header's org label.
|
|
2442
2694
|
const config = readDenConfig();
|
|
2443
2695
|
// `default_org` (ORG-1a) is the default work org, `github_org`; `org`
|
|
2444
|
-
// stays the instance org (
|
|
2696
|
+
// stays the instance org (readOrgView).
|
|
2445
2697
|
return sendJson(res, {
|
|
2446
2698
|
...config,
|
|
2447
|
-
org:
|
|
2699
|
+
org: readOrgView(),
|
|
2448
2700
|
default_org: config.github_org?.trim() || null,
|
|
2449
2701
|
});
|
|
2450
2702
|
}
|
|
@@ -2452,11 +2704,17 @@ async function handleRequest(
|
|
|
2452
2704
|
// ── API: server info + staleness (drives the header "stale server" pill) ────
|
|
2453
2705
|
if (pathname === "/api/server-info" && req.method === "GET") {
|
|
2454
2706
|
const { stale, scanned } = computeStale();
|
|
2707
|
+
const engine = engineCheck();
|
|
2455
2708
|
return sendJson(res, {
|
|
2456
2709
|
pid: process.pid,
|
|
2457
2710
|
port: PORT,
|
|
2458
|
-
// Den
|
|
2459
|
-
|
|
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,
|
|
2460
2718
|
// Whose server this is: `<cli> web up|down`, `mcp connect` and
|
|
2461
2719
|
// `service install` compare both with their own and never act on
|
|
2462
2720
|
// another distribution's server (see cli/server-identity.ts).
|
|
@@ -2856,8 +3114,9 @@ async function handleRequest(
|
|
|
2856
3114
|
// ── API: sudo password ────────────────────────────────────────────────────
|
|
2857
3115
|
// POST /api/sudo-password body: { password: string, saveToVault?: boolean }
|
|
2858
3116
|
// GET /api/sudo-password/status → { set: boolean }
|
|
2859
|
-
// GET /api/sudo-password/vault-status → {
|
|
2860
|
-
//
|
|
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? }
|
|
2861
3120
|
if (pathname === "/api/sudo-password") {
|
|
2862
3121
|
if (req.method !== "POST") {
|
|
2863
3122
|
res.writeHead(405);
|
|
@@ -2909,11 +3168,10 @@ async function handleRequest(
|
|
|
2909
3168
|
}
|
|
2910
3169
|
|
|
2911
3170
|
// Advertises vault capability to the UI so it knows whether to show the
|
|
2912
|
-
// "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.
|
|
2913
3173
|
if (pathname === "/api/sudo-password/vault-status") {
|
|
2914
|
-
|
|
2915
|
-
const hasStored = opAvailable ? await hasSudoInVault() : false;
|
|
2916
|
-
return sendJson(res, { opAvailable, hasStored });
|
|
3174
|
+
return sendJson(res, await sudoVaultStatus());
|
|
2917
3175
|
}
|
|
2918
3176
|
|
|
2919
3177
|
// Auto-unlock path: UI calls this when status.set is false. We read from
|
|
@@ -2926,6 +3184,13 @@ async function handleRequest(
|
|
|
2926
3184
|
res.end();
|
|
2927
3185
|
return;
|
|
2928
3186
|
}
|
|
3187
|
+
if (!sudoRef()) {
|
|
3188
|
+
return sendJson(res, {
|
|
3189
|
+
ok: false,
|
|
3190
|
+
reason: "not-configured",
|
|
3191
|
+
message: notConfiguredMessage(SUDO_REF_NAME),
|
|
3192
|
+
});
|
|
3193
|
+
}
|
|
2929
3194
|
const password = await readSudoFromVault();
|
|
2930
3195
|
if (!password) {
|
|
2931
3196
|
return sendJson(res, { ok: false, reason: "not-found" });
|
|
@@ -2947,9 +3212,43 @@ async function handleRequest(
|
|
|
2947
3212
|
}
|
|
2948
3213
|
|
|
2949
3214
|
// ── API: user settings ────────────────────────────────────────────────────
|
|
2950
|
-
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages
|
|
2951
|
-
//
|
|
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? }
|
|
2952
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
|
+
|
|
2953
3252
|
if (pathname === "/api/settings") {
|
|
2954
3253
|
if (req.method === "GET") {
|
|
2955
3254
|
const user = readUserConfig();
|
|
@@ -2964,6 +3263,16 @@ async function handleRequest(
|
|
|
2964
3263
|
languages: SUPPORTED_LANGUAGES,
|
|
2965
3264
|
log_window_lines: user.log_window_lines ?? 20_000,
|
|
2966
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,
|
|
2967
3276
|
});
|
|
2968
3277
|
}
|
|
2969
3278
|
if (req.method === "POST") {
|
|
@@ -2973,12 +3282,57 @@ async function handleRequest(
|
|
|
2973
3282
|
terminal?: string | null;
|
|
2974
3283
|
editor_overrides?: Record<string, string> | null;
|
|
2975
3284
|
graph?: unknown;
|
|
3285
|
+
appearance?: unknown;
|
|
3286
|
+
shell?: unknown;
|
|
3287
|
+
language?: unknown;
|
|
2976
3288
|
};
|
|
2977
3289
|
const user = readUserConfig();
|
|
2978
|
-
|
|
2979
|
-
|
|
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);
|
|
2980
3306
|
if (!g.ok) return send400(res, g.error);
|
|
2981
|
-
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;
|
|
2982
3336
|
}
|
|
2983
3337
|
if (body.editor === null || body.editor === "") {
|
|
2984
3338
|
delete user.editor;
|
|
@@ -3024,6 +3378,9 @@ async function handleRequest(
|
|
|
3024
3378
|
terminal: user.terminal ?? null,
|
|
3025
3379
|
editor_overrides: user.editor_overrides ?? {},
|
|
3026
3380
|
graph: graphPrefs(user.graph),
|
|
3381
|
+
appearance: appearancePrefs(user.appearance),
|
|
3382
|
+
shell: shellPrefs(user.shell),
|
|
3383
|
+
language: uiLanguage(user.language),
|
|
3027
3384
|
});
|
|
3028
3385
|
} catch (e) {
|
|
3029
3386
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
@@ -3191,37 +3548,157 @@ async function handleRequest(
|
|
|
3191
3548
|
}
|
|
3192
3549
|
}
|
|
3193
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
|
+
|
|
3194
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
|
+
});
|
|
3195
3607
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
3196
3608
|
res.end("Not found");
|
|
3197
3609
|
}
|
|
3198
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
|
+
|
|
3199
3622
|
const server = http.createServer((req, res) => {
|
|
3200
3623
|
// An async handler with no catch turns any route throw into an unhandled
|
|
3201
3624
|
// rejection — which CRASHES the whole server on modern Node (freezing every
|
|
3202
3625
|
// health dot) and leaves the request hanging. Fail the one request instead.
|
|
3203
|
-
handleRequest(req, res).catch((err) => {
|
|
3204
|
-
//
|
|
3205
|
-
//
|
|
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.
|
|
3206
3636
|
if (isConfigError(err)) {
|
|
3207
3637
|
serverLog(
|
|
3208
|
-
` ⚠ ${
|
|
3638
|
+
` ⚠ ${method} ${route} (req ${requestId}): ${err.message}`,
|
|
3639
|
+
"error",
|
|
3209
3640
|
);
|
|
3210
3641
|
try {
|
|
3211
|
-
if (
|
|
3212
|
-
|
|
3213
|
-
|
|
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})`);
|
|
3214
3663
|
} catch {
|
|
3215
3664
|
/* socket already gone */
|
|
3216
3665
|
}
|
|
3217
3666
|
return;
|
|
3218
3667
|
}
|
|
3219
|
-
|
|
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
|
+
);
|
|
3220
3679
|
try {
|
|
3221
|
-
if (
|
|
3222
|
-
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;
|
|
3223
3699
|
}
|
|
3224
|
-
res.
|
|
3700
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
3701
|
+
res.end(`internal server error (request ${requestId})`);
|
|
3225
3702
|
} catch {
|
|
3226
3703
|
/* socket already gone */
|
|
3227
3704
|
}
|
|
@@ -3279,6 +3756,8 @@ void (async () => {
|
|
|
3279
3756
|
delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
3280
3757
|
retryDelayMs: DAEMON_RETRY_DELAY_MS,
|
|
3281
3758
|
rootOf: daemonRootOf,
|
|
3759
|
+
staleReason: daemonStaleReason,
|
|
3760
|
+
runningContainers: runningContainerBasesAsync,
|
|
3282
3761
|
});
|
|
3283
3762
|
if (reaped.removed.length > 0)
|
|
3284
3763
|
console.error(
|
|
@@ -3356,6 +3835,8 @@ server.listen(PORT, BIND_HOST, () => {
|
|
|
3356
3835
|
console.log();
|
|
3357
3836
|
console.log(` Web UI → http://${host}:${PORT}`);
|
|
3358
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}`);
|
|
3359
3840
|
});
|
|
3360
3841
|
|
|
3361
3842
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|