@monster-orange/den 0.0.0 → 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 +165 -1
- 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 +99 -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,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live log tailing for a local service — the CLI-side counterpart of the web
|
|
3
|
+
* /api/service/taillog SSE endpoint. Source resolution, in order:
|
|
4
|
+
* 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
|
|
5
|
+
* 2. Conventional container derivation: a running `opctl_<base>_<hash>`
|
|
6
|
+
* container → `docker logs -f` — real runtime logs with zero manifest config
|
|
7
|
+
* 3. The den daemon startup log (~/.den/logs/<repo>/<subsystem>.log) —
|
|
8
|
+
* last resort, covers host-run processes with no container
|
|
9
|
+
* A source whose command exits within the quick-exit window (container not
|
|
10
|
+
* found, stale filter) is treated as broken and the next tier takes over.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import { DAEMON_QUICK_EXIT_MS, LIVE_TAIL_LINES } from "../constants.js";
|
|
15
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
16
|
+
import { findConventionalContainer } from "./opctl.js";
|
|
17
|
+
import {
|
|
18
|
+
getServiceLogPath,
|
|
19
|
+
spawnStreaming,
|
|
20
|
+
} from "../process/spawn-streaming.js";
|
|
21
|
+
import type { LogSource, ResolvedService } from "../types/index.js";
|
|
22
|
+
|
|
23
|
+
export interface TailSource {
|
|
24
|
+
kind: "manifest" | "docker" | "file";
|
|
25
|
+
/** Executable (argv mode) or a full shell command (when args is empty). */
|
|
26
|
+
cmd: string;
|
|
27
|
+
/**
|
|
28
|
+
* Argv for cmd. Empty → cmd runs through the shell (manifest `logs.local`
|
|
29
|
+
* commands are arbitrary shell by design). Den-BUILT commands always use
|
|
30
|
+
* argv mode so container/subsystem names from manifests or docker output
|
|
31
|
+
* can never be shell-interpreted (`$(…)`, backticks, quotes).
|
|
32
|
+
*/
|
|
33
|
+
args: string[];
|
|
34
|
+
/** Working directory for the command (manifest commands run in the repo). */
|
|
35
|
+
cwd?: string;
|
|
36
|
+
/** Human-readable description for the stream header. */
|
|
37
|
+
label: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TailSourceInputs {
|
|
41
|
+
repoPath?: string;
|
|
42
|
+
/** The subsystem's manifest `logs.local` entry, if any. */
|
|
43
|
+
logSource?: LogSource;
|
|
44
|
+
/** A running conventional opctl container name, if any. */
|
|
45
|
+
container: string | null;
|
|
46
|
+
daemonLogPath: string;
|
|
47
|
+
daemonLogExists: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ordered tail-source candidates (best source first). Pure — see resolveTailSources. */
|
|
51
|
+
export function tailSourceCandidates(
|
|
52
|
+
inputs: TailSourceInputs,
|
|
53
|
+
lines: number = LIVE_TAIL_LINES,
|
|
54
|
+
): TailSource[] {
|
|
55
|
+
const candidates: TailSource[] = [];
|
|
56
|
+
if (inputs.logSource?.type === "shell" && inputs.logSource.cmd) {
|
|
57
|
+
candidates.push({
|
|
58
|
+
kind: "manifest",
|
|
59
|
+
cmd: inputs.logSource.cmd,
|
|
60
|
+
args: [],
|
|
61
|
+
cwd: inputs.repoPath,
|
|
62
|
+
label: `manifest log command: ${inputs.logSource.cmd}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (inputs.container) {
|
|
66
|
+
candidates.push({
|
|
67
|
+
kind: "docker",
|
|
68
|
+
cmd: "docker",
|
|
69
|
+
args: ["logs", "-f", "--tail", String(lines), inputs.container],
|
|
70
|
+
label: `container logs: ${inputs.container}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (inputs.daemonLogExists) {
|
|
74
|
+
candidates.push({
|
|
75
|
+
kind: "file",
|
|
76
|
+
cmd: "tail",
|
|
77
|
+
args: ["-F", "-n", String(lines), inputs.daemonLogPath],
|
|
78
|
+
label: `startup log: ${inputs.daemonLogPath}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return candidates;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Gather live inputs for `resolved` and build its tail-source candidates. */
|
|
85
|
+
export function resolveTailSources(resolved: ResolvedService): TailSource[] {
|
|
86
|
+
const manifest = readManifest(resolved.repoPath);
|
|
87
|
+
const logSource = manifest?.services?.[resolved.subsystem]?.logs?.local;
|
|
88
|
+
const container = findConventionalContainer(
|
|
89
|
+
resolved.repoName,
|
|
90
|
+
resolved.repoPath,
|
|
91
|
+
resolved.subsystem,
|
|
92
|
+
);
|
|
93
|
+
const daemonLogPath = getServiceLogPath(
|
|
94
|
+
resolved.repoName,
|
|
95
|
+
resolved.subsystem,
|
|
96
|
+
);
|
|
97
|
+
return tailSourceCandidates({
|
|
98
|
+
repoPath: resolved.repoPath,
|
|
99
|
+
logSource,
|
|
100
|
+
container,
|
|
101
|
+
daemonLogPath,
|
|
102
|
+
daemonLogExists: fs.existsSync(daemonLogPath),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FollowCallbacks {
|
|
107
|
+
onLine: (line: string) => void;
|
|
108
|
+
/** Stream headers / end-of-stream notices (rendered dim by the CLI). */
|
|
109
|
+
onMeta: (line: string) => void;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface FollowHandle {
|
|
113
|
+
/** Resolves with the final stream's exit code (0 after stop()). */
|
|
114
|
+
done: Promise<number>;
|
|
115
|
+
/** Stop tailing without stopping the service. */
|
|
116
|
+
stop: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface FollowDeps {
|
|
120
|
+
spawn?: typeof spawnStreaming;
|
|
121
|
+
quickExitMs?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Follow the first working candidate, falling through the tiers when a
|
|
126
|
+
* command quick-exits. Lines seen before a source survives the quick-exit
|
|
127
|
+
* window are buffered (flushed on confirm, discarded on fallback) so a broken
|
|
128
|
+
* tier's error spew never reaches the caller.
|
|
129
|
+
*/
|
|
130
|
+
export function followTailSources(
|
|
131
|
+
candidates: TailSource[],
|
|
132
|
+
{ onLine, onMeta }: FollowCallbacks,
|
|
133
|
+
deps: FollowDeps = {},
|
|
134
|
+
): FollowHandle {
|
|
135
|
+
const spawn = deps.spawn ?? spawnStreaming;
|
|
136
|
+
const quickExitMs = deps.quickExitMs ?? DAEMON_QUICK_EXIT_MS;
|
|
137
|
+
|
|
138
|
+
let stopped = false;
|
|
139
|
+
let current: ReturnType<typeof spawnStreaming> | null = null;
|
|
140
|
+
let resolveDone!: (code: number) => void;
|
|
141
|
+
const done = new Promise<number>((resolve) => (resolveDone = resolve));
|
|
142
|
+
|
|
143
|
+
const tryCandidate = (index: number): void => {
|
|
144
|
+
if (index >= candidates.length) {
|
|
145
|
+
onMeta("── no live log stream available ──");
|
|
146
|
+
resolveDone(1);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const source = candidates[index];
|
|
150
|
+
const buffer: string[] = [];
|
|
151
|
+
let confirmed = false;
|
|
152
|
+
let quickTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
|
153
|
+
quickTimer = null;
|
|
154
|
+
confirmed = true;
|
|
155
|
+
onMeta(`── ${source.label} ──`);
|
|
156
|
+
for (const line of buffer) onLine(line);
|
|
157
|
+
buffer.length = 0;
|
|
158
|
+
}, quickExitMs);
|
|
159
|
+
|
|
160
|
+
// Detached: the tail child leads its own process group, so stop() can
|
|
161
|
+
// SIGTERM the whole tree. Shell-wrapped commands (e.g. `docker logs -f
|
|
162
|
+
// $(docker ps …)`) keep `sh` as the parent — killing only its pid would
|
|
163
|
+
// orphan a live `docker logs -f`. The child's stdio pipes keep the event
|
|
164
|
+
// loop alive; killing it stops the stream, never the service.
|
|
165
|
+
current = spawn(
|
|
166
|
+
source.cmd,
|
|
167
|
+
source.args,
|
|
168
|
+
{ ...process.env },
|
|
169
|
+
(line) => (confirmed ? onLine(line) : buffer.push(line)),
|
|
170
|
+
(exitCode) => {
|
|
171
|
+
if (quickTimer !== null) {
|
|
172
|
+
clearTimeout(quickTimer);
|
|
173
|
+
quickTimer = null;
|
|
174
|
+
}
|
|
175
|
+
if (stopped) {
|
|
176
|
+
resolveDone(0);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!confirmed) {
|
|
180
|
+
buffer.length = 0;
|
|
181
|
+
tryCandidate(index + 1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
onMeta(`── log stream ended (${exitCode}) ──`);
|
|
185
|
+
resolveDone(exitCode);
|
|
186
|
+
},
|
|
187
|
+
source.cwd,
|
|
188
|
+
true,
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
tryCandidate(0);
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
done,
|
|
196
|
+
stop: (): void => {
|
|
197
|
+
stopped = true;
|
|
198
|
+
if (!current) return;
|
|
199
|
+
try {
|
|
200
|
+
// Negative pid → the child's whole process group (it is detached).
|
|
201
|
+
if (current.pid) process.kill(-current.pid, "SIGTERM");
|
|
202
|
+
else current.kill("SIGTERM");
|
|
203
|
+
} catch {
|
|
204
|
+
try {
|
|
205
|
+
current.kill("SIGTERM");
|
|
206
|
+
} catch {
|
|
207
|
+
/* already exited */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Makefile target discovery.
|
|
3
|
+
*
|
|
4
|
+
* `den graph`/`up` annotate each node with the make targets it exposes and
|
|
5
|
+
* warn when the ones den drives (`setup`/`up`/`down`) are missing. We parse
|
|
6
|
+
* the root `Makefile` plus any `include`d `*.mk` for target definitions —
|
|
7
|
+
* deliberately simple (no variable expansion), reading a fixed set of files.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
|
|
13
|
+
export interface MakeTargets {
|
|
14
|
+
/** Sorted, unique runnable target names. */
|
|
15
|
+
targets: string[];
|
|
16
|
+
/** Makefiles parsed, relative to repo root. */
|
|
17
|
+
files: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TARGET_LINE = /^([A-Za-z0-9][A-Za-z0-9_.-]*)[ \t]*:(?!=)/gm;
|
|
21
|
+
const INCLUDE_LINE = /^[ \t]*-?include[ \t]+(.+)$/gm;
|
|
22
|
+
|
|
23
|
+
function readSafe(abs: string): string | null {
|
|
24
|
+
try {
|
|
25
|
+
return fs.readFileSync(abs, "utf-8");
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectTargets(content: string, into: Set<string>): void {
|
|
32
|
+
for (const m of content.matchAll(TARGET_LINE)) {
|
|
33
|
+
const name = m[1];
|
|
34
|
+
if (name.startsWith(".")) continue; // .PHONY, .DEFAULT_GOAL, …
|
|
35
|
+
into.add(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse the root Makefile (+ statically-includable `*.mk`) for target names. */
|
|
40
|
+
export function listMakeTargets(repoPath: string): MakeTargets {
|
|
41
|
+
const rootMk = path.join(repoPath, "Makefile");
|
|
42
|
+
const rootContent = readSafe(rootMk);
|
|
43
|
+
if (rootContent === null) return { targets: [], files: [] };
|
|
44
|
+
|
|
45
|
+
const targets = new Set<string>();
|
|
46
|
+
const filesParsed: string[] = ["Makefile"];
|
|
47
|
+
collectTargets(rootContent, targets);
|
|
48
|
+
|
|
49
|
+
// Follow one level of `include`d makefiles with literal (non-variable) paths.
|
|
50
|
+
const seen = new Set<string>([rootMk]);
|
|
51
|
+
for (const inc of rootContent.matchAll(INCLUDE_LINE)) {
|
|
52
|
+
const spec = inc[1].trim();
|
|
53
|
+
if (spec.includes("$")) continue; // variable path — can't resolve statically
|
|
54
|
+
for (const rel of spec.split(/\s+/)) {
|
|
55
|
+
const abs = path.join(repoPath, rel);
|
|
56
|
+
if (seen.has(abs)) continue;
|
|
57
|
+
seen.add(abs);
|
|
58
|
+
const content = readSafe(abs);
|
|
59
|
+
if (content === null) continue;
|
|
60
|
+
filesParsed.push(rel);
|
|
61
|
+
collectTargets(content, targets);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { targets: [...targets].sort(), files: filesParsed };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Split `required` into those present in the repo's Makefile(s) and those missing. */
|
|
69
|
+
export function requireTargets(
|
|
70
|
+
repoPath: string,
|
|
71
|
+
required: string[],
|
|
72
|
+
): { present: string[]; missing: string[] } {
|
|
73
|
+
const have = new Set(listMakeTargets(repoPath).targets);
|
|
74
|
+
const present: string[] = [];
|
|
75
|
+
const missing: string[] = [];
|
|
76
|
+
for (const t of required) (have.has(t) ? present : missing).push(t);
|
|
77
|
+
return { present, missing };
|
|
78
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opctl runtime helpers.
|
|
3
|
+
*
|
|
4
|
+
* opctl, when installed, is a container control plane; Den leans on it for
|
|
5
|
+
* the parts that would otherwise need stale per-repo
|
|
6
|
+
* declarations:
|
|
7
|
+
* - shutting a service down → `opctl container down <name>` (invariant: every
|
|
8
|
+
* service's containers can be cleanly stopped this way)
|
|
9
|
+
* - identifying a service's containers → the `container.name` declared in the
|
|
10
|
+
* repo's dev/up op, captured at spawn time and/or parsed on demand.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync, execSync } from "child_process";
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
|
|
18
|
+
/** Candidate dev/up op manifests for a repo, in resolution order. */
|
|
19
|
+
function devOpManifestPaths(repoPath: string): string[] {
|
|
20
|
+
return [
|
|
21
|
+
path.join(repoPath, ".opspec", "dev", "op.yml"),
|
|
22
|
+
path.join(repoPath, ".opspec", "up", "op.yml"),
|
|
23
|
+
path.join(repoPath, ".opspec", "local", "up", "op.yml"), // infra-stack
|
|
24
|
+
path.join(repoPath, "apps", "backend", ".opspec", "up", "op.yml"), // monorepo (shop)
|
|
25
|
+
path.join(repoPath, "apps", "backend", ".opspec", "dev", "op.yml"),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Recursively collect every `container.name` string in an opctl op tree. */
|
|
30
|
+
function collectContainerNames(node: unknown, out: Set<string>): void {
|
|
31
|
+
if (!node || typeof node !== "object") return;
|
|
32
|
+
if (Array.isArray(node)) {
|
|
33
|
+
for (const item of node) collectContainerNames(item, out);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const obj = node as Record<string, unknown>;
|
|
37
|
+
const container = obj.container;
|
|
38
|
+
if (container && typeof container === "object" && !Array.isArray(container)) {
|
|
39
|
+
const name = (container as Record<string, unknown>).name;
|
|
40
|
+
if (typeof name === "string" && name.trim()) out.add(name.trim());
|
|
41
|
+
}
|
|
42
|
+
for (const value of Object.values(obj)) collectContainerNames(value, out);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The opctl container names a repo's dev/up op declares (e.g. ["links-api"]).
|
|
47
|
+
* These are the short `container.name` labels `opctl container down` matches on.
|
|
48
|
+
* Returns [] when no op manifest is found or it can't be parsed.
|
|
49
|
+
*/
|
|
50
|
+
export function devOpContainerNames(repoPath: string): string[] {
|
|
51
|
+
for (const manifestPath of devOpManifestPaths(repoPath)) {
|
|
52
|
+
try {
|
|
53
|
+
if (!fs.existsSync(manifestPath)) continue;
|
|
54
|
+
const op = parseYaml(fs.readFileSync(manifestPath, "utf-8"));
|
|
55
|
+
const names = new Set<string>();
|
|
56
|
+
collectContainerNames(op, names);
|
|
57
|
+
if (names.size > 0) return [...names];
|
|
58
|
+
} catch {
|
|
59
|
+
/* unparseable — try the next candidate */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let opctlAvailableCache: boolean | null = null;
|
|
66
|
+
|
|
67
|
+
/** Whether the `opctl` CLI is on PATH (cached for the process lifetime). */
|
|
68
|
+
export function isOpctlAvailable(): boolean {
|
|
69
|
+
if (opctlAvailableCache !== null) return opctlAvailableCache;
|
|
70
|
+
try {
|
|
71
|
+
execSync("command -v opctl", { stdio: "ignore" });
|
|
72
|
+
opctlAvailableCache = true;
|
|
73
|
+
} catch {
|
|
74
|
+
opctlAvailableCache = false;
|
|
75
|
+
}
|
|
76
|
+
return opctlAvailableCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Docker names opctl-spawned containers `opctl_<base>_<8-hex>`. Capture the
|
|
81
|
+
* base so down can find them even when `opctl container ls` is empty or
|
|
82
|
+
* opctl itself can't elevate (`sudo -E` rejected).
|
|
83
|
+
*/
|
|
84
|
+
const DOCKER_OPCTL_NAME = /^opctl_(.+)_([0-9a-f]{8})$/i;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether a docker container name is opctl's container for exactly `base`.
|
|
88
|
+
*
|
|
89
|
+
* A bare `startsWith("opctl_<base>_")` test is ambiguous whenever one base is a
|
|
90
|
+
* prefix of another that contains an underscore — base `api` would claim
|
|
91
|
+
* `opctl_api_v2_<hash>` — and these names feed `docker rm -f`, so a wrong match
|
|
92
|
+
* tears down someone else's service. Requiring the remainder to be a single
|
|
93
|
+
* trailing segment (opctl's hash never contains `_`) makes the base comparison
|
|
94
|
+
* exact without depending on the hash's length or alphabet.
|
|
95
|
+
*/
|
|
96
|
+
export function isOpctlContainerFor(name: string, base: string): boolean {
|
|
97
|
+
const prefix = `opctl_${base}_`;
|
|
98
|
+
if (!name.startsWith(prefix)) return false;
|
|
99
|
+
const suffix = name.slice(prefix.length);
|
|
100
|
+
return suffix.length > 0 && !suffix.includes("_");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function runningDockerNames(): string[] {
|
|
104
|
+
return execSync('docker ps --format "{{.Names}}"', {
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
107
|
+
timeout: 5_000,
|
|
108
|
+
})
|
|
109
|
+
.split("\n")
|
|
110
|
+
.map((l) => l.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Running opctl containers discovered via the docker socket (no sudo).
|
|
116
|
+
* `name` is the full docker name; `base` is the opctl `container.name`.
|
|
117
|
+
*/
|
|
118
|
+
export function listRunningDockerOpctlContainers(): {
|
|
119
|
+
name: string;
|
|
120
|
+
base: string;
|
|
121
|
+
}[] {
|
|
122
|
+
try {
|
|
123
|
+
const rows: { name: string; base: string }[] = [];
|
|
124
|
+
for (const name of runningDockerNames()) {
|
|
125
|
+
const m = name.match(DOCKER_OPCTL_NAME);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
rows.push({ name, base: m[1] });
|
|
128
|
+
}
|
|
129
|
+
return rows;
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Names of currently-running opctl containers.
|
|
137
|
+
*
|
|
138
|
+
* Prefers `opctl container ls` (NAME column, trailing `-<hash>` stripped to
|
|
139
|
+
* the base `container.name`), and unions in anything docker still shows as
|
|
140
|
+
* `opctl_<base>_<hash>`. Docker is the source of truth when opctl can't
|
|
141
|
+
* elevate or its ls is empty while containers keep running — exactly the
|
|
142
|
+
* failure mode that made links-api / notes-api look "already stopped"
|
|
143
|
+
* (or report a successful scripted down) while their containers stayed up.
|
|
144
|
+
*/
|
|
145
|
+
export function listRunningOpctlContainers(): { name: string; base: string }[] {
|
|
146
|
+
const byBase = new Map<string, { name: string; base: string }>();
|
|
147
|
+
|
|
148
|
+
if (isOpctlAvailable()) {
|
|
149
|
+
try {
|
|
150
|
+
const out = execSync("opctl container ls", {
|
|
151
|
+
encoding: "utf-8",
|
|
152
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
153
|
+
});
|
|
154
|
+
const lines = out.split("\n");
|
|
155
|
+
// Skip the first line only when it is actually a header row.
|
|
156
|
+
const firstToken = lines[0]?.trim().split(/\s+/)[0];
|
|
157
|
+
const body =
|
|
158
|
+
firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
|
|
159
|
+
for (const line of body) {
|
|
160
|
+
const name = line.trim().split(/\s+/)[0];
|
|
161
|
+
if (!name) continue;
|
|
162
|
+
const base = name.replace(/-[0-9a-f]{8}$/i, "");
|
|
163
|
+
byBase.set(base, { name, base });
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
/* fall through to docker */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const c of listRunningDockerOpctlContainers()) {
|
|
171
|
+
if (!byBase.has(c.base)) byBase.set(c.base, c);
|
|
172
|
+
}
|
|
173
|
+
return [...byBase.values()];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve a service's RUNNING docker container purely by opctl naming
|
|
178
|
+
* convention — docker names opctl-spawned containers `opctl_<base>_<hash>`.
|
|
179
|
+
*
|
|
180
|
+
* Candidates for `<base>`, in order: the repo's op-manifest container name
|
|
181
|
+
* matching the subsystem, any op-manifest container name, then the repo name
|
|
182
|
+
* itself. Returns the full docker container name, or null when nothing
|
|
183
|
+
* matches (not running, not containerized, or docker unavailable).
|
|
184
|
+
*/
|
|
185
|
+
export function findConventionalContainer(
|
|
186
|
+
repoName: string,
|
|
187
|
+
repoPath?: string,
|
|
188
|
+
subsystem?: string,
|
|
189
|
+
): string | null {
|
|
190
|
+
let running: string[];
|
|
191
|
+
try {
|
|
192
|
+
running = runningDockerNames();
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const declared = repoPath ? devOpContainerNames(repoPath) : [];
|
|
197
|
+
// Only candidates with NAME AFFINITY to the request — a blind scan of the
|
|
198
|
+
// declared list would hand a sidecar (e.g. shop-postgres) to an api
|
|
199
|
+
// request. The full declared list is only trusted when it's unambiguous.
|
|
200
|
+
const candidates = [
|
|
201
|
+
...(subsystem
|
|
202
|
+
? declared.filter(
|
|
203
|
+
(n) =>
|
|
204
|
+
n === subsystem ||
|
|
205
|
+
n === `${repoName}-${subsystem}` ||
|
|
206
|
+
n.endsWith(`-${subsystem}`),
|
|
207
|
+
)
|
|
208
|
+
: []),
|
|
209
|
+
repoName,
|
|
210
|
+
...(declared.length === 1 ? declared : []),
|
|
211
|
+
];
|
|
212
|
+
for (const base of candidates) {
|
|
213
|
+
const hit = running.find((n) => isOpctlContainerFor(n, base));
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The running opctl containers for one `container.name`, and their origin. */
|
|
220
|
+
export interface OpctlContainerInfo {
|
|
221
|
+
/** Full docker names (`opctl_<base>_<hash>`) — usually one, see below. */
|
|
222
|
+
names: string[];
|
|
223
|
+
/** Host paths bind-mounted into any of those containers. */
|
|
224
|
+
mountSources: string[];
|
|
225
|
+
/** Earliest container start time, or null when docker didn't report one. */
|
|
226
|
+
startedAt: Date | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Inspect the running containers for an opctl `container.name`.
|
|
231
|
+
*
|
|
232
|
+
* Bind-mount sources are the only trail back to the checkout a container was
|
|
233
|
+
* started from — opctl's labels record the container name, not the source repo.
|
|
234
|
+
* That distinction is what lets a caller tell "my own checkout is already
|
|
235
|
+
* running, untracked" apart from "another checkout occupies this name".
|
|
236
|
+
*
|
|
237
|
+
* A base normally has exactly one container, but a leaked container from an
|
|
238
|
+
* earlier run can coexist with the current one under a different hash. All of
|
|
239
|
+
* them are inspected and their mounts unioned: deciding adoption from whichever
|
|
240
|
+
* one `docker ps` happened to list first would make the answer depend on
|
|
241
|
+
* container age. Returns null when nothing runs or docker can't be reached.
|
|
242
|
+
*/
|
|
243
|
+
export function inspectOpctlContainer(base: string): OpctlContainerInfo | null {
|
|
244
|
+
try {
|
|
245
|
+
const names = runningDockerNames().filter((n) =>
|
|
246
|
+
isOpctlContainerFor(n, base),
|
|
247
|
+
);
|
|
248
|
+
if (names.length === 0) return null;
|
|
249
|
+
|
|
250
|
+
const mountSources = new Set<string>();
|
|
251
|
+
let startedAt: Date | null = null;
|
|
252
|
+
for (const name of names) {
|
|
253
|
+
const raw = execFileSync("docker", ["inspect", name], {
|
|
254
|
+
encoding: "utf-8",
|
|
255
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
256
|
+
timeout: 5_000,
|
|
257
|
+
});
|
|
258
|
+
const entry = (JSON.parse(raw) as unknown[])[0] as
|
|
259
|
+
| {
|
|
260
|
+
Mounts?: { Source?: unknown }[];
|
|
261
|
+
State?: { StartedAt?: unknown };
|
|
262
|
+
}
|
|
263
|
+
| undefined;
|
|
264
|
+
for (const m of entry?.Mounts ?? []) {
|
|
265
|
+
if (typeof m.Source === "string" && m.Source)
|
|
266
|
+
mountSources.add(m.Source);
|
|
267
|
+
}
|
|
268
|
+
const startedRaw = entry?.State?.StartedAt;
|
|
269
|
+
const started =
|
|
270
|
+
typeof startedRaw === "string" ? new Date(startedRaw) : null;
|
|
271
|
+
if (started && !Number.isNaN(started.getTime())) {
|
|
272
|
+
if (!startedAt || started < startedAt) startedAt = started;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { names, mountSources: [...mountSources], startedAt };
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Force-stop docker containers for opctl `container.name` bases when
|
|
283
|
+
* `opctl container down` cannot elevate (typical: sudoers grants NOPASSWD for
|
|
284
|
+
* opctl but not SETENV, so opctl's `sudo -E` is rejected).
|
|
285
|
+
*
|
|
286
|
+
* Docker names opctl containers `opctl_<base>_<hash>`; `docker rm -f` stops and
|
|
287
|
+
* removes them without needing sudo when the user can talk to the docker socket.
|
|
288
|
+
*/
|
|
289
|
+
export function forceStopOpctlContainers(bases: string[]): {
|
|
290
|
+
stopped: string[];
|
|
291
|
+
failed: string[];
|
|
292
|
+
} {
|
|
293
|
+
const stopped: string[] = [];
|
|
294
|
+
const failed: string[] = [];
|
|
295
|
+
if (bases.length === 0) return { stopped, failed };
|
|
296
|
+
|
|
297
|
+
let running: string[];
|
|
298
|
+
try {
|
|
299
|
+
running = runningDockerNames();
|
|
300
|
+
} catch {
|
|
301
|
+
return { stopped, failed: [...bases] };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const names = running.filter((n) =>
|
|
305
|
+
bases.some((base) => isOpctlContainerFor(n, base)),
|
|
306
|
+
);
|
|
307
|
+
for (const name of names) {
|
|
308
|
+
try {
|
|
309
|
+
execFileSync("docker", ["rm", "-f", name], {
|
|
310
|
+
encoding: "utf-8",
|
|
311
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
312
|
+
timeout: 30_000,
|
|
313
|
+
});
|
|
314
|
+
stopped.push(name);
|
|
315
|
+
} catch {
|
|
316
|
+
failed.push(name);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { stopped, failed };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Base container names that newly appeared between two `opctl container ls`
|
|
324
|
+
* snapshots — i.e. the containers an `up` just spawned.
|
|
325
|
+
*/
|
|
326
|
+
export function diffSpawnedContainers(
|
|
327
|
+
before: { base: string }[],
|
|
328
|
+
after: { base: string }[],
|
|
329
|
+
): string[] {
|
|
330
|
+
const had = new Set(before.map((c) => c.base));
|
|
331
|
+
const fresh = new Set<string>();
|
|
332
|
+
for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
|
|
333
|
+
return [...fresh];
|
|
334
|
+
}
|