@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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency DAG for local service startup ordering.
|
|
3
|
+
*
|
|
4
|
+
* Reads `depends_on` from each service's .den.yml and produces a
|
|
5
|
+
* topologically sorted startup order (dependencies before dependents).
|
|
6
|
+
*
|
|
7
|
+
* depends_on entries are service addresses: "tasks-runner", "shop/api".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
11
|
+
import { depService, normalizeDep } from "../manifest/deps.js";
|
|
12
|
+
import type { ServiceCatalog, WorkspaceConfig } from "../types/index.js";
|
|
13
|
+
import {
|
|
14
|
+
parseAddress,
|
|
15
|
+
resolveServiceSource,
|
|
16
|
+
resolveDepAddress,
|
|
17
|
+
} from "./workspaces.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read `depends_on` for one service address from its manifest.
|
|
21
|
+
* Returns [] if the manifest is absent or the service has no declared deps.
|
|
22
|
+
*/
|
|
23
|
+
function readDepsForAddress(
|
|
24
|
+
address: string,
|
|
25
|
+
catalog: ServiceCatalog,
|
|
26
|
+
workspace: WorkspaceConfig,
|
|
27
|
+
workspaceName: string,
|
|
28
|
+
): string[] {
|
|
29
|
+
const resolved = resolveServiceSource(
|
|
30
|
+
address,
|
|
31
|
+
workspaceName,
|
|
32
|
+
workspace,
|
|
33
|
+
catalog,
|
|
34
|
+
);
|
|
35
|
+
if (!resolved) return [];
|
|
36
|
+
const { subsystem } = parseAddress(address, catalog);
|
|
37
|
+
const manifest = readManifest(resolved.repoPath);
|
|
38
|
+
const rawDeps = manifest?.services?.[subsystem]?.depends_on ?? [];
|
|
39
|
+
// Normalize bare repo deps to their entrypoint address ("files-api" →
|
|
40
|
+
// "files-api/api" when files-api declares entrypoint: api).
|
|
41
|
+
return rawDeps.map((dep) =>
|
|
42
|
+
resolveDepAddress(depService(dep), catalog, workspace, workspaceName),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return the deps flagged with `wait: true` for one service address, resolved
|
|
48
|
+
* to canonical addresses. The workspace runner calls this before starting a
|
|
49
|
+
* service to decide which deps to block on (`waitForReady`).
|
|
50
|
+
*/
|
|
51
|
+
export function getWaitDeps(
|
|
52
|
+
address: string,
|
|
53
|
+
catalog: ServiceCatalog,
|
|
54
|
+
workspace: WorkspaceConfig,
|
|
55
|
+
workspaceName: string,
|
|
56
|
+
): string[] {
|
|
57
|
+
const resolved = resolveServiceSource(
|
|
58
|
+
address,
|
|
59
|
+
workspaceName,
|
|
60
|
+
workspace,
|
|
61
|
+
catalog,
|
|
62
|
+
);
|
|
63
|
+
if (!resolved) return [];
|
|
64
|
+
const { subsystem } = parseAddress(address, catalog);
|
|
65
|
+
const manifest = readManifest(resolved.repoPath);
|
|
66
|
+
const rawDeps = manifest?.services?.[subsystem]?.depends_on ?? [];
|
|
67
|
+
return rawDeps
|
|
68
|
+
.map(normalizeDep)
|
|
69
|
+
.filter((d) => d.wait)
|
|
70
|
+
.map((d) =>
|
|
71
|
+
resolveDepAddress(d.service, catalog, workspace, workspaceName),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Expand an explicit start list with SAME-REPO `wait: true` dependencies whose
|
|
77
|
+
* health check is actively failing, deps-first.
|
|
78
|
+
*
|
|
79
|
+
* An explicit address (card ▶ up) runs only that address, while the exec
|
|
80
|
+
* runner blocks on its `wait: true` deps' HEALTH before starting it — and for
|
|
81
|
+
* a sibling subsystem, the thing that makes the dep healthy is the dep's own
|
|
82
|
+
* `up`, which nothing else will run. That inverted the order every manifest
|
|
83
|
+
* intends (init the dep, THEN wait) into a wait that could only time out.
|
|
84
|
+
*
|
|
85
|
+
* Cross-repo deps are deliberately not expanded: they belong to another
|
|
86
|
+
* system's lifecycle, and waiting is the correct posture there. Deps whose
|
|
87
|
+
* health is "ok" need nothing; "unknown" (no check / no manifest) cannot
|
|
88
|
+
* deadlock the wait because readiness skips it.
|
|
89
|
+
*
|
|
90
|
+
* Injected callbacks keep this pure: `repoOf`/`waitDepsOf` wrap the catalog
|
|
91
|
+
* and manifest lookups, `isFailing` wraps resolve + health check.
|
|
92
|
+
*/
|
|
93
|
+
export async function expandSameRepoWaitDeps(
|
|
94
|
+
ordered: string[],
|
|
95
|
+
deps: {
|
|
96
|
+
repoOf: (addr: string) => string;
|
|
97
|
+
waitDepsOf: (addr: string) => string[];
|
|
98
|
+
isFailing: (addr: string) => Promise<boolean>;
|
|
99
|
+
/**
|
|
100
|
+
* Canonical identity for an address — repo + subsystem, so two spellings
|
|
101
|
+
* of the SAME service collapse. Several address forms can name one service
|
|
102
|
+
* (a bare repo name vs `repo/entrypoint`, or a `repo/repo` dep against a
|
|
103
|
+
* manifest whose service key equals the repo name), and treating those as
|
|
104
|
+
* distinct would start it twice. Defaults to the address itself.
|
|
105
|
+
*/
|
|
106
|
+
identityOf?: (addr: string) => string;
|
|
107
|
+
},
|
|
108
|
+
): Promise<{ ordered: string[]; added: string[] }> {
|
|
109
|
+
const idOf = deps.identityOf ?? ((addr: string) => addr);
|
|
110
|
+
const expanded: string[] = [];
|
|
111
|
+
const queued = new Set<string>(); // identities, not raw addresses
|
|
112
|
+
// identity -> the CALLER's spelling of it. A hoisted dep must be emitted
|
|
113
|
+
// under the address the caller listed, not the manifest's spelling of the
|
|
114
|
+
// same service: downstream keys off the exact string (isPrimaryAddr in
|
|
115
|
+
// local-dev.html compares the full address), so substituting a dep-only
|
|
116
|
+
// spelling silently stops the live log auto-attaching.
|
|
117
|
+
const explicitAddr = new Map<string, string>();
|
|
118
|
+
for (const addr of ordered) {
|
|
119
|
+
const id = idOf(addr);
|
|
120
|
+
if (!explicitAddr.has(id)) explicitAddr.set(id, addr);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const addWithDeps = async (
|
|
124
|
+
addr: string,
|
|
125
|
+
stack: Set<string>,
|
|
126
|
+
): Promise<void> => {
|
|
127
|
+
const id = idOf(addr);
|
|
128
|
+
if (queued.has(id) || stack.has(id)) return; // done / dep cycle
|
|
129
|
+
stack.add(id);
|
|
130
|
+
const repoName = deps.repoOf(addr);
|
|
131
|
+
|
|
132
|
+
// Filter BEFORE probing: an already-queued dep needs nothing, an in-stack
|
|
133
|
+
// dep is a cycle addWithDeps would bounce anyway, and a cross-repo dep is
|
|
134
|
+
// never expanded — none of them should cost a live health check.
|
|
135
|
+
const candidates = deps
|
|
136
|
+
.waitDepsOf(addr)
|
|
137
|
+
.filter(
|
|
138
|
+
(d) =>
|
|
139
|
+
!queued.has(idOf(d)) &&
|
|
140
|
+
!stack.has(idOf(d)) &&
|
|
141
|
+
deps.repoOf(d) === repoName,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Two tiers, because ordering and adding are different questions:
|
|
145
|
+
//
|
|
146
|
+
// - A dep ALREADY in the run list is hoisted regardless of health. Health
|
|
147
|
+
// now says nothing about health later: on `restart` every listed service
|
|
148
|
+
// is going down anyway, so leaving a currently-healthy dep after its
|
|
149
|
+
// dependent means it gets torn down and recreated underneath a service
|
|
150
|
+
// that has already been restarted against it. Hoisting is also free —
|
|
151
|
+
// it needs no probe, which is why a whole-workspace start (where every
|
|
152
|
+
// dep is listed) costs zero probes.
|
|
153
|
+
// - A dep NOT in the run list is only pulled in when its health is
|
|
154
|
+
// actively failing, so a start never quietly grows into services the
|
|
155
|
+
// caller did not ask for.
|
|
156
|
+
const unlisted = candidates.filter((d) => !explicitAddr.has(idOf(d)));
|
|
157
|
+
// Probe concurrently — these are spawns/fetches, and doing them serially is
|
|
158
|
+
// what put several seconds of dead air before the first log line.
|
|
159
|
+
const failing = new Set(
|
|
160
|
+
(
|
|
161
|
+
await Promise.all(
|
|
162
|
+
unlisted.map(async (d) => ((await deps.isFailing(d)) ? d : null)),
|
|
163
|
+
)
|
|
164
|
+
).filter((d): d is string => d !== null),
|
|
165
|
+
);
|
|
166
|
+
// Recurse in the manifest's declared order so the result is deterministic.
|
|
167
|
+
for (const depAddr of candidates) {
|
|
168
|
+
if (explicitAddr.has(idOf(depAddr)) || failing.has(depAddr)) {
|
|
169
|
+
await addWithDeps(depAddr, stack);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
stack.delete(id);
|
|
174
|
+
queued.add(id);
|
|
175
|
+
expanded.push(explicitAddr.get(id) ?? addr);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
for (const addr of ordered) await addWithDeps(addr, new Set());
|
|
179
|
+
return {
|
|
180
|
+
ordered: expanded,
|
|
181
|
+
added: expanded.filter((a) => !explicitAddr.has(idOf(a))),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build the full dependency graph for the given seed addresses, following
|
|
187
|
+
* transitive dependencies that are also present in the catalog.
|
|
188
|
+
* Returns a map of address → direct dependency addresses.
|
|
189
|
+
*/
|
|
190
|
+
export function buildDependencyGraph(
|
|
191
|
+
seedAddresses: string[],
|
|
192
|
+
catalog: ServiceCatalog,
|
|
193
|
+
workspace: WorkspaceConfig,
|
|
194
|
+
workspaceName: string,
|
|
195
|
+
): Map<string, string[]> {
|
|
196
|
+
const graph = new Map<string, string[]>();
|
|
197
|
+
const queue = [...seedAddresses];
|
|
198
|
+
|
|
199
|
+
while (queue.length > 0) {
|
|
200
|
+
const addr = queue.pop()!;
|
|
201
|
+
if (graph.has(addr)) continue;
|
|
202
|
+
|
|
203
|
+
const deps = readDepsForAddress(
|
|
204
|
+
addr,
|
|
205
|
+
catalog,
|
|
206
|
+
workspace,
|
|
207
|
+
workspaceName,
|
|
208
|
+
).filter((d) => {
|
|
209
|
+
const { repoName } = parseAddress(d, catalog);
|
|
210
|
+
if (!catalog[repoName]) return false;
|
|
211
|
+
// Only follow deps present in the workspace (check both explicit subsystem
|
|
212
|
+
// address and bare repo-name key for mono-repo bare entries like "shop").
|
|
213
|
+
return !!(workspace.services[d] ?? workspace.services[repoName]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
graph.set(addr, deps);
|
|
217
|
+
|
|
218
|
+
for (const dep of deps) {
|
|
219
|
+
if (!graph.has(dep)) queue.push(dep);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return graph;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Topologically sort the dependency graph using Kahn's algorithm.
|
|
228
|
+
* Returns addresses in startup order: dependencies before dependents.
|
|
229
|
+
* Throws if a cycle is detected.
|
|
230
|
+
*/
|
|
231
|
+
export function topoSort(graph: Map<string, string[]>): string[] {
|
|
232
|
+
const reversedInDegree = new Map<string, number>();
|
|
233
|
+
const reversedEdges = new Map<string, string[]>();
|
|
234
|
+
for (const addr of graph.keys()) {
|
|
235
|
+
reversedInDegree.set(addr, 0);
|
|
236
|
+
reversedEdges.set(addr, []);
|
|
237
|
+
}
|
|
238
|
+
for (const [addr, deps] of graph.entries()) {
|
|
239
|
+
reversedInDegree.set(addr, deps.length);
|
|
240
|
+
for (const dep of deps) {
|
|
241
|
+
reversedEdges.get(dep)!.push(addr);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ready: string[] = [];
|
|
246
|
+
for (const [addr, deg] of reversedInDegree.entries()) {
|
|
247
|
+
if (deg === 0) ready.push(addr);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const order: string[] = [];
|
|
251
|
+
while (ready.length > 0) {
|
|
252
|
+
const node = ready.shift()!;
|
|
253
|
+
order.push(node);
|
|
254
|
+
for (const dependent of reversedEdges.get(node) ?? []) {
|
|
255
|
+
const newDeg = (reversedInDegree.get(dependent) ?? 1) - 1;
|
|
256
|
+
reversedInDegree.set(dependent, newDeg);
|
|
257
|
+
if (newDeg === 0) ready.push(dependent);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (order.length !== graph.size) {
|
|
262
|
+
const cycle = [...graph.keys()].filter((a) => !order.includes(a));
|
|
263
|
+
throw new Error(`Dependency cycle detected among: ${cycle.join(", ")}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return order;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Given seed service addresses, return the full startup order including
|
|
271
|
+
* transitive dependencies (deps first). Deps not in the catalog are ignored.
|
|
272
|
+
*/
|
|
273
|
+
export function resolveStartupOrder(
|
|
274
|
+
addresses: string[],
|
|
275
|
+
catalog: ServiceCatalog,
|
|
276
|
+
workspace: WorkspaceConfig,
|
|
277
|
+
workspaceName: string,
|
|
278
|
+
): string[] {
|
|
279
|
+
const graph = buildDependencyGraph(
|
|
280
|
+
addresses,
|
|
281
|
+
catalog,
|
|
282
|
+
workspace,
|
|
283
|
+
workspaceName,
|
|
284
|
+
);
|
|
285
|
+
return topoSort(graph);
|
|
286
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-workspace blueprint + seeding.
|
|
3
|
+
*
|
|
4
|
+
* `config/den.yml` `defaults.workspace` declares the out-of-the-box repo set,
|
|
5
|
+
* all managed. On a fresh clone (no local workspace) den offers — once —
|
|
6
|
+
* to seed the `default` workspace from it so `den graph` / `den up` show the
|
|
7
|
+
* full system graph immediately; `den init` (run in the den root) re-seeds.
|
|
8
|
+
* Catalog clone URLs derive as `<github_org>/<repo>`. Switch any repo to a local
|
|
9
|
+
* checkout with `den use-local <repo>`.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
parseAddress,
|
|
13
|
+
readWorkspacesFile,
|
|
14
|
+
writeWorkspacesFile,
|
|
15
|
+
} from "./workspaces.js";
|
|
16
|
+
import { readCatalog, writeCatalog } from "./catalog.js";
|
|
17
|
+
import { readDenConfig, requireGithubOrg } from "../den-config.js";
|
|
18
|
+
import type { WorkspaceServiceEntry } from "../types/index.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BRANCH = "main";
|
|
21
|
+
|
|
22
|
+
export function defaultWorkspaceRepos(): string[] {
|
|
23
|
+
return readDenConfig().defaults?.workspace?.repos ?? [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function defaultBranch(): string {
|
|
27
|
+
return readDenConfig().defaults?.workspace?.branch ?? DEFAULT_BRANCH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function org(): string {
|
|
31
|
+
return requireGithubOrg();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True on a fresh clone: a blueprint exists but the `default` workspace is empty. */
|
|
35
|
+
export function defaultWorkspaceNeedsSeeding(): boolean {
|
|
36
|
+
if (defaultWorkspaceRepos().length === 0) return false;
|
|
37
|
+
const def = readWorkspacesFile().workspaces["default"];
|
|
38
|
+
return !def || Object.keys(def.services ?? {}).length === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether the `default` workspace already has services (so seeding would reset it). */
|
|
42
|
+
export function defaultWorkspaceHasServices(): boolean {
|
|
43
|
+
const def = readWorkspacesFile().workspaces["default"];
|
|
44
|
+
return !!def && Object.keys(def.services ?? {}).length > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Seed (or reset) the `default` workspace from the blueprint — every repo as a
|
|
49
|
+
* bare, first-class managed entry on `branch` — and ensure the catalog has
|
|
50
|
+
* clone URLs (`<org>/<repo>`, only filled where missing). Returns the seeded repos.
|
|
51
|
+
*/
|
|
52
|
+
export function seedDefaultWorkspace(): string[] {
|
|
53
|
+
const repos = defaultWorkspaceRepos();
|
|
54
|
+
const branch = defaultBranch();
|
|
55
|
+
// Team default for systems a fresh workspace should NOT auto-start (e.g.
|
|
56
|
+
// billing-api's `make up` opens an interactive Stripe tab). Personal after
|
|
57
|
+
// seeding — toggled per system in the web ⋮ menu.
|
|
58
|
+
const autostartOff = new Set(
|
|
59
|
+
readDenConfig().defaults?.workspace?.autostart_off ?? [],
|
|
60
|
+
);
|
|
61
|
+
const services: Record<string, WorkspaceServiceEntry> = {};
|
|
62
|
+
for (const repo of repos) {
|
|
63
|
+
services[repo] = {
|
|
64
|
+
mode: "managed",
|
|
65
|
+
branch,
|
|
66
|
+
...(autostartOff.has(repo) ? { autostart: false } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const file = readWorkspacesFile();
|
|
71
|
+
file.workspaces.default = {
|
|
72
|
+
...(file.workspaces.default ?? {}),
|
|
73
|
+
services,
|
|
74
|
+
seeded_repos: [...repos],
|
|
75
|
+
};
|
|
76
|
+
if (!file.active?.length) {
|
|
77
|
+
file.active = ["default"];
|
|
78
|
+
file.focus = "default";
|
|
79
|
+
}
|
|
80
|
+
writeWorkspacesFile(file);
|
|
81
|
+
|
|
82
|
+
const catalog = readCatalog();
|
|
83
|
+
const o = org();
|
|
84
|
+
for (const repo of repos) {
|
|
85
|
+
if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
|
|
86
|
+
}
|
|
87
|
+
writeCatalog(catalog);
|
|
88
|
+
|
|
89
|
+
return repos;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Converge an EXISTING `default` workspace on the blueprint. Seeding is
|
|
94
|
+
* one-shot, so a workspace seeded before a repo joined
|
|
95
|
+
* `defaults.workspace.repos` (tasks-api, say) would never gain it on its own.
|
|
96
|
+
* Adds each missing blueprint repo exactly as seeding would (bare
|
|
97
|
+
* managed entry on `branch`, team autostart default, catalog clone URL
|
|
98
|
+
* where absent) and returns the added names. No-op — and no file writes —
|
|
99
|
+
* when already converged, or when the `default` workspace is empty/missing:
|
|
100
|
+
* that state belongs to the seeding/declined flow, not top-up.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Converge an EXISTING `default` workspace on the blueprint. Seeding is
|
|
104
|
+
* one-shot, so a workspace seeded before a repo joined
|
|
105
|
+
* `defaults.workspace.repos` (tasks-api, say) would never gain it on its own.
|
|
106
|
+
*
|
|
107
|
+
* Converges on blueprint DELTAS, not set difference: only repos that JOINED
|
|
108
|
+
* the blueprint since it was last applied (`seeded_repos`) are candidates, so
|
|
109
|
+
* a standard repo the user removed from the workspace stays removed. Presence
|
|
110
|
+
* is checked at REPO level (workspace keys may be `repo/subsystem`
|
|
111
|
+
* addresses), so a repo held as subsystem entries never gains a duplicate
|
|
112
|
+
* bare entry. Added repos get exactly what seeding writes: bare
|
|
113
|
+
* managed entry on `branch`, team autostart default, catalog clone
|
|
114
|
+
* URL where absent. Returns the added names.
|
|
115
|
+
*
|
|
116
|
+
* No-op — and no file writes once converged — when the blueprint is empty or
|
|
117
|
+
* the `default` workspace is empty/missing (that state belongs to the
|
|
118
|
+
* seeding/declined flow). Workspaces from before `seeded_repos` existed
|
|
119
|
+
* treat the whole blueprint as new once — the migration cannot distinguish
|
|
120
|
+
* "removed" from "seeded before the repo joined"; afterwards removals stick.
|
|
121
|
+
*/
|
|
122
|
+
export function topUpDefaultWorkspace(): string[] {
|
|
123
|
+
const repos = defaultWorkspaceRepos();
|
|
124
|
+
if (repos.length === 0) return [];
|
|
125
|
+
|
|
126
|
+
const file = readWorkspacesFile();
|
|
127
|
+
const def = file.workspaces["default"];
|
|
128
|
+
if (!def || Object.keys(def.services ?? {}).length === 0) return [];
|
|
129
|
+
|
|
130
|
+
const applied = new Set(def.seeded_repos ?? []);
|
|
131
|
+
const catalog = readCatalog();
|
|
132
|
+
const present = new Set(
|
|
133
|
+
Object.keys(def.services).map((k) => parseAddress(k, catalog).repoName),
|
|
134
|
+
);
|
|
135
|
+
const branch = defaultBranch();
|
|
136
|
+
const autostartOff = new Set(
|
|
137
|
+
readDenConfig().defaults?.workspace?.autostart_off ?? [],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const added: string[] = [];
|
|
141
|
+
for (const repo of repos) {
|
|
142
|
+
if (applied.has(repo) || present.has(repo)) continue;
|
|
143
|
+
def.services[repo] = {
|
|
144
|
+
mode: "managed",
|
|
145
|
+
branch,
|
|
146
|
+
...(autostartOff.has(repo) ? { autostart: false } : {}),
|
|
147
|
+
};
|
|
148
|
+
added.push(repo);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const blueprintChanged =
|
|
152
|
+
def.seeded_repos?.length !== repos.length ||
|
|
153
|
+
repos.some((r, i) => def.seeded_repos?.[i] !== r);
|
|
154
|
+
if (added.length === 0 && !blueprintChanged) return [];
|
|
155
|
+
|
|
156
|
+
def.seeded_repos = [...repos];
|
|
157
|
+
writeWorkspacesFile(file);
|
|
158
|
+
|
|
159
|
+
if (added.length > 0) {
|
|
160
|
+
const o = org();
|
|
161
|
+
for (const repo of added) {
|
|
162
|
+
if (!catalog[repo]?.gh) catalog[repo] = { gh: `${o}/${repo}` };
|
|
163
|
+
}
|
|
164
|
+
writeCatalog(catalog);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return added;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Persist an empty `default` workspace so the one-time auto-seed prompt isn't
|
|
172
|
+
* asked again after the user declines it.
|
|
173
|
+
*/
|
|
174
|
+
export function markDefaultWorkspaceDeclined(): void {
|
|
175
|
+
const file = readWorkspacesFile();
|
|
176
|
+
file.workspaces.default = file.workspaces.default ?? { services: {} };
|
|
177
|
+
if (!file.active?.length) {
|
|
178
|
+
file.active = ["default"];
|
|
179
|
+
file.focus = "default";
|
|
180
|
+
}
|
|
181
|
+
writeWorkspacesFile(file);
|
|
182
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias normalization for dependency derivation.
|
|
3
|
+
*
|
|
4
|
+
* Service references in code/config use many spellings — `shop-api`, `backend`,
|
|
5
|
+
* `shop.backend`, `files-sdk`, `job-runner`, … — that all point at one
|
|
6
|
+
* canonical repo-system. The alias map (built-in defaults merged with
|
|
7
|
+
* `config/den.yml` `graph.aliases`) collapses those tokens to repo names,
|
|
8
|
+
* and its keys define the universe of known repo-systems.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { GraphConfig } from "../../den-config.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Built-in topology: none. Den ships no repo map of its own; the graph's
|
|
15
|
+
* repo-systems come from `config/den.yml` `graph.aliases`.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_ALIASES: Record<string, string[]> = {};
|
|
18
|
+
|
|
19
|
+
export interface AliasResolver {
|
|
20
|
+
/** Canonical repo-system names known to the graph. */
|
|
21
|
+
knownRepos: Set<string>;
|
|
22
|
+
/** The shared local-infra repo (`graph.infra.repo`), or null. */
|
|
23
|
+
infraRepo: string | null;
|
|
24
|
+
/** `graph.infra.signals`, compiled. */
|
|
25
|
+
infraSignals: RegExp[];
|
|
26
|
+
/** Resolve a raw token to a canonical repo name, or null if unknown. */
|
|
27
|
+
resolve(token: string): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Repos that are EVENT SINKS — you publish events to them (EventBridge) rather
|
|
30
|
+
* than call them synchronously. Detected by a `*-events` alias token (e.g.
|
|
31
|
+
* notes-api's `notes-events` SDK). Edges into these are reclassified to
|
|
32
|
+
* `event` so producer→sink flows show as events, not http.
|
|
33
|
+
*/
|
|
34
|
+
eventSinks: Set<string>;
|
|
35
|
+
/** GitHub orgs whose Go modules count as SDK imports (`graph.sdk_orgs`). */
|
|
36
|
+
sdkOrgs: string[];
|
|
37
|
+
/** npm scopes, without `@`, whose packages count as SDK imports (`graph.npm_scopes`). */
|
|
38
|
+
npmScopes: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Normalize a raw token to the form used for lookups. */
|
|
42
|
+
function norm(token: string): string {
|
|
43
|
+
return token.trim().toLowerCase().replace(/_/g, "-");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a resolver from the built-in defaults merged with config overrides.
|
|
48
|
+
* For a canonical repo, config aliases are ADDED to the defaults (not replaced)
|
|
49
|
+
* so partial config entries don't drop built-in spellings.
|
|
50
|
+
*/
|
|
51
|
+
export function buildAliasResolver(
|
|
52
|
+
config?: GraphConfig,
|
|
53
|
+
extraRepos: Iterable<string> = [],
|
|
54
|
+
): AliasResolver {
|
|
55
|
+
const merged: Record<string, string[]> = {};
|
|
56
|
+
for (const [repo, tokens] of Object.entries(DEFAULT_ALIASES)) {
|
|
57
|
+
merged[repo] = [...tokens];
|
|
58
|
+
}
|
|
59
|
+
for (const [repo, tokens] of Object.entries(config?.aliases ?? {})) {
|
|
60
|
+
merged[repo] = [...(merged[repo] ?? []), ...tokens];
|
|
61
|
+
}
|
|
62
|
+
// Every catalogued repo is a graph node, alias or not: with no built-in
|
|
63
|
+
// topology, a repo known only to the catalog would otherwise never appear
|
|
64
|
+
// and any edge to it (graph.edges included) would be dropped.
|
|
65
|
+
for (const repo of extraRepos) merged[repo] ??= [];
|
|
66
|
+
|
|
67
|
+
const lookup = new Map<string, string>();
|
|
68
|
+
const eventSinks = new Set<string>();
|
|
69
|
+
for (const repo of Object.keys(merged)) {
|
|
70
|
+
// The canonical name and its bare-name fragments resolve to itself.
|
|
71
|
+
lookup.set(norm(repo), repo);
|
|
72
|
+
for (const token of merged[repo]) lookup.set(norm(token), repo);
|
|
73
|
+
// A `*-events` alias token marks an event sink (its SDK publishes events).
|
|
74
|
+
if ([repo, ...merged[repo]].some((t) => /-events$/.test(norm(t)))) {
|
|
75
|
+
eventSinks.add(repo);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
knownRepos: new Set(Object.keys(merged)),
|
|
81
|
+
infraRepo: config?.infra?.repo?.trim() || null,
|
|
82
|
+
infraSignals: (config?.infra?.signals ?? []).map((s) => new RegExp(s)),
|
|
83
|
+
resolve(token: string): string | null {
|
|
84
|
+
return lookup.get(norm(token)) ?? null;
|
|
85
|
+
},
|
|
86
|
+
eventSinks,
|
|
87
|
+
sdkOrgs: config?.sdk_orgs ?? [],
|
|
88
|
+
npmScopes: (config?.npm_scopes ?? []).map((s) => s.replace(/^@/, "")),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-repo derivation cache.
|
|
3
|
+
*
|
|
4
|
+
* Discovery runs on every `den up`/`graph`, but a repo only needs re-parsing
|
|
5
|
+
* when its input files change. We fingerprint each repo's input file set by
|
|
6
|
+
* `mtime+size` (a cheap `stat`, no read) and cache the edges that scanning that
|
|
7
|
+
* repo produced. Warm runs are near-instant; the graph still always reflects
|
|
8
|
+
* current repo state. The cache is an optimization, never a source of truth —
|
|
9
|
+
* deleting it just forces a cold derive.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as crypto from "node:crypto";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
import { getDistRoot } from "../../paths.js";
|
|
16
|
+
import type { DerivedEdge } from "./types.js";
|
|
17
|
+
import { repoInputFiles } from "./scan.js";
|
|
18
|
+
|
|
19
|
+
// Bump whenever derivation RULES change (signals, alias handling, edge
|
|
20
|
+
// classification) — the fingerprint only tracks repo inputs, so without a
|
|
21
|
+
// bump every clone keeps serving graphs derived under the old rules.
|
|
22
|
+
const CACHE_VERSION = 3;
|
|
23
|
+
const CACHE_PATH = path.join(
|
|
24
|
+
getDistRoot(),
|
|
25
|
+
"config",
|
|
26
|
+
"local",
|
|
27
|
+
".graph-cache.json",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
interface CacheEntry {
|
|
31
|
+
fingerprint: string;
|
|
32
|
+
edges: DerivedEdge[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CacheFile {
|
|
36
|
+
version: number;
|
|
37
|
+
/** Keyed by `repoName@absoluteCheckoutPath` (a repo name is not unique across workspaces). */
|
|
38
|
+
repos: Record<string, CacheEntry>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stable fingerprint of a repo's input files (`mtime+size`, no content read). */
|
|
42
|
+
export function repoFingerprint(repoPath: string): string {
|
|
43
|
+
const hash = crypto.createHash("sha1");
|
|
44
|
+
for (const abs of repoInputFiles(repoPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const s = fs.statSync(abs);
|
|
47
|
+
hash.update(`${abs}:${s.mtimeMs}:${s.size}\n`);
|
|
48
|
+
} catch {
|
|
49
|
+
/* file vanished between listing and stat — ignore */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return hash.digest("hex");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readGraphCache(): CacheFile {
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(CACHE_PATH, "utf-8");
|
|
58
|
+
const parsed = JSON.parse(raw) as CacheFile;
|
|
59
|
+
if (parsed.version !== CACHE_VERSION)
|
|
60
|
+
return { version: CACHE_VERSION, repos: {} };
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return { version: CACHE_VERSION, repos: {} };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeGraphCache(cache: CacheFile): void {
|
|
68
|
+
try {
|
|
69
|
+
const dir = path.dirname(CACHE_PATH);
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
// Temp-then-rename so a concurrent reader (another den up/graph) never
|
|
72
|
+
// observes a half-written cache file. Unique temp name avoids writer
|
|
73
|
+
// collisions; rename is atomic within the same directory/filesystem.
|
|
74
|
+
const tmp = path.join(
|
|
75
|
+
dir,
|
|
76
|
+
`.graph-cache.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`,
|
|
77
|
+
);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(tmp, JSON.stringify(cache), "utf-8");
|
|
80
|
+
fs.renameSync(tmp, CACHE_PATH);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// Don't leave an orphaned temp file behind when the write/rename fails.
|
|
83
|
+
try {
|
|
84
|
+
fs.unlinkSync(tmp);
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore cleanup failure */
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* cache is best-effort — never fail a command because it couldn't write */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function emptyCache(): CacheFile {
|
|
96
|
+
return { version: CACHE_VERSION, repos: {} };
|
|
97
|
+
}
|