@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,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size-capped rotation for service logs.
|
|
3
|
+
*
|
|
4
|
+
* Service logs are written by DETACHED child processes that inherited an
|
|
5
|
+
* O_APPEND file descriptor (see spawnDaemon): den opens the file, hands the
|
|
6
|
+
* fd to the child, then closes its own copy. Nothing can make that child
|
|
7
|
+
* reopen — the command is arbitrary shell with no SIGHUP contract — and it
|
|
8
|
+
* outlives den. A chatty service therefore grows the file forever; a
|
|
9
|
+
* localstack tail was measured at 8.6 GB.
|
|
10
|
+
*
|
|
11
|
+
* That single fact rules out the usual strategies, and is why no logging
|
|
12
|
+
* library helps here (they all rotate a stream THEY own):
|
|
13
|
+
*
|
|
14
|
+
* - RENAME (`mv x.log x.log.1`) is silently fatal. The child's fd follows the
|
|
15
|
+
* inode, so it keeps writing into the renamed file while `x.log` stays
|
|
16
|
+
* empty — and every reader tails by name, so live logs stop forever with no
|
|
17
|
+
* error anywhere.
|
|
18
|
+
* - TRUNCATE-HEAD (keep the last N bytes at offset 0) makes `tail -F` notice
|
|
19
|
+
* the file shrank and restart from the beginning, re-flooding every viewer
|
|
20
|
+
* with the whole retained tail. It also shifts every byte offset, so the
|
|
21
|
+
* history pager silently renders the wrong page.
|
|
22
|
+
*
|
|
23
|
+
* What works is copy-then-truncate-to-ZERO: copy the tail into `<name>.log.1`,
|
|
24
|
+
* then truncate the live file in place. The inode never moves, so the child's
|
|
25
|
+
* fd stays valid and O_APPEND puts its next write at the new EOF. Readers cope:
|
|
26
|
+
* `tail -F` restarts at offset 0 and finds one banner line rather than a flood,
|
|
27
|
+
* and a stale history cursor clamps to a tiny file and ends paging cleanly.
|
|
28
|
+
*
|
|
29
|
+
* INVARIANT: the child's fd must stay O_APPEND. With plain O_WRONLY the kernel
|
|
30
|
+
* keeps the old offset and truncation produces a multi-gigabyte sparse hole of
|
|
31
|
+
* NULs. spawn-streaming opens with "a"; a test pins it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import * as fs from "fs";
|
|
35
|
+
import * as path from "path";
|
|
36
|
+
import { MAX_LOG_BYTES, ROTATED_KEEP_BYTES } from "../constants.js";
|
|
37
|
+
|
|
38
|
+
import { product } from "../product.js";
|
|
39
|
+
/** Where a rotated generation is kept — one only, overwritten each time. */
|
|
40
|
+
export function rotatedPathFor(logPath: string): string {
|
|
41
|
+
return `${logPath}.1`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rotate `logPath` when it exceeds `maxBytes`. Returns true when it rotated.
|
|
46
|
+
*
|
|
47
|
+
* Best-effort by design: a log that cannot be rotated must never break the
|
|
48
|
+
* service that writes to it, so every failure degrades to "left as-is".
|
|
49
|
+
*/
|
|
50
|
+
export function rotateIfOversized(
|
|
51
|
+
logPath: string,
|
|
52
|
+
maxBytes: number = MAX_LOG_BYTES,
|
|
53
|
+
keepBytes: number = ROTATED_KEEP_BYTES,
|
|
54
|
+
): boolean {
|
|
55
|
+
let size: number;
|
|
56
|
+
try {
|
|
57
|
+
const st = fs.statSync(logPath);
|
|
58
|
+
if (!st.isFile()) return false;
|
|
59
|
+
size = st.size;
|
|
60
|
+
} catch {
|
|
61
|
+
return false; // no log yet
|
|
62
|
+
}
|
|
63
|
+
if (size <= maxBytes) return false;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
copyTail(logPath, rotatedPathFor(logPath), Math.min(keepBytes, size));
|
|
67
|
+
// Truncate to ZERO, not to a retained tail — see the module docstring.
|
|
68
|
+
fs.truncateSync(logPath, 0);
|
|
69
|
+
fs.appendFileSync(
|
|
70
|
+
logPath,
|
|
71
|
+
`\n─── ${product().cli} rotated ${new Date().toISOString()} — earlier output in ${path.basename(rotatedPathFor(logPath))} ───\n`,
|
|
72
|
+
);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Copy the last `bytes` of `from` into `to`, starting at the first line break
|
|
81
|
+
* so the rotated file doesn't open mid-line. Streams in chunks — the whole
|
|
82
|
+
* point is that these files are too big to hold in memory.
|
|
83
|
+
*/
|
|
84
|
+
function copyTail(from: string, to: string, bytes: number): void {
|
|
85
|
+
const src = fs.openSync(from, "r");
|
|
86
|
+
try {
|
|
87
|
+
const size = fs.fstatSync(src).size;
|
|
88
|
+
let start = Math.max(0, size - bytes);
|
|
89
|
+
if (start > 0) {
|
|
90
|
+
// Snap forward to just past the next newline.
|
|
91
|
+
const probe = Buffer.alloc(Math.min(64 * 1024, size - start));
|
|
92
|
+
const read = fs.readSync(src, probe, 0, probe.length, start);
|
|
93
|
+
const nl = probe.subarray(0, read).indexOf(0x0a);
|
|
94
|
+
if (nl !== -1) start += nl + 1;
|
|
95
|
+
}
|
|
96
|
+
const dst = fs.openSync(to, "w");
|
|
97
|
+
try {
|
|
98
|
+
const buf = Buffer.alloc(1024 * 1024);
|
|
99
|
+
let pos = start;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const read = fs.readSync(src, buf, 0, buf.length, pos);
|
|
102
|
+
if (read <= 0) break;
|
|
103
|
+
fs.writeSync(dst, buf, 0, read);
|
|
104
|
+
pos += read;
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
fs.closeSync(dst);
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
fs.closeSync(src);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Rotate every oversized `*.log` under a log root (`<root>/<repo>/<name>.log`).
|
|
116
|
+
* Returns the paths rotated. Never throws — a sweep failure must not take the
|
|
117
|
+
* server down.
|
|
118
|
+
*/
|
|
119
|
+
export function sweepLogs(
|
|
120
|
+
logRoot: string,
|
|
121
|
+
maxBytes: number = MAX_LOG_BYTES,
|
|
122
|
+
): string[] {
|
|
123
|
+
const rotated: string[] = [];
|
|
124
|
+
let dirs: fs.Dirent[];
|
|
125
|
+
try {
|
|
126
|
+
dirs = fs.readdirSync(logRoot, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
return rotated; // no logs yet
|
|
129
|
+
}
|
|
130
|
+
for (const dir of dirs) {
|
|
131
|
+
if (!dir.isDirectory()) continue;
|
|
132
|
+
const sub = path.join(logRoot, dir.name);
|
|
133
|
+
let files: string[];
|
|
134
|
+
try {
|
|
135
|
+
files = fs.readdirSync(sub);
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
// `.log.1` is the rotated generation — capping it again would discard it.
|
|
141
|
+
if (!file.endsWith(".log")) continue;
|
|
142
|
+
const full = path.join(sub, file);
|
|
143
|
+
if (rotateIfOversized(full, maxBytes)) rotated.push(full);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return rotated;
|
|
147
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared streaming process spawner
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/ui/cli/test-runner.ts so both the CLI test runner
|
|
5
|
+
* and the environment manager can spawn processes without importing from
|
|
6
|
+
* the UI layer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { rotateIfOversized } from "./log-rotation.js";
|
|
13
|
+
import { getStateDir } from "../paths.js";
|
|
14
|
+
|
|
15
|
+
import { product } from "../product.js";
|
|
16
|
+
// ── Log path ──────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Canonical path for a service's daemon log file.
|
|
20
|
+
* ~/.den/logs/<repoName>/<subsystem>.log
|
|
21
|
+
*/
|
|
22
|
+
export function getServiceLogPath(repoName: string, subsystem: string): string {
|
|
23
|
+
const dir = path.join(getServiceLogRoot(), repoName);
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
return path.join(dir, `${subsystem}.log`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Root of the per-service log tree: `~/.den/logs/<repo>/<service>.log`. */
|
|
29
|
+
export function getServiceLogRoot(): string {
|
|
30
|
+
return path.join(getStateDir(), "logs");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── ANSI strip ────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function stripAnsi(data: Buffer, onLine: (line: string) => void): void {
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
const text = data.toString().replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
38
|
+
for (const line of text.split("\n")) {
|
|
39
|
+
const visible = line.split("\r").filter(Boolean).pop() ?? "";
|
|
40
|
+
if (visible.trim()) onLine(visible);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Daemon spawner ────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Spawn a long-running service as a true daemon, fully decoupled from den's
|
|
48
|
+
* process space.
|
|
49
|
+
*
|
|
50
|
+
* Why this matters
|
|
51
|
+
* ────────────────
|
|
52
|
+
* `spawnStreaming` with `stdio: pipe` keeps a file-descriptor link between
|
|
53
|
+
* den and the service. When den exits (or the SSE response closes and
|
|
54
|
+
* Node GC's the stream), the pipe read-end closes → the child gets SIGPIPE on
|
|
55
|
+
* its next write → it dies. `proc.unref()` only removes the process from
|
|
56
|
+
* Node's event-loop ref-count; it does NOT sever the FD connection.
|
|
57
|
+
*
|
|
58
|
+
* This function fixes that by:
|
|
59
|
+
* 1. Writing a start-marker to `logPath` then opening it with `O_APPEND`.
|
|
60
|
+
* 2. Spawning the service with `stdio: ['ignore', logFd, logFd]` — no pipe,
|
|
61
|
+
* no connection to den's FD table.
|
|
62
|
+
* 3. Immediately closing the parent's copy of the log fd.
|
|
63
|
+
* 4. Starting a `tail -F` subprocess to stream output back to the caller.
|
|
64
|
+
*
|
|
65
|
+
* The returned process is the `tail` subprocess — kill it when you want to
|
|
66
|
+
* stop streaming without stopping the service. `onClose` fires when tail
|
|
67
|
+
* exits (i.e. when the caller kills it or the SSE detach window expires).
|
|
68
|
+
*/
|
|
69
|
+
export function spawnDaemon(
|
|
70
|
+
command: string,
|
|
71
|
+
env: NodeJS.ProcessEnv,
|
|
72
|
+
logPath: string,
|
|
73
|
+
onLine: (line: string) => void,
|
|
74
|
+
onClose: (exitCode: number) => void,
|
|
75
|
+
cwd?: string,
|
|
76
|
+
onServiceExit?: (exitCode: number) => void,
|
|
77
|
+
): ReturnType<typeof spawn> {
|
|
78
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
79
|
+
|
|
80
|
+
// Cap the log before the next run appends to it. Cheap (one statSync) and it
|
|
81
|
+
// covers restart-heavy services; the periodic sweep in the server catches
|
|
82
|
+
// long-lived ones that never respawn.
|
|
83
|
+
rotateIfOversized(logPath);
|
|
84
|
+
|
|
85
|
+
// Stamp the log so the tail starts reading from a clean boundary.
|
|
86
|
+
const ts = new Date().toISOString();
|
|
87
|
+
fs.appendFileSync(logPath, `\n\n─── ${product().cli} start ${ts} ───\n`);
|
|
88
|
+
|
|
89
|
+
// Open the log fd in the parent so we can hand it to the child.
|
|
90
|
+
// MUST stay "a" (O_APPEND): rotation truncates this file in place, and only
|
|
91
|
+
// O_APPEND recomputes the offset to EOF on every write. With plain O_WRONLY
|
|
92
|
+
// the child would keep its old offset and write a multi-GB sparse hole of
|
|
93
|
+
// NULs. See log-rotation.ts.
|
|
94
|
+
const logFd = fs.openSync(logPath, "a");
|
|
95
|
+
|
|
96
|
+
const svcProc = spawn(command, [], {
|
|
97
|
+
env,
|
|
98
|
+
cwd,
|
|
99
|
+
shell: true,
|
|
100
|
+
detached: true, // own process group → immune to Ctrl+C on den
|
|
101
|
+
stdio: ["ignore", logFd, logFd], // NO pipe to the CLI — survives its shutdown
|
|
102
|
+
});
|
|
103
|
+
// Notify caller when the service process itself exits (e.g. `make up` finishes
|
|
104
|
+
// after starting Docker containers). The service may still be "running" even
|
|
105
|
+
// after this fires — the process just handed off to the container runtime.
|
|
106
|
+
if (onServiceExit) {
|
|
107
|
+
svcProc.on("exit", (code) => onServiceExit(code ?? 0));
|
|
108
|
+
}
|
|
109
|
+
// Without an 'error' listener a spawn failure (e.g. cwd missing, ENOENT)
|
|
110
|
+
// emits an unhandled 'error' that crashes den. Surface it instead.
|
|
111
|
+
svcProc.on("error", (err) => {
|
|
112
|
+
onLine(`Error: ${err.message}`);
|
|
113
|
+
onServiceExit?.(1);
|
|
114
|
+
});
|
|
115
|
+
svcProc.unref(); // den's event loop can exit independently
|
|
116
|
+
fs.closeSync(logFd); // parent closes its copy; child keeps its own fd
|
|
117
|
+
|
|
118
|
+
// Tail the log for streaming back to the caller.
|
|
119
|
+
// -F follows by name (handles log rotation). -n 0 shows only new lines written
|
|
120
|
+
// after tail started (we already stamped the marker above).
|
|
121
|
+
const tailProc = spawn("tail", ["-F", "-n", "0", logPath], {
|
|
122
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
tailProc.stdout?.on("data", (data: Buffer) => stripAnsi(data, onLine));
|
|
126
|
+
tailProc.on("close", () => onClose(0));
|
|
127
|
+
tailProc.on("error", () => onClose(0));
|
|
128
|
+
|
|
129
|
+
return tailProc;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Streaming spawner ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Spawn a process and stream stdout/stderr line-by-line via callbacks.
|
|
136
|
+
* ANSI colour codes are stripped so browser/terminal output renders cleanly.
|
|
137
|
+
*
|
|
138
|
+
* When `detached` is true AND `logPath` is provided the process is spawned as
|
|
139
|
+
* a true daemon (see `spawnDaemon`). The returned proc is then the `tail`
|
|
140
|
+
* subprocess used for streaming — kill it to stop streaming without stopping
|
|
141
|
+
* the service.
|
|
142
|
+
*
|
|
143
|
+
* @param command Executable name or shell string (when args is empty, shell mode is used)
|
|
144
|
+
* @param args Argument array — pass [] to invoke command as a raw shell string
|
|
145
|
+
* @param env Process environment
|
|
146
|
+
* @param onLine Called for each non-empty output line
|
|
147
|
+
* @param onClose Called with exit code when the process terminates
|
|
148
|
+
* @param cwd Working directory (defaults to process.cwd())
|
|
149
|
+
* @param detached Put the child in its own process group (default false)
|
|
150
|
+
* @param logPath If provided together with detached=true, run as a daemon writing to this path
|
|
151
|
+
*/
|
|
152
|
+
export function spawnStreaming(
|
|
153
|
+
command: string,
|
|
154
|
+
args: string[],
|
|
155
|
+
env: NodeJS.ProcessEnv,
|
|
156
|
+
onLine: (line: string) => void,
|
|
157
|
+
onClose: (exitCode: number) => void,
|
|
158
|
+
cwd?: string,
|
|
159
|
+
detached?: boolean,
|
|
160
|
+
logPath?: string,
|
|
161
|
+
onServiceExit?: (exitCode: number) => void,
|
|
162
|
+
): ReturnType<typeof spawn> {
|
|
163
|
+
// Daemon mode: service runs independently from den — survives restarts.
|
|
164
|
+
if (detached && logPath) {
|
|
165
|
+
return spawnDaemon(
|
|
166
|
+
command,
|
|
167
|
+
env,
|
|
168
|
+
logPath,
|
|
169
|
+
onLine,
|
|
170
|
+
onClose,
|
|
171
|
+
cwd,
|
|
172
|
+
onServiceExit,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Standard mode: pipe stdout/stderr for streaming, optionally detached.
|
|
177
|
+
const proc = spawn(command, args, {
|
|
178
|
+
env,
|
|
179
|
+
cwd,
|
|
180
|
+
shell: args.length === 0, // shell mode when command is a raw string (service commands)
|
|
181
|
+
detached: detached ?? false,
|
|
182
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
proc.stdout?.on("data", (data: Buffer) => stripAnsi(data, onLine));
|
|
186
|
+
proc.stderr?.on("data", (data: Buffer) => stripAnsi(data, onLine));
|
|
187
|
+
|
|
188
|
+
proc.on("close", (code) => onClose(code ?? 1));
|
|
189
|
+
proc.on("error", (err) => {
|
|
190
|
+
onLine(`Error: ${err.message}`);
|
|
191
|
+
onClose(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (detached) proc.unref();
|
|
195
|
+
|
|
196
|
+
return proc;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Interactive spawner ───────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Spawn a process with full TTY access — stdin, stdout, and stderr are all
|
|
203
|
+
* inherited from the parent process. Use this for interactive commands that
|
|
204
|
+
* require human input (HITL), such as auth logins, license prompts, etc.
|
|
205
|
+
*
|
|
206
|
+
* No output streaming is performed — the user sees and interacts with the
|
|
207
|
+
* process directly in their terminal.
|
|
208
|
+
*/
|
|
209
|
+
export function spawnInteractive(
|
|
210
|
+
command: string,
|
|
211
|
+
env: NodeJS.ProcessEnv,
|
|
212
|
+
onClose: (exitCode: number) => void,
|
|
213
|
+
cwd?: string,
|
|
214
|
+
): ReturnType<typeof spawn> {
|
|
215
|
+
const proc = spawn(command, [], {
|
|
216
|
+
env,
|
|
217
|
+
cwd,
|
|
218
|
+
shell: true,
|
|
219
|
+
stdio: "inherit",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
proc.on("close", (code) => onClose(code ?? 1));
|
|
223
|
+
proc.on("error", (err) => {
|
|
224
|
+
void err;
|
|
225
|
+
onClose(1);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return proc;
|
|
229
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The running product's names, for code that needs them at runtime.
|
|
3
|
+
*
|
|
4
|
+
* Den never hard-codes a product name: the CLI, env-var prefix, config and
|
|
5
|
+
* manifest file names and directories all come from the distribution's
|
|
6
|
+
* profile (den.yml, see profile.ts). This module is the safe accessor: it
|
|
7
|
+
* never throws, falling back to DEN_PROFILE or Den's own profile when no
|
|
8
|
+
* distribution root can be found (e.g. help text printed outside a checkout).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getDistRoot } from "./paths.js";
|
|
12
|
+
import {
|
|
13
|
+
loadProfile,
|
|
14
|
+
profileFrom,
|
|
15
|
+
readProfileFile,
|
|
16
|
+
type ProductProfile,
|
|
17
|
+
} from "./profile.js";
|
|
18
|
+
|
|
19
|
+
export interface Product extends ProductProfile {
|
|
20
|
+
/** Display name: the name with its first letter upper-cased ("Den"). */
|
|
21
|
+
title: string;
|
|
22
|
+
/** The YAML manifest file name (".den.yml"). */
|
|
23
|
+
manifestYml: string;
|
|
24
|
+
/** The JSON manifest file name (".den.json"). */
|
|
25
|
+
manifestJson: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withDerived(p: ProductProfile): Product {
|
|
29
|
+
const yml = p.manifestFiles[0];
|
|
30
|
+
return {
|
|
31
|
+
...p,
|
|
32
|
+
title: p.name.charAt(0).toUpperCase() + p.name.slice(1),
|
|
33
|
+
manifestYml: yml,
|
|
34
|
+
manifestJson: p.manifestFiles[1] ?? yml.replace(/\.ya?ml$/, "") + ".json",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The running product. Never throws. */
|
|
39
|
+
export function product(): Product {
|
|
40
|
+
try {
|
|
41
|
+
return withDerived(loadProfile(getDistRoot()));
|
|
42
|
+
} catch {
|
|
43
|
+
const pinned = process.env.DEN_PROFILE?.trim();
|
|
44
|
+
try {
|
|
45
|
+
return withDerived(pinned ? readProfileFile(pinned) : profileFrom());
|
|
46
|
+
} catch {
|
|
47
|
+
return withDerived(profileFrom());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The env-var name this product uses for `key`: envKey("ROOT") → "DEN_ROOT". */
|
|
53
|
+
export function envKey(key: string): string {
|
|
54
|
+
return `${product().envPrefix}_${key}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The value of this product's `key` env var, or undefined. */
|
|
58
|
+
export function envValue(
|
|
59
|
+
key: string,
|
|
60
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
61
|
+
): string | undefined {
|
|
62
|
+
return env[envKey(key)];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Placeholders the web pages use for product names; serveFile fills them in.
|
|
67
|
+
* Pages are static files, so they cannot ask the profile themselves.
|
|
68
|
+
*/
|
|
69
|
+
export function productTokens(p: Product = product()): Record<string, string> {
|
|
70
|
+
return {
|
|
71
|
+
__DEN_CLI__: p.cli,
|
|
72
|
+
__DEN_NAME__: p.name,
|
|
73
|
+
__DEN_TITLE__: p.title,
|
|
74
|
+
__DEN_ENV_PREFIX__: p.envPrefix,
|
|
75
|
+
__DEN_CONFIG_FILE__: p.configFile,
|
|
76
|
+
__DEN_MANIFEST_YML__: p.manifestYml,
|
|
77
|
+
__DEN_MANIFEST_JSON__: p.manifestJson,
|
|
78
|
+
__DEN_SERVICES_DIR__: p.servicesDir,
|
|
79
|
+
__DEN_WORKSPACES_DIR__: p.workspacesDir,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Replace every product placeholder in `text`. */
|
|
84
|
+
export function fillProductTokens(
|
|
85
|
+
text: string,
|
|
86
|
+
p: Product = product(),
|
|
87
|
+
): string {
|
|
88
|
+
let out = text;
|
|
89
|
+
for (const [token, value] of Object.entries(productTokens(p)))
|
|
90
|
+
out = out.split(token).join(value);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product profile: who this distribution is.
|
|
3
|
+
*
|
|
4
|
+
* Den is one engine; each distribution runs it under its own name. Everything product-named comes from here: the CLI, the env-var
|
|
5
|
+
* prefix, the config and manifest file names, and the state, services and
|
|
6
|
+
* workspaces directories. The profile lives in `den.yml` at the
|
|
7
|
+
* distribution root:
|
|
8
|
+
*
|
|
9
|
+
* product:
|
|
10
|
+
* name: acme # everything below derives from it by default
|
|
11
|
+
* # cli: acme
|
|
12
|
+
* # env_prefix: ACME
|
|
13
|
+
* # config: config/acme.yml
|
|
14
|
+
* # manifest: .acme.yml (.json is also read)
|
|
15
|
+
* # state_dir: ~/.acme
|
|
16
|
+
* # services_dir: .acme-services
|
|
17
|
+
* # workspaces_dir: acme-workspaces
|
|
18
|
+
*
|
|
19
|
+
* `DEN_PROFILE` may point at another profile file (tests use this). With no
|
|
20
|
+
* profile at all, Den runs under its own name.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { parse as parseYaml } from "yaml";
|
|
27
|
+
|
|
28
|
+
export const PROFILE_FILE = "den.yml";
|
|
29
|
+
|
|
30
|
+
export interface ProductProfile {
|
|
31
|
+
/** Display and slug name ("acme"). */
|
|
32
|
+
name: string;
|
|
33
|
+
/** The command a person types. */
|
|
34
|
+
cli: string;
|
|
35
|
+
/** Upper-case env-var prefix, without the trailing "_" ("ACME"). */
|
|
36
|
+
envPrefix: string;
|
|
37
|
+
/** Org config, relative to the distribution root. */
|
|
38
|
+
configFile: string;
|
|
39
|
+
/** Manifest file names probed in a repo, first match wins. */
|
|
40
|
+
manifestFiles: string[];
|
|
41
|
+
/** Per-user state directory (absolute, `~` expanded). */
|
|
42
|
+
stateDir: string;
|
|
43
|
+
/** Managed clones, relative to the distribution root. */
|
|
44
|
+
servicesDir: string;
|
|
45
|
+
/** Workspace checkouts, relative to the distribution root. */
|
|
46
|
+
workspacesDir: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RawProduct {
|
|
50
|
+
name?: unknown;
|
|
51
|
+
cli?: unknown;
|
|
52
|
+
env_prefix?: unknown;
|
|
53
|
+
config?: unknown;
|
|
54
|
+
manifest?: unknown;
|
|
55
|
+
state_dir?: unknown;
|
|
56
|
+
services_dir?: unknown;
|
|
57
|
+
workspaces_dir?: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SLUG = /^[a-z][a-z0-9-]*$/;
|
|
61
|
+
const PREFIX = /^[A-Z][A-Z0-9_]*$/;
|
|
62
|
+
|
|
63
|
+
const str = (v: unknown): string | undefined =>
|
|
64
|
+
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
65
|
+
|
|
66
|
+
const expandHome = (p: string): string =>
|
|
67
|
+
p === "~"
|
|
68
|
+
? os.homedir()
|
|
69
|
+
: p.startsWith("~/")
|
|
70
|
+
? path.join(os.homedir(), p.slice(2))
|
|
71
|
+
: p;
|
|
72
|
+
|
|
73
|
+
/** A profile from a `product:` block; unset fields derive from the name. */
|
|
74
|
+
export function profileFrom(raw: RawProduct = {}): ProductProfile {
|
|
75
|
+
const name = str(raw.name) ?? "den";
|
|
76
|
+
if (!SLUG.test(name))
|
|
77
|
+
throw new Error(
|
|
78
|
+
`den.yml: product.name "${name}" must be lower-case letters, digits and "-"`,
|
|
79
|
+
);
|
|
80
|
+
const envPrefix =
|
|
81
|
+
str(raw.env_prefix) ?? name.toUpperCase().replace(/-/g, "_");
|
|
82
|
+
if (!PREFIX.test(envPrefix))
|
|
83
|
+
throw new Error(
|
|
84
|
+
`den.yml: product.env_prefix "${envPrefix}" must be upper-case letters, digits and "_"`,
|
|
85
|
+
);
|
|
86
|
+
const manifest = str(raw.manifest) ?? `.${name}.yml`;
|
|
87
|
+
const json = manifest.replace(/\.ya?ml$/, ".json");
|
|
88
|
+
return {
|
|
89
|
+
name,
|
|
90
|
+
cli: str(raw.cli) ?? name,
|
|
91
|
+
envPrefix,
|
|
92
|
+
configFile: str(raw.config) ?? `config/${name}.yml`,
|
|
93
|
+
manifestFiles: json === manifest ? [manifest] : [manifest, json],
|
|
94
|
+
stateDir: path.resolve(expandHome(str(raw.state_dir) ?? `~/.${name}`)),
|
|
95
|
+
servicesDir: str(raw.services_dir) ?? `.${name}-services`,
|
|
96
|
+
workspacesDir: str(raw.workspaces_dir) ?? `${name}-workspaces`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read a profile file; a missing file is Den's own profile. */
|
|
101
|
+
export function readProfileFile(file: string): ProductProfile {
|
|
102
|
+
let raw: { product?: RawProduct } | null;
|
|
103
|
+
try {
|
|
104
|
+
raw = parseYaml(fs.readFileSync(file, "utf-8")) as typeof raw;
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return profileFrom();
|
|
107
|
+
throw new Error(`${file}: ${(e as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
return profileFrom(raw?.product ?? {});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let cached: { key: string; profile: ProductProfile } | null = null;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The profile for a distribution root: `DEN_PROFILE` if set, else
|
|
116
|
+
* `<root>/den.yml`, else Den's own. Cached per file.
|
|
117
|
+
*/
|
|
118
|
+
export function loadProfile(root: string): ProductProfile {
|
|
119
|
+
const file = process.env.DEN_PROFILE?.trim() || path.join(root, PROFILE_FILE);
|
|
120
|
+
if (cached?.key !== file)
|
|
121
|
+
cached = { key: file, profile: readProfileFile(file) };
|
|
122
|
+
return cached.profile;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function resetProfileCache(): void {
|
|
126
|
+
cached = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
|
|
130
|
+
export function envName(profile: ProductProfile, key: string): string {
|
|
131
|
+
return `${profile.envPrefix}_${key}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The value of `${PREFIX}_${key}`, or undefined. */
|
|
135
|
+
export function readEnv(
|
|
136
|
+
profile: ProductProfile,
|
|
137
|
+
key: string,
|
|
138
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
return env[envName(profile, key)];
|
|
141
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo kinds: only a `service` has a runtime (up/down/status). A `module`
|
|
3
|
+
* (bolt-on a host invokes) or `library` (imported code) contributes to other
|
|
4
|
+
* repos instead.
|
|
5
|
+
*/
|
|
6
|
+
import { readManifest } from "./manifest/read-manifest.js";
|
|
7
|
+
import type { DerivedGraph } from "./environment-manager/derivation/types.js";
|
|
8
|
+
import type { RepoKind } from "./types/index.js";
|
|
9
|
+
|
|
10
|
+
export function repoKind(repoPath: string): RepoKind {
|
|
11
|
+
return readManifest(repoPath)?.kind ?? "service";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasRuntime(kind: RepoKind): boolean {
|
|
15
|
+
return kind === "service";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a non-service repo contributes to: its declared `contributes_to`, plus
|
|
20
|
+
* every repo that imports it (an SDK-import edge pointing at it).
|
|
21
|
+
*/
|
|
22
|
+
export function contributesTo(
|
|
23
|
+
repo: string,
|
|
24
|
+
repoPath: string,
|
|
25
|
+
graph?: DerivedGraph,
|
|
26
|
+
): string[] {
|
|
27
|
+
const declared = readManifest(repoPath)?.contributes_to ?? [];
|
|
28
|
+
const importers = (graph?.edges ?? [])
|
|
29
|
+
.filter((e) => e.to === repo && e.signal === "http:sdk-import")
|
|
30
|
+
.map((e) => e.from);
|
|
31
|
+
return [...new Set([...declared, ...importers])].filter((r) => r !== repo);
|
|
32
|
+
}
|