@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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest reader — supports .den.yml (preferred) and .den.json (fallback).
|
|
3
|
+
*
|
|
4
|
+
* Import this helper instead of calling fs.readFileSync('.den.json') directly
|
|
5
|
+
* so all callers automatically get YAML support.
|
|
6
|
+
*
|
|
7
|
+
* A repo's own manifest always wins. When a checkout has none, Den's own
|
|
8
|
+
* copy is used: `manifests/<github-repo>.den.yml` in the Den repo, keyed by
|
|
9
|
+
* the checkout's `origin` repo name. That covers repos whose manifest is not
|
|
10
|
+
* merged yet and repos Den should not commit to.
|
|
11
|
+
*
|
|
12
|
+
* Whichever base won, `manifests/<github-repo>.override.yml` (when present)
|
|
13
|
+
* is deep-merged on top: see merge-override.ts for the rules.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { execFileSync } from "child_process";
|
|
19
|
+
import { getDistRoot } from "../paths.js";
|
|
20
|
+
import { parse as parseYaml } from "yaml";
|
|
21
|
+
import type { RawManifest } from "../types/index.js";
|
|
22
|
+
|
|
23
|
+
import { product } from "../product.js";
|
|
24
|
+
import { mergeOverride } from "./merge-override.js";
|
|
25
|
+
/** Names probed in order. First match wins. */
|
|
26
|
+
const MANIFEST_NAMES = [
|
|
27
|
+
`${product().manifestYml}`,
|
|
28
|
+
`${product().manifestJson}`,
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
/** The repo's own manifest file, or null. */
|
|
32
|
+
export function repoManifestPath(repoPath: string): string | null {
|
|
33
|
+
for (const name of MANIFEST_NAMES) {
|
|
34
|
+
const p = path.join(repoPath, name);
|
|
35
|
+
if (fs.existsSync(p)) return p;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Den-held manifests ───────────────────────────────────────────────────
|
|
41
|
+
// For a repo that doesn't carry a manifest yet (one we don't commit to, or
|
|
42
|
+
// whose PR hasn't merged), den keeps one in manifests/<repo>.den.yml,
|
|
43
|
+
// keyed by the git origin's repo name (falling back to the folder name). The
|
|
44
|
+
// repo's own file always wins.
|
|
45
|
+
|
|
46
|
+
const originCache = new Map<string, string | null>();
|
|
47
|
+
|
|
48
|
+
/** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
|
|
49
|
+
export function originRepoName(repoPath: string): string | null {
|
|
50
|
+
if (originCache.has(repoPath)) return originCache.get(repoPath)!;
|
|
51
|
+
let name: string | null = null;
|
|
52
|
+
try {
|
|
53
|
+
const url = execFileSync(
|
|
54
|
+
"git",
|
|
55
|
+
["-C", repoPath, "config", "--get", "remote.origin.url"],
|
|
56
|
+
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
|
|
57
|
+
).trim();
|
|
58
|
+
name =
|
|
59
|
+
url
|
|
60
|
+
.replace(/\.git$/, "")
|
|
61
|
+
.split(/[/:]/)
|
|
62
|
+
.pop() || null;
|
|
63
|
+
} catch {
|
|
64
|
+
/* not a git checkout, or no origin */
|
|
65
|
+
}
|
|
66
|
+
originCache.set(repoPath, name);
|
|
67
|
+
return name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Directory of den-held manifests (override: DEN_MANIFESTS_DIR). */
|
|
71
|
+
export function heldManifestsDir(): string {
|
|
72
|
+
return process.env.DEN_MANIFESTS_DIR ?? path.join(getDistRoot(), "manifests");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The repo name of a `.den-services/<repo>/<branch>` path, else null. */
|
|
76
|
+
function managedCloneRepo(repoPath: string): string | null {
|
|
77
|
+
const parent = path.dirname(repoPath);
|
|
78
|
+
return path.basename(path.dirname(parent)) === `${product().servicesDir}`
|
|
79
|
+
? path.basename(parent)
|
|
80
|
+
: null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The names `repoPath` is known by: origin repo, managed clone, folder. */
|
|
84
|
+
function repoKeys(repoPath: string): string[] {
|
|
85
|
+
return [
|
|
86
|
+
originRepoName(repoPath),
|
|
87
|
+
// A managed clone lives at .den-services/<repo>/<branch>,
|
|
88
|
+
// and need not be cloned yet.
|
|
89
|
+
managedCloneRepo(repoPath),
|
|
90
|
+
path.basename(repoPath),
|
|
91
|
+
].filter((k): k is string => !!k);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The first `manifests/<key><suffix>` that exists, or null. */
|
|
95
|
+
function heldFile(repoPath: string, suffix: string): string | null {
|
|
96
|
+
for (const key of repoKeys(repoPath)) {
|
|
97
|
+
const p = path.join(heldManifestsDir(), `${key}${suffix}`);
|
|
98
|
+
if (fs.existsSync(p)) return p;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The den-held manifest for `repoPath`, or null. */
|
|
104
|
+
export function heldManifestPath(repoPath: string): string | null {
|
|
105
|
+
return heldFile(repoPath, `${product().manifestYml}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The override suffix: `.override` plus the manifest's extension, so a
|
|
110
|
+
* profile whose manifest is `.acme.yml` reads `manifests/<repo>.override.yml`.
|
|
111
|
+
*/
|
|
112
|
+
export function overrideSuffix(): string {
|
|
113
|
+
return `.override${path.extname(product().manifestYml) || ".yml"}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The override for `repoPath` (`manifests/<repo>.override.yml`), or null.
|
|
118
|
+
* Matched by the same names, in the same order, as the held manifest.
|
|
119
|
+
*/
|
|
120
|
+
export function overrideManifestPath(repoPath: string): string | null {
|
|
121
|
+
return heldFile(repoPath, overrideSuffix());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the base manifest for `repoPath`: the repo's own .den.yml (or
|
|
126
|
+
* .json), else the one den holds for it. Returns null if neither exists.
|
|
127
|
+
* An override is layered on top by readManifest; it is never a base.
|
|
128
|
+
*/
|
|
129
|
+
export function resolveManifestPath(repoPath: string): string | null {
|
|
130
|
+
return repoManifestPath(repoPath) ?? heldManifestPath(repoPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Where `repoPath`'s base manifest comes from. */
|
|
134
|
+
export function manifestSource(repoPath: string): "repo" | "held" | null {
|
|
135
|
+
if (repoManifestPath(repoPath)) return "repo";
|
|
136
|
+
return heldManifestPath(repoPath) ? "held" : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Layers ───────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** A layer of a resolved manifest. The base comes first. */
|
|
142
|
+
export type ManifestLayer = "repo" | "held" | "override";
|
|
143
|
+
|
|
144
|
+
export interface ManifestLoad {
|
|
145
|
+
/** The merged manifest, or null when none is usable. */
|
|
146
|
+
manifest: RawManifest | null;
|
|
147
|
+
/** The layers that applied, base first: e.g. ["repo", "override"]. */
|
|
148
|
+
layers: ManifestLayer[];
|
|
149
|
+
/** The base manifest file (repo or held), or null. */
|
|
150
|
+
baseFile: string | null;
|
|
151
|
+
/** The base's layer, or null when there is no base. */
|
|
152
|
+
baseLayer: "repo" | "held" | null;
|
|
153
|
+
/** The override file, or null. */
|
|
154
|
+
overrideFile: string | null;
|
|
155
|
+
/** Why no manifest is usable, when files exist but cannot be used. */
|
|
156
|
+
error?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Parse a manifest file's text: a map, or null for anything else. */
|
|
160
|
+
export function parseManifestText(
|
|
161
|
+
filePath: string,
|
|
162
|
+
raw: string,
|
|
163
|
+
): Record<string, unknown> | null {
|
|
164
|
+
const parsed: unknown = filePath.endsWith(".json")
|
|
165
|
+
? JSON.parse(raw)
|
|
166
|
+
: parseYaml(raw);
|
|
167
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
168
|
+
return null; // scalar/array/empty manifest is not usable
|
|
169
|
+
return parsed as Record<string, unknown>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The message for an override that has no base to apply to. */
|
|
173
|
+
export function orphanOverrideMessage(): string {
|
|
174
|
+
return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface Located {
|
|
178
|
+
baseFile: string | null;
|
|
179
|
+
baseLayer: "repo" | "held" | null;
|
|
180
|
+
overrideFile: string | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function locate(repoPath: string): Located {
|
|
184
|
+
const own = repoManifestPath(repoPath);
|
|
185
|
+
const held = own ? null : heldManifestPath(repoPath);
|
|
186
|
+
return {
|
|
187
|
+
baseFile: own ?? held,
|
|
188
|
+
baseLayer: own ? "repo" : held ? "held" : null,
|
|
189
|
+
overrideFile: overrideManifestPath(repoPath),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Parse and merge the located files' texts. Never throws. */
|
|
194
|
+
function combine(
|
|
195
|
+
at: Located,
|
|
196
|
+
baseRaw: string | null,
|
|
197
|
+
overrideRaw: string | null,
|
|
198
|
+
): ManifestLoad {
|
|
199
|
+
const out: ManifestLoad = { manifest: null, layers: [], ...at };
|
|
200
|
+
const parse = (file: string, raw: string): Record<string, unknown> => {
|
|
201
|
+
const parsed = parseManifestText(file, raw);
|
|
202
|
+
if (!parsed) throw new Error(`${file}: not a map`);
|
|
203
|
+
return parsed;
|
|
204
|
+
};
|
|
205
|
+
if (!at.baseFile || !at.baseLayer || baseRaw === null) {
|
|
206
|
+
if (at.overrideFile)
|
|
207
|
+
out.error = `${at.overrideFile}: ${orphanOverrideMessage()}`;
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
let manifest = parse(at.baseFile, baseRaw);
|
|
212
|
+
const layers: ManifestLayer[] = [at.baseLayer];
|
|
213
|
+
if (at.overrideFile && overrideRaw !== null) {
|
|
214
|
+
manifest = mergeOverride(manifest, parse(at.overrideFile, overrideRaw));
|
|
215
|
+
layers.push("override");
|
|
216
|
+
}
|
|
217
|
+
out.manifest = manifest as unknown as RawManifest;
|
|
218
|
+
out.layers = layers;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
out.error = (e as Error).message;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
|
|
226
|
+
export function loadManifest(repoPath: string): ManifestLoad {
|
|
227
|
+
const at = locate(repoPath);
|
|
228
|
+
try {
|
|
229
|
+
return combine(
|
|
230
|
+
at,
|
|
231
|
+
at.baseFile && fs.readFileSync(at.baseFile, "utf-8"),
|
|
232
|
+
at.overrideFile && fs.readFileSync(at.overrideFile, "utf-8"),
|
|
233
|
+
);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { manifest: null, layers: [], ...at, error: (e as Error).message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The layers of `repoPath`'s manifest that applied, e.g. ["held", "override"]. */
|
|
240
|
+
export function manifestLayers(repoPath: string): ManifestLayer[] {
|
|
241
|
+
return loadManifest(repoPath).layers;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** "repo", "held + override", … ("none" when no manifest applied). */
|
|
245
|
+
export function manifestLayersLabel(layers: readonly ManifestLayer[]): string {
|
|
246
|
+
return layers.length > 0 ? layers.join(" + ") : "none";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read and parse the manifest from `repoPath`, its override merged in.
|
|
251
|
+
* Supports both YAML and JSON.
|
|
252
|
+
* Returns null if no manifest file exists or parsing fails.
|
|
253
|
+
*/
|
|
254
|
+
export function readManifest(repoPath: string): RawManifest | null {
|
|
255
|
+
return loadManifest(repoPath).manifest;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Async version of readManifest.
|
|
260
|
+
*/
|
|
261
|
+
export async function readManifestAsync(
|
|
262
|
+
repoPath: string,
|
|
263
|
+
): Promise<RawManifest | null> {
|
|
264
|
+
const at = locate(repoPath);
|
|
265
|
+
try {
|
|
266
|
+
const { readFile } = await import("fs/promises");
|
|
267
|
+
return combine(
|
|
268
|
+
at,
|
|
269
|
+
at.baseFile && (await readFile(at.baseFile, "utf-8")),
|
|
270
|
+
at.overrideFile && (await readFile(at.overrideFile, "utf-8")),
|
|
271
|
+
).manifest;
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The canonical manifest filename for display/error messages. */
|
|
278
|
+
export const MANIFEST_DISPLAY_NAME = `${product().manifestYml}`;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for .den.yml manifests.
|
|
3
|
+
*
|
|
4
|
+
* Each schema version is defined separately so migrations can validate
|
|
5
|
+
* inputs and outputs at their respective version boundaries.
|
|
6
|
+
*
|
|
7
|
+
* Current version: 1 (all existing manifests without a version field are v1)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { journeysAppSchema } from "../../pillars/journeys/app-config.js";
|
|
12
|
+
|
|
13
|
+
export const CURRENT_SCHEMA_VERSION = 1;
|
|
14
|
+
export const KNOWN_VERSIONS = [1] as const;
|
|
15
|
+
|
|
16
|
+
// ── Shared primitives ─────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
// Command definition: plain string or object with options
|
|
19
|
+
const commandDef = z.union([
|
|
20
|
+
z.string(),
|
|
21
|
+
z.object({
|
|
22
|
+
cmd: z.string(),
|
|
23
|
+
detachable: z.boolean().optional(),
|
|
24
|
+
interactive: z.boolean().optional(),
|
|
25
|
+
}),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Health check: HTTP (with or without explicit type) or shell
|
|
29
|
+
//
|
|
30
|
+
// Valid shapes:
|
|
31
|
+
// { url: "http://..." } — implicit HTTP
|
|
32
|
+
// { type: "http", url: "http://..." } — explicit HTTP
|
|
33
|
+
// { type: "shell", cmd: "pg_isready ..." } — shell
|
|
34
|
+
const healthConfig = z.union([
|
|
35
|
+
// Shell health check — must have cmd
|
|
36
|
+
z.object({
|
|
37
|
+
type: z.literal("shell"),
|
|
38
|
+
cmd: z.string({
|
|
39
|
+
required_error: '"cmd" is required for shell health checks',
|
|
40
|
+
}),
|
|
41
|
+
label: z.string().optional(),
|
|
42
|
+
}),
|
|
43
|
+
// HTTP health check (explicit or implicit type) — must have url
|
|
44
|
+
z.object({
|
|
45
|
+
type: z.union([z.literal("http"), z.undefined()]).optional(),
|
|
46
|
+
url: z.string({
|
|
47
|
+
required_error: '"url" is required for HTTP health checks',
|
|
48
|
+
}),
|
|
49
|
+
label: z.string().optional(),
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// `distinct: true` marks a SUBSYSTEM's log as its own listed source in the
|
|
54
|
+
// /logs view (e.g. shop/db — shop runs its own postgres, not infra-stack's).
|
|
55
|
+
// Without it, only a system's primary service log is listed there.
|
|
56
|
+
const logSource = z.union([
|
|
57
|
+
z.object({ type: z.literal("process"), distinct: z.boolean().optional() }),
|
|
58
|
+
z.object({
|
|
59
|
+
type: z.literal("shell"),
|
|
60
|
+
cmd: z.string(),
|
|
61
|
+
distinct: z.boolean().optional(),
|
|
62
|
+
}),
|
|
63
|
+
z.object({
|
|
64
|
+
type: z.literal("cloudwatch"),
|
|
65
|
+
logGroup: z.string(),
|
|
66
|
+
logStream: z.string().optional(),
|
|
67
|
+
region: z.string().optional(),
|
|
68
|
+
distinct: z.boolean().optional(),
|
|
69
|
+
}),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// ── Service management config ─────────────────────────────────────────────────
|
|
73
|
+
//
|
|
74
|
+
// Keys for commands/logs/health are environment names (local, dev, …).
|
|
75
|
+
// We accept any string key here — Zod's z.record(z.enum(…)) requires all
|
|
76
|
+
// enum keys to be present (exhaustive), which is too strict for optional env blocks.
|
|
77
|
+
|
|
78
|
+
// depends_on entry: plain string or object with a `service` field and optional
|
|
79
|
+
// `wait` flag (opts this service's startup into blocking on the dep's health
|
|
80
|
+
// check — otherwise default fire-and-forget / detach-window behavior applies).
|
|
81
|
+
const dependsOnEntry = z.union([
|
|
82
|
+
z.string(),
|
|
83
|
+
z.object({
|
|
84
|
+
service: z.string(),
|
|
85
|
+
wait: z.boolean().optional(),
|
|
86
|
+
}),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const httpUrlString = z
|
|
90
|
+
.string()
|
|
91
|
+
.regex(/^https?:\/\/\S+$/, "must be an http(s) URL");
|
|
92
|
+
|
|
93
|
+
// Placement (R13): where a service runs per env, and where a remote one is.
|
|
94
|
+
// Env names are checked against the org's environments in placement.ts.
|
|
95
|
+
const statusCode = z.number().int().min(100).max(599);
|
|
96
|
+
const serviceTarget = z
|
|
97
|
+
.object({
|
|
98
|
+
env: z.string().min(1),
|
|
99
|
+
account: z.string().min(1).optional(),
|
|
100
|
+
url: httpUrlString.optional(),
|
|
101
|
+
expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
|
|
102
|
+
})
|
|
103
|
+
.strict();
|
|
104
|
+
|
|
105
|
+
const serviceManagementConfig = z.object({
|
|
106
|
+
runs: z.record(z.string(), z.enum(["local", "remote"])).optional(),
|
|
107
|
+
target: serviceTarget.optional(),
|
|
108
|
+
depends_on: z.array(dependsOnEntry).optional(),
|
|
109
|
+
commands: z.record(z.string(), z.record(z.string(), commandDef)).optional(),
|
|
110
|
+
logs: z.record(z.string(), logSource).optional(),
|
|
111
|
+
health: z.record(z.string(), healthConfig).optional(),
|
|
112
|
+
// Open-in-new-tab: env → app URL, plus named extras. http(s) only.
|
|
113
|
+
urls: z.record(z.string(), httpUrlString).optional(),
|
|
114
|
+
links: z
|
|
115
|
+
.array(
|
|
116
|
+
z.object({
|
|
117
|
+
label: z.string().min(1),
|
|
118
|
+
urls: z.record(z.string(), httpUrlString),
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
.optional(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── Smoke / perf / synthetics ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
const validAuthRoles = ["admin", "user", "beta"] as const;
|
|
127
|
+
|
|
128
|
+
const smokeServiceConfig = z.object({
|
|
129
|
+
testDir: z.string(),
|
|
130
|
+
testMatch: z.string(),
|
|
131
|
+
baseUrlKey: z.enum(["api", "web"]),
|
|
132
|
+
auth: z.array(z.enum(validAuthRoles)),
|
|
133
|
+
fixtures: z.record(z.string(), z.unknown()).optional(),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const performanceServiceConfig = z.object({
|
|
137
|
+
enabled: z.boolean().optional(),
|
|
138
|
+
testDir: z.string(),
|
|
139
|
+
testMatch: z.string(),
|
|
140
|
+
baseUrlKey: z.enum(["api", "web"]),
|
|
141
|
+
service: z.string(),
|
|
142
|
+
tags: z.array(z.string()).optional(),
|
|
143
|
+
fixtures: z.record(z.string(), z.unknown()).optional(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const syntheticsConfig = z.object({
|
|
147
|
+
testDir: z.string(),
|
|
148
|
+
testMatch: z.string(),
|
|
149
|
+
baseUrlKey: z.enum(["api", "web"]),
|
|
150
|
+
auth: z.array(z.enum(validAuthRoles)),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Journeys (agent-driven E2E) — top-level like `synthetics:`, one spec home
|
|
154
|
+
// per repo. Deliberately thinner than syntheticsConfig: no baseUrlKey (the
|
|
155
|
+
// journeys runner resolves both web and api base URLs from env config) and
|
|
156
|
+
// no auth array (each journey spec's persona.role declares its own role).
|
|
157
|
+
// `app` describes the app under test (resources, session token, web-origin
|
|
158
|
+
// API feed): see src/pillars/journeys/app-config.ts.
|
|
159
|
+
const journeysConfig = z.object({
|
|
160
|
+
testDir: z.string(),
|
|
161
|
+
testMatch: z.string(),
|
|
162
|
+
app: journeysAppSchema.optional(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Security (ZAP scanning) — top-level record keyed by service name, like
|
|
166
|
+
// `smoke:`/`performance:`. Production may only ever list the passive
|
|
167
|
+
// `baseline` mode: the pillar caps it at runtime too, but a manifest that
|
|
168
|
+
// asks for more is rejected up front so the mistake is visible at validation.
|
|
169
|
+
const securityEnvironments = [
|
|
170
|
+
"local",
|
|
171
|
+
"dev",
|
|
172
|
+
"sandbox",
|
|
173
|
+
"staging",
|
|
174
|
+
"production",
|
|
175
|
+
] as const;
|
|
176
|
+
const securityModes = ["baseline", "api", "full"] as const;
|
|
177
|
+
|
|
178
|
+
const securityServiceConfig = z
|
|
179
|
+
.object({
|
|
180
|
+
baseUrlKey: z.enum(["api", "web"]).optional(),
|
|
181
|
+
hosts: z
|
|
182
|
+
.partialRecord(
|
|
183
|
+
z.enum(securityEnvironments),
|
|
184
|
+
z.array(
|
|
185
|
+
z
|
|
186
|
+
.string()
|
|
187
|
+
.url()
|
|
188
|
+
.refine((u) => /^https?:\/\//.test(u), {
|
|
189
|
+
message: "host must be an http(s) URL",
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
.optional(),
|
|
194
|
+
openapi: z.string().min(1).optional(),
|
|
195
|
+
auth: z.array(z.enum(validAuthRoles)).optional(),
|
|
196
|
+
// Passed to ZAP's `-z` option, which splits on whitespace — a regex with
|
|
197
|
+
// a literal space would silently become two options, so reject it.
|
|
198
|
+
exclude: z
|
|
199
|
+
.array(
|
|
200
|
+
z
|
|
201
|
+
.string()
|
|
202
|
+
.min(1)
|
|
203
|
+
.refine((r) => !/\s/.test(r), {
|
|
204
|
+
message: "exclude regex must not contain whitespace (use \\s)",
|
|
205
|
+
})
|
|
206
|
+
.refine(
|
|
207
|
+
(r) => {
|
|
208
|
+
try {
|
|
209
|
+
new RegExp(r);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
{ message: "exclude entry is not a valid regular expression" },
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
.optional(),
|
|
219
|
+
modes: z
|
|
220
|
+
.partialRecord(
|
|
221
|
+
z.enum(securityEnvironments),
|
|
222
|
+
z.array(z.enum(securityModes)),
|
|
223
|
+
)
|
|
224
|
+
.optional(),
|
|
225
|
+
})
|
|
226
|
+
.refine((c) => c.baseUrlKey !== undefined || c.hosts !== undefined, {
|
|
227
|
+
message: 'one of "baseUrlKey" or "hosts" is required',
|
|
228
|
+
})
|
|
229
|
+
.refine(
|
|
230
|
+
(c) => (c.modes?.production ?? ["baseline"]).every((m) => m === "baseline"),
|
|
231
|
+
{
|
|
232
|
+
message: 'production may only allow the passive "baseline" mode',
|
|
233
|
+
path: ["modes", "production"],
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
// What is deployed where. Per Den environment, the first source that answers
|
|
237
|
+
// wins: `version` (a JSON endpoint; `field` names the commit SHA, default
|
|
238
|
+
// "build"), then `deployment` (a GitHub environment's deployments, made by
|
|
239
|
+
// `workflow`), then the newest successful `workflow` run on main.
|
|
240
|
+
const releaseSource = z.object({
|
|
241
|
+
version: z.string().optional(),
|
|
242
|
+
field: z.string().optional(),
|
|
243
|
+
deployment: z.string().optional(),
|
|
244
|
+
});
|
|
245
|
+
const releaseConfig = z.object({
|
|
246
|
+
workflow: z.string().optional(),
|
|
247
|
+
environments: z.record(z.string(), releaseSource).optional(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// The repo's own test suites, run from its checkout. Local only unless
|
|
251
|
+
// `environments` says otherwise; the command gets DEN_ENV and BASE_URL.
|
|
252
|
+
const testSuite = z.object({
|
|
253
|
+
cmd: z.string(),
|
|
254
|
+
cwd: z.string().optional(),
|
|
255
|
+
label: z.string().optional(),
|
|
256
|
+
environments: z.array(z.string()).optional(),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const githubManifestConfig = z.object({
|
|
260
|
+
environments: z.record(z.string(), z.string()).optional(),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Docker Compose repos run behind the edge (see src/shared/stacks/types.ts).
|
|
264
|
+
const composeConfig = z.object({
|
|
265
|
+
name: z.string().optional(),
|
|
266
|
+
dir: z.string().optional(),
|
|
267
|
+
files: z.array(z.string()).optional(),
|
|
268
|
+
routes: z.record(z.string(), z.string()).optional(),
|
|
269
|
+
generate: z.array(z.string()).optional(),
|
|
270
|
+
uses: z.array(z.string()).optional(),
|
|
271
|
+
env: z
|
|
272
|
+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
273
|
+
.optional(),
|
|
274
|
+
profiles: z.array(z.string()).optional(),
|
|
275
|
+
healthchecks: z.record(z.string(), z.array(z.string()).min(1)).optional(),
|
|
276
|
+
// service → env vars set on that service in the override (wins by key).
|
|
277
|
+
service_env: z
|
|
278
|
+
.record(
|
|
279
|
+
z.string(),
|
|
280
|
+
z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
|
281
|
+
)
|
|
282
|
+
.optional(),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// ── v1 manifest schema ────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
export const v1ManifestSchema = z.object({
|
|
288
|
+
// Accept any integer version — the validator handles known/unknown version
|
|
289
|
+
// warnings separately so that future versions don't fail schema validation.
|
|
290
|
+
version: z.number().int().positive().optional(),
|
|
291
|
+
name: z.string({ required_error: "name is required" }),
|
|
292
|
+
entrypoint: z.string().optional(),
|
|
293
|
+
services: z.record(z.string(), serviceManagementConfig).optional(),
|
|
294
|
+
smoke: z.record(z.string(), smokeServiceConfig).optional(),
|
|
295
|
+
performance: z.record(z.string(), performanceServiceConfig).optional(),
|
|
296
|
+
synthetics: syntheticsConfig.optional(),
|
|
297
|
+
journeys: journeysConfig.optional(),
|
|
298
|
+
security: z.record(z.string(), securityServiceConfig).optional(),
|
|
299
|
+
github: githubManifestConfig.optional(),
|
|
300
|
+
compose: composeConfig.optional(),
|
|
301
|
+
application: z.string().optional(),
|
|
302
|
+
kind: z.enum(["service", "module", "library"]).optional(),
|
|
303
|
+
contributes_to: z.array(z.string()).optional(),
|
|
304
|
+
release: releaseConfig.optional(),
|
|
305
|
+
tests: z.record(z.string(), testSuite).optional(),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
export type V1Manifest = z.infer<typeof v1ManifestSchema>;
|
|
309
|
+
|
|
310
|
+
/** Map from version number to its Zod schema. */
|
|
311
|
+
export const schemaByVersion: Record<number, z.ZodTypeAny> = {
|
|
312
|
+
1: v1ManifestSchema,
|
|
313
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a person opens a service, and which org a repo belongs to.
|
|
3
|
+
*
|
|
4
|
+
* A service's open URL for an environment is its manifest `urls.<env>`, else
|
|
5
|
+
* the origin of that environment's HTTP health URL (health checks point at
|
|
6
|
+
* /readyz or /api/health, never the app, so only the origin is kept). Shell
|
|
7
|
+
* health checks and services with no HTTP check have no URL.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Environment,
|
|
12
|
+
ServiceCatalog,
|
|
13
|
+
ServiceManagementConfig,
|
|
14
|
+
} from "../types/index.js";
|
|
15
|
+
|
|
16
|
+
export interface OpenLink {
|
|
17
|
+
label: string;
|
|
18
|
+
url: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function httpOrigin(raw: string | undefined): string | null {
|
|
22
|
+
if (!raw) return null;
|
|
23
|
+
try {
|
|
24
|
+
const u = new URL(raw);
|
|
25
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
|
26
|
+
return `${u.origin}/`;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function httpUrl(raw: string | undefined): string | null {
|
|
33
|
+
if (!raw) return null;
|
|
34
|
+
try {
|
|
35
|
+
const u = new URL(raw);
|
|
36
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openUrlFor(
|
|
43
|
+
svc: ServiceManagementConfig | undefined,
|
|
44
|
+
env: Environment,
|
|
45
|
+
): string | null {
|
|
46
|
+
if (!svc) return null;
|
|
47
|
+
const declared = httpUrl(svc.urls?.[env]);
|
|
48
|
+
if (declared) return declared;
|
|
49
|
+
const health = svc.health?.[env];
|
|
50
|
+
if (!health || ("type" in health && health.type === "shell")) return null;
|
|
51
|
+
return httpOrigin((health as { url?: string }).url);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function linksFor(
|
|
55
|
+
svc: ServiceManagementConfig | undefined,
|
|
56
|
+
env: Environment,
|
|
57
|
+
): OpenLink[] {
|
|
58
|
+
const out: OpenLink[] = [];
|
|
59
|
+
for (const l of svc?.links ?? []) {
|
|
60
|
+
const url = httpUrl(l.urls?.[env]);
|
|
61
|
+
if (url && l.label) out.push({ label: l.label, url });
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The GitHub owner a repo is catalogued under ("acme"), or "". */
|
|
67
|
+
export function orgOf(repo: string, catalog: ServiceCatalog): string {
|
|
68
|
+
const gh = catalog[repo]?.gh ?? "";
|
|
69
|
+
const slash = gh.indexOf("/");
|
|
70
|
+
return slash > 0 ? gh.slice(0, slash) : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface OrgDisplay {
|
|
74
|
+
label?: string;
|
|
75
|
+
color?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every org in the catalog with its display name, configured ones first. */
|
|
79
|
+
export function listOrgs(
|
|
80
|
+
catalog: ServiceCatalog,
|
|
81
|
+
configured: Record<string, OrgDisplay> = {},
|
|
82
|
+
): Array<{ id: string; label: string; color?: string }> {
|
|
83
|
+
const ids = new Set<string>(Object.keys(configured));
|
|
84
|
+
for (const repo of Object.keys(catalog)) {
|
|
85
|
+
const o = orgOf(repo, catalog);
|
|
86
|
+
if (o) ids.add(o);
|
|
87
|
+
}
|
|
88
|
+
return [...ids].map((id) => ({
|
|
89
|
+
id,
|
|
90
|
+
label: configured[id]?.label ?? id,
|
|
91
|
+
...(configured[id]?.color ? { color: configured[id].color } : {}),
|
|
92
|
+
}));
|
|
93
|
+
}
|