@monster-orange/den 0.0.0-stage → 0.1.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 +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -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 +104 -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 +406 -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 +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -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/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 +566 -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/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 +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -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-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -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/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -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 +178 -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/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 +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -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 +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -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 +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -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/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 +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -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/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -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 +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -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/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .den.yml manifest validator.
|
|
3
|
+
*
|
|
4
|
+
* Accepts a parsed object or a raw YAML string.
|
|
5
|
+
* Returns a ValidationResult describing any errors or warnings.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as yaml from "yaml";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import {
|
|
11
|
+
CURRENT_SCHEMA_VERSION,
|
|
12
|
+
KNOWN_VERSIONS,
|
|
13
|
+
schemaByVersion,
|
|
14
|
+
} from "./schema.js";
|
|
15
|
+
import type { ValidationError, ValidationResult } from "../types/index.js";
|
|
16
|
+
|
|
17
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Convert a Zod issue path to a dot-separated string. */
|
|
20
|
+
function zodPathToString(path: (string | number)[]): string {
|
|
21
|
+
return path
|
|
22
|
+
.map((p, i) => (typeof p === "number" ? `[${p}]` : i === 0 ? p : `.${p}`))
|
|
23
|
+
.join("");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Friendly message for common Zod issue codes. */
|
|
27
|
+
function zodIssueMessage(issue: z.ZodIssue): string {
|
|
28
|
+
switch (issue.code) {
|
|
29
|
+
case z.ZodIssueCode.invalid_type:
|
|
30
|
+
if (issue.received === "undefined")
|
|
31
|
+
return `"${issue.path.at(-1)}" is required`;
|
|
32
|
+
return `expected ${issue.expected}, received ${issue.received}`;
|
|
33
|
+
case z.ZodIssueCode.invalid_enum_value:
|
|
34
|
+
return `invalid value "${issue.received}" — must be one of: ${issue.options.join(", ")}`;
|
|
35
|
+
case z.ZodIssueCode.invalid_union:
|
|
36
|
+
return "value does not match any valid shape";
|
|
37
|
+
default:
|
|
38
|
+
return issue.message;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Suggestion text for specific paths / issues. */
|
|
43
|
+
function suggestionFor(issue: z.ZodIssue): string | undefined {
|
|
44
|
+
const pathStr = zodPathToString(issue.path);
|
|
45
|
+
|
|
46
|
+
// auth arrays in smoke / synthetics
|
|
47
|
+
if (pathStr.includes("auth")) {
|
|
48
|
+
return 'Valid roles are: "admin", "user", "beta"';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// health with missing url/cmd
|
|
52
|
+
if (pathStr.includes("health")) {
|
|
53
|
+
return 'HTTP checks need "url"; shell checks need "type: shell" and "cmd"';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate a .den.yml manifest.
|
|
63
|
+
*
|
|
64
|
+
* @param input - A parsed object or a raw YAML string.
|
|
65
|
+
* @returns A ValidationResult with errors, warnings, and the detected version.
|
|
66
|
+
*/
|
|
67
|
+
export function validateManifest(input: unknown): ValidationResult {
|
|
68
|
+
// 1. Parse YAML string if needed
|
|
69
|
+
let parsed: unknown = input;
|
|
70
|
+
if (typeof input === "string") {
|
|
71
|
+
try {
|
|
72
|
+
parsed = yaml.parse(input);
|
|
73
|
+
} catch (err: unknown) {
|
|
74
|
+
return {
|
|
75
|
+
valid: false,
|
|
76
|
+
version: 1,
|
|
77
|
+
errors: [
|
|
78
|
+
{
|
|
79
|
+
path: "",
|
|
80
|
+
message: `Could not parse YAML: ${(err as Error).message}`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
warnings: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Detect version
|
|
89
|
+
const rawVersion =
|
|
90
|
+
parsed !== null &&
|
|
91
|
+
typeof parsed === "object" &&
|
|
92
|
+
"version" in (parsed as object)
|
|
93
|
+
? (parsed as Record<string, unknown>).version
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
const detectedVersion = rawVersion === undefined ? 1 : Number(rawVersion);
|
|
97
|
+
|
|
98
|
+
const warnings: ValidationError[] = [];
|
|
99
|
+
|
|
100
|
+
// 3. Warn on unknown future versions but still validate with the latest schema
|
|
101
|
+
const isKnown = KNOWN_VERSIONS.includes(
|
|
102
|
+
detectedVersion as (typeof KNOWN_VERSIONS)[number],
|
|
103
|
+
);
|
|
104
|
+
if (rawVersion !== undefined && !isKnown) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
path: "version",
|
|
107
|
+
message: `Unknown schema version ${detectedVersion}. Validating against v${CURRENT_SCHEMA_VERSION} (latest known).`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 4. Pick schema — fall back to latest for unknown future versions
|
|
112
|
+
const schema =
|
|
113
|
+
schemaByVersion[detectedVersion] ?? schemaByVersion[CURRENT_SCHEMA_VERSION];
|
|
114
|
+
|
|
115
|
+
// 5. Run Zod validation
|
|
116
|
+
const result = schema.safeParse(parsed);
|
|
117
|
+
|
|
118
|
+
if (result.success) {
|
|
119
|
+
return {
|
|
120
|
+
valid: true,
|
|
121
|
+
version: detectedVersion,
|
|
122
|
+
errors: [],
|
|
123
|
+
warnings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 6. Map Zod issues → ValidationError[]
|
|
128
|
+
const errors: ValidationError[] = result.error.issues.map((issue) => ({
|
|
129
|
+
path: zodPathToString(issue.path),
|
|
130
|
+
message: zodIssueMessage(issue),
|
|
131
|
+
suggestion: suggestionFor(issue),
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
valid: false,
|
|
136
|
+
version: detectedVersion,
|
|
137
|
+
errors,
|
|
138
|
+
warnings,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every service of the active workspaces, with its repo's manifest: the one
|
|
3
|
+
* list the security scan, the repo suites and the release check walk.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the server's health walk: the focused workspace first, then the
|
|
6
|
+
* other active ones; an address listed twice keeps its first owner.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readCatalog } from "../environment-manager/catalog.js";
|
|
10
|
+
import {
|
|
11
|
+
getActiveWorkspaces,
|
|
12
|
+
getFocusedWorkspaceName,
|
|
13
|
+
getWorkspaceByName,
|
|
14
|
+
listWorkspaceServices,
|
|
15
|
+
readManifestAnyBranch,
|
|
16
|
+
} from "../environment-manager/workspaces.js";
|
|
17
|
+
import type {
|
|
18
|
+
RawManifest,
|
|
19
|
+
ServiceCatalog,
|
|
20
|
+
ServiceManagementConfig,
|
|
21
|
+
} from "../types/index.js";
|
|
22
|
+
import { orgOf } from "./service-links.js";
|
|
23
|
+
|
|
24
|
+
export interface WorkspaceService {
|
|
25
|
+
address: string;
|
|
26
|
+
repo: string;
|
|
27
|
+
service: string;
|
|
28
|
+
repoPath: string;
|
|
29
|
+
org: string;
|
|
30
|
+
manifest: RawManifest;
|
|
31
|
+
config: ServiceManagementConfig | undefined;
|
|
32
|
+
isEntrypoint: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function workspaceNames(workspaceName?: string | null): string[] {
|
|
36
|
+
if (workspaceName)
|
|
37
|
+
return getWorkspaceByName(workspaceName) ? [workspaceName] : [];
|
|
38
|
+
const focus = getFocusedWorkspaceName();
|
|
39
|
+
return [
|
|
40
|
+
...(focus ? [focus] : []),
|
|
41
|
+
...getActiveWorkspaces().filter((n) => n !== focus),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listServicesWithManifests(
|
|
46
|
+
workspaceName?: string | null,
|
|
47
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
48
|
+
): WorkspaceService[] {
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
const out: WorkspaceService[] = [];
|
|
51
|
+
for (const name of workspaceNames(workspaceName)) {
|
|
52
|
+
const ws = getWorkspaceByName(name);
|
|
53
|
+
if (!ws) continue;
|
|
54
|
+
for (const svc of listWorkspaceServices(ws.config, catalog, name)) {
|
|
55
|
+
if (seen.has(svc.address)) continue;
|
|
56
|
+
seen.add(svc.address);
|
|
57
|
+
const manifest = readManifestAnyBranch(svc.repoName, svc.repoPath);
|
|
58
|
+
if (!manifest) continue;
|
|
59
|
+
out.push({
|
|
60
|
+
address: svc.address,
|
|
61
|
+
repo: svc.repoName,
|
|
62
|
+
service: svc.subsystem,
|
|
63
|
+
repoPath: svc.repoPath,
|
|
64
|
+
org: orgOf(svc.repoName, catalog),
|
|
65
|
+
manifest,
|
|
66
|
+
config: manifest.services?.[svc.subsystem],
|
|
67
|
+
isEntrypoint: manifest.entrypoint === svc.subsystem,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One entry per repo (its first service carries the manifest). */
|
|
75
|
+
export function listReposWithManifests(
|
|
76
|
+
workspaceName?: string | null,
|
|
77
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
78
|
+
): Array<Pick<WorkspaceService, "repo" | "repoPath" | "org" | "manifest">> {
|
|
79
|
+
const byRepo = new Map<
|
|
80
|
+
string,
|
|
81
|
+
Pick<WorkspaceService, "repo" | "repoPath" | "org" | "manifest">
|
|
82
|
+
>();
|
|
83
|
+
for (const s of listServicesWithManifests(workspaceName, catalog))
|
|
84
|
+
if (!byRepo.has(s.repo))
|
|
85
|
+
byRepo.set(s.repo, {
|
|
86
|
+
repo: s.repo,
|
|
87
|
+
repoPath: s.repoPath,
|
|
88
|
+
org: s.org,
|
|
89
|
+
manifest: s.manifest,
|
|
90
|
+
});
|
|
91
|
+
return [...byRepo.values()];
|
|
92
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a service's app opens in a browser, per environment, for the UI's
|
|
3
|
+
* open-in-new-tab links. Health URLs point at /healthz and friends, never the
|
|
4
|
+
* app, so they only ever contribute their origin.
|
|
5
|
+
*
|
|
6
|
+
* Order, first answer wins:
|
|
7
|
+
* 1. manifest `services.<svc>.urls.<env>`
|
|
8
|
+
* 2. local only: the edge route for the service's compose container,
|
|
9
|
+
* `https://<route>.<stack>.<edge domain>`; the stack-wide service (the
|
|
10
|
+
* entrypoint, or one named after the stack) takes the `app` route, else
|
|
11
|
+
* the first route
|
|
12
|
+
* 3. the origin of the env's HTTP health URL
|
|
13
|
+
* Only http(s) URLs are returned.
|
|
14
|
+
*/
|
|
15
|
+
import { manifestStackName } from "./stacks/config.js";
|
|
16
|
+
import { parseTarget } from "./stacks/render.js";
|
|
17
|
+
import type { RawManifest as DenManifest } from "./types/index.js";
|
|
18
|
+
|
|
19
|
+
export interface OpenLink {
|
|
20
|
+
label: string;
|
|
21
|
+
url: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The URL if it is absolute http(s), else null. */
|
|
25
|
+
export function httpUrl(raw: unknown): string | null {
|
|
26
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(raw);
|
|
29
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.href : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function edgeRouteUrl(
|
|
36
|
+
manifest: DenManifest,
|
|
37
|
+
subsystem: string,
|
|
38
|
+
repoName: string,
|
|
39
|
+
edgeDomain: string,
|
|
40
|
+
): string | null {
|
|
41
|
+
const routes = manifest.compose?.routes;
|
|
42
|
+
if (!routes || !Object.keys(routes).length) return null;
|
|
43
|
+
const stack = manifestStackName(manifest, repoName);
|
|
44
|
+
const names = Object.keys(routes).sort();
|
|
45
|
+
let route = names.find((r) => {
|
|
46
|
+
try {
|
|
47
|
+
return parseTarget(routes[r]).service === subsystem;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (!route && (manifest.entrypoint === subsystem || subsystem === stack))
|
|
53
|
+
route = routes.app ? "app" : names[0];
|
|
54
|
+
return route ? `https://${route}.${stack}.${edgeDomain}/` : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function openUrlFor(opts: {
|
|
58
|
+
manifest: DenManifest;
|
|
59
|
+
subsystem: string;
|
|
60
|
+
repoName: string;
|
|
61
|
+
env: string;
|
|
62
|
+
/** The env's HTTP health URL, when the check is HTTP. */
|
|
63
|
+
healthUrl?: string | null;
|
|
64
|
+
/** Primary edge domain; local edge routes are skipped without it. */
|
|
65
|
+
edgeDomain?: string | null;
|
|
66
|
+
}): string | null {
|
|
67
|
+
const svc = opts.manifest.services?.[opts.subsystem];
|
|
68
|
+
const declared = httpUrl(
|
|
69
|
+
(svc?.urls as Record<string, string> | undefined)?.[opts.env],
|
|
70
|
+
);
|
|
71
|
+
if (declared) return declared;
|
|
72
|
+
if (opts.env === "local" && opts.edgeDomain) {
|
|
73
|
+
const routed = edgeRouteUrl(
|
|
74
|
+
opts.manifest,
|
|
75
|
+
opts.subsystem,
|
|
76
|
+
opts.repoName,
|
|
77
|
+
opts.edgeDomain,
|
|
78
|
+
);
|
|
79
|
+
if (routed) return routed;
|
|
80
|
+
}
|
|
81
|
+
const health = httpUrl(opts.healthUrl);
|
|
82
|
+
return health ? `${new URL(health).origin}/` : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extra named links (`services.<svc>.links`) that have a URL for the env. */
|
|
86
|
+
export function linksFor(
|
|
87
|
+
manifest: DenManifest,
|
|
88
|
+
subsystem: string,
|
|
89
|
+
env: string,
|
|
90
|
+
): OpenLink[] {
|
|
91
|
+
const out: OpenLink[] = [];
|
|
92
|
+
for (const l of manifest.services?.[subsystem]?.links ?? []) {
|
|
93
|
+
const url = httpUrl((l?.urls as Record<string, string> | undefined)?.[env]);
|
|
94
|
+
if (url && typeof l.label === "string") out.push({ label: l.label, url });
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Swap the primary edge domain for the alias the viewer is on (e.g. the UI was
|
|
101
|
+
* opened as den.<alias> from another machine), so links resolve there too.
|
|
102
|
+
*/
|
|
103
|
+
export function rehost(
|
|
104
|
+
url: string,
|
|
105
|
+
primary: string,
|
|
106
|
+
host: string | undefined,
|
|
107
|
+
aliases: string[],
|
|
108
|
+
): string {
|
|
109
|
+
const h = (host ?? "").split(":")[0].toLowerCase();
|
|
110
|
+
const alias = aliases.find((a) => h === a || h.endsWith(`.${a}`));
|
|
111
|
+
if (!alias) return url;
|
|
112
|
+
try {
|
|
113
|
+
const u = new URL(url);
|
|
114
|
+
if (u.hostname === primary || u.hostname.endsWith(`.${primary}`)) {
|
|
115
|
+
u.hostname = u.hostname.slice(0, -primary.length) + alias;
|
|
116
|
+
return u.href;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
/* not a URL: leave it */
|
|
120
|
+
}
|
|
121
|
+
return url;
|
|
122
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The org this den instance serves: the root of
|
|
3
|
+
* Org > Application > Repo > Services. One instance = one org.
|
|
4
|
+
*
|
|
5
|
+
* org:
|
|
6
|
+
* id: globex # defaults to edge.org
|
|
7
|
+
* title: Globex # defaults to the id
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import * as yaml from "yaml";
|
|
12
|
+
import { getDistRoot } from "./paths.js";
|
|
13
|
+
|
|
14
|
+
import { product } from "./product.js";
|
|
15
|
+
export interface OrgConfig {
|
|
16
|
+
id?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Org {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Pure: the org from a parsed config/den.yml, or undefined if unnamed. */
|
|
26
|
+
export function resolveOrg(
|
|
27
|
+
parsed: { org?: OrgConfig; edge?: { org?: string } } | null | undefined,
|
|
28
|
+
): Org | undefined {
|
|
29
|
+
const id = parsed?.org?.id?.trim() || parsed?.edge?.org?.trim();
|
|
30
|
+
if (!id) return undefined;
|
|
31
|
+
return { id, title: parsed?.org?.title?.trim() || id };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readOrg(root: string = getDistRoot()): Org | undefined {
|
|
35
|
+
try {
|
|
36
|
+
return resolveOrg(
|
|
37
|
+
yaml.parse(
|
|
38
|
+
fs.readFileSync(path.join(root, product().configFile), "utf-8"),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
loadProfile,
|
|
5
|
+
PROFILE_FILE,
|
|
6
|
+
readEnv,
|
|
7
|
+
type ProductProfile,
|
|
8
|
+
} from "./profile.js";
|
|
9
|
+
|
|
10
|
+
/** Den's package name: the engine root is the directory that has it. */
|
|
11
|
+
export const ENGINE_PACKAGE = "@monster-orange/den";
|
|
12
|
+
|
|
13
|
+
/** A distribution root is a directory holding den.yml. */
|
|
14
|
+
function isDistRootAt(dir: string): boolean {
|
|
15
|
+
return fs.existsSync(path.join(dir, PROFILE_FILE));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isEngineRootAt(dir: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8");
|
|
21
|
+
return (JSON.parse(raw) as { name?: unknown }).name === ENGINE_PACKAGE;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function walkUp(start: string, test: (dir: string) => boolean): string | null {
|
|
28
|
+
let dir = path.resolve(start);
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (test(dir)) return dir;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir) return null;
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const entryDir = (): string | null =>
|
|
38
|
+
process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* An explicit root from the environment: DEN_ROOT, or `<PREFIX>_ROOT` where
|
|
42
|
+
* <PREFIX> is the env prefix of the profile found there (ACME_ROOT for a
|
|
43
|
+
* product named acme). Validated rather than trusted: everything downstream (clone
|
|
44
|
+
* destinations, the clone-guard allowlist, config writes) anchors here, so a
|
|
45
|
+
* stale or mistyped value must not re-open the Jun 29 incident.
|
|
46
|
+
*/
|
|
47
|
+
function rootFromEnv(): string | null {
|
|
48
|
+
const den = process.env.DEN_ROOT?.trim();
|
|
49
|
+
if (den) {
|
|
50
|
+
const resolved = path.resolve(den);
|
|
51
|
+
if (!isDistRootAt(resolved))
|
|
52
|
+
throw new Error(
|
|
53
|
+
`DEN_ROOT is set to "${den}" but that directory has no ${PROFILE_FILE}. ` +
|
|
54
|
+
`Point it at your distribution's checkout, or unset it.`,
|
|
55
|
+
);
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
59
|
+
if (!/^[A-Z][A-Z0-9_]*_ROOT$/.test(key) || !value?.trim()) continue;
|
|
60
|
+
const resolved = path.resolve(value.trim());
|
|
61
|
+
if (
|
|
62
|
+
isDistRootAt(resolved) &&
|
|
63
|
+
`${loadProfile(resolved).envPrefix}_ROOT` === key
|
|
64
|
+
)
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The distribution root: where den.yml, config/, manifests/, reports/ and
|
|
72
|
+
* results/ live. Never process.cwd(): on Jun 29 a cwd-derived root caused a
|
|
73
|
+
* bulk clone of 13 repos into an unrelated checkout. Anything the walk can't
|
|
74
|
+
* anchor fails loudly.
|
|
75
|
+
*/
|
|
76
|
+
export function getDistRoot(): string {
|
|
77
|
+
const fromEnv = rootFromEnv();
|
|
78
|
+
if (fromEnv) return fromEnv;
|
|
79
|
+
const start = entryDir();
|
|
80
|
+
const root = start ? walkUp(start, isDistRootAt) : null;
|
|
81
|
+
if (root !== null) {
|
|
82
|
+
// The distribution's own <PREFIX>_ROOT, if set, must have been accepted
|
|
83
|
+
// above: one that points elsewhere is a mistake, never silently ignored.
|
|
84
|
+
const key = `${loadProfile(root).envPrefix}_ROOT`;
|
|
85
|
+
const set = process.env[key]?.trim();
|
|
86
|
+
if (set)
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${key} is set to "${set}" but that directory has no ${PROFILE_FILE} ` +
|
|
89
|
+
`for this product. Point ${key} at your checkout, or unset it.`,
|
|
90
|
+
);
|
|
91
|
+
return root;
|
|
92
|
+
}
|
|
93
|
+
// Name any product root variable that was set but pointed nowhere useful.
|
|
94
|
+
for (const [key, value] of Object.entries(process.env))
|
|
95
|
+
if (/^[A-Z][A-Z0-9_]*_ROOT$/.test(key) && value?.trim()) {
|
|
96
|
+
const resolved = path.resolve(value.trim());
|
|
97
|
+
if (
|
|
98
|
+
fs.existsSync(resolved) &&
|
|
99
|
+
!isDistRootAt(resolved) &&
|
|
100
|
+
isProductRootVar(resolved, key)
|
|
101
|
+
)
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${key} is set to "${value}" but that directory has no ${PROFILE_FILE}. ` +
|
|
104
|
+
`Point ${key} at your checkout, or unset it.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(
|
|
108
|
+
`cannot locate the distribution root: walked up from ` +
|
|
109
|
+
`"${start ?? "(no entry script)"}" without finding ${PROFILE_FILE}. ` +
|
|
110
|
+
`Set DEN_ROOT (or <PRODUCT>_ROOT) to your checkout directory ` +
|
|
111
|
+
`(the current directory, "${process.cwd()}", is never used as an anchor).`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether `<PREFIX>_ROOT` is a product root variable (rather than some other
|
|
117
|
+
* tool's): the active profile, if one is pinned by DEN_PROFILE, owns it.
|
|
118
|
+
*/
|
|
119
|
+
function isProductRootVar(dir: string, key: string): boolean {
|
|
120
|
+
const pinned = process.env.DEN_PROFILE?.trim();
|
|
121
|
+
if (!pinned) return false;
|
|
122
|
+
return `${loadProfile(dir).envPrefix}_ROOT` === key;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where Den's own files live (src/, public pages, make/): the directory whose
|
|
127
|
+
* package.json is @monster-orange/den. In a distribution that is
|
|
128
|
+
* node_modules/@monster-orange/den; in Den's own checkout, its root.
|
|
129
|
+
*/
|
|
130
|
+
export function getEngineRoot(): string {
|
|
131
|
+
const env = process.env.DEN_ENGINE_ROOT?.trim();
|
|
132
|
+
if (env) return path.resolve(env);
|
|
133
|
+
for (const start of [entryDir(), process.cwd()]) {
|
|
134
|
+
if (!start) continue;
|
|
135
|
+
const viaWalk = walkUp(start, isEngineRootAt);
|
|
136
|
+
if (viaWalk) return viaWalk;
|
|
137
|
+
const nested = path.join(
|
|
138
|
+
start,
|
|
139
|
+
"node_modules",
|
|
140
|
+
...ENGINE_PACKAGE.split("/"),
|
|
141
|
+
);
|
|
142
|
+
if (isEngineRootAt(nested)) return nested;
|
|
143
|
+
}
|
|
144
|
+
throw new Error(
|
|
145
|
+
`cannot locate Den's own files: no package.json named ${ENGINE_PACKAGE} ` +
|
|
146
|
+
`above the entry script or the current directory. Set DEN_ENGINE_ROOT.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** This distribution's profile. */
|
|
151
|
+
export function getProfile(): ProductProfile {
|
|
152
|
+
return loadProfile(getDistRoot());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The caller's cwd, handed over by the CLI wrapper (bin/den) in
|
|
157
|
+
* DEN_INVOKE_CWD, or the product's own `<PREFIX>_INVOKE_CWD`.
|
|
158
|
+
*/
|
|
159
|
+
export function getInvokeCwd(): string {
|
|
160
|
+
const handed = process.env.DEN_INVOKE_CWD?.trim();
|
|
161
|
+
if (handed) return handed;
|
|
162
|
+
try {
|
|
163
|
+
return readEnv(getProfile(), "INVOKE_CWD") ?? process.cwd();
|
|
164
|
+
} catch {
|
|
165
|
+
return process.cwd(); // no distribution root yet: the caller's cwd
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Per-user state directory for this instance (web server, daemons, logs,
|
|
171
|
+
* history): the profile's state_dir (~/.<name>), or `<PREFIX>_HOME`, so two
|
|
172
|
+
* instances on one machine never share or stop each other's processes.
|
|
173
|
+
*/
|
|
174
|
+
export function getStateDir(): string {
|
|
175
|
+
const profile = getProfile();
|
|
176
|
+
const home = readEnv(profile, "HOME")?.trim();
|
|
177
|
+
return home ? path.resolve(home) : profile.stateDir;
|
|
178
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure resolver for declarative per-perf-test "optional launch fields".
|
|
3
|
+
*
|
|
4
|
+
* A perf test declares `PerfOptionalField[]` (in its co-located manifest.yml or
|
|
5
|
+
* `.den.yml`). The web UI renders these generically and collects raw values;
|
|
6
|
+
* this resolver turns (fields, values) into the env-var map that gets injected
|
|
7
|
+
* into THAT test's k6 process. No I/O, no globals — fully unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PerfOptionalField } from "../types/index.js";
|
|
11
|
+
|
|
12
|
+
type FieldValue = string | number | boolean | undefined;
|
|
13
|
+
|
|
14
|
+
/** True for the canonical truthy spellings a boolean field may arrive as. */
|
|
15
|
+
function isTruthy(value: FieldValue): boolean {
|
|
16
|
+
if (typeof value === "boolean") return value;
|
|
17
|
+
if (typeof value === "number") return value !== 0;
|
|
18
|
+
if (typeof value === "string") {
|
|
19
|
+
const v = value.trim().toLowerCase();
|
|
20
|
+
return v === "true" || v === "1" || v === "yes" || v === "on";
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve declared optional fields against user-supplied values into an env map.
|
|
27
|
+
*
|
|
28
|
+
* Rules (applied per field, in declaration order):
|
|
29
|
+
* - `disabledWhen` satisfied by the (possibly defaulted) controlling field's
|
|
30
|
+
* effective value → SKIP (no env emitted).
|
|
31
|
+
* - effective value = values[name] ?? default.
|
|
32
|
+
* - boolean: truthy → env = emit ?? "1"; falsy/undefined → omit.
|
|
33
|
+
* - select: env set only when the value is one of options[].value; else omit.
|
|
34
|
+
* - string/number: env = String(value) when defined and non-empty.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveFieldEnv(
|
|
37
|
+
fields: PerfOptionalField[],
|
|
38
|
+
values: Record<string, FieldValue>,
|
|
39
|
+
): Record<string, string> {
|
|
40
|
+
const effective = (field: PerfOptionalField): FieldValue =>
|
|
41
|
+
values[field.name] ?? field.default;
|
|
42
|
+
|
|
43
|
+
const byName = new Map(fields.map((f) => [f.name, f]));
|
|
44
|
+
|
|
45
|
+
const env: Record<string, string> = {};
|
|
46
|
+
|
|
47
|
+
for (const field of fields) {
|
|
48
|
+
if (field.disabledWhen) {
|
|
49
|
+
const controller = byName.get(field.disabledWhen.field);
|
|
50
|
+
const controllerValue = controller
|
|
51
|
+
? effective(controller)
|
|
52
|
+
: values[field.disabledWhen.field];
|
|
53
|
+
if (matchesEquals(controllerValue, field.disabledWhen.equals)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const value = effective(field);
|
|
59
|
+
|
|
60
|
+
switch (field.type) {
|
|
61
|
+
case "boolean": {
|
|
62
|
+
if (isTruthy(value)) {
|
|
63
|
+
env[field.env] = field.emit ?? "1";
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "select": {
|
|
68
|
+
const candidate =
|
|
69
|
+
value === undefined || value === null ? undefined : String(value);
|
|
70
|
+
const allowed = (field.options ?? []).some(
|
|
71
|
+
(opt) => opt.value === candidate,
|
|
72
|
+
);
|
|
73
|
+
if (candidate !== undefined && allowed) {
|
|
74
|
+
env[field.env] = candidate;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "string":
|
|
79
|
+
case "number": {
|
|
80
|
+
if (value !== undefined && value !== null && String(value) !== "") {
|
|
81
|
+
env[field.env] = String(value);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return env;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compare a controlling field's value against a `disabledWhen.equals` target.
|
|
93
|
+
* Booleans compare against the field's canonical truthiness so a checkbox that
|
|
94
|
+
* arrives as the string "true" still matches `equals: true`.
|
|
95
|
+
*/
|
|
96
|
+
function matchesEquals(
|
|
97
|
+
value: FieldValue,
|
|
98
|
+
equals: string | number | boolean,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (typeof equals === "boolean") {
|
|
101
|
+
return isTruthy(value) === equals;
|
|
102
|
+
}
|
|
103
|
+
if (value === undefined || value === null) return false;
|
|
104
|
+
return String(value) === String(equals);
|
|
105
|
+
}
|