@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,855 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* den-init — interactive wizard to scaffold a new .den.yml.
|
|
3
|
+
*
|
|
4
|
+
* Exported pure functions (buildInitManifest, detectStackConfig,
|
|
5
|
+
* formatManifestYaml) are unit-tested independently of the interactive flow.
|
|
6
|
+
* The runInit() function drives the interactive CLI prompts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import * as readline from "readline";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import * as yaml from "yaml";
|
|
14
|
+
import type { RawManifest, Environment } from "../../shared/types/index.js";
|
|
15
|
+
import { listMakeTargets } from "../../shared/environment-manager/make-targets.js";
|
|
16
|
+
import { readDenConfig } from "../../shared/den-config.js";
|
|
17
|
+
import { getInvokeCwd } from "../../shared/paths.js";
|
|
18
|
+
import { loadAwsProfiles } from "../../shared/environment/aws-profiles.js";
|
|
19
|
+
|
|
20
|
+
import { product } from "../../shared/product.js";
|
|
21
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface ServiceInitConfig {
|
|
24
|
+
name: string;
|
|
25
|
+
healthUrl?: string;
|
|
26
|
+
healthCmd?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface InitOptions {
|
|
30
|
+
name: string;
|
|
31
|
+
services: ServiceInitConfig[];
|
|
32
|
+
entrypoint?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Standard make targets actually present in the repo root Makefile. When given,
|
|
35
|
+
* `commands.local` reflects the real targets (adding `setup`, dropping absent
|
|
36
|
+
* ones) instead of a blind up/down/restart default.
|
|
37
|
+
*/
|
|
38
|
+
makeTargets?: string[];
|
|
39
|
+
/**
|
|
40
|
+
* Non-local health-URL templates (config `env_health_urls`), `{repo}`
|
|
41
|
+
* substituted with `name`. Applied to the PRIMARY service only (the single
|
|
42
|
+
* service, or the entrypoint of a multi-service repo) — deployed ALBs exist
|
|
43
|
+
* per system, not per sidecar. These feed the TEST scope: smoke/perf resolve
|
|
44
|
+
* each env's base URL from the manifest's `health.<env>.url`.
|
|
45
|
+
*/
|
|
46
|
+
envHealthUrls?: Record<string, string>;
|
|
47
|
+
/**
|
|
48
|
+
* CloudWatch log groups detected per deployed env (aws describe-log-groups).
|
|
49
|
+
* Applied to the PRIMARY service only; feeds the /logs page's env dropdown.
|
|
50
|
+
*/
|
|
51
|
+
envLogGroups?: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The manifest shape this wizard writes: a RawManifest plus the explicit
|
|
56
|
+
* `version` header field (serialised first by formatManifestYaml). RawManifest
|
|
57
|
+
* itself has no `version` — the parser treats absence as version 1.
|
|
58
|
+
*/
|
|
59
|
+
export type InitManifest = RawManifest & { version: number };
|
|
60
|
+
|
|
61
|
+
export interface StackDetectionResult {
|
|
62
|
+
services: string[];
|
|
63
|
+
ports?: Record<string, number>;
|
|
64
|
+
/**
|
|
65
|
+
* Repo-level local port from the stack v2 `config.local.PORT` (falling back to
|
|
66
|
+
* `config.defaults.PORT`). This is the port the service listens on inside its
|
|
67
|
+
* opctl container in local dev — the basis for the local health-check URL.
|
|
68
|
+
*/
|
|
69
|
+
localPort?: number;
|
|
70
|
+
/** Project language (e.g. "go", "python"). From stack.yml v2 `language` field. */
|
|
71
|
+
language?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── detectStackConfig ─────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if the target directory has an stack.yml (or .stack.yml) file and
|
|
78
|
+
* extract service info + optional language field.
|
|
79
|
+
* Checks: stack.yml → .stack.yml → .stack.yaml (first found wins).
|
|
80
|
+
* Returns null when no usable file is found.
|
|
81
|
+
*/
|
|
82
|
+
export function detectStackConfig(
|
|
83
|
+
repoPath: string,
|
|
84
|
+
): StackDetectionResult | null {
|
|
85
|
+
const candidates = [
|
|
86
|
+
path.join(repoPath, "stack.yml"),
|
|
87
|
+
path.join(repoPath, ".stack.yml"),
|
|
88
|
+
path.join(repoPath, ".stack.yaml"),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
92
|
+
if (!found) return null;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const raw = fs.readFileSync(found, "utf-8") as string;
|
|
96
|
+
const parsed = yaml.parse(raw) as Record<string, unknown> | null;
|
|
97
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
98
|
+
|
|
99
|
+
// stack v1 shape: a top-level `services:` map with per-service `port`.
|
|
100
|
+
let serviceNames: string[] = [];
|
|
101
|
+
let ports: Record<string, number> | undefined;
|
|
102
|
+
if (parsed.services && typeof parsed.services === "object") {
|
|
103
|
+
const servicesBlock = parsed.services as Record<
|
|
104
|
+
string,
|
|
105
|
+
Record<string, unknown>
|
|
106
|
+
>;
|
|
107
|
+
serviceNames = Object.keys(servicesBlock);
|
|
108
|
+
const collected: Record<string, number> = {};
|
|
109
|
+
for (const [svcName, svcConfig] of Object.entries(servicesBlock)) {
|
|
110
|
+
if (typeof svcConfig?.port === "number") {
|
|
111
|
+
collected[svcName] = svcConfig.port;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(collected).length > 0) ports = collected;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// stack v2 shape: the local port lives under `config.local.PORT` (a repo-level
|
|
118
|
+
// env var), falling back to `config.defaults.PORT`. yaml.parse does NOT resolve
|
|
119
|
+
// the `<<: *defaults` merge, so we read both explicitly.
|
|
120
|
+
const localPort = extractLocalPort(parsed);
|
|
121
|
+
|
|
122
|
+
const language =
|
|
123
|
+
typeof parsed.language === "string" ? parsed.language : undefined;
|
|
124
|
+
|
|
125
|
+
// Nothing usable found → behave as before (no detection).
|
|
126
|
+
if (
|
|
127
|
+
serviceNames.length === 0 &&
|
|
128
|
+
localPort === undefined &&
|
|
129
|
+
language === undefined
|
|
130
|
+
) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
services: serviceNames,
|
|
136
|
+
ports,
|
|
137
|
+
localPort,
|
|
138
|
+
language,
|
|
139
|
+
};
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract the local listen port from an stack v2 config block. Reads
|
|
147
|
+
* `config.local.PORT` first, then `config.defaults.PORT` (yaml.parse does not
|
|
148
|
+
* resolve the `<<: *defaults` merge, so an env that inherits PORT from defaults
|
|
149
|
+
* has no explicit `config.local.PORT`). PORT may be a number or a string.
|
|
150
|
+
*/
|
|
151
|
+
function extractLocalPort(parsed: Record<string, unknown>): number | undefined {
|
|
152
|
+
const config = parsed.config as Record<string, unknown> | undefined;
|
|
153
|
+
if (!config || typeof config !== "object") return undefined;
|
|
154
|
+
const local = config.local as Record<string, unknown> | undefined;
|
|
155
|
+
const defaults = config.defaults as Record<string, unknown> | undefined;
|
|
156
|
+
const raw = local && local.PORT !== undefined ? local.PORT : defaults?.PORT;
|
|
157
|
+
return coerceNumber(raw);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function coerceNumber(value: unknown): number | undefined {
|
|
161
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
162
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
163
|
+
const n = Number(value);
|
|
164
|
+
if (Number.isFinite(n)) return n;
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Default local health-check URL following the opctl DNS convention: opctl
|
|
171
|
+
* exposes each service on the host under a DNS name derived from stack.yml's
|
|
172
|
+
* `name`, routed to port 80. So when the local port is 80 (the opctl default)
|
|
173
|
+
* we use the bare-name form (`http://<name>/healthz`); a non-80 port is appended
|
|
174
|
+
* explicitly (`http://<name>:<port>/healthz`). This is what den's host-side
|
|
175
|
+
* health check actually reaches — NOT `http://localhost:<port>`, which only works
|
|
176
|
+
* if the dev op publishes a host port (most do not).
|
|
177
|
+
*/
|
|
178
|
+
export function defaultLocalHealthUrl(
|
|
179
|
+
repoName: string,
|
|
180
|
+
localPort?: number,
|
|
181
|
+
): string {
|
|
182
|
+
const port = localPort ?? 80;
|
|
183
|
+
const host = port === 80 ? repoName : `${repoName}:${port}`;
|
|
184
|
+
return `http://${host}/healthz`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Deployment evidence (pulumi) ──────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
export interface PulumiEnvInfo {
|
|
190
|
+
/** Public app subdomain (e.g. "app" → app.dev.example.dev). */
|
|
191
|
+
appSubdomain?: string;
|
|
192
|
+
/** Internal-ALB marker (e.g. "control-plane-dev"). */
|
|
193
|
+
albSubdomain?: string;
|
|
194
|
+
/** The repo's OWN declared health path (alb_health_check_path). */
|
|
195
|
+
healthPath?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read deployment evidence from the repo's pulumi stack configs instead of
|
|
200
|
+
* assuming: `alb_health_check_path` is the repo's own declared health path;
|
|
201
|
+
* `app_subdomain` marks a PUBLIC app (app.<env>.example.dev); `alb_subdomain`
|
|
202
|
+
* without an app subdomain marks an internal-ALB service. Keys are namespaced
|
|
203
|
+
* ("PlatformApp:app_subdomain"), so we match on suffix.
|
|
204
|
+
*/
|
|
205
|
+
export function detectPulumiDeployment(
|
|
206
|
+
targetDir: string,
|
|
207
|
+
): Record<string, PulumiEnvInfo> {
|
|
208
|
+
const files: Record<string, string> = {
|
|
209
|
+
dev: "Pulumi.dev.yaml",
|
|
210
|
+
sandbox: "Pulumi.sandbox.yaml",
|
|
211
|
+
staging: "Pulumi.staging.yaml",
|
|
212
|
+
production: "Pulumi.prod.yaml",
|
|
213
|
+
};
|
|
214
|
+
const envs: Record<string, PulumiEnvInfo> = {};
|
|
215
|
+
for (const [env, file] of Object.entries(files)) {
|
|
216
|
+
const p = path.join(targetDir, "pulumi", file);
|
|
217
|
+
try {
|
|
218
|
+
if (!fs.existsSync(p)) continue;
|
|
219
|
+
const parsed = yaml.parse(fs.readFileSync(p, "utf-8")) as Record<
|
|
220
|
+
string,
|
|
221
|
+
unknown
|
|
222
|
+
> | null;
|
|
223
|
+
const cfg = (
|
|
224
|
+
parsed && typeof parsed.config === "object" ? parsed.config : parsed
|
|
225
|
+
) as Record<string, unknown> | null;
|
|
226
|
+
if (!cfg) continue;
|
|
227
|
+
const bySuffix = (suffix: string): string | undefined => {
|
|
228
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
229
|
+
if (k.endsWith(`:${suffix}`) && typeof v === "string") return v;
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
};
|
|
233
|
+
const info: PulumiEnvInfo = {
|
|
234
|
+
appSubdomain: bySuffix("app_subdomain"),
|
|
235
|
+
albSubdomain: bySuffix("alb_subdomain"),
|
|
236
|
+
healthPath: bySuffix("alb_health_check_path"),
|
|
237
|
+
};
|
|
238
|
+
if (info.appSubdomain || info.albSubdomain || info.healthPath) {
|
|
239
|
+
envs[env] = info;
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
/* unparseable stack file — no evidence for this env */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return envs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** "https://{repo}.internal.dev.example.dev/healthz" → "dev.example.dev" (env domain). */
|
|
249
|
+
function envDomainFromTemplate(template: string): string | null {
|
|
250
|
+
try {
|
|
251
|
+
const host = new URL(template.replaceAll("{repo}", "x")).hostname;
|
|
252
|
+
const i = host.indexOf(".internal.");
|
|
253
|
+
return i >= 0 ? host.slice(i + ".internal.".length) : null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Build deployed-env health URLs from EVIDENCE only — envs with no pulumi
|
|
261
|
+
* markers get nothing (no fabricated hosts). Public apps (`app_subdomain`) get
|
|
262
|
+
* `https://<sub>.<env-domain><path>`; internal-ALB services get the config
|
|
263
|
+
* template with the declared health path. The declared path wins; else the
|
|
264
|
+
* locally detected path; else /healthz.
|
|
265
|
+
*/
|
|
266
|
+
export function buildEnvHealthUrls(
|
|
267
|
+
name: string,
|
|
268
|
+
pulumiEnvs: Record<string, PulumiEnvInfo>,
|
|
269
|
+
templates: Record<string, string> | undefined,
|
|
270
|
+
localPathHint?: string,
|
|
271
|
+
): Record<string, string> {
|
|
272
|
+
const urls: Record<string, string> = {};
|
|
273
|
+
for (const [env, info] of Object.entries(pulumiEnvs)) {
|
|
274
|
+
const template = templates?.[env];
|
|
275
|
+
const healthPath = info.healthPath ?? localPathHint ?? "/healthz";
|
|
276
|
+
if (info.appSubdomain) {
|
|
277
|
+
const domain = template ? envDomainFromTemplate(template) : null;
|
|
278
|
+
if (domain)
|
|
279
|
+
urls[env] = `https://${info.appSubdomain}.${domain}${healthPath}`;
|
|
280
|
+
} else if (info.albSubdomain && template) {
|
|
281
|
+
const u = new URL(template.replaceAll("{repo}", name));
|
|
282
|
+
u.pathname = healthPath;
|
|
283
|
+
urls[env] = u.toString();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return urls;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── CloudWatch log-group detection (deployed envs) ────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Detect each deployed env's CloudWatch log group by ASKING AWS
|
|
293
|
+
* (`aws logs describe-log-groups`) rather than assuming a naming convention —
|
|
294
|
+
* observed conventions differ per repo (`/jobs_runtime/dev`,
|
|
295
|
+
* `/aws/ecs/search-api/dev`, `/aws/ec2/platform__api_instance__dev`). Candidate
|
|
296
|
+
* prefixes are derived from the repo name; a group only counts on an EXACT
|
|
297
|
+
* match of `<candidate>` or `<candidate>/<envToken>` (so
|
|
298
|
+
* `/jobs_runtime_coldstart_test/dev` never shadows `/jobs_runtime/dev`).
|
|
299
|
+
* Envs without a working AWS session are skipped silently — these blocks only
|
|
300
|
+
* feed the /logs page's env dropdown, so absence just means "add later".
|
|
301
|
+
*/
|
|
302
|
+
export function detectCloudwatchLogGroups(
|
|
303
|
+
repoName: string,
|
|
304
|
+
awsProfiles: Record<string, string>,
|
|
305
|
+
opts: {
|
|
306
|
+
region?: string;
|
|
307
|
+
execImpl?: (cmd: string) => string;
|
|
308
|
+
} = {},
|
|
309
|
+
): Record<string, string> {
|
|
310
|
+
const region = opts.region ?? "us-east-1";
|
|
311
|
+
const exec =
|
|
312
|
+
opts.execImpl ??
|
|
313
|
+
((cmd: string): string =>
|
|
314
|
+
execSync(cmd, {
|
|
315
|
+
encoding: "utf-8",
|
|
316
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
317
|
+
timeout: 5_000,
|
|
318
|
+
}));
|
|
319
|
+
const snake = repoName.replaceAll("-", "_");
|
|
320
|
+
const candidates = [
|
|
321
|
+
`/${snake}`,
|
|
322
|
+
`/${repoName}`,
|
|
323
|
+
`/aws/ecs/${repoName}`,
|
|
324
|
+
`/aws/ecs/${snake}`,
|
|
325
|
+
`/aws/ec2/${snake}`,
|
|
326
|
+
];
|
|
327
|
+
const found: Record<string, string> = {};
|
|
328
|
+
for (const [env, profile] of Object.entries(awsProfiles)) {
|
|
329
|
+
const envToken = env === "production" ? "prod" : env;
|
|
330
|
+
for (const prefix of candidates) {
|
|
331
|
+
try {
|
|
332
|
+
const raw = exec(
|
|
333
|
+
`aws logs describe-log-groups --log-group-name-prefix "${prefix}" ` +
|
|
334
|
+
`--profile "${profile}" --region "${region}" ` +
|
|
335
|
+
`--query 'logGroups[].logGroupName' --output json`,
|
|
336
|
+
);
|
|
337
|
+
const names = JSON.parse(raw) as string[];
|
|
338
|
+
const hit = names.find(
|
|
339
|
+
(n) => n === prefix || n === `${prefix}/${envToken}`,
|
|
340
|
+
);
|
|
341
|
+
if (hit) {
|
|
342
|
+
found[env] = hit;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
// No session / expired / no such groups — try next candidate; a hard
|
|
347
|
+
// auth failure will fail every candidate and the env is skipped.
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return found;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Live local health detection ───────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Detect the repo's real local health endpoint by probing it (opctl DNS),
|
|
358
|
+
* instead of assuming /healthz: the pulumi-declared path first, then common
|
|
359
|
+
* conventions. Only a 2xx counts. Returns null when the service isn't
|
|
360
|
+
* reachable (first network error aborts — no point trying more paths) or
|
|
361
|
+
* nothing responds 2xx.
|
|
362
|
+
*/
|
|
363
|
+
export async function detectLiveHealthPath(
|
|
364
|
+
repoName: string,
|
|
365
|
+
opts: {
|
|
366
|
+
port?: number;
|
|
367
|
+
declaredPath?: string;
|
|
368
|
+
fetchImpl?: typeof fetch;
|
|
369
|
+
} = {},
|
|
370
|
+
): Promise<{ path: string; status: number } | null> {
|
|
371
|
+
const f = opts.fetchImpl ?? fetch;
|
|
372
|
+
const host =
|
|
373
|
+
opts.port && opts.port !== 80 ? `${repoName}:${opts.port}` : repoName;
|
|
374
|
+
const candidates = [
|
|
375
|
+
...new Set(
|
|
376
|
+
[
|
|
377
|
+
opts.declaredPath,
|
|
378
|
+
"/healthz",
|
|
379
|
+
"/api/health",
|
|
380
|
+
"/health",
|
|
381
|
+
"/livez",
|
|
382
|
+
"/readyz",
|
|
383
|
+
].filter((p): p is string => !!p),
|
|
384
|
+
),
|
|
385
|
+
];
|
|
386
|
+
for (const p of candidates) {
|
|
387
|
+
try {
|
|
388
|
+
const ctl = new AbortController();
|
|
389
|
+
const t = setTimeout(() => ctl.abort(), 1500);
|
|
390
|
+
const res = await f(`http://${host}${p}`, {
|
|
391
|
+
signal: ctl.signal,
|
|
392
|
+
redirect: "manual",
|
|
393
|
+
});
|
|
394
|
+
clearTimeout(t);
|
|
395
|
+
if (res.status >= 200 && res.status < 300)
|
|
396
|
+
return { path: p, status: res.status };
|
|
397
|
+
} catch {
|
|
398
|
+
return null; // host unreachable — service not running, stop probing
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── buildInitManifest ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Build a RawManifest from the user's init choices.
|
|
408
|
+
* Pure function — no I/O, easily testable.
|
|
409
|
+
*/
|
|
410
|
+
export function buildInitManifest(opts: InitOptions): InitManifest {
|
|
411
|
+
const manifest: InitManifest = {
|
|
412
|
+
version: 1,
|
|
413
|
+
name: opts.name,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// Only set entrypoint for multi-service repos with an explicit choice
|
|
417
|
+
if (opts.services.length > 1 && opts.entrypoint) {
|
|
418
|
+
manifest.entrypoint = opts.entrypoint;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Prefer the real make targets when detected (adds `setup`, drops absent ones);
|
|
422
|
+
// otherwise fall back to the conventional up/down/restart scaffold.
|
|
423
|
+
const STANDARD = ["up", "down", "setup", "restart"];
|
|
424
|
+
const detected = opts.makeTargets
|
|
425
|
+
? STANDARD.filter((t) => opts.makeTargets!.includes(t))
|
|
426
|
+
: [];
|
|
427
|
+
const cmdNames = detected.length > 0 ? detected : ["up", "down", "restart"];
|
|
428
|
+
const localCommands: Record<string, string> = {};
|
|
429
|
+
for (const t of cmdNames) localCommands[t] = `make ${t}`;
|
|
430
|
+
|
|
431
|
+
if (opts.services.length > 0) {
|
|
432
|
+
manifest.services = {};
|
|
433
|
+
for (const svc of opts.services) {
|
|
434
|
+
const serviceEntry: NonNullable<RawManifest["services"]>[string] = {
|
|
435
|
+
commands: {
|
|
436
|
+
local: { ...localCommands },
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
if (svc.healthUrl) {
|
|
441
|
+
serviceEntry.health = { local: { url: svc.healthUrl } };
|
|
442
|
+
} else if (svc.healthCmd) {
|
|
443
|
+
serviceEntry.health = {
|
|
444
|
+
local: { type: "shell", cmd: svc.healthCmd },
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Scaffold deployed-env health URLs on the primary service (single
|
|
449
|
+
// service, or the multi-service entrypoint) from the config templates.
|
|
450
|
+
const isPrimary =
|
|
451
|
+
opts.services.length === 1 || svc.name === opts.entrypoint;
|
|
452
|
+
if (isPrimary && opts.envHealthUrls) {
|
|
453
|
+
serviceEntry.health = serviceEntry.health ?? {};
|
|
454
|
+
for (const [env, template] of Object.entries(opts.envHealthUrls)) {
|
|
455
|
+
if (env === "local") continue; // local comes from the user's answer
|
|
456
|
+
serviceEntry.health[env as Environment] = {
|
|
457
|
+
url: template.replaceAll("{repo}", opts.name),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (isPrimary && opts.envLogGroups) {
|
|
463
|
+
for (const [env, logGroup] of Object.entries(opts.envLogGroups)) {
|
|
464
|
+
if (env === "local") continue;
|
|
465
|
+
serviceEntry.logs = serviceEntry.logs ?? {};
|
|
466
|
+
serviceEntry.logs[env as Environment] = {
|
|
467
|
+
type: "cloudwatch",
|
|
468
|
+
logGroup,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
manifest.services![svc.name] = serviceEntry;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return manifest;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ── mergeManifestPreserving ───────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Merge a freshly generated init manifest over an existing one WITHOUT losing
|
|
484
|
+
* hand-authored content. Init only OWNS what it generates; everything else in
|
|
485
|
+
* the existing manifest survives regeneration:
|
|
486
|
+
* - top-level sections init doesn't produce (smoke, performance, synthetics,
|
|
487
|
+
* github, unknown future keys) carry over verbatim;
|
|
488
|
+
* - `commands`: per-env merge, existing entries win (extra targets like
|
|
489
|
+
* preflight/seed/migrate and hand-tuned cmds survive the make scaffold);
|
|
490
|
+
* - `health`: the generated `local` wins (the user just answered the prompt);
|
|
491
|
+
* for non-local envs, EXISTING entries win over generated defaults (e.g.
|
|
492
|
+
* shop's public api.dev.example.dev vs the internal-ALB template);
|
|
493
|
+
* - `logs`: per-env merge, existing entries win (cloudwatch groups etc.);
|
|
494
|
+
* - `depends_on`: preserved (init never generates it).
|
|
495
|
+
* A single existing service matches a single generated service even when the
|
|
496
|
+
* key was renamed by detection (services.shop-web → services.app).
|
|
497
|
+
* Services only in the existing manifest are NOT resurrected — the new service
|
|
498
|
+
* set is the user's explicit choice; runInit warns about the drop.
|
|
499
|
+
* Pure function — easily testable.
|
|
500
|
+
*/
|
|
501
|
+
export function mergeManifestPreserving(
|
|
502
|
+
existing: RawManifest | null,
|
|
503
|
+
generated: RawManifest,
|
|
504
|
+
): RawManifest {
|
|
505
|
+
if (!existing) return generated;
|
|
506
|
+
|
|
507
|
+
// Top-level sections init doesn't generate (smoke, performance,
|
|
508
|
+
// synthetics, github, and anything a future schema adds) are
|
|
509
|
+
// hand-authored — carry them over verbatim.
|
|
510
|
+
const OWNED_KEYS = new Set(["version", "name", "entrypoint", "services"]);
|
|
511
|
+
const existingRec = existing as unknown as Record<string, unknown>;
|
|
512
|
+
const generatedRec = generated as unknown as Record<string, unknown>;
|
|
513
|
+
for (const [key, value] of Object.entries(existingRec)) {
|
|
514
|
+
if (OWNED_KEYS.has(key) || value === undefined) continue;
|
|
515
|
+
if (generatedRec[key] === undefined) generatedRec[key] = value;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (!existing.services || !generated.services) return generated;
|
|
519
|
+
|
|
520
|
+
const oldKeys = Object.keys(existing.services);
|
|
521
|
+
const newKeys = Object.keys(generated.services);
|
|
522
|
+
|
|
523
|
+
for (const [name, genSvc] of Object.entries(generated.services)) {
|
|
524
|
+
// Single-service repos: match across a detection-driven key rename
|
|
525
|
+
// (e.g. services.shop-web regenerated as services.app) — otherwise the
|
|
526
|
+
// rename silently discards every hand-authored block.
|
|
527
|
+
const oldSvc =
|
|
528
|
+
existing.services[name] ??
|
|
529
|
+
(oldKeys.length === 1 && newKeys.length === 1
|
|
530
|
+
? existing.services[oldKeys[0]]
|
|
531
|
+
: undefined);
|
|
532
|
+
if (!oldSvc) continue;
|
|
533
|
+
|
|
534
|
+
// commands: per-env merge — hand-authored commands win over the make
|
|
535
|
+
// scaffold and extra targets (preflight, seed, migrate, …) survive.
|
|
536
|
+
if (oldSvc.commands) {
|
|
537
|
+
genSvc.commands = genSvc.commands ?? {};
|
|
538
|
+
for (const [env, cmds] of Object.entries(oldSvc.commands)) {
|
|
539
|
+
if (!cmds) continue;
|
|
540
|
+
genSvc.commands[env as Environment] = {
|
|
541
|
+
...(genSvc.commands[env as Environment] ?? {}),
|
|
542
|
+
...cmds,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (oldSvc.health) {
|
|
548
|
+
const merged = { ...(genSvc.health ?? {}) };
|
|
549
|
+
for (const [env, cfg] of Object.entries(oldSvc.health)) {
|
|
550
|
+
if (env === "local" || !cfg) continue; // freshly answered — generated wins
|
|
551
|
+
merged[env as Environment] = cfg; // hand-authored env URL wins over the template
|
|
552
|
+
}
|
|
553
|
+
if (Object.keys(merged).length > 0) genSvc.health = merged;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (oldSvc.logs) {
|
|
557
|
+
// Per-env merge, existing hand-authored entries win over detected ones.
|
|
558
|
+
genSvc.logs = { ...(genSvc.logs ?? {}), ...oldSvc.logs };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// depends_on is never generated — preserve explicit wait-gates etc.
|
|
562
|
+
if (oldSvc.depends_on && !genSvc.depends_on) {
|
|
563
|
+
genSvc.depends_on = oldSvc.depends_on;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return generated;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ── formatManifestYaml ────────────────────────────────────────────────────────
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Serialise a manifest to a pretty YAML string with a header comment.
|
|
573
|
+
* Ensures `version` appears first in the output.
|
|
574
|
+
*/
|
|
575
|
+
export function formatManifestYaml(manifest: Record<string, unknown>): string {
|
|
576
|
+
const header = [
|
|
577
|
+
`# ${product().manifestYml} — service manifest (format: see the Den README)`,
|
|
578
|
+
"#",
|
|
579
|
+
"# Dependency ORDER is derived automatically from real signals (SDK imports,",
|
|
580
|
+
"# *_URL env vars, IaC refs, EventBridge rules) — you do NOT need depends_on.",
|
|
581
|
+
"# Add depends_on only for an explicit `wait: true` readiness gate.",
|
|
582
|
+
"",
|
|
583
|
+
].join("\n");
|
|
584
|
+
|
|
585
|
+
// Hoist version and name to the top of the document
|
|
586
|
+
const { version, name, ...rest } = manifest;
|
|
587
|
+
const ordered: Record<string, unknown> = {};
|
|
588
|
+
if (version !== undefined) ordered.version = version;
|
|
589
|
+
if (name !== undefined) ordered.name = name;
|
|
590
|
+
Object.assign(ordered, rest);
|
|
591
|
+
|
|
592
|
+
return header + yaml.stringify(ordered, { lineWidth: 100 });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ── Interactive wizard ────────────────────────────────────────────────────────
|
|
596
|
+
// The functions below are not unit-tested — they drive readline I/O.
|
|
597
|
+
|
|
598
|
+
/* istanbul ignore next */
|
|
599
|
+
function prompt(rl: readline.Interface, question: string): Promise<string> {
|
|
600
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Interactive den-init wizard. Writes a .den.yml to the target directory.
|
|
605
|
+
*/
|
|
606
|
+
/* istanbul ignore next */
|
|
607
|
+
export async function runInit(
|
|
608
|
+
// cwd-appropriate BY DESIGN: init scaffolds .den.yml where the user is
|
|
609
|
+
// standing — but honor the canonical invoke-cwd signal (bin/den exports
|
|
610
|
+
// DEN_INVOKE_CWD) in case a spawned process repointed its own cwd.
|
|
611
|
+
targetDir: string = getInvokeCwd(),
|
|
612
|
+
): Promise<void> {
|
|
613
|
+
const rl = readline.createInterface({
|
|
614
|
+
input: process.stdin,
|
|
615
|
+
output: process.stdout,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const denYmlPath = path.join(targetDir, `${product().manifestYml}`);
|
|
620
|
+
|
|
621
|
+
// Guard: don't silently overwrite
|
|
622
|
+
if (fs.existsSync(denYmlPath)) {
|
|
623
|
+
const answer = await prompt(
|
|
624
|
+
rl,
|
|
625
|
+
`${product().manifestYml} already exists. Overwrite? (y/N): `,
|
|
626
|
+
);
|
|
627
|
+
if (!answer.match(/^y$/i)) {
|
|
628
|
+
console.log("Aborted.");
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Read the existing manifest (if any) BEFORE overwriting — hand-authored
|
|
634
|
+
// env-specific blocks (deployed health URLs, cloudwatch logs) are merged
|
|
635
|
+
// back into the regenerated manifest so re-running init never loses them.
|
|
636
|
+
let existingManifest: RawManifest | null = null;
|
|
637
|
+
try {
|
|
638
|
+
existingManifest = yaml.parse(
|
|
639
|
+
fs.readFileSync(denYmlPath, "utf-8"),
|
|
640
|
+
) as RawManifest;
|
|
641
|
+
} catch {
|
|
642
|
+
/* no existing manifest / unparseable — nothing to preserve */
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
console.log(
|
|
646
|
+
`\n ${product().cli} init — scaffold a new ${product().manifestYml}\n`,
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// Detect repo name from directory name
|
|
650
|
+
const dirName = path.basename(targetDir);
|
|
651
|
+
|
|
652
|
+
// Check for .stack.yml
|
|
653
|
+
const stackDetection = detectStackConfig(targetDir);
|
|
654
|
+
let serviceNames: string[] = [];
|
|
655
|
+
|
|
656
|
+
if (stackDetection?.localPort !== undefined) {
|
|
657
|
+
console.log(
|
|
658
|
+
` Detected local port ${stackDetection.localPort} (stack.yml config.local.PORT)`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (stackDetection && stackDetection.services.length > 0) {
|
|
662
|
+
console.log(
|
|
663
|
+
` Detected .stack.yml with services: ${stackDetection.services.join(", ")}`,
|
|
664
|
+
);
|
|
665
|
+
const useStack = await prompt(
|
|
666
|
+
rl,
|
|
667
|
+
" Import service config from .stack.yml? (Y/n): ",
|
|
668
|
+
);
|
|
669
|
+
if (!useStack.match(/^n$/i)) {
|
|
670
|
+
serviceNames = stackDetection.services;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const name = await prompt(rl, ` Repo name [${dirName}]: `);
|
|
675
|
+
const resolvedName = name.trim() || dirName;
|
|
676
|
+
|
|
677
|
+
if (serviceNames.length === 0) {
|
|
678
|
+
const svcsInput = await prompt(
|
|
679
|
+
rl,
|
|
680
|
+
" Service names (comma-separated, e.g. api,worker) [api]: ",
|
|
681
|
+
);
|
|
682
|
+
serviceNames = svcsInput.trim()
|
|
683
|
+
? svcsInput
|
|
684
|
+
.split(",")
|
|
685
|
+
.map((s) => s.trim())
|
|
686
|
+
.filter(Boolean)
|
|
687
|
+
: ["api"];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Entrypoint for multi-service repos
|
|
691
|
+
let entrypoint: string | undefined;
|
|
692
|
+
if (serviceNames.length > 1) {
|
|
693
|
+
const ep = await prompt(
|
|
694
|
+
rl,
|
|
695
|
+
` Front-door service (entrypoint) [${serviceNames[0]}]: `,
|
|
696
|
+
);
|
|
697
|
+
entrypoint = ep.trim() || serviceNames[0];
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Health evidence — detect, don't assume:
|
|
701
|
+
// 1. pulumi alb_health_check_path = the repo's OWN declared health path
|
|
702
|
+
// 2. live probe of the running service via opctl DNS (2xx wins)
|
|
703
|
+
// 3. /healthz convention only as the last resort
|
|
704
|
+
const pulumiEnvs = detectPulumiDeployment(targetDir);
|
|
705
|
+
const declaredPath = Object.values(pulumiEnvs).find(
|
|
706
|
+
(e) => e.healthPath,
|
|
707
|
+
)?.healthPath;
|
|
708
|
+
const live = await detectLiveHealthPath(resolvedName, {
|
|
709
|
+
port: stackDetection?.localPort,
|
|
710
|
+
declaredPath,
|
|
711
|
+
});
|
|
712
|
+
if (live) {
|
|
713
|
+
console.log(
|
|
714
|
+
` Detected live health endpoint: ${live.path} (HTTP ${live.status})`,
|
|
715
|
+
);
|
|
716
|
+
} else if (declaredPath) {
|
|
717
|
+
console.log(
|
|
718
|
+
` Using pulumi-declared health path: ${declaredPath} (service not running — unverified)`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Health check per service. The primary service (single, or the entrypoint)
|
|
723
|
+
// defaults to the DETECTED path; sidecars fall back to the opctl DNS
|
|
724
|
+
// convention (http://<name>/healthz, port 80) using the stack.yml port.
|
|
725
|
+
const services: ServiceInitConfig[] = [];
|
|
726
|
+
for (const svcName of serviceNames) {
|
|
727
|
+
const svcPort =
|
|
728
|
+
stackDetection?.ports?.[svcName] ?? stackDetection?.localPort;
|
|
729
|
+
const isPrimary =
|
|
730
|
+
serviceNames.length === 1 || svcName === (entrypoint ?? "");
|
|
731
|
+
const detectedPath = live?.path ?? declaredPath;
|
|
732
|
+
const port = svcPort ?? 80;
|
|
733
|
+
const host = port === 80 ? resolvedName : `${resolvedName}:${port}`;
|
|
734
|
+
const defaultUrl =
|
|
735
|
+
isPrimary && detectedPath
|
|
736
|
+
? `http://${host}${detectedPath}`
|
|
737
|
+
: defaultLocalHealthUrl(resolvedName, svcPort);
|
|
738
|
+
const health = await prompt(
|
|
739
|
+
rl,
|
|
740
|
+
` Health check for "${svcName}" — URL or shell cmd [${defaultUrl}]: `,
|
|
741
|
+
);
|
|
742
|
+
const resolved = health.trim() || defaultUrl;
|
|
743
|
+
if (resolved.startsWith("http")) {
|
|
744
|
+
services.push({ name: svcName, healthUrl: resolved });
|
|
745
|
+
} else {
|
|
746
|
+
services.push({ name: svcName, healthCmd: resolved });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Reflect the repo's actual make targets in commands.local (adds setup,
|
|
751
|
+
// drops targets that don't exist) instead of a blind up/down/restart default.
|
|
752
|
+
const makeTargets = listMakeTargets(targetDir).targets;
|
|
753
|
+
if (makeTargets.length > 0) {
|
|
754
|
+
const std = ["up", "down", "setup", "restart"].filter((t) =>
|
|
755
|
+
makeTargets.includes(t),
|
|
756
|
+
);
|
|
757
|
+
if (std.length > 0) {
|
|
758
|
+
console.log(` Detected make targets: ${std.join(", ")}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Deployed-env health URLs from EVIDENCE (pulumi app_subdomain /
|
|
763
|
+
// alb_subdomain / alb_health_check_path) — the TEST scope resolves per-env
|
|
764
|
+
// base URLs from these manifest entries. Envs with no evidence get nothing
|
|
765
|
+
// (no fabricated hosts); hand edits survive re-init via the merge below.
|
|
766
|
+
const envHealthUrls = buildEnvHealthUrls(
|
|
767
|
+
resolvedName,
|
|
768
|
+
pulumiEnvs,
|
|
769
|
+
readDenConfig().env_health_urls,
|
|
770
|
+
live?.path ?? declaredPath,
|
|
771
|
+
);
|
|
772
|
+
if (Object.keys(envHealthUrls).length > 0) {
|
|
773
|
+
const kind = Object.values(pulumiEnvs).some((e) => e.appSubdomain)
|
|
774
|
+
? "public app host"
|
|
775
|
+
: "internal ALB";
|
|
776
|
+
console.log(
|
|
777
|
+
` Deployed health URLs (${kind}, from pulumi): ${Object.keys(envHealthUrls).join(", ")}`,
|
|
778
|
+
);
|
|
779
|
+
} else {
|
|
780
|
+
console.log(
|
|
781
|
+
" No deployed-host evidence in pulumi/ — skipping health.<env> URLs (add by hand; they survive re-init).",
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// CloudWatch log groups per deployed env — asked of AWS directly (exact
|
|
786
|
+
// matches only), skipped silently for envs without a live session. These
|
|
787
|
+
// feed the /logs page's env dropdown.
|
|
788
|
+
const envLogGroups = detectCloudwatchLogGroups(
|
|
789
|
+
resolvedName,
|
|
790
|
+
loadAwsProfiles() as Record<string, string>,
|
|
791
|
+
);
|
|
792
|
+
if (Object.keys(envLogGroups).length > 0) {
|
|
793
|
+
console.log(
|
|
794
|
+
` Detected CloudWatch log groups: ${Object.entries(envLogGroups)
|
|
795
|
+
.map(([e, g]) => `${e}=${g}`)
|
|
796
|
+
.join(" ")}`,
|
|
797
|
+
);
|
|
798
|
+
} else {
|
|
799
|
+
console.log(
|
|
800
|
+
" No CloudWatch log groups detected (no live AWS session or no matching groups) — add logs.<env> by hand if needed.",
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const manifest = mergeManifestPreserving(
|
|
805
|
+
existingManifest,
|
|
806
|
+
buildInitManifest({
|
|
807
|
+
name: resolvedName,
|
|
808
|
+
services,
|
|
809
|
+
entrypoint,
|
|
810
|
+
makeTargets,
|
|
811
|
+
envHealthUrls:
|
|
812
|
+
Object.keys(envHealthUrls).length > 0 ? envHealthUrls : undefined,
|
|
813
|
+
envLogGroups:
|
|
814
|
+
Object.keys(envLogGroups).length > 0 ? envLogGroups : undefined,
|
|
815
|
+
}),
|
|
816
|
+
);
|
|
817
|
+
const yamlContent = formatManifestYaml(
|
|
818
|
+
manifest as unknown as Record<string, unknown>,
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
// Regeneration transparency: name every existing service that is not in
|
|
822
|
+
// the new manifest (renamed = settings carried over; dropped = gone).
|
|
823
|
+
if (existingManifest?.services && manifest.services) {
|
|
824
|
+
const keptKeys = Object.keys(manifest.services);
|
|
825
|
+
const missing = Object.keys(existingManifest.services).filter(
|
|
826
|
+
(k) => !keptKeys.includes(k),
|
|
827
|
+
);
|
|
828
|
+
const renamed =
|
|
829
|
+
missing.length === 1 &&
|
|
830
|
+
Object.keys(existingManifest.services).length === 1 &&
|
|
831
|
+
keptKeys.length === 1;
|
|
832
|
+
if (renamed) {
|
|
833
|
+
console.log(
|
|
834
|
+
` ⚠ service key renamed: ${missing[0]} → ${keptKeys[0]} (existing settings carried over)`,
|
|
835
|
+
);
|
|
836
|
+
} else if (missing.length > 0) {
|
|
837
|
+
console.log(
|
|
838
|
+
` ⚠ services dropped from the manifest: ${missing.join(", ")} — restore from git if unintended`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
fs.writeFileSync(denYmlPath, yamlContent, "utf-8");
|
|
844
|
+
console.log(`\n ✓ Wrote ${denYmlPath}\n`);
|
|
845
|
+
console.log(" Next steps:");
|
|
846
|
+
console.log(
|
|
847
|
+
` make ${product().cli}-register — add this repo to your service catalog`,
|
|
848
|
+
);
|
|
849
|
+
console.log(
|
|
850
|
+
` make ${product().cli}-validate — validate the manifest\n`,
|
|
851
|
+
);
|
|
852
|
+
} finally {
|
|
853
|
+
rl.close();
|
|
854
|
+
}
|
|
855
|
+
}
|