@monster-orange/den 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +68 -0
- package/docs/distributions.md +499 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +101 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +403 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +124 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +757 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/agent-actor.ts +77 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +688 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/status.ts +74 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +635 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +108 -0
- package/src/shared/environment-manager/log-tail.ts +213 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +335 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-container.ts +47 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1603 -0
- package/src/shared/environment-manager/sync-targets.ts +66 -0
- package/src/shared/environment-manager/up-plan.ts +326 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/merge-override.ts +75 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/placement.ts +321 -0
- package/src/shared/manifest/read-manifest.ts +278 -0
- package/src/shared/manifest/schema.ts +313 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-layers.ts +229 -0
- package/src/shared/manifest/validate-manifest.ts +200 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +190 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/redact.ts +107 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +118 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/status.ts +99 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +701 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +148 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +81 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +570 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3742 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/mcp-main.ts +13 -0
- package/src/ui/cli/mcp-stdout-guard.ts +10 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +117 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1319 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/mcp/api.ts +284 -0
- package/src/ui/mcp/guards.ts +141 -0
- package/src/ui/mcp/server.ts +147 -0
- package/src/ui/mcp/tools.ts +565 -0
- package/src/ui/server/audit.ts +71 -0
- package/src/ui/server/changelog-parser.ts +105 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/graph-prefs.ts +89 -0
- package/src/ui/server/health-row.ts +15 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3350 -0
- package/src/ui/server/log-history.ts +453 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +496 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/graph-model.js +416 -0
- package/src/ui/server/public/graph.css +976 -0
- package/src/ui/server/public/graph.js +986 -0
- package/src/ui/server/public/header.js +1373 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +5344 -0
- package/src/ui/server/public/logs.html +1496 -0
- package/src/ui/server/public/panel.js +787 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1103 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/readonly-routes.ts +200 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/ui-assets.ts +73 -0
- package/src/ui/server/workspace-routes.ts +2185 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages config/local/workspaces.yml and resolves which repo path / branch
|
|
5
|
+
* to use for a given service address in the active workspace.
|
|
6
|
+
*
|
|
7
|
+
* Addressing conventions:
|
|
8
|
+
* Single-service repo: "tasks-runner" → repoName="tasks-runner", subsystem="tasks-runner"
|
|
9
|
+
* Mono-repo subsystem: "shop/api" → repoName="shop", subsystem="api"
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import * as yaml from "yaml";
|
|
15
|
+
import type {
|
|
16
|
+
WorkspacesFile,
|
|
17
|
+
WorkspaceConfig,
|
|
18
|
+
ServiceCatalog,
|
|
19
|
+
ResolvedService,
|
|
20
|
+
WorkspaceServiceEntry,
|
|
21
|
+
RawManifest,
|
|
22
|
+
} from "../types/index.js";
|
|
23
|
+
import { readManifest } from "../manifest/read-manifest.js";
|
|
24
|
+
import { depService } from "../manifest/deps.js";
|
|
25
|
+
import { getDistRoot } from "../paths.js";
|
|
26
|
+
import { readCatalog } from "./catalog.js";
|
|
27
|
+
|
|
28
|
+
import { product } from "../product.js";
|
|
29
|
+
const ROOT = getDistRoot();
|
|
30
|
+
const WORKSPACES_PATH = path.join(ROOT, "config", "local", "workspaces.yml");
|
|
31
|
+
/** Sibling directory where den stores feature-workspace clones. */
|
|
32
|
+
export const DEN_WORKSPACES_DIR = path.join(
|
|
33
|
+
ROOT,
|
|
34
|
+
"..",
|
|
35
|
+
`${product().workspacesDir}`,
|
|
36
|
+
);
|
|
37
|
+
export const DEN_SERVICES_DIR = path.join(ROOT, `${product().servicesDir}`);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read a manifest for `repoName`, trying `primaryPath` first, then falling back
|
|
41
|
+
* to any other available clone of the same repo.
|
|
42
|
+
*
|
|
43
|
+
* This handles the case where the workspace branch doesn't have a .den.yml
|
|
44
|
+
* (e.g. the file was added after the feature branch was cut). We still want
|
|
45
|
+
* dependency information, so we look in:
|
|
46
|
+
* 1. Other branch clones under .den-services/<repoName>/
|
|
47
|
+
* 2. The default sibling-repo location (<ROOT>/../<repoName>)
|
|
48
|
+
*/
|
|
49
|
+
export function readManifestAnyBranch(
|
|
50
|
+
repoName: string,
|
|
51
|
+
primaryPath: string,
|
|
52
|
+
): RawManifest | null {
|
|
53
|
+
const primary = readManifest(primaryPath);
|
|
54
|
+
if (primary) return primary;
|
|
55
|
+
|
|
56
|
+
// Try other branch clones for this repo
|
|
57
|
+
const managedDir = path.join(DEN_SERVICES_DIR, repoName);
|
|
58
|
+
try {
|
|
59
|
+
const branches = fs.readdirSync(managedDir, { withFileTypes: true });
|
|
60
|
+
for (const b of branches) {
|
|
61
|
+
if (!b.isDirectory()) continue;
|
|
62
|
+
const m = readManifest(path.join(managedDir, b.name));
|
|
63
|
+
if (m) return m;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
/* no managed clones */
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Try the default sibling location (repos_root defaults to parent of ROOT)
|
|
70
|
+
const sibling = readManifest(path.join(ROOT, "..", repoName));
|
|
71
|
+
if (sibling) return sibling;
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEFAULT_FILE: WorkspacesFile = {
|
|
77
|
+
active: ["default"],
|
|
78
|
+
focus: "default",
|
|
79
|
+
workspaces: {
|
|
80
|
+
default: { services: {} },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Migrate persisted mode values from old names to current names. */
|
|
85
|
+
/**
|
|
86
|
+
* Mode values on disk → current names. "linked" is the old dev-managed;
|
|
87
|
+
* "<product>-managed" (written before 0.1.0, named after the distribution)
|
|
88
|
+
* is "managed".
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeMode(mode: unknown): "dev-managed" | "managed" {
|
|
91
|
+
if (mode === "linked" || mode === "dev-managed") return "dev-managed";
|
|
92
|
+
if (typeof mode === "string" && /^([a-z][a-z0-9-]*-)?managed$/.test(mode))
|
|
93
|
+
return "managed";
|
|
94
|
+
return (mode ?? "dev-managed") as "dev-managed" | "managed";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Coerce legacy `active: string` → `active: string[]`, ensure a valid focus,
|
|
99
|
+
* and drop active names that no longer exist. Returns whether the on-disk
|
|
100
|
+
* shape changed (caller may persist).
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeWorkspacesFileShape(parsed: {
|
|
103
|
+
active?: string | string[];
|
|
104
|
+
focus?: string;
|
|
105
|
+
workspaces?: Record<string, WorkspaceConfig>;
|
|
106
|
+
}): { file: WorkspacesFile; changed: boolean } {
|
|
107
|
+
let changed = false;
|
|
108
|
+
const workspaces =
|
|
109
|
+
parsed.workspaces && typeof parsed.workspaces === "object"
|
|
110
|
+
? parsed.workspaces
|
|
111
|
+
: {};
|
|
112
|
+
|
|
113
|
+
let active: string[];
|
|
114
|
+
if (typeof parsed.active === "string") {
|
|
115
|
+
active = parsed.active ? [parsed.active] : [];
|
|
116
|
+
changed = true;
|
|
117
|
+
} else if (Array.isArray(parsed.active)) {
|
|
118
|
+
active = parsed.active.filter((n): n is string => typeof n === "string");
|
|
119
|
+
if (active.length !== parsed.active.length) changed = true;
|
|
120
|
+
} else {
|
|
121
|
+
active = [];
|
|
122
|
+
changed = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const existing = active.filter((n) => Boolean(workspaces[n]));
|
|
126
|
+
if (existing.length !== active.length) changed = true;
|
|
127
|
+
active = existing;
|
|
128
|
+
|
|
129
|
+
if (active.length === 0) {
|
|
130
|
+
if (workspaces.default) {
|
|
131
|
+
active = ["default"];
|
|
132
|
+
} else {
|
|
133
|
+
const first = Object.keys(workspaces)[0];
|
|
134
|
+
if (first) active = [first];
|
|
135
|
+
}
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let focus = typeof parsed.focus === "string" ? parsed.focus : undefined;
|
|
140
|
+
if (!focus || !active.includes(focus)) {
|
|
141
|
+
focus = active[0];
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
file: { active, focus, workspaces },
|
|
147
|
+
changed,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Set once the migration rewrite has failed, so a read-only or otherwise
|
|
153
|
+
* unwritable config dir costs one failed write per process instead of one per
|
|
154
|
+
* `readWorkspacesFile` call. Normalization is in-memory anyway — callers always
|
|
155
|
+
* get the coerced shape whether or not it reaches disk.
|
|
156
|
+
*/
|
|
157
|
+
let migrationWriteFailed = false;
|
|
158
|
+
|
|
159
|
+
function persistNormalizedIfNeeded(
|
|
160
|
+
file: WorkspacesFile,
|
|
161
|
+
changed: boolean,
|
|
162
|
+
): void {
|
|
163
|
+
if (!changed || migrationWriteFailed) return;
|
|
164
|
+
try {
|
|
165
|
+
// The write is idempotent and lands via an atomic rename, so a concurrent
|
|
166
|
+
// CLI/web-server pair racing the same migration converges rather than
|
|
167
|
+
// corrupting the file.
|
|
168
|
+
writeWorkspacesFile(file);
|
|
169
|
+
} catch {
|
|
170
|
+
migrationWriteFailed = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function readWorkspacesFile(): WorkspacesFile {
|
|
175
|
+
try {
|
|
176
|
+
const raw = fs.readFileSync(WORKSPACES_PATH, "utf-8");
|
|
177
|
+
const parsedRaw = yaml.parse(raw) as unknown;
|
|
178
|
+
// A scalar/array (or empty) document would crash getActiveWorkspace's
|
|
179
|
+
// `file.workspaces[name]` later — fall back to a well-formed default.
|
|
180
|
+
if (!parsedRaw || typeof parsedRaw !== "object") {
|
|
181
|
+
return structuredClone(DEFAULT_FILE);
|
|
182
|
+
}
|
|
183
|
+
const parsed = parsedRaw as {
|
|
184
|
+
active?: string | string[];
|
|
185
|
+
focus?: string;
|
|
186
|
+
workspaces?: Record<string, WorkspaceConfig>;
|
|
187
|
+
};
|
|
188
|
+
if (!parsed.workspaces || typeof parsed.workspaces !== "object") {
|
|
189
|
+
parsed.workspaces = {};
|
|
190
|
+
}
|
|
191
|
+
// Normalize old mode values ("linked"/"managed") to current names.
|
|
192
|
+
for (const ws of Object.values(parsed.workspaces ?? {})) {
|
|
193
|
+
for (const entry of Object.values(
|
|
194
|
+
ws.services ?? {},
|
|
195
|
+
) as WorkspaceServiceEntry[]) {
|
|
196
|
+
(entry as { mode: string }).mode = normalizeMode(entry.mode);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const { file, changed } = normalizeWorkspacesFileShape(parsed);
|
|
200
|
+
persistNormalizedIfNeeded(file, changed);
|
|
201
|
+
return file;
|
|
202
|
+
} catch (err: unknown) {
|
|
203
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
204
|
+
return structuredClone(DEFAULT_FILE);
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* First-class multi-service repos: collapse per-subsystem sibling entries
|
|
211
|
+
* (`shop/db` + `shop/api`) into a single bare repo entry (`shop`). Den already
|
|
212
|
+
* treats a repo as the unit (derived-graph nodes, up/down ordering) and
|
|
213
|
+
* `expandMonoRepoEntry` re-expands a bare entry to its manifest subsystems, so the
|
|
214
|
+
* two shapes are functionally equivalent — this converges the *persisted* config
|
|
215
|
+
* to the first-class shape (the "lazy self-heal"). Catalog-aware so a nested-repo
|
|
216
|
+
* catalog key ("a/b") is never mistaken for a subsystem of "a". Idempotent; only
|
|
217
|
+
* collapses siblings that share identical mode/path/branch.
|
|
218
|
+
*/
|
|
219
|
+
export function normalizeWorkspaceServices(
|
|
220
|
+
config: WorkspaceConfig,
|
|
221
|
+
catalog: ServiceCatalog,
|
|
222
|
+
): WorkspaceConfig {
|
|
223
|
+
const services = config.services ?? {};
|
|
224
|
+
const bareRepos = new Set<string>();
|
|
225
|
+
const subsystemsByRepo = new Map<string, string[]>();
|
|
226
|
+
for (const key of Object.keys(services)) {
|
|
227
|
+
const { repoName } = parseAddress(key, catalog);
|
|
228
|
+
if (key === repoName) {
|
|
229
|
+
bareRepos.add(repoName);
|
|
230
|
+
} else if (key.startsWith(`${repoName}/`)) {
|
|
231
|
+
const list = subsystemsByRepo.get(repoName) ?? [];
|
|
232
|
+
list.push(key);
|
|
233
|
+
subsystemsByRepo.set(repoName, list);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let changed = false;
|
|
238
|
+
const next: Record<string, WorkspaceServiceEntry> = { ...services };
|
|
239
|
+
for (const [repo, keys] of subsystemsByRepo) {
|
|
240
|
+
const hasBare = bareRepos.has(repo);
|
|
241
|
+
// Collapse when ≥2 subsystem entries exist, OR a bare entry already coexists
|
|
242
|
+
// with subsystem entries (drop the redundant subsystem rows).
|
|
243
|
+
if (keys.length < 2 && !hasBare) continue;
|
|
244
|
+
const ref = hasBare ? services[repo] : services[keys[0]];
|
|
245
|
+
const sameConfig = keys.every(
|
|
246
|
+
(k) =>
|
|
247
|
+
services[k].mode === ref.mode &&
|
|
248
|
+
(services[k].path ?? "") === (ref.path ?? "") &&
|
|
249
|
+
(services[k].branch ?? "") === (ref.branch ?? ""),
|
|
250
|
+
);
|
|
251
|
+
if (!sameConfig) continue; // heterogeneous — leave as-is, don't guess
|
|
252
|
+
// Preserve an auto-start-off toggle from any collapsed sibling.
|
|
253
|
+
const autostartOff =
|
|
254
|
+
(hasBare && services[repo].autostart === false) ||
|
|
255
|
+
keys.some((k) => services[k].autostart === false);
|
|
256
|
+
for (const k of keys) delete next[k];
|
|
257
|
+
next[repo] = {
|
|
258
|
+
mode: ref.mode,
|
|
259
|
+
...(ref.path ? { path: ref.path } : {}),
|
|
260
|
+
...(ref.branch ? { branch: ref.branch } : {}),
|
|
261
|
+
...(autostartOff ? { autostart: false } : {}),
|
|
262
|
+
};
|
|
263
|
+
changed = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return changed ? { ...config, services: next } : config;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Repos the user toggled to auto-start OFF in this workspace
|
|
271
|
+
* (`autostart: false` on the service entry — set from the web ⋮ menu).
|
|
272
|
+
* These are skipped by the STARTING half of workspace-wide ops (up/restart);
|
|
273
|
+
* workspace down still stops them, and explicit targeting always wins.
|
|
274
|
+
* Entry keys may be bare repo names or "repo/subsystem" — resolved to repo
|
|
275
|
+
* level, since the toggle is a per-system concept.
|
|
276
|
+
*/
|
|
277
|
+
export function noAutostartRepos(
|
|
278
|
+
config: WorkspaceConfig,
|
|
279
|
+
catalog: ServiceCatalog,
|
|
280
|
+
): Set<string> {
|
|
281
|
+
const off = new Set<string>();
|
|
282
|
+
for (const [key, entry] of Object.entries(config.services ?? {})) {
|
|
283
|
+
if (entry.autostart === false) {
|
|
284
|
+
off.add(parseAddress(key, catalog).repoName);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return off;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function writeWorkspacesFile(file: WorkspacesFile): void {
|
|
291
|
+
const dir = path.dirname(WORKSPACES_PATH);
|
|
292
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
293
|
+
// Self-heal to first-class repo entries on persist. Best-effort catalog read —
|
|
294
|
+
// if it fails (e.g. none yet), normalize without it (still correct for the
|
|
295
|
+
// common single-slash subsystem case).
|
|
296
|
+
let catalog: ServiceCatalog = {};
|
|
297
|
+
try {
|
|
298
|
+
catalog = readCatalog();
|
|
299
|
+
} catch {
|
|
300
|
+
catalog = {};
|
|
301
|
+
}
|
|
302
|
+
const out: WorkspacesFile = {
|
|
303
|
+
...file,
|
|
304
|
+
workspaces: Object.fromEntries(
|
|
305
|
+
Object.entries(file.workspaces ?? {}).map(([name, ws]) => [
|
|
306
|
+
name,
|
|
307
|
+
normalizeWorkspaceServices(ws, catalog),
|
|
308
|
+
]),
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
// Write to a temp file then atomically rename, so a crash or a concurrent
|
|
312
|
+
// den process can never observe a half-written workspaces.yml.
|
|
313
|
+
const tmp = path.join(dir, `.workspaces.${process.pid}.tmp`);
|
|
314
|
+
try {
|
|
315
|
+
fs.writeFileSync(tmp, yaml.stringify(out), "utf-8");
|
|
316
|
+
fs.renameSync(tmp, WORKSPACES_PATH);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
// Don't leave an orphaned temp file behind when the write/rename fails.
|
|
319
|
+
try {
|
|
320
|
+
fs.unlinkSync(tmp);
|
|
321
|
+
} catch {
|
|
322
|
+
/* ignore cleanup failure */
|
|
323
|
+
}
|
|
324
|
+
throw err;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function getWorkspaceByName(name: string): {
|
|
329
|
+
name: string;
|
|
330
|
+
config: WorkspaceConfig;
|
|
331
|
+
} | null {
|
|
332
|
+
const file = readWorkspacesFile();
|
|
333
|
+
const config = file.workspaces[name];
|
|
334
|
+
if (!config) return null;
|
|
335
|
+
return { name, config };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Require `name` to be in the active set. Throws a clear activate-first error.
|
|
340
|
+
*/
|
|
341
|
+
export function requireActiveWorkspace(name: string): {
|
|
342
|
+
name: string;
|
|
343
|
+
config: WorkspaceConfig;
|
|
344
|
+
} {
|
|
345
|
+
const ws = getWorkspaceByName(name);
|
|
346
|
+
if (!ws) {
|
|
347
|
+
throw new Error(`Workspace "${name}" does not exist`);
|
|
348
|
+
}
|
|
349
|
+
if (!isWorkspaceActive(name)) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`Workspace "${name}" is not active — run \`${product().cli} workspace activate ${name}\` first`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return ws;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Names of all currently active workspaces. */
|
|
358
|
+
export function getActiveWorkspaces(): string[] {
|
|
359
|
+
return [...readWorkspacesFile().active];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function isWorkspaceActive(name: string): boolean {
|
|
363
|
+
return getActiveWorkspaces().includes(name);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Focused workspace name: DEN_WORKSPACE_OVERRIDE → focus → active[0].
|
|
368
|
+
* Returns null when the resolved name is missing from the file.
|
|
369
|
+
*/
|
|
370
|
+
export function getFocusedWorkspaceName(): string | null {
|
|
371
|
+
const file = readWorkspacesFile();
|
|
372
|
+
const name =
|
|
373
|
+
process.env.DEN_WORKSPACE_OVERRIDE ?? file.focus ?? file.active[0] ?? null;
|
|
374
|
+
if (!name || !file.workspaces[name]) return null;
|
|
375
|
+
return name;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Focused (default-target) workspace. Back-compat name: historically this
|
|
380
|
+
* returned the single active workspace; it now returns the focus.
|
|
381
|
+
*/
|
|
382
|
+
export function getActiveWorkspace(): {
|
|
383
|
+
name: string;
|
|
384
|
+
config: WorkspaceConfig;
|
|
385
|
+
} | null {
|
|
386
|
+
const file = readWorkspacesFile();
|
|
387
|
+
const name = getFocusedWorkspaceName();
|
|
388
|
+
if (!name) return null;
|
|
389
|
+
const config = file.workspaces[name];
|
|
390
|
+
if (!config) return null;
|
|
391
|
+
return { name, config };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Alias for clarity at call sites that mean focus, not the active set. */
|
|
395
|
+
export function getFocusedWorkspace(): {
|
|
396
|
+
name: string;
|
|
397
|
+
config: WorkspaceConfig;
|
|
398
|
+
} | null {
|
|
399
|
+
return getActiveWorkspace();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function activateWorkspace(name: string): void {
|
|
403
|
+
const file = readWorkspacesFile();
|
|
404
|
+
if (!file.workspaces[name]) {
|
|
405
|
+
throw new Error(`Workspace "${name}" does not exist`);
|
|
406
|
+
}
|
|
407
|
+
if (!file.active.includes(name)) {
|
|
408
|
+
file.active.push(name);
|
|
409
|
+
}
|
|
410
|
+
writeWorkspacesFile(file);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function deactivateWorkspace(name: string): void {
|
|
414
|
+
const file = readWorkspacesFile();
|
|
415
|
+
if (!file.active.includes(name)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
file.active = file.active.filter((n) => n !== name);
|
|
419
|
+
if (file.active.length === 0) {
|
|
420
|
+
if (file.workspaces.default) {
|
|
421
|
+
file.active = ["default"];
|
|
422
|
+
} else {
|
|
423
|
+
const first = Object.keys(file.workspaces)[0];
|
|
424
|
+
if (first) file.active = [first];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!file.active.includes(file.focus ?? "")) {
|
|
428
|
+
file.focus = file.active[0];
|
|
429
|
+
}
|
|
430
|
+
writeWorkspacesFile(file);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function setFocusedWorkspace(name: string): void {
|
|
434
|
+
const file = readWorkspacesFile();
|
|
435
|
+
if (!file.workspaces[name]) {
|
|
436
|
+
throw new Error(`Workspace "${name}" does not exist`);
|
|
437
|
+
}
|
|
438
|
+
if (!file.active.includes(name)) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Workspace "${name}" is not active — run \`${product().cli} workspace activate ${name}\` first`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
file.focus = name;
|
|
444
|
+
writeWorkspacesFile(file);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Activate `name` and set it as focus (back-compat for exclusive "use"). */
|
|
448
|
+
export function setActiveWorkspace(name: string): void {
|
|
449
|
+
const file = readWorkspacesFile();
|
|
450
|
+
if (!file.workspaces[name]) {
|
|
451
|
+
throw new Error(`Workspace "${name}" does not exist`);
|
|
452
|
+
}
|
|
453
|
+
if (!file.active.includes(name)) {
|
|
454
|
+
file.active.push(name);
|
|
455
|
+
}
|
|
456
|
+
file.focus = name;
|
|
457
|
+
writeWorkspacesFile(file);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function createWorkspace(name: string, config: WorkspaceConfig): void {
|
|
461
|
+
const file = readWorkspacesFile();
|
|
462
|
+
file.workspaces[name] = config;
|
|
463
|
+
writeWorkspacesFile(file);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function deleteWorkspaceConfig(name: string): void {
|
|
467
|
+
const file = readWorkspacesFile();
|
|
468
|
+
delete file.workspaces[name];
|
|
469
|
+
file.active = file.active.filter((n) => n !== name);
|
|
470
|
+
if (file.active.length === 0) {
|
|
471
|
+
if (file.workspaces.default) {
|
|
472
|
+
file.active = ["default"];
|
|
473
|
+
} else {
|
|
474
|
+
const first = Object.keys(file.workspaces)[0];
|
|
475
|
+
if (first) file.active = [first];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!file.focus || !file.active.includes(file.focus)) {
|
|
479
|
+
file.focus = file.active[0];
|
|
480
|
+
}
|
|
481
|
+
writeWorkspacesFile(file);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Parse a service address into repo name and subsystem.
|
|
486
|
+
*
|
|
487
|
+
* With multi-level repo support, catalog keys may contain slashes
|
|
488
|
+
* (e.g. "projectA/serviceA"). Pass the catalog so that slash-delimited
|
|
489
|
+
* catalog keys are distinguished from mono-repo "repoName/subsystem" addresses.
|
|
490
|
+
*
|
|
491
|
+
* Resolution order:
|
|
492
|
+
* 1. Exact catalog match → bare repo address ("projectA/serviceA" is the repo)
|
|
493
|
+
* 2. Longest catalog-key prefix → mono-repo subsystem ("shop" + "api" → shop/api)
|
|
494
|
+
* 3. Fallback: first slash (no catalog provided, or no match found)
|
|
495
|
+
*/
|
|
496
|
+
export function parseAddress(
|
|
497
|
+
address: string,
|
|
498
|
+
catalog?: ServiceCatalog,
|
|
499
|
+
): { repoName: string; subsystem: string } {
|
|
500
|
+
if (!address.includes("/")) return { repoName: address, subsystem: address };
|
|
501
|
+
|
|
502
|
+
// Exact match: the full address is itself a catalog key (nested repo path).
|
|
503
|
+
if (catalog?.[address]) return { repoName: address, subsystem: address };
|
|
504
|
+
|
|
505
|
+
// Try progressively shorter prefixes (longest first) to find a catalog key.
|
|
506
|
+
if (catalog) {
|
|
507
|
+
let pos = address.lastIndexOf("/");
|
|
508
|
+
while (pos > 0) {
|
|
509
|
+
const prefix = address.slice(0, pos);
|
|
510
|
+
if (catalog[prefix]) {
|
|
511
|
+
return { repoName: prefix, subsystem: address.slice(pos + 1) };
|
|
512
|
+
}
|
|
513
|
+
pos = address.lastIndexOf("/", pos - 1);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Fallback: split at first slash (handles shop/api when catalog not provided).
|
|
518
|
+
const slash = address.indexOf("/");
|
|
519
|
+
return {
|
|
520
|
+
repoName: address.slice(0, slash),
|
|
521
|
+
subsystem: address.slice(slash + 1),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Resolve the filesystem path and branch for a service address given an active
|
|
527
|
+
* workspace and catalog.
|
|
528
|
+
*
|
|
529
|
+
* Path resolution:
|
|
530
|
+
* feature workspace repos map → .den-workspaces/<ws>/<repo>/
|
|
531
|
+
* mode=managed → .den-services/<repo>/<branch>/
|
|
532
|
+
* mode=dev-managed → workspace service entry path
|
|
533
|
+
*
|
|
534
|
+
* Managed clones are scoped per workspace so that different workspaces
|
|
535
|
+
* can track the same repo at different branches simultaneously.
|
|
536
|
+
*/
|
|
537
|
+
export function resolveServiceSource(
|
|
538
|
+
address: string,
|
|
539
|
+
_workspaceName: string,
|
|
540
|
+
workspace: WorkspaceConfig,
|
|
541
|
+
catalog: ServiceCatalog,
|
|
542
|
+
): ResolvedService | null {
|
|
543
|
+
const { repoName, subsystem } = parseAddress(address, catalog);
|
|
544
|
+
if (!catalog[repoName]) return null;
|
|
545
|
+
|
|
546
|
+
// Try the exact address first; fall back to the bare repo-name key so that
|
|
547
|
+
// expanded addresses like "shop/api" resolve against a bare "shop" entry.
|
|
548
|
+
const wsEntry = workspace.services[address] ?? workspace.services[repoName];
|
|
549
|
+
if (!wsEntry) return null;
|
|
550
|
+
|
|
551
|
+
const { mode } = wsEntry;
|
|
552
|
+
let repoPath: string;
|
|
553
|
+
let branch: string | null = null;
|
|
554
|
+
|
|
555
|
+
const reposBranch = workspace.repos?.[repoName];
|
|
556
|
+
if (reposBranch && workspace.directory) {
|
|
557
|
+
// Feature workspace clone
|
|
558
|
+
repoPath = path.join(ROOT, workspace.directory, repoName);
|
|
559
|
+
branch = reposBranch;
|
|
560
|
+
} else if (mode === "managed") {
|
|
561
|
+
// Clones live at .den-services/<repo>/<branch>/ so the same repo at
|
|
562
|
+
// different branches can coexist and be shared across workspaces.
|
|
563
|
+
branch = wsEntry.branch ?? "main";
|
|
564
|
+
repoPath = path.join(DEN_SERVICES_DIR, repoName, branch);
|
|
565
|
+
} else {
|
|
566
|
+
repoPath = wsEntry.path ?? "";
|
|
567
|
+
branch = null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return { address, repoName, subsystem, repoPath, branch, mode };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Resolve the concrete subsystem for a bare repo address.
|
|
575
|
+
*
|
|
576
|
+
* Two repo shapes are supported:
|
|
577
|
+
*
|
|
578
|
+
* Single-service repo — exactly one service in the manifest, regardless of
|
|
579
|
+
* what that service key is called. The workspace address stays as the repo
|
|
580
|
+
* name; we just correct the subsystem to the manifest key so command lookup
|
|
581
|
+
* works. e.g. repo "tasks-runner", manifest key "tool-runner"
|
|
582
|
+
* → address stays "tasks-runner", subsystem becomes "tool-runner".
|
|
583
|
+
*
|
|
584
|
+
* Mono-repo — multiple services. The workspace entry must use an explicit
|
|
585
|
+
* "repo/subsystem" address for each service it wants to include.
|
|
586
|
+
* As a convenience, a bare "repo" entry auto-expands to ALL subsystems
|
|
587
|
+
* when none of the service keys matches the repo name.
|
|
588
|
+
*
|
|
589
|
+
* If the address already contains a slash it is an explicit subsystem address
|
|
590
|
+
* and is returned unchanged.
|
|
591
|
+
*/
|
|
592
|
+
function expandMonoRepoEntry(resolved: ResolvedService): ResolvedService[] {
|
|
593
|
+
if (resolved.subsystem !== resolved.repoName) {
|
|
594
|
+
// Already an explicit "repo/subsystem" address — nothing to do.
|
|
595
|
+
return [resolved];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const manifest = readManifestAnyBranch(resolved.repoName, resolved.repoPath);
|
|
599
|
+
const serviceKeys = Object.keys(manifest?.services ?? {});
|
|
600
|
+
|
|
601
|
+
if (serviceKeys.length === 0) {
|
|
602
|
+
// No manifest services defined — pass through as-is.
|
|
603
|
+
return [resolved];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (serviceKeys.length === 1) {
|
|
607
|
+
// Single-service repo. The service key may differ from the repo name
|
|
608
|
+
// (e.g. repo "tasks-runner", key "tool-runner"). Correct
|
|
609
|
+
// the subsystem so command lookup hits the right manifest entry while
|
|
610
|
+
// keeping the clean repo-name address visible everywhere.
|
|
611
|
+
return [{ ...resolved, subsystem: serviceKeys[0] }];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (serviceKeys.includes(resolved.repoName)) {
|
|
615
|
+
const rootService = {
|
|
616
|
+
...resolved,
|
|
617
|
+
address: resolved.repoName,
|
|
618
|
+
subsystem: resolved.repoName,
|
|
619
|
+
};
|
|
620
|
+
const subsystemServices = serviceKeys
|
|
621
|
+
.filter((subsystem) => subsystem !== resolved.repoName)
|
|
622
|
+
.map((subsystem) => ({
|
|
623
|
+
...resolved,
|
|
624
|
+
address: `${resolved.repoName}/${subsystem}`,
|
|
625
|
+
subsystem,
|
|
626
|
+
}));
|
|
627
|
+
return [rootService, ...subsystemServices];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Bare mono-repo entry: expand to one resolved entry per subsystem.
|
|
631
|
+
// (If the user wants only a specific subsystem they use "shop/shop" explicitly.)
|
|
632
|
+
return serviceKeys.map((subsystem) => ({
|
|
633
|
+
...resolved,
|
|
634
|
+
address: `${resolved.repoName}/${subsystem}`,
|
|
635
|
+
subsystem,
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Compute missing dependencies for every service in a workspace.
|
|
641
|
+
*
|
|
642
|
+
* Returns one entry per service that has at least one `depends_on` entry not
|
|
643
|
+
* covered by the workspace. Services whose manifest cannot be read (e.g. the
|
|
644
|
+
* repo hasn't been cloned yet) are silently skipped.
|
|
645
|
+
*/
|
|
646
|
+
export function computeMissingDeps(
|
|
647
|
+
workspace: WorkspaceConfig,
|
|
648
|
+
catalog: ServiceCatalog,
|
|
649
|
+
workspaceName: string,
|
|
650
|
+
): Array<{ address: string; missingDeps: string[] }> {
|
|
651
|
+
const expandedServices = listWorkspaceServices(
|
|
652
|
+
workspace,
|
|
653
|
+
catalog,
|
|
654
|
+
workspaceName,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
// Build the full set of present addresses — both expanded "shop/api" forms
|
|
658
|
+
// and the bare repo name "shop" so either form matches in the filter below.
|
|
659
|
+
const existingAddresses = new Set<string>();
|
|
660
|
+
for (const s of expandedServices) {
|
|
661
|
+
existingAddresses.add(s.address);
|
|
662
|
+
existingAddresses.add(s.repoName);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const result: Array<{ address: string; missingDeps: string[] }> = [];
|
|
666
|
+
|
|
667
|
+
for (const resolved of expandedServices) {
|
|
668
|
+
try {
|
|
669
|
+
const manifest = readManifestAnyBranch(
|
|
670
|
+
resolved.repoName,
|
|
671
|
+
resolved.repoPath,
|
|
672
|
+
);
|
|
673
|
+
const deps = manifest?.services?.[resolved.subsystem]?.depends_on ?? [];
|
|
674
|
+
const missingDeps = deps.map(depService).filter((d) => {
|
|
675
|
+
const resolvedDep = resolveDepAddress(
|
|
676
|
+
d,
|
|
677
|
+
catalog,
|
|
678
|
+
workspace,
|
|
679
|
+
workspaceName,
|
|
680
|
+
);
|
|
681
|
+
const { repoName: depRepo } = parseAddress(resolvedDep, catalog);
|
|
682
|
+
return (
|
|
683
|
+
!existingAddresses.has(resolvedDep) && !existingAddresses.has(depRepo)
|
|
684
|
+
);
|
|
685
|
+
});
|
|
686
|
+
if (missingDeps.length > 0)
|
|
687
|
+
result.push({ address: resolved.address, missingDeps });
|
|
688
|
+
} catch {
|
|
689
|
+
/* manifest not readable — skip */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Normalize a bare `depends_on` entry to its canonical service address.
|
|
698
|
+
*
|
|
699
|
+
* If `dep` already contains "/", it is returned as-is (explicit subsystem).
|
|
700
|
+
* Otherwise it's a bare repo name and resolves as follows:
|
|
701
|
+
* - Multi-service repo with `entrypoint:` declared → "repoName/entrypoint"
|
|
702
|
+
* - All other cases (single-service repo with or without entrypoint,
|
|
703
|
+
* multi-service repo with no entrypoint, repo not in workspace, repo
|
|
704
|
+
* not in catalog) → returned as-is (bare repo name).
|
|
705
|
+
*
|
|
706
|
+
* Single-service repos are addressed by bare repo name regardless of an
|
|
707
|
+
* `entrypoint:` declaration in their manifest — `expandMonoRepoEntry` keeps
|
|
708
|
+
* the display address bare for them, and the init CLI only emits entrypoint
|
|
709
|
+
* for multi-service repos. Appending the entrypoint subsystem here for a
|
|
710
|
+
* single-service repo would produce an address that doesn't match the
|
|
711
|
+
* display node, turning dep edges into orphan ghosts in the workspace graph.
|
|
712
|
+
*/
|
|
713
|
+
export function resolveDepAddress(
|
|
714
|
+
dep: string,
|
|
715
|
+
catalog: ServiceCatalog,
|
|
716
|
+
workspace: WorkspaceConfig,
|
|
717
|
+
workspaceName: string,
|
|
718
|
+
): string {
|
|
719
|
+
if (dep.includes("/")) return dep; // already explicit — pass through
|
|
720
|
+
if (!catalog[dep]) return dep; // not in catalog — leave as-is
|
|
721
|
+
|
|
722
|
+
const resolved = resolveServiceSource(dep, workspaceName, workspace, catalog);
|
|
723
|
+
if (!resolved) return dep;
|
|
724
|
+
|
|
725
|
+
// Use readManifestAnyBranch (not readManifest) so we stay consistent with
|
|
726
|
+
// expandMonoRepoEntry / computeMissingDeps. If the workspace branch lacks
|
|
727
|
+
// .den.yml (e.g. file was added after the feature branch was cut), a
|
|
728
|
+
// direct readManifest returns null here and the dep falls back to bare —
|
|
729
|
+
// but expansion would still produce a `repo/entrypoint` display node from
|
|
730
|
+
// an alt-branch manifest, leaving the edge dangling.
|
|
731
|
+
const manifest = readManifestAnyBranch(resolved.repoName, resolved.repoPath);
|
|
732
|
+
if (!manifest?.entrypoint) return dep;
|
|
733
|
+
|
|
734
|
+
// Single-service repos: bare name is canonical (see expandMonoRepoEntry).
|
|
735
|
+
// The entrypoint declaration is off-convention here but is tolerated so a
|
|
736
|
+
// hand-authored manifest doesn't silently break the dep graph.
|
|
737
|
+
const serviceKeys = Object.keys(manifest.services ?? {});
|
|
738
|
+
if (serviceKeys.length <= 1) return dep;
|
|
739
|
+
|
|
740
|
+
return `${dep}/${manifest.entrypoint}`;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Return all service addresses defined in the active workspace that have
|
|
745
|
+
* a corresponding catalog entry. Mono-repo root entries are expanded to
|
|
746
|
+
* per-subsystem addresses using the repo's .den.yml.
|
|
747
|
+
*/
|
|
748
|
+
export function listWorkspaceServices(
|
|
749
|
+
workspace: WorkspaceConfig,
|
|
750
|
+
catalog: ServiceCatalog,
|
|
751
|
+
workspaceName: string,
|
|
752
|
+
): ResolvedService[] {
|
|
753
|
+
const results: ResolvedService[] = [];
|
|
754
|
+
for (const address of Object.keys(workspace.services)) {
|
|
755
|
+
const resolved = resolveServiceSource(
|
|
756
|
+
address,
|
|
757
|
+
workspaceName,
|
|
758
|
+
workspace,
|
|
759
|
+
catalog,
|
|
760
|
+
);
|
|
761
|
+
if (resolved) {
|
|
762
|
+
results.push(...expandMonoRepoEntry(resolved));
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return results;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Sync symlinks for JetBrains: each dev-managed repo outside the workspace
|
|
770
|
+
* folder gets a symlink inside it so JetBrains detects the .git roots.
|
|
771
|
+
* Only dev-managed repos are linked. Idempotent.
|
|
772
|
+
*/
|
|
773
|
+
export function syncJetBrainsWorkspaceSymlinks(wsFile: WorkspacesFile): void {
|
|
774
|
+
for (const [name, ws] of Object.entries(wsFile.workspaces)) {
|
|
775
|
+
const wsDir = path.join(DEN_WORKSPACES_DIR, name);
|
|
776
|
+
try {
|
|
777
|
+
fs.mkdirSync(wsDir, { recursive: true });
|
|
778
|
+
} catch {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const seen = new Set<string>();
|
|
782
|
+
for (const svc of Object.values(
|
|
783
|
+
ws.services ?? {},
|
|
784
|
+
) as WorkspaceServiceEntry[]) {
|
|
785
|
+
if (svc.mode !== "dev-managed" || !svc.path) continue;
|
|
786
|
+
const target = svc.path;
|
|
787
|
+
if (seen.has(target)) continue;
|
|
788
|
+
seen.add(target);
|
|
789
|
+
if (target.startsWith(wsDir + path.sep) || target === wsDir) continue;
|
|
790
|
+
const linkPath = path.join(wsDir, path.basename(target));
|
|
791
|
+
try {
|
|
792
|
+
const stat = fs.lstatSync(linkPath);
|
|
793
|
+
if (stat.isSymbolicLink()) {
|
|
794
|
+
if (fs.readlinkSync(linkPath) === target) continue;
|
|
795
|
+
fs.unlinkSync(linkPath);
|
|
796
|
+
} else {
|
|
797
|
+
continue; // real file/dir — don't overwrite
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
// doesn't exist — fall through to create
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
fs.symlinkSync(target, linkPath);
|
|
804
|
+
} catch {
|
|
805
|
+
/* best-effort */
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Generate (or update) a VS Code multi-root workspace file for a den workspace.
|
|
813
|
+
*
|
|
814
|
+
* Only dev-managed repos are included — managed clones are excluded.
|
|
815
|
+
* Dev-managed repos inside the workspace directory are listed with a relative
|
|
816
|
+
* path (portable); those outside are listed with their absolute path — no
|
|
817
|
+
* symlinks are created.
|
|
818
|
+
*
|
|
819
|
+
* Returns the absolute path to the generated `.code-workspace` file.
|
|
820
|
+
*/
|
|
821
|
+
export function generateVSCodeWorkspace(
|
|
822
|
+
workspaceName: string,
|
|
823
|
+
workspace: WorkspaceConfig,
|
|
824
|
+
catalog: ServiceCatalog,
|
|
825
|
+
): string {
|
|
826
|
+
const wsDir = workspace.directory
|
|
827
|
+
? path.join(ROOT, workspace.directory)
|
|
828
|
+
: path.join(DEN_WORKSPACES_DIR, workspaceName);
|
|
829
|
+
|
|
830
|
+
fs.mkdirSync(wsDir, { recursive: true });
|
|
831
|
+
|
|
832
|
+
// One entry per unique repo path (mono-repos expand to multiple services).
|
|
833
|
+
const services = listWorkspaceServices(workspace, catalog, workspaceName);
|
|
834
|
+
const seen = new Set<string>();
|
|
835
|
+
const folders: Array<{ name: string; path: string }> = [];
|
|
836
|
+
|
|
837
|
+
for (const svc of services) {
|
|
838
|
+
if (svc.mode !== "dev-managed") continue;
|
|
839
|
+
if (!svc.repoPath || seen.has(svc.repoPath)) continue;
|
|
840
|
+
seen.add(svc.repoPath);
|
|
841
|
+
|
|
842
|
+
// Repos already inside the workspace folder (real clone or symlink) → relative.
|
|
843
|
+
// Repos outside (dev-managed elsewhere) → absolute path.
|
|
844
|
+
const insideWs =
|
|
845
|
+
svc.repoPath === wsDir || svc.repoPath.startsWith(wsDir + path.sep);
|
|
846
|
+
|
|
847
|
+
folders.push({
|
|
848
|
+
name: svc.repoName,
|
|
849
|
+
path: insideWs ? path.relative(wsDir, svc.repoPath) : svc.repoPath,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const outPath = path.join(wsDir, `${workspaceName}.code-workspace`);
|
|
854
|
+
fs.writeFileSync(
|
|
855
|
+
outPath,
|
|
856
|
+
JSON.stringify({ folders }, null, 2) + "\n",
|
|
857
|
+
"utf-8",
|
|
858
|
+
);
|
|
859
|
+
return outPath;
|
|
860
|
+
}
|