@monster-orange/den 0.2.8 → 0.4.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 +1731 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +730 -6
- 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 +12 -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/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- 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 +257 -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/engine.ts +23 -0
- 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/environment-manager/workspaces.ts +19 -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/secret-env.ts +70 -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/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- 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/agents-command.ts +812 -0
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +260 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +18 -5
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +580 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/sessions-command.ts +718 -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 +145 -20
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +94 -12
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/audit.ts +35 -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 +539 -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 +37 -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 +48 -18
- 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 {
|
|
@@ -150,12 +168,33 @@ import {
|
|
|
150
168
|
actorSuffix,
|
|
151
169
|
recordAction,
|
|
152
170
|
recordAgentRefusal,
|
|
171
|
+
recordCrossWorkspace,
|
|
153
172
|
repoAtPath,
|
|
154
173
|
setAuditSink,
|
|
155
174
|
} from "./audit.js";
|
|
156
175
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
157
176
|
import { serveUiAsset } from "./ui-assets.js";
|
|
158
|
-
import { graphPrefs,
|
|
177
|
+
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
178
|
+
import { graphPageHtml } from "./graph-page.js";
|
|
179
|
+
import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
|
|
180
|
+
import { distributionSettings, userWebPort } from "./settings-distribution.js";
|
|
181
|
+
import {
|
|
182
|
+
appearancePrefs,
|
|
183
|
+
mergeAppearancePrefs,
|
|
184
|
+
storedAppearance,
|
|
185
|
+
} from "./appearance-prefs.js";
|
|
186
|
+
import {
|
|
187
|
+
activeTheme,
|
|
188
|
+
handleThemeRoutes,
|
|
189
|
+
type ThemeRouteDeps,
|
|
190
|
+
} from "./theme-routes.js";
|
|
191
|
+
import { findTheme } from "./theme-runtime.js";
|
|
192
|
+
import {
|
|
193
|
+
UI_LANGUAGES,
|
|
194
|
+
isUiLanguage,
|
|
195
|
+
localizePage,
|
|
196
|
+
uiLanguage,
|
|
197
|
+
} from "./ui-language.js";
|
|
159
198
|
import { singleFlight } from "./single-flight.js";
|
|
160
199
|
import { graphOrder } from "./graph-order.js";
|
|
161
200
|
import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
|
|
@@ -197,6 +236,13 @@ interface HealthResult {
|
|
|
197
236
|
/** Where it runs for this env, and where a remote one is (R13). */
|
|
198
237
|
runs: Placement["runs"];
|
|
199
238
|
target: Placement["target"];
|
|
239
|
+
/** From the focused workspace: the header sorts these first. */
|
|
240
|
+
focused?: boolean;
|
|
241
|
+
/**
|
|
242
|
+
* The workspaces in view that define this service (focus first). More
|
|
243
|
+
* than one means a shared service: it is listed once, with its owners.
|
|
244
|
+
*/
|
|
245
|
+
workspaces?: string[];
|
|
200
246
|
}
|
|
201
247
|
|
|
202
248
|
/**
|
|
@@ -255,9 +301,12 @@ const SERVICES_CACHE_TTL_MS = 2_000;
|
|
|
255
301
|
const READ_CACHE_TTL_MS = 5_000;
|
|
256
302
|
enableReadCache(READ_CACHE_TTL_MS);
|
|
257
303
|
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
258
|
-
/**
|
|
304
|
+
/**
|
|
305
|
+
* A workspace-scoped run covers a different service set than the default
|
|
306
|
+
* one, and the default one covers whatever the view scope has in view.
|
|
307
|
+
*/
|
|
259
308
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
260
|
-
return `${env}|${workspace ??
|
|
309
|
+
return `${env}|${workspace ?? `*:${scopeKey(scopeView())}`}`;
|
|
261
310
|
}
|
|
262
311
|
import {
|
|
263
312
|
LOG_SWEEP_INTERVAL_MS,
|
|
@@ -389,6 +438,98 @@ function serverLog(msg: string, kind: string = "info", actor?: string): void {
|
|
|
389
438
|
if (!SILENT) process.stdout.write(msg + "\n");
|
|
390
439
|
}
|
|
391
440
|
|
|
441
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
442
|
+
// The active theme (user's, else the org config's `theme:`, else den). A
|
|
443
|
+
// theme switch is a write of user.yml `appearance:` and a new /theme.css:
|
|
444
|
+
// nothing else (no edge, no services, no restart).
|
|
445
|
+
const themeDeps: ThemeRouteDeps = {
|
|
446
|
+
roots: () => ({ distRoot: getDistRoot(), engineRoot: getEngineRoot() }),
|
|
447
|
+
prefs: () => appearancePrefs(readUserConfig().appearance),
|
|
448
|
+
distributionTheme: () => readBrand().theme,
|
|
449
|
+
warn: (line) => serverLog(line, "warn"),
|
|
450
|
+
denFavicon: path.join(PUBLIC_DIR, "favicon.ico"),
|
|
451
|
+
};
|
|
452
|
+
// The pre-paint script in each page reads the user's mode and the theme's
|
|
453
|
+
// default mode from these placeholders, so the first paint is right.
|
|
454
|
+
// The UI language (R19): a page in another language than English loads
|
|
455
|
+
// its catalog before shell.js, so the first paint is in it.
|
|
456
|
+
setHtmlFill((html) => {
|
|
457
|
+
let mode = "system";
|
|
458
|
+
let def = "dark";
|
|
459
|
+
let lang = uiLanguage(undefined);
|
|
460
|
+
try {
|
|
461
|
+
const user = readUserConfig();
|
|
462
|
+
lang = uiLanguage(user.language);
|
|
463
|
+
mode = appearancePrefs(user.appearance).mode;
|
|
464
|
+
def = activeTheme(themeDeps)?.theme.default_mode ?? "dark";
|
|
465
|
+
} catch {
|
|
466
|
+
/* defaults */
|
|
467
|
+
}
|
|
468
|
+
let out = localizePage(html, lang)
|
|
469
|
+
.split("@@DEN_MODE@@")
|
|
470
|
+
.join(mode)
|
|
471
|
+
.split("@@DEN_DEFAULT_MODE@@")
|
|
472
|
+
.join(def);
|
|
473
|
+
// MD-2: every page says when the engine on disk is not the one running.
|
|
474
|
+
const engine = engineCheck();
|
|
475
|
+
if (engine.stale)
|
|
476
|
+
out = out.replace(
|
|
477
|
+
'<div id="den-shell"',
|
|
478
|
+
() =>
|
|
479
|
+
`<div id="den-shell" data-engine-stale="${escAttr(JSON.stringify(engineBanner(engine)))}"`,
|
|
480
|
+
);
|
|
481
|
+
return out;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ── MD-2: the engine this server loaded, against the one on disk ─────────────
|
|
485
|
+
const ENGINE = engineWatch(getEngineRoot);
|
|
486
|
+
function engineCheck(): EngineCheck {
|
|
487
|
+
return ENGINE.check();
|
|
488
|
+
}
|
|
489
|
+
/** What the shell's banner and the error pages say on a mismatch. */
|
|
490
|
+
function engineBanner(c: EngineCheck): {
|
|
491
|
+
loaded: string | null;
|
|
492
|
+
onDisk: string | null;
|
|
493
|
+
message: string;
|
|
494
|
+
} {
|
|
495
|
+
return {
|
|
496
|
+
loaded: c.loaded.version,
|
|
497
|
+
onDisk: c.onDisk.version,
|
|
498
|
+
message: engineStaleMessage(c, product().cli),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function escAttr(v: string): string {
|
|
502
|
+
return v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* R26a: the shell's error page for a failed page request (404 or 500).
|
|
507
|
+
* HEAD gets the status and headers only.
|
|
508
|
+
*/
|
|
509
|
+
function sendErrorPage(
|
|
510
|
+
req: http.IncomingMessage,
|
|
511
|
+
res: http.ServerResponse,
|
|
512
|
+
data: Omit<ErrorPageData, "time" | "version" | "engine">,
|
|
513
|
+
): void {
|
|
514
|
+
const engine = engineCheck();
|
|
515
|
+
const full: ErrorPageData = {
|
|
516
|
+
...data,
|
|
517
|
+
time: new Date().toISOString(),
|
|
518
|
+
version: engine.loaded.version,
|
|
519
|
+
...(engine.stale ? { engine: engineBanner(engine) } : {}),
|
|
520
|
+
};
|
|
521
|
+
if (res.headersSent) {
|
|
522
|
+
res.end();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
serveFile(
|
|
526
|
+
res,
|
|
527
|
+
path.join(PUBLIC_DIR, "error.html"),
|
|
528
|
+
(html) => fillErrorPage(html, full),
|
|
529
|
+
data.status,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
392
533
|
// Every audited action (exec, run, sync) lands in the log as a lifecycle
|
|
393
534
|
// line; an agent's carries its label so the /logs page can mark it.
|
|
394
535
|
setAuditSink((line, actor) =>
|
|
@@ -436,15 +577,35 @@ function allWorkspaceCheckouts(): ResolvedService[] {
|
|
|
436
577
|
/**
|
|
437
578
|
* API routes that answer whatever the config holds: the identity probe the
|
|
438
579
|
* CLI and MCP use to find this server, its own log (where a config error is
|
|
439
|
-
* also written), the changelog, and restarting it.
|
|
580
|
+
* also written), the changelog, and restarting it. And (0.3.0-dev.40, R26a's
|
|
581
|
+
* 422 page) what the shell and the Settings and Logs pages need, so the
|
|
582
|
+
* rail stays usable to reach the fix: the UI prefs (theme, mode, language,
|
|
583
|
+
* the rail's pins) and editor settings (/api/settings: user.yml, and the
|
|
584
|
+
* org config's Distribution rows read without validation), the installed
|
|
585
|
+
* themes, the view scope and a workspace's editor (workspaces.yml), and
|
|
586
|
+
* whether a sudo password is held. None of them reads org data.
|
|
440
587
|
*/
|
|
441
588
|
const CONFIG_FREE_ROUTES = new Set([
|
|
442
589
|
"/api/server-info",
|
|
443
590
|
"/api/server-log",
|
|
444
591
|
"/api/changelog",
|
|
445
592
|
"/api/web/restart",
|
|
593
|
+
"/api/settings",
|
|
594
|
+
"/api/themes",
|
|
595
|
+
"/api/scope",
|
|
596
|
+
"/api/workspaces/settings",
|
|
597
|
+
"/api/sudo-password/status",
|
|
598
|
+
// The env picker on every page (falls back to local; a consumer's dev.40 finding).
|
|
599
|
+
"/api/environments",
|
|
446
600
|
]);
|
|
447
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Pages that load whatever the config holds (0.3.0-dev.40): the rail's way
|
|
604
|
+
* to the fix (Settings, Logs). Any other page navigation with a config that
|
|
605
|
+
* fails validation gets the 422 "Config is invalid" page.
|
|
606
|
+
*/
|
|
607
|
+
const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
|
|
608
|
+
|
|
448
609
|
// ── Sudo password store ───────────────────────────────────────────────────────
|
|
449
610
|
import {
|
|
450
611
|
setSudoPassword,
|
|
@@ -463,8 +624,23 @@ import {
|
|
|
463
624
|
serveFile,
|
|
464
625
|
send400,
|
|
465
626
|
send404,
|
|
627
|
+
setHtmlFill,
|
|
466
628
|
} from "./http-helpers.js";
|
|
467
629
|
import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
|
|
630
|
+
import {
|
|
631
|
+
engineStaleMessage,
|
|
632
|
+
engineWatch,
|
|
633
|
+
type EngineCheck,
|
|
634
|
+
} from "./engine-stamp.js";
|
|
635
|
+
import {
|
|
636
|
+
fillErrorPage,
|
|
637
|
+
jsonApiErrors,
|
|
638
|
+
newRequestId,
|
|
639
|
+
pluginForPath,
|
|
640
|
+
routeTemplate,
|
|
641
|
+
wantsHtmlPage,
|
|
642
|
+
type ErrorPageData,
|
|
643
|
+
} from "./http-errors.js";
|
|
468
644
|
import {
|
|
469
645
|
readOrgContext,
|
|
470
646
|
resolveAllWorkspaceOrgs,
|
|
@@ -484,14 +660,17 @@ import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
|
484
660
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
485
661
|
import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
|
|
486
662
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
663
|
+
import { edgeHttps } from "../../shared/edge/render.js";
|
|
487
664
|
import {
|
|
488
665
|
linksFor,
|
|
489
666
|
openUrlFor,
|
|
490
667
|
rehost,
|
|
668
|
+
type EdgeOrigin,
|
|
491
669
|
type OpenLink,
|
|
492
670
|
} from "../../shared/open-url.js";
|
|
493
671
|
|
|
494
672
|
import { product } from "../../shared/product.js";
|
|
673
|
+
import { readBrand } from "../../shared/brand.js";
|
|
495
674
|
/** config `web.port` (user > team), falling back to the built-in default. */
|
|
496
675
|
function readWebPortSafe(): number {
|
|
497
676
|
try {
|
|
@@ -605,6 +784,12 @@ async function handleRequest(
|
|
|
605
784
|
): Promise<void> {
|
|
606
785
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
607
786
|
const pathname = url.pathname;
|
|
787
|
+
// R26: one id per request, on every response and on its failure's log line.
|
|
788
|
+
const requestId = requestIdOf(req);
|
|
789
|
+
if (!res.headersSent) res.setHeader("X-Request-Id", requestId);
|
|
790
|
+
// R26b: every /api error answers JSON, whatever the route sends.
|
|
791
|
+
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
792
|
+
jsonApiErrors(res, requestId);
|
|
608
793
|
// A mutating request may change what /api/services would list (exec and
|
|
609
794
|
// sync stream over GET, for EventSource).
|
|
610
795
|
if (
|
|
@@ -630,7 +815,8 @@ async function handleRequest(
|
|
|
630
815
|
res.on("finish", () => {
|
|
631
816
|
const ms = dim(`${Date.now() - start}ms`);
|
|
632
817
|
serverLog(
|
|
633
|
-
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}
|
|
818
|
+
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
|
|
819
|
+
(res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
|
|
634
820
|
"request",
|
|
635
821
|
);
|
|
636
822
|
});
|
|
@@ -643,8 +829,31 @@ async function handleRequest(
|
|
|
643
829
|
// catch can turn it into a 400, a 500 or an empty list. Pages still load.
|
|
644
830
|
if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
|
|
645
831
|
assertDenConfigValid();
|
|
832
|
+
// A page navigation, likewise: the shell frame with the 422 page, not a
|
|
833
|
+
// page whose every call then fails (R26a). Static files never get here
|
|
834
|
+
// as a navigation (wantsHtmlPage needs Accept: text/html).
|
|
835
|
+
if (
|
|
836
|
+
wantsHtmlPage(req, pathname) &&
|
|
837
|
+
!CONFIG_FREE_PAGES.has(pathname) &&
|
|
838
|
+
(path.extname(pathname) === ".html" ||
|
|
839
|
+
!STATIC_EXTS.has(path.extname(pathname)))
|
|
840
|
+
)
|
|
841
|
+
assertDenConfigValid();
|
|
646
842
|
|
|
647
843
|
// ── Static UI ──────────────────────────────────────────────────────────────
|
|
844
|
+
// The Settings page (R21). Local Dev's old settings panel link
|
|
845
|
+
// (/local-dev?settings=1) lands on its category there.
|
|
846
|
+
if (pathname === "/settings" || pathname === "/settings.html") {
|
|
847
|
+
return serveFile(res, path.join(PUBLIC_DIR, "settings.html"));
|
|
848
|
+
}
|
|
849
|
+
if (
|
|
850
|
+
(pathname === "/local-dev" || pathname === "/local-dev.html") &&
|
|
851
|
+
url.searchParams.has("settings")
|
|
852
|
+
) {
|
|
853
|
+
res.writeHead(302, { Location: "/settings#editor" });
|
|
854
|
+
res.end();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
648
857
|
// Local Dev is the default page when local environment is enabled.
|
|
649
858
|
if (
|
|
650
859
|
pathname === "/" ||
|
|
@@ -659,6 +868,20 @@ async function handleRequest(
|
|
|
659
868
|
return serveFile(res, path.join(PUBLIC_DIR, "local-dev.html"));
|
|
660
869
|
}
|
|
661
870
|
|
|
871
|
+
// The Graph page (R22): Local Dev's page in its full-width graph view.
|
|
872
|
+
if (pathname === "/graph" || pathname === "/graph.html") {
|
|
873
|
+
if (!isLocalEnabled()) {
|
|
874
|
+
res.writeHead(302, { Location: "/tests" });
|
|
875
|
+
res.end();
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
return serveFile(
|
|
879
|
+
res,
|
|
880
|
+
path.join(PUBLIC_DIR, "local-dev.html"),
|
|
881
|
+
graphPageHtml,
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
|
|
662
885
|
if (pathname === "/tests" || pathname === "/index.html") {
|
|
663
886
|
return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
|
|
664
887
|
}
|
|
@@ -674,6 +897,11 @@ async function handleRequest(
|
|
|
674
897
|
return serveFile(res, path.join(PUBLIC_DIR, "release.html"));
|
|
675
898
|
}
|
|
676
899
|
|
|
900
|
+
// The Edge page (PL-SPEC §10): read-only, from /api/edge.
|
|
901
|
+
if (pathname === "/edge" || pathname === "/edge.html") {
|
|
902
|
+
return serveFile(res, path.join(PUBLIC_DIR, "edge.html"));
|
|
903
|
+
}
|
|
904
|
+
|
|
677
905
|
if (pathname === "/logs") {
|
|
678
906
|
return serveFile(res, path.join(PUBLIC_DIR, "logs.html"));
|
|
679
907
|
}
|
|
@@ -682,6 +910,9 @@ async function handleRequest(
|
|
|
682
910
|
return serveFile(res, path.join(PUBLIC_DIR, "header.js"));
|
|
683
911
|
}
|
|
684
912
|
|
|
913
|
+
// The active theme as CSS, its mark and fonts, and the theme list.
|
|
914
|
+
if (handleThemeRoutes(req, res, pathname, themeDeps)) return;
|
|
915
|
+
|
|
685
916
|
// The graph's modules (graph.js, panel.js, …) and vendored libraries (ELK).
|
|
686
917
|
if (serveUiAsset(res, pathname, PUBLIC_DIR)) return;
|
|
687
918
|
|
|
@@ -858,6 +1089,12 @@ async function handleRequest(
|
|
|
858
1089
|
}
|
|
859
1090
|
if (repo) report = { ...report, repo };
|
|
860
1091
|
}
|
|
1092
|
+
// A call into a granted workspace (AG-2a): both workspaces recorded.
|
|
1093
|
+
if (report.crossWorkspace !== undefined) {
|
|
1094
|
+
const crossed = recordCrossWorkspace(actor, report);
|
|
1095
|
+
if (!crossed) return send400(res, "not a cross-workspace report");
|
|
1096
|
+
return sendJson(res, { recorded: true, ts: crossed.ts });
|
|
1097
|
+
}
|
|
861
1098
|
const entry = recordAgentRefusal(actor, report);
|
|
862
1099
|
return sendJson(res, { recorded: true, ts: entry.ts });
|
|
863
1100
|
}
|
|
@@ -935,9 +1172,11 @@ async function handleRequest(
|
|
|
935
1172
|
|
|
936
1173
|
// ── API: health check ─────────────────────────────────────────────────────
|
|
937
1174
|
/**
|
|
938
|
-
* Workspaces a health run covers: one named workspace, else
|
|
939
|
-
* (focus first). Health is
|
|
940
|
-
*
|
|
1175
|
+
* Workspaces a health run covers: one named workspace, else the view
|
|
1176
|
+
* scope's (every active one by default, focus first). Health is
|
|
1177
|
+
* machine-level: a service running under a non-focused active workspace
|
|
1178
|
+
* is still up, and its card must say so. The pages and `status` read the
|
|
1179
|
+
* same scope (src/shared/environment-manager/scope.ts).
|
|
941
1180
|
*/
|
|
942
1181
|
function healthWorkspaces(
|
|
943
1182
|
workspaceName: string | null,
|
|
@@ -946,12 +1185,7 @@ async function handleRequest(
|
|
|
946
1185
|
const named = getWorkspaceByName(workspaceName);
|
|
947
1186
|
return named ? [named] : [];
|
|
948
1187
|
}
|
|
949
|
-
|
|
950
|
-
const names = [
|
|
951
|
-
...(focus ? [focus] : []),
|
|
952
|
-
...getActiveWorkspaces().filter((n) => n !== focus),
|
|
953
|
-
];
|
|
954
|
-
return names.flatMap((n) => getWorkspaceByName(n) ?? []);
|
|
1188
|
+
return scopeView().workspaces.filter((w) => w.config);
|
|
955
1189
|
}
|
|
956
1190
|
|
|
957
1191
|
/**
|
|
@@ -978,11 +1212,16 @@ async function handleRequest(
|
|
|
978
1212
|
string,
|
|
979
1213
|
{ svc: ResolvedService; ws: { name: string; config: WorkspaceConfig } }
|
|
980
1214
|
>();
|
|
1215
|
+
const owners = new Map<string, string[]>();
|
|
981
1216
|
for (const ws of workspaces)
|
|
982
|
-
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name))
|
|
1217
|
+
for (const svc of listWorkspaceServices(ws.config, catalog, ws.name)) {
|
|
983
1218
|
// Modules and libraries have no runtime, so no health dot.
|
|
984
|
-
if (!
|
|
1219
|
+
if (!hasRuntime(repoKind(svc.repoPath))) continue;
|
|
1220
|
+
if (!byAddress.has(svc.address))
|
|
985
1221
|
byAddress.set(svc.address, { svc, ws });
|
|
1222
|
+
const o = owners.get(svc.address) ?? [];
|
|
1223
|
+
if (!o.includes(ws.name)) owners.set(svc.address, [...o, ws.name]);
|
|
1224
|
+
}
|
|
986
1225
|
let services = [...byAddress.values()];
|
|
987
1226
|
if (addressFilter) {
|
|
988
1227
|
services = services.filter((s) => s.svc.address === addressFilter);
|
|
@@ -1011,11 +1250,19 @@ async function handleRequest(
|
|
|
1011
1250
|
compose: ComposeHealthTarget | null;
|
|
1012
1251
|
/** A `type: container` check: answered from the shared listing. */
|
|
1013
1252
|
container: ContainerHealthTarget | null;
|
|
1014
|
-
meta: Pick<HealthResult, "runs" | "target">;
|
|
1253
|
+
meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
|
|
1015
1254
|
}> = [];
|
|
1255
|
+
const focusName = getFocusedWorkspaceName();
|
|
1016
1256
|
let edgeDomain: string | null = null;
|
|
1257
|
+
// The links follow the edge's TLS: http on its http port for `tls: none`
|
|
1258
|
+
// (0.3.0-dev.40, DOC-1: the panel's Opens said https).
|
|
1259
|
+
let edgeOrigin: EdgeOrigin | undefined;
|
|
1017
1260
|
try {
|
|
1018
|
-
|
|
1261
|
+
const edge = readEdgeConfig();
|
|
1262
|
+
edgeDomain = edge.domain;
|
|
1263
|
+
edgeOrigin = edgeHttps(edge)
|
|
1264
|
+
? { https: true, port: edge.ports?.https ?? 443 }
|
|
1265
|
+
: { https: false, port: edge.ports?.http ?? 80 };
|
|
1019
1266
|
} catch {
|
|
1020
1267
|
/* no edge configured: local links come from manifests only */
|
|
1021
1268
|
}
|
|
@@ -1045,7 +1292,12 @@ async function handleRequest(
|
|
|
1045
1292
|
org: orgOf(resolved.repoName, catalog),
|
|
1046
1293
|
compose: null,
|
|
1047
1294
|
container: null,
|
|
1048
|
-
meta: {
|
|
1295
|
+
meta: {
|
|
1296
|
+
runs: "none",
|
|
1297
|
+
target: null,
|
|
1298
|
+
focused: ws.name === focusName,
|
|
1299
|
+
workspaces: owners.get(resolved.address) ?? [ws.name],
|
|
1300
|
+
},
|
|
1049
1301
|
});
|
|
1050
1302
|
continue;
|
|
1051
1303
|
}
|
|
@@ -1123,6 +1375,7 @@ async function handleRequest(
|
|
|
1123
1375
|
env,
|
|
1124
1376
|
healthUrl: healthCfg && "url" in healthCfg ? healthCfg.url : null,
|
|
1125
1377
|
edgeDomain,
|
|
1378
|
+
edgeOrigin,
|
|
1126
1379
|
}),
|
|
1127
1380
|
links: linksFor(manifest, resolved.subsystem, env),
|
|
1128
1381
|
org: orgOf(resolved.repoName, catalog),
|
|
@@ -1130,7 +1383,12 @@ async function handleRequest(
|
|
|
1130
1383
|
compose,
|
|
1131
1384
|
container:
|
|
1132
1385
|
healthCfg?.type === "container" ? containerTarget(healthCfg) : null,
|
|
1133
|
-
meta: {
|
|
1386
|
+
meta: {
|
|
1387
|
+
runs: plan.placement.runs,
|
|
1388
|
+
target: plan.placement.target,
|
|
1389
|
+
focused: ws.name === focusName,
|
|
1390
|
+
workspaces: owners.get(resolved.address) ?? [ws.name],
|
|
1391
|
+
},
|
|
1134
1392
|
});
|
|
1135
1393
|
}
|
|
1136
1394
|
|
|
@@ -1367,7 +1625,8 @@ async function handleRequest(
|
|
|
1367
1625
|
return sendJson(
|
|
1368
1626
|
res,
|
|
1369
1627
|
await servicesCache.get(
|
|
1370
|
-
|
|
1628
|
+
// No workspace named: what the view scope has in view.
|
|
1629
|
+
`${env}|${wsOverride ?? `*:${scopeKey(scopeView())}`}`,
|
|
1371
1630
|
() => buildServicesList(env, wsOverride),
|
|
1372
1631
|
url.searchParams.get("refresh") === "1",
|
|
1373
1632
|
),
|
|
@@ -2441,10 +2700,10 @@ async function handleRequest(
|
|
|
2441
2700
|
// `org` resolved (id defaults to edge.org) for the header's org label.
|
|
2442
2701
|
const config = readDenConfig();
|
|
2443
2702
|
// `default_org` (ORG-1a) is the default work org, `github_org`; `org`
|
|
2444
|
-
// stays the instance org (
|
|
2703
|
+
// stays the instance org (readOrgView).
|
|
2445
2704
|
return sendJson(res, {
|
|
2446
2705
|
...config,
|
|
2447
|
-
org:
|
|
2706
|
+
org: readOrgView(),
|
|
2448
2707
|
default_org: config.github_org?.trim() || null,
|
|
2449
2708
|
});
|
|
2450
2709
|
}
|
|
@@ -2452,11 +2711,17 @@ async function handleRequest(
|
|
|
2452
2711
|
// ── API: server info + staleness (drives the header "stale server" pill) ────
|
|
2453
2712
|
if (pathname === "/api/server-info" && req.method === "GET") {
|
|
2454
2713
|
const { stale, scanned } = computeStale();
|
|
2714
|
+
const engine = engineCheck();
|
|
2455
2715
|
return sendJson(res, {
|
|
2456
2716
|
pid: process.pid,
|
|
2457
2717
|
port: PORT,
|
|
2458
|
-
// Den
|
|
2459
|
-
|
|
2718
|
+
// The Den this server runs (its routes): the MCP server compares it
|
|
2719
|
+
// with the routes it needs. loaded and onDisk (MD-2): the engine at
|
|
2720
|
+
// startup and on disk now; engineStale when they differ.
|
|
2721
|
+
version: ENGINE.loaded.version ?? engineVersion() ?? null,
|
|
2722
|
+
loaded: engine.loaded,
|
|
2723
|
+
onDisk: engine.onDisk,
|
|
2724
|
+
engineStale: engine.stale,
|
|
2460
2725
|
// Whose server this is: `<cli> web up|down`, `mcp connect` and
|
|
2461
2726
|
// `service install` compare both with their own and never act on
|
|
2462
2727
|
// another distribution's server (see cli/server-identity.ts).
|
|
@@ -2856,8 +3121,9 @@ async function handleRequest(
|
|
|
2856
3121
|
// ── API: sudo password ────────────────────────────────────────────────────
|
|
2857
3122
|
// POST /api/sudo-password body: { password: string, saveToVault?: boolean }
|
|
2858
3123
|
// GET /api/sudo-password/status → { set: boolean }
|
|
2859
|
-
// GET /api/sudo-password/vault-status → {
|
|
2860
|
-
//
|
|
3124
|
+
// GET /api/sudo-password/vault-status → { backend, label, configured,
|
|
3125
|
+
// backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
3126
|
+
// POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
|
|
2861
3127
|
if (pathname === "/api/sudo-password") {
|
|
2862
3128
|
if (req.method !== "POST") {
|
|
2863
3129
|
res.writeHead(405);
|
|
@@ -2909,11 +3175,10 @@ async function handleRequest(
|
|
|
2909
3175
|
}
|
|
2910
3176
|
|
|
2911
3177
|
// Advertises vault capability to the UI so it knows whether to show the
|
|
2912
|
-
// "Save to
|
|
3178
|
+
// "Save to <backend>" checkbox and whether auto-load is worth trying. With
|
|
3179
|
+
// no secrets.refs.sudo it says "not configured" and spawns nothing.
|
|
2913
3180
|
if (pathname === "/api/sudo-password/vault-status") {
|
|
2914
|
-
|
|
2915
|
-
const hasStored = opAvailable ? await hasSudoInVault() : false;
|
|
2916
|
-
return sendJson(res, { opAvailable, hasStored });
|
|
3181
|
+
return sendJson(res, await sudoVaultStatus());
|
|
2917
3182
|
}
|
|
2918
3183
|
|
|
2919
3184
|
// Auto-unlock path: UI calls this when status.set is false. We read from
|
|
@@ -2926,6 +3191,13 @@ async function handleRequest(
|
|
|
2926
3191
|
res.end();
|
|
2927
3192
|
return;
|
|
2928
3193
|
}
|
|
3194
|
+
if (!sudoRef()) {
|
|
3195
|
+
return sendJson(res, {
|
|
3196
|
+
ok: false,
|
|
3197
|
+
reason: "not-configured",
|
|
3198
|
+
message: notConfiguredMessage(SUDO_REF_NAME),
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
2929
3201
|
const password = await readSudoFromVault();
|
|
2930
3202
|
if (!password) {
|
|
2931
3203
|
return sendJson(res, { ok: false, reason: "not-found" });
|
|
@@ -2947,9 +3219,43 @@ async function handleRequest(
|
|
|
2947
3219
|
}
|
|
2948
3220
|
|
|
2949
3221
|
// ── API: user settings ────────────────────────────────────────────────────
|
|
2950
|
-
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages
|
|
2951
|
-
//
|
|
3222
|
+
// GET /api/settings → { editor, editors, terminal, terminals, editor_overrides, languages,
|
|
3223
|
+
// log_window_lines, graph, appearance, shell, distribution,
|
|
3224
|
+
// user_web_port, local_dev, cli_tabs, language, ui_languages }
|
|
3225
|
+
// POST /api/settings body: { editor?, terminal?, editor_overrides?, graph?, appearance?, shell?,
|
|
3226
|
+
// language? }
|
|
2952
3227
|
// graph: { direction, group, density, panelWidth } (see graph-prefs.ts)
|
|
3228
|
+
// appearance: { theme: id | null, mode: system|dark|light } (appearance-prefs.ts)
|
|
3229
|
+
// shell: { pins, collapsed, hidden, order, dock, dockHeight } (shell-prefs.ts)
|
|
3230
|
+
// language: the UI's language, en | ja | es (ui-language.ts); null or
|
|
3231
|
+
// "en" clears it (English is the default)
|
|
3232
|
+
// It writes config/local/user.yml only. `distribution` is read-only: what
|
|
3233
|
+
// the org config (tracked) sets, for the Settings page's Distribution rows.
|
|
3234
|
+
// Every key POST accepts has a row in the Settings page's registry
|
|
3235
|
+
// (settings-registry.js; settings-inventory.test.ts checks).
|
|
3236
|
+
// The view scope: which active workspaces the pages, the health row and
|
|
3237
|
+
// `status` show. A view preference only (user.yml `view`): it changes no
|
|
3238
|
+
// edge route, container or service, and never the focus.
|
|
3239
|
+
if (pathname === "/api/scope") {
|
|
3240
|
+
if (req.method === "GET") return sendJson(res, scopePayload(scopeView()));
|
|
3241
|
+
if (req.method === "POST") {
|
|
3242
|
+
let body: { scope?: unknown };
|
|
3243
|
+
try {
|
|
3244
|
+
body = JSON.parse(await readBody(req)) as { scope?: unknown };
|
|
3245
|
+
} catch {
|
|
3246
|
+
return send400(res, "invalid JSON");
|
|
3247
|
+
}
|
|
3248
|
+
const parsed = parseScope(body.scope, getActiveWorkspaces());
|
|
3249
|
+
if (!parsed.ok) return send400(res, parsed.error);
|
|
3250
|
+
const user = readUserConfig();
|
|
3251
|
+
const view = storedView(parsed.value);
|
|
3252
|
+
if (view) user.view = view;
|
|
3253
|
+
else delete user.view;
|
|
3254
|
+
writeUserConfig(user);
|
|
3255
|
+
return sendJson(res, scopePayload(scopeView(parsed.value)));
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
2953
3259
|
if (pathname === "/api/settings") {
|
|
2954
3260
|
if (req.method === "GET") {
|
|
2955
3261
|
const user = readUserConfig();
|
|
@@ -2964,6 +3270,16 @@ async function handleRequest(
|
|
|
2964
3270
|
languages: SUPPORTED_LANGUAGES,
|
|
2965
3271
|
log_window_lines: user.log_window_lines ?? 20_000,
|
|
2966
3272
|
graph: graphPrefs(user.graph),
|
|
3273
|
+
appearance: appearancePrefs(user.appearance),
|
|
3274
|
+
shell: shellPrefs(user.shell),
|
|
3275
|
+
distribution: distributionSettings(getDistRoot()),
|
|
3276
|
+
// Set from the command line (`<cli> config`), shown read-only.
|
|
3277
|
+
user_web_port: userWebPort(user as Record<string, unknown>),
|
|
3278
|
+
local_dev: user.local_dev !== false,
|
|
3279
|
+
cli_tabs: user.cli_tabs === true,
|
|
3280
|
+
// The UI's language (R19), and the ones it has catalogs for.
|
|
3281
|
+
language: uiLanguage(user.language),
|
|
3282
|
+
ui_languages: UI_LANGUAGES,
|
|
2967
3283
|
});
|
|
2968
3284
|
}
|
|
2969
3285
|
if (req.method === "POST") {
|
|
@@ -2973,12 +3289,57 @@ async function handleRequest(
|
|
|
2973
3289
|
terminal?: string | null;
|
|
2974
3290
|
editor_overrides?: Record<string, string> | null;
|
|
2975
3291
|
graph?: unknown;
|
|
3292
|
+
appearance?: unknown;
|
|
3293
|
+
shell?: unknown;
|
|
3294
|
+
language?: unknown;
|
|
2976
3295
|
};
|
|
2977
3296
|
const user = readUserConfig();
|
|
2978
|
-
|
|
2979
|
-
|
|
3297
|
+
// null clears (0.3.0-dev.32, a consumer's nit): a block or a key
|
|
3298
|
+
// set to null is removed from user.yml, and a block left empty
|
|
3299
|
+
// goes too, so setting a pref and clearing it again leaves the
|
|
3300
|
+
// file byte-identical.
|
|
3301
|
+
// The rail's pins and collapse (R14): a view preference only.
|
|
3302
|
+
if (body.shell === null) delete user.shell;
|
|
3303
|
+
else if (body.shell !== undefined) {
|
|
3304
|
+
const sh = mergeShellPrefs(shellPrefs(user.shell), body.shell);
|
|
3305
|
+
if (!sh.ok) return send400(res, sh.error);
|
|
3306
|
+
const stored = storedShell(sh.value);
|
|
3307
|
+
if (stored) user.shell = stored;
|
|
3308
|
+
else delete user.shell;
|
|
3309
|
+
}
|
|
3310
|
+
if (body.graph === null) delete user.graph;
|
|
3311
|
+
else if (body.graph !== undefined) {
|
|
3312
|
+
const g = patchStoredGraph(user.graph, body.graph);
|
|
2980
3313
|
if (!g.ok) return send400(res, g.error);
|
|
2981
|
-
user.graph = g.value;
|
|
3314
|
+
if (g.value) user.graph = g.value as UserConfig["graph"];
|
|
3315
|
+
else delete user.graph;
|
|
3316
|
+
}
|
|
3317
|
+
// A UI preference only: the page swaps /theme.css and data-mode.
|
|
3318
|
+
if (body.appearance === null) delete user.appearance;
|
|
3319
|
+
else if (body.appearance !== undefined) {
|
|
3320
|
+
const a = mergeAppearancePrefs(
|
|
3321
|
+
appearancePrefs(user.appearance),
|
|
3322
|
+
body.appearance,
|
|
3323
|
+
(id) => !!findTheme(themeDeps.roots(), id)?.ok,
|
|
3324
|
+
);
|
|
3325
|
+
if (!a.ok) return send400(res, a.error);
|
|
3326
|
+
const stored = storedAppearance(a.value);
|
|
3327
|
+
if (stored) user.appearance = stored;
|
|
3328
|
+
else delete user.appearance;
|
|
3329
|
+
}
|
|
3330
|
+
// The UI's language (R19): stored only when it isn't English.
|
|
3331
|
+
if (body.language !== undefined) {
|
|
3332
|
+
const clear =
|
|
3333
|
+
body.language === null ||
|
|
3334
|
+
body.language === "" ||
|
|
3335
|
+
body.language === "en";
|
|
3336
|
+
if (!clear && !isUiLanguage(body.language))
|
|
3337
|
+
return send400(
|
|
3338
|
+
res,
|
|
3339
|
+
`language must be one of ${UI_LANGUAGES.map((l) => `"${l}"`).join(", ")}`,
|
|
3340
|
+
);
|
|
3341
|
+
if (clear) delete user.language;
|
|
3342
|
+
else user.language = body.language as string;
|
|
2982
3343
|
}
|
|
2983
3344
|
if (body.editor === null || body.editor === "") {
|
|
2984
3345
|
delete user.editor;
|
|
@@ -3024,6 +3385,9 @@ async function handleRequest(
|
|
|
3024
3385
|
terminal: user.terminal ?? null,
|
|
3025
3386
|
editor_overrides: user.editor_overrides ?? {},
|
|
3026
3387
|
graph: graphPrefs(user.graph),
|
|
3388
|
+
appearance: appearancePrefs(user.appearance),
|
|
3389
|
+
shell: shellPrefs(user.shell),
|
|
3390
|
+
language: uiLanguage(user.language),
|
|
3027
3391
|
});
|
|
3028
3392
|
} catch (e) {
|
|
3029
3393
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
@@ -3191,37 +3555,157 @@ async function handleRequest(
|
|
|
3191
3555
|
}
|
|
3192
3556
|
}
|
|
3193
3557
|
|
|
3558
|
+
// A route that throws, for the error frame's tests and screenshots only
|
|
3559
|
+
// (DEN_DEBUG_ROUTES=1): a page and an API call.
|
|
3560
|
+
if (
|
|
3561
|
+
process.env.DEN_DEBUG_ROUTES === "1" &&
|
|
3562
|
+
(pathname === "/_den/throw" || pathname === "/api/_den/throw")
|
|
3563
|
+
) {
|
|
3564
|
+
throw new Error("a debug route threw (DEN_DEBUG_ROUTES)");
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3194
3567
|
// ── 404 ────────────────────────────────────────────────────────────────────
|
|
3568
|
+
sendNotFound(req, res, pathname, requestId);
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/**
|
|
3572
|
+
* No route answered. /api: the R26b JSON (an `engine_stale` code naming
|
|
3573
|
+
* both versions when the engine on disk is newer: its routes are the ones
|
|
3574
|
+
* that 404 here). A page: the shell's 404 page (R26a). Anything else (an
|
|
3575
|
+
* asset): plain text, as before.
|
|
3576
|
+
*/
|
|
3577
|
+
function sendNotFound(
|
|
3578
|
+
req: http.IncomingMessage,
|
|
3579
|
+
res: http.ServerResponse,
|
|
3580
|
+
pathname: string,
|
|
3581
|
+
requestId: string,
|
|
3582
|
+
): void {
|
|
3583
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3584
|
+
const engine = engineCheck();
|
|
3585
|
+
if (engine.stale)
|
|
3586
|
+
return sendJson(
|
|
3587
|
+
res,
|
|
3588
|
+
{
|
|
3589
|
+
ok: false,
|
|
3590
|
+
error: engineStaleMessage(engine, product().cli),
|
|
3591
|
+
code: "engine_stale",
|
|
3592
|
+
loaded: engine.loaded.version,
|
|
3593
|
+
onDisk: engine.onDisk.version,
|
|
3594
|
+
},
|
|
3595
|
+
404,
|
|
3596
|
+
);
|
|
3597
|
+
return sendJson(
|
|
3598
|
+
res,
|
|
3599
|
+
{
|
|
3600
|
+
ok: false,
|
|
3601
|
+
error: `No such API route: ${req.method ?? "GET"} ${pathname}`,
|
|
3602
|
+
code: "not_found",
|
|
3603
|
+
},
|
|
3604
|
+
404,
|
|
3605
|
+
);
|
|
3606
|
+
}
|
|
3607
|
+
if (wantsHtmlPage(req, pathname))
|
|
3608
|
+
return sendErrorPage(req, res, {
|
|
3609
|
+
status: 404,
|
|
3610
|
+
path: pathname,
|
|
3611
|
+
requestId,
|
|
3612
|
+
plugin: pluginForPath(pathname, ROOT),
|
|
3613
|
+
});
|
|
3195
3614
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
3196
3615
|
res.end("Not found");
|
|
3197
3616
|
}
|
|
3198
3617
|
|
|
3618
|
+
/** The request's id: assigned once (the handler and its catch share it). */
|
|
3619
|
+
const REQUEST_IDS = new WeakMap<http.IncomingMessage, string>();
|
|
3620
|
+
function requestIdOf(req: http.IncomingMessage): string {
|
|
3621
|
+
let id = REQUEST_IDS.get(req);
|
|
3622
|
+
if (!id) {
|
|
3623
|
+
id = newRequestId();
|
|
3624
|
+
REQUEST_IDS.set(req, id);
|
|
3625
|
+
}
|
|
3626
|
+
return id;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3199
3629
|
const server = http.createServer((req, res) => {
|
|
3200
3630
|
// An async handler with no catch turns any route throw into an unhandled
|
|
3201
3631
|
// rejection — which CRASHES the whole server on modern Node (freezing every
|
|
3202
3632
|
// health dot) and leaves the request hanging. Fail the one request instead.
|
|
3203
|
-
handleRequest(req, res).catch((err) => {
|
|
3204
|
-
//
|
|
3205
|
-
//
|
|
3633
|
+
handleRequest(req, res).catch((err: unknown) => {
|
|
3634
|
+
// R26: the log has the stack under the request id; the browser gets the
|
|
3635
|
+
// id, never the stack. The route is its template: no query values.
|
|
3636
|
+
const requestId = requestIdOf(req);
|
|
3637
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
3638
|
+
const route = routeTemplate(pathname);
|
|
3639
|
+
const method = req.method ?? "GET";
|
|
3640
|
+
// A config that fails validation is the user's to fix, not a crash: the
|
|
3641
|
+
// /api frame answers 422 config_invalid (jsonApiErrors adds requestId), a
|
|
3642
|
+
// page gets the error page saying so, anything else the message as text.
|
|
3206
3643
|
if (isConfigError(err)) {
|
|
3207
3644
|
serverLog(
|
|
3208
|
-
` ⚠ ${
|
|
3645
|
+
` ⚠ ${method} ${route} (req ${requestId}): ${err.message}`,
|
|
3646
|
+
"error",
|
|
3209
3647
|
);
|
|
3210
3648
|
try {
|
|
3211
|
-
if (
|
|
3212
|
-
|
|
3213
|
-
|
|
3649
|
+
if (res.headersSent) {
|
|
3650
|
+
res.end();
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3654
|
+
sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
if (wantsHtmlPage(req, pathname)) {
|
|
3658
|
+
sendErrorPage(req, res, {
|
|
3659
|
+
status: CONFIG_ERROR_STATUS,
|
|
3660
|
+
path: pathname,
|
|
3661
|
+
message: err.message,
|
|
3662
|
+
issues: configErrorBody(err).issues,
|
|
3663
|
+
cli: product().cli,
|
|
3664
|
+
requestId,
|
|
3665
|
+
});
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
res.writeHead(CONFIG_ERROR_STATUS, { "Content-Type": "text/plain" });
|
|
3669
|
+
res.end(`${err.message} (request ${requestId})`);
|
|
3214
3670
|
} catch {
|
|
3215
3671
|
/* socket already gone */
|
|
3216
3672
|
}
|
|
3217
3673
|
return;
|
|
3218
3674
|
}
|
|
3219
|
-
|
|
3675
|
+
const stack =
|
|
3676
|
+
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
3677
|
+
serverLog(
|
|
3678
|
+
` ✗ ${method} ${route} failed (req ${requestId}): ${stack}`,
|
|
3679
|
+
"error",
|
|
3680
|
+
);
|
|
3681
|
+
if (SILENT)
|
|
3682
|
+
console.error(
|
|
3683
|
+
`[server] ${method} ${route} failed (req ${requestId}):`,
|
|
3684
|
+
err,
|
|
3685
|
+
);
|
|
3220
3686
|
try {
|
|
3221
|
-
if (
|
|
3222
|
-
res.
|
|
3687
|
+
if (res.headersSent) {
|
|
3688
|
+
res.end();
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
3692
|
+
sendJson(
|
|
3693
|
+
res,
|
|
3694
|
+
{
|
|
3695
|
+
ok: false,
|
|
3696
|
+
error: `${method} ${route} failed on the server`,
|
|
3697
|
+
code: "internal",
|
|
3698
|
+
},
|
|
3699
|
+
500,
|
|
3700
|
+
);
|
|
3701
|
+
return;
|
|
3702
|
+
}
|
|
3703
|
+
if (wantsHtmlPage(req, pathname)) {
|
|
3704
|
+
sendErrorPage(req, res, { status: 500, method, route, requestId });
|
|
3705
|
+
return;
|
|
3223
3706
|
}
|
|
3224
|
-
res.
|
|
3707
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
3708
|
+
res.end(`internal server error (request ${requestId})`);
|
|
3225
3709
|
} catch {
|
|
3226
3710
|
/* socket already gone */
|
|
3227
3711
|
}
|
|
@@ -3279,6 +3763,8 @@ void (async () => {
|
|
|
3279
3763
|
delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
3280
3764
|
retryDelayMs: DAEMON_RETRY_DELAY_MS,
|
|
3281
3765
|
rootOf: daemonRootOf,
|
|
3766
|
+
staleReason: daemonStaleReason,
|
|
3767
|
+
runningContainers: runningContainerBasesAsync,
|
|
3282
3768
|
});
|
|
3283
3769
|
if (reaped.removed.length > 0)
|
|
3284
3770
|
console.error(
|
|
@@ -3356,6 +3842,8 @@ server.listen(PORT, BIND_HOST, () => {
|
|
|
3356
3842
|
console.log();
|
|
3357
3843
|
console.log(` Web UI → http://${host}:${PORT}`);
|
|
3358
3844
|
console.log();
|
|
3845
|
+
// Once per start: a secrets config that leaves Den's own features off.
|
|
3846
|
+
for (const w of secretsConfigWarnings()) serverLog(` ⚠ ${w}`);
|
|
3359
3847
|
});
|
|
3360
3848
|
|
|
3361
3849
|
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|