@monster-orange/den 0.0.0 → 0.2.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 +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace pill / picker models.
|
|
3
|
+
*
|
|
4
|
+
* Pure and DOM-free (like perf-report.js) so the shape of the header's
|
|
5
|
+
* workspace menu is unit-testable under Jest's node environment; header.js
|
|
6
|
+
* owns everything that touches the DOM.
|
|
7
|
+
*
|
|
8
|
+
* Input is always the `/api/workspaces` payload:
|
|
9
|
+
* { workspaces: { [name]: config }, active: string[] | string, focus?: string }
|
|
10
|
+
* `active` is an array since multi-active workspaces; older payloads sent a
|
|
11
|
+
* single string, which both helpers still accept.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Active workspace names, normalised to an array. */
|
|
15
|
+
function activeNames(data) {
|
|
16
|
+
const active = data?.active;
|
|
17
|
+
if (Array.isArray(active)) return active.filter(Boolean);
|
|
18
|
+
return active ? [active] : [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Focused workspace name: explicit focus → first active → null. */
|
|
22
|
+
function focusName(data) {
|
|
23
|
+
const active = activeNames(data);
|
|
24
|
+
if (data?.focus) return data.focus;
|
|
25
|
+
return active[0] ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Label + tooltip for the header pill.
|
|
30
|
+
* Falls back to "default" so the pill never renders empty.
|
|
31
|
+
*/
|
|
32
|
+
export function buildWorkspacePillModel(data) {
|
|
33
|
+
const active = activeNames(data);
|
|
34
|
+
const label = focusName(data) ?? "default";
|
|
35
|
+
return {
|
|
36
|
+
label,
|
|
37
|
+
// Only worth spelling out when more than one workspace is active —
|
|
38
|
+
// otherwise the pill text already says everything.
|
|
39
|
+
title:
|
|
40
|
+
active.length > 1
|
|
41
|
+
? `Active: ${active.join(", ")} · Focus: ${label}`
|
|
42
|
+
: "Switch workspace",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Menu model for the pill's dropdown: every known workspace, active ones
|
|
48
|
+
* first (they are the likely targets), each side alphabetical so the list
|
|
49
|
+
* order is stable across polls.
|
|
50
|
+
*/
|
|
51
|
+
export function buildWorkspaceMenuModel(data) {
|
|
52
|
+
const active = new Set(activeNames(data));
|
|
53
|
+
const focus = focusName(data);
|
|
54
|
+
// Union of the catalog and the active set: a workspace listed as active but
|
|
55
|
+
// missing from `workspaces` still has to be selectable (and visible).
|
|
56
|
+
const names = new Set([...Object.keys(data?.workspaces ?? {}), ...active]);
|
|
57
|
+
|
|
58
|
+
const items = [...names].map((name) => ({
|
|
59
|
+
name,
|
|
60
|
+
active: active.has(name),
|
|
61
|
+
focused: name === focus,
|
|
62
|
+
}));
|
|
63
|
+
items.sort(
|
|
64
|
+
(a, b) =>
|
|
65
|
+
Number(b.active) - Number(a.active) || a.name.localeCompare(b.name),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return { focus, items };
|
|
69
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only status endpoints (GET only):
|
|
3
|
+
*
|
|
4
|
+
* /api/stacks what `stack ls` + `stack health` show
|
|
5
|
+
* ?workspace=<den workspace> ?health=0 skips docker
|
|
6
|
+
* /api/edge what `edge status` shows: up/down, routes, cert names
|
|
7
|
+
* /api/security/plan the security CLI's own dry run for ?env&mode&service:
|
|
8
|
+
* target discovery and the owned-host check, nothing run
|
|
9
|
+
* /api/activity the audit trail for one service (?address=repo/svc) or
|
|
10
|
+
* repo (?repo=): execs and syncs, yours and agents',
|
|
11
|
+
* newest first (the graph panel's Activity tab)
|
|
12
|
+
*
|
|
13
|
+
* The MCP server's `stacks`, `edge_status` and `run_tests` (its security
|
|
14
|
+
* pre-flight) read these. Collaborators are injectable for tests.
|
|
15
|
+
*/
|
|
16
|
+
import * as http from "http";
|
|
17
|
+
import { readEdgeConfig } from "../../shared/edge/config.js";
|
|
18
|
+
import { edgeStatus, type EdgeStatus } from "../../shared/edge/status.js";
|
|
19
|
+
import { getWorkspaceByName } from "../../shared/environment-manager/workspaces.js";
|
|
20
|
+
import {
|
|
21
|
+
loadStackContext,
|
|
22
|
+
resolveStackWorkspaces,
|
|
23
|
+
stackStatuses,
|
|
24
|
+
type StackStatus,
|
|
25
|
+
} from "../../shared/stacks/status.js";
|
|
26
|
+
import { send400, sendJson } from "./http-helpers.js";
|
|
27
|
+
import { recentActions, type AuditEntry } from "./audit.js";
|
|
28
|
+
|
|
29
|
+
export interface SecurityPlanArgs {
|
|
30
|
+
env: string;
|
|
31
|
+
mode: string;
|
|
32
|
+
service?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SecurityPlanResult {
|
|
36
|
+
exitCode: number;
|
|
37
|
+
log: string[];
|
|
38
|
+
errors: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ReadonlyRouteDeps {
|
|
42
|
+
workspaceExists: (name: string) => boolean;
|
|
43
|
+
stacks: (opts: { workspace: string | null; health: boolean }) => {
|
|
44
|
+
workspace: string;
|
|
45
|
+
stackWorkspace: string;
|
|
46
|
+
stacks: StackStatus[];
|
|
47
|
+
};
|
|
48
|
+
edge: () => EdgeStatus;
|
|
49
|
+
actions: () => AuditEntry[];
|
|
50
|
+
securityPlan: (args: SecurityPlanArgs) => Promise<SecurityPlanResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const defaultReadonlyRouteDeps: ReadonlyRouteDeps = {
|
|
54
|
+
workspaceExists: (name) => !!getWorkspaceByName(name),
|
|
55
|
+
stacks: ({ workspace, health }) => {
|
|
56
|
+
const ws = resolveStackWorkspaces(workspace);
|
|
57
|
+
const ctx = loadStackContext(ws.denWorkspace);
|
|
58
|
+
return {
|
|
59
|
+
workspace: ws.denWorkspace,
|
|
60
|
+
stackWorkspace: ws.stackWorkspace,
|
|
61
|
+
stacks: stackStatuses(ctx, { health }),
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
edge: () => edgeStatus(readEdgeConfig()),
|
|
65
|
+
actions: recentActions,
|
|
66
|
+
securityPlan: async ({ env, mode, service }) => {
|
|
67
|
+
// The CLI's own entrypoint in --dry-run: the same discovery, mode
|
|
68
|
+
// ceiling and owned-host check, with no token minted and nothing run.
|
|
69
|
+
const { runSecurityCli, defaultEntrypointDeps } =
|
|
70
|
+
await import("../../pillars/security/run.js");
|
|
71
|
+
const log: string[] = [];
|
|
72
|
+
const errors: string[] = [];
|
|
73
|
+
const argv = [`--env=${env}`, `--mode=${mode}`, "--dry-run"];
|
|
74
|
+
if (service) argv.push(`--service=${service}`);
|
|
75
|
+
const exitCode = await runSecurityCli(argv, {
|
|
76
|
+
...defaultEntrypointDeps(),
|
|
77
|
+
log: (m) => log.push(m),
|
|
78
|
+
logError: (m) => errors.push(m),
|
|
79
|
+
});
|
|
80
|
+
return { exitCode, log, errors };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const SAFE_NAME = /^[A-Za-z0-9._/-]{1,128}$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The entries about one service or repo. An exec's detail starts with the
|
|
88
|
+
* address ("shop/api up (local) [id]"); a sync names its repo ("· repo=shop").
|
|
89
|
+
*/
|
|
90
|
+
export function activityFor(
|
|
91
|
+
entries: AuditEntry[],
|
|
92
|
+
target: { address?: string; repo?: string },
|
|
93
|
+
): AuditEntry[] {
|
|
94
|
+
const matches = (e: AuditEntry): boolean => {
|
|
95
|
+
if (target.address)
|
|
96
|
+
return e.verb === "exec" && e.detail.startsWith(target.address + " ");
|
|
97
|
+
const repo = target.repo ?? "";
|
|
98
|
+
if (e.verb === "exec") return e.detail.startsWith(repo + "/");
|
|
99
|
+
if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
|
|
100
|
+
return false;
|
|
101
|
+
};
|
|
102
|
+
return entries.filter(matches).reverse();
|
|
103
|
+
}
|
|
104
|
+
const MODES = ["baseline", "api", "full", "all"];
|
|
105
|
+
|
|
106
|
+
/** Handles the routes above; false when `pathname` is not one of them. */
|
|
107
|
+
export async function handleReadonlyRoutes(
|
|
108
|
+
req: http.IncomingMessage,
|
|
109
|
+
res: http.ServerResponse,
|
|
110
|
+
pathname: string,
|
|
111
|
+
url: URL,
|
|
112
|
+
deps: ReadonlyRouteDeps = defaultReadonlyRouteDeps,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
if (
|
|
115
|
+
pathname !== "/api/stacks" &&
|
|
116
|
+
pathname !== "/api/edge" &&
|
|
117
|
+
pathname !== "/api/activity" &&
|
|
118
|
+
pathname !== "/api/security/plan"
|
|
119
|
+
)
|
|
120
|
+
return false;
|
|
121
|
+
if (req.method !== "GET") {
|
|
122
|
+
res.writeHead(405, { Allow: "GET" });
|
|
123
|
+
res.end("read-only");
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pathname === "/api/stacks") {
|
|
128
|
+
const workspace = url.searchParams.get("workspace") || null;
|
|
129
|
+
if (workspace && !deps.workspaceExists(workspace)) {
|
|
130
|
+
send400(res, `unknown workspace "${workspace}"`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
sendJson(
|
|
135
|
+
res,
|
|
136
|
+
deps.stacks({
|
|
137
|
+
workspace,
|
|
138
|
+
health: url.searchParams.get("health") !== "0",
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
sendJson(res, { error: (e as Error).message }, 500);
|
|
143
|
+
}
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (pathname === "/api/edge") {
|
|
148
|
+
try {
|
|
149
|
+
sendJson(res, { configured: true, ...deps.edge() });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
// No edge configured is a state, not a server error.
|
|
152
|
+
sendJson(res, { configured: false, error: (e as Error).message });
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pathname === "/api/activity") {
|
|
158
|
+
const address = url.searchParams.get("address") ?? undefined;
|
|
159
|
+
const repo = url.searchParams.get("repo") ?? undefined;
|
|
160
|
+
const name = address ?? repo;
|
|
161
|
+
if (!name || !SAFE_NAME.test(name)) {
|
|
162
|
+
send400(res, "address or repo is required");
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
sendJson(res, { entries: activityFor(deps.actions(), { address, repo }) });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// /api/security/plan
|
|
170
|
+
const env = url.searchParams.get("env") ?? "";
|
|
171
|
+
const mode = url.searchParams.get("mode") ?? "baseline";
|
|
172
|
+
const service = url.searchParams.get("service") ?? undefined;
|
|
173
|
+
if (!SAFE_NAME.test(env)) {
|
|
174
|
+
send400(res, "env is required");
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (!MODES.includes(mode)) {
|
|
178
|
+
send400(res, `mode must be one of ${MODES.join(", ")}`);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (service !== undefined && !SAFE_NAME.test(service)) {
|
|
182
|
+
send400(res, "invalid service");
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const plan = await deps.securityPlan({
|
|
187
|
+
env,
|
|
188
|
+
mode,
|
|
189
|
+
...(service ? { service } : {}),
|
|
190
|
+
});
|
|
191
|
+
sendJson(res, { ok: plan.exitCode === 0, ...plan });
|
|
192
|
+
} catch (e) {
|
|
193
|
+
sendJson(
|
|
194
|
+
res,
|
|
195
|
+
{ ok: false, exitCode: 1, log: [], errors: [(e as Error).message] },
|
|
196
|
+
500,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy session manager for the Release tab.
|
|
3
|
+
*
|
|
4
|
+
* A deploy is a long-running, server-side thing (dispatch → gate → monitor) that
|
|
5
|
+
* MUST outlive any single browser page. Den's tabs are separate full-page
|
|
6
|
+
* navigations, so if the deploy were tied to the SSE request (as the first cut
|
|
7
|
+
* was), navigating away from Release would both lose the log AND abort den's
|
|
8
|
+
* monitoring of a real, already-dispatched deploy.
|
|
9
|
+
*
|
|
10
|
+
* This decouples them: `start()` runs the deploy in the background against an
|
|
11
|
+
* in-memory replay buffer; browser clients `attach()` to REPLAY the log so far
|
|
12
|
+
* and then stream live events. Re-attaching after a reload restores the full
|
|
13
|
+
* log. The deploy runs to completion regardless of who is (or isn't) attached;
|
|
14
|
+
* only an explicit `dismiss()` (the user closing the log) stops it.
|
|
15
|
+
*
|
|
16
|
+
* Sessions run CONCURRENTLY. Deploys of different systems are independent —
|
|
17
|
+
* they dispatch to different repos and GitHub scopes its own `concurrency:`
|
|
18
|
+
* group per repo-and-environment — so a global lock only stopped the operator
|
|
19
|
+
* from doing things that were never in conflict. The one real collision is
|
|
20
|
+
* dispatching the SAME repo to the SAME environment twice while the first is
|
|
21
|
+
* still in flight, which `conflicts()` rejects.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
releaseService,
|
|
26
|
+
type DeployPlanEntry,
|
|
27
|
+
type ReleaseEvent,
|
|
28
|
+
} from "./release-service.js";
|
|
29
|
+
import {
|
|
30
|
+
recordInvocation,
|
|
31
|
+
recordFailure,
|
|
32
|
+
} from "../../shared/dev-metrics/emit.js";
|
|
33
|
+
|
|
34
|
+
/** Public (serializable) view of a session — no subscribers/internal flags. */
|
|
35
|
+
export interface DeploySessionInfo {
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
env: string;
|
|
39
|
+
mode: string;
|
|
40
|
+
status: "running" | "done";
|
|
41
|
+
ok: boolean | null;
|
|
42
|
+
startedAt: number;
|
|
43
|
+
endedAt: number | null;
|
|
44
|
+
/** Repos this session deploys — lets the UI disable just their buttons. */
|
|
45
|
+
repos: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Subscriber = (ev: ReleaseEvent) => void;
|
|
49
|
+
|
|
50
|
+
interface Session extends DeploySessionInfo {
|
|
51
|
+
events: ReleaseEvent[];
|
|
52
|
+
subs: Set<Subscriber>;
|
|
53
|
+
dismissed: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A deploy log is modest (dispatch/gate/monitor lines); cap the replay buffer so
|
|
57
|
+
// a pathologically long-running deploy can't grow it without bound.
|
|
58
|
+
const MAX_EVENTS = 5000;
|
|
59
|
+
|
|
60
|
+
// Finished sessions are retained so a reload can restore their log. Cap how
|
|
61
|
+
// many, so a long session of many deploys can't grow the map without bound.
|
|
62
|
+
const MAX_FINISHED_SESSIONS = 20;
|
|
63
|
+
|
|
64
|
+
function newId(): string {
|
|
65
|
+
return `dep_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class DeploySessionManager {
|
|
69
|
+
private sessions = new Map<string, Session>();
|
|
70
|
+
|
|
71
|
+
private info(s: Session): DeploySessionInfo {
|
|
72
|
+
const { events: _e, subs: _s, dismissed: _d, ...info } = s;
|
|
73
|
+
void _e;
|
|
74
|
+
void _s;
|
|
75
|
+
void _d;
|
|
76
|
+
return info;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** All restorable sessions (running or finished), newest first. */
|
|
80
|
+
active(): DeploySessionInfo[] {
|
|
81
|
+
return [...this.sessions.values()]
|
|
82
|
+
.filter((s) => !s.dismissed)
|
|
83
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
84
|
+
.map((s) => this.info(s));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** True while ANY deploy is live. Kept for callers that just need a flag. */
|
|
88
|
+
busy(): boolean {
|
|
89
|
+
return [...this.sessions.values()].some(
|
|
90
|
+
(s) => !s.dismissed && s.status === "running",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Repos in `plan` that already have a live deploy to `env` — dispatching the
|
|
96
|
+
* same repo+env twice would race two runs against one GitHub concurrency
|
|
97
|
+
* group. Different repos, or the same repo to a different env, do not
|
|
98
|
+
* conflict. Empty array means the deploy is safe to start.
|
|
99
|
+
*/
|
|
100
|
+
conflicts(env: string, plan: DeployPlanEntry[]): string[] {
|
|
101
|
+
const live = new Set<string>();
|
|
102
|
+
for (const s of this.sessions.values()) {
|
|
103
|
+
if (s.dismissed || s.status !== "running" || s.env !== env) continue;
|
|
104
|
+
for (const r of s.repos) live.add(r);
|
|
105
|
+
}
|
|
106
|
+
return plan.map((p) => p.repo).filter((r) => live.has(r));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Start a deploy in the background. `notices` are emitted into the log before
|
|
111
|
+
* the deploy begins (e.g. the header line and a hotfix warning). Callers
|
|
112
|
+
* should check `conflicts()` first — this does not re-check.
|
|
113
|
+
*/
|
|
114
|
+
start(opts: {
|
|
115
|
+
env: string;
|
|
116
|
+
mode: string;
|
|
117
|
+
label: string;
|
|
118
|
+
plan: DeployPlanEntry[];
|
|
119
|
+
notices?: ReleaseEvent[];
|
|
120
|
+
}): DeploySessionInfo {
|
|
121
|
+
const session: Session = {
|
|
122
|
+
id: newId(),
|
|
123
|
+
label: opts.label,
|
|
124
|
+
env: opts.env,
|
|
125
|
+
mode: opts.mode,
|
|
126
|
+
status: "running",
|
|
127
|
+
ok: null,
|
|
128
|
+
startedAt: Date.now(),
|
|
129
|
+
endedAt: null,
|
|
130
|
+
repos: opts.plan.map((p) => p.repo),
|
|
131
|
+
events: [],
|
|
132
|
+
subs: new Set(),
|
|
133
|
+
dismissed: false,
|
|
134
|
+
};
|
|
135
|
+
this.sessions.set(session.id, session);
|
|
136
|
+
this.prune();
|
|
137
|
+
|
|
138
|
+
const emit = (ev: ReleaseEvent): void => {
|
|
139
|
+
session.events.push(ev);
|
|
140
|
+
if (session.events.length > MAX_EVENTS) {
|
|
141
|
+
session.events.splice(0, session.events.length - MAX_EVENTS);
|
|
142
|
+
}
|
|
143
|
+
for (const sub of session.subs) {
|
|
144
|
+
try {
|
|
145
|
+
sub(ev);
|
|
146
|
+
} catch {
|
|
147
|
+
/* a dead subscriber must not break the fan-out */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
for (const n of opts.notices ?? []) emit(n);
|
|
153
|
+
|
|
154
|
+
const startedAt = session.startedAt;
|
|
155
|
+
// Decoupled from any client. aborted() fires only when the user dismisses
|
|
156
|
+
// the log, giving an escape hatch for a stuck monitor; navigation does not.
|
|
157
|
+
void releaseService
|
|
158
|
+
.deploy(opts.env, opts.plan, emit, () => session.dismissed)
|
|
159
|
+
.then((ok) => {
|
|
160
|
+
session.ok = ok;
|
|
161
|
+
recordInvocation({
|
|
162
|
+
command: "release.deploy",
|
|
163
|
+
surface: "ux",
|
|
164
|
+
durationMs: Date.now() - startedAt,
|
|
165
|
+
exit: ok ? 0 : 1,
|
|
166
|
+
extra: { mode: opts.mode, env: opts.env, repos: opts.plan.length },
|
|
167
|
+
});
|
|
168
|
+
})
|
|
169
|
+
.catch((e) => {
|
|
170
|
+
session.ok = false;
|
|
171
|
+
recordFailure({
|
|
172
|
+
command: "release.deploy",
|
|
173
|
+
surface: "ux",
|
|
174
|
+
reason:
|
|
175
|
+
(e as { constructor?: { name?: string } })?.constructor?.name ??
|
|
176
|
+
"Error",
|
|
177
|
+
});
|
|
178
|
+
emit({
|
|
179
|
+
type: "log",
|
|
180
|
+
level: "error",
|
|
181
|
+
text: `Deploy error: ${(e as Error).message}`,
|
|
182
|
+
});
|
|
183
|
+
emit({ type: "done", ok: false, summary: "Deploy error." });
|
|
184
|
+
})
|
|
185
|
+
.finally(() => {
|
|
186
|
+
session.status = "done";
|
|
187
|
+
session.endedAt = Date.now();
|
|
188
|
+
// Dismissed mid-run: nothing can attach to it now, so drop it once the
|
|
189
|
+
// deploy loop has actually exited.
|
|
190
|
+
if (session.dismissed) this.sessions.delete(session.id);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return this.info(session);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Bound the map. Finished sessions are kept so a reload can still restore the
|
|
198
|
+
* log, but not forever — drop the oldest finished ones past the cap. Running
|
|
199
|
+
* sessions are never pruned.
|
|
200
|
+
*/
|
|
201
|
+
private prune(): void {
|
|
202
|
+
const finished = [...this.sessions.values()]
|
|
203
|
+
.filter((s) => s.status === "done")
|
|
204
|
+
.sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
|
|
205
|
+
const excess = finished.length - MAX_FINISHED_SESSIONS;
|
|
206
|
+
for (let i = 0; i < excess; i++) this.sessions.delete(finished[i].id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Attach a subscriber to session `id`: replays every buffered event (the full
|
|
211
|
+
* log, terminal `done` included when finished) then streams live ones.
|
|
212
|
+
* Returns an unsubscribe fn, or null if the session is gone/dismissed.
|
|
213
|
+
*/
|
|
214
|
+
attach(id: string, sub: Subscriber): (() => void) | null {
|
|
215
|
+
const s = this.sessions.get(id);
|
|
216
|
+
if (!s || s.dismissed) return null;
|
|
217
|
+
for (const ev of s.events) {
|
|
218
|
+
try {
|
|
219
|
+
sub(ev);
|
|
220
|
+
} catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
s.subs.add(sub);
|
|
225
|
+
return () => {
|
|
226
|
+
s.subs.delete(sub);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Dismiss (close) session `id`: signals the deploy to stop monitoring and
|
|
232
|
+
* removes it from restore. A finished session's slot is freed immediately; a
|
|
233
|
+
* running one frees once its loop exits.
|
|
234
|
+
*/
|
|
235
|
+
dismiss(id: string): boolean {
|
|
236
|
+
const s = this.sessions.get(id);
|
|
237
|
+
if (!s) return false;
|
|
238
|
+
s.dismissed = true;
|
|
239
|
+
// A finished session can go now; a running one is dropped by its own
|
|
240
|
+
// .finally() once the deploy loop notices the dismissal and exits.
|
|
241
|
+
if (s.status === "done") this.sessions.delete(id);
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export const deploySessions = new DeploySessionManager();
|