@monster-orange/den 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +165 -1
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +99 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace API route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from server/index.ts. Owns all /api/workspaces/* endpoints
|
|
5
|
+
* including workspace CRUD, service management, available repos,
|
|
6
|
+
* dependency checks, workspace-level commands, and sync.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
10
|
+
import { loadApplicationTree } from "../../shared/environment-manager/load-applications.js";
|
|
11
|
+
import { localCommandsFor } from "../../shared/stacks/manifest-commands.js";
|
|
12
|
+
import * as http from "http";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import { readBody, sendJson, send400 } from "./http-helpers.js";
|
|
17
|
+
import type { ResolvedService } from "../../shared/types/index.js";
|
|
18
|
+
import type { EditorManager } from "./editor-manager.js";
|
|
19
|
+
import type { GitHubService } from "./github-service.js";
|
|
20
|
+
import { JETBRAINS_EDITORS } from "./editor-manager.js";
|
|
21
|
+
import {
|
|
22
|
+
readWorkspacesFile,
|
|
23
|
+
writeWorkspacesFile,
|
|
24
|
+
setActiveWorkspace,
|
|
25
|
+
activateWorkspace,
|
|
26
|
+
deactivateWorkspace,
|
|
27
|
+
setFocusedWorkspace,
|
|
28
|
+
getActiveWorkspace,
|
|
29
|
+
getWorkspaceByName,
|
|
30
|
+
isWorkspaceActive,
|
|
31
|
+
createWorkspace,
|
|
32
|
+
deleteWorkspaceConfig,
|
|
33
|
+
listWorkspaceServices,
|
|
34
|
+
resolveServiceSource,
|
|
35
|
+
parseAddress,
|
|
36
|
+
noAutostartRepos,
|
|
37
|
+
computeMissingDeps,
|
|
38
|
+
generateVSCodeWorkspace,
|
|
39
|
+
syncJetBrainsWorkspaceSymlinks,
|
|
40
|
+
DEN_SERVICES_DIR,
|
|
41
|
+
DEN_WORKSPACES_DIR,
|
|
42
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
43
|
+
import {
|
|
44
|
+
checkServiceHealth,
|
|
45
|
+
claimHealthyService,
|
|
46
|
+
runServiceCommand,
|
|
47
|
+
resolveService,
|
|
48
|
+
hasRunningOpctlContainers,
|
|
49
|
+
releaseWorkspaceDaemons,
|
|
50
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
51
|
+
import {
|
|
52
|
+
ensureManagedServiceClone,
|
|
53
|
+
ensureManagedServiceCloneNewBranch,
|
|
54
|
+
ensureDevManagedClone,
|
|
55
|
+
deleteWorkspaceDirectory,
|
|
56
|
+
renameWorkspaceDirectory,
|
|
57
|
+
checkManagedCloneBehind,
|
|
58
|
+
pullManagedClone,
|
|
59
|
+
runSetupHidden,
|
|
60
|
+
} from "../../shared/environment-manager/workspace-manager.js";
|
|
61
|
+
import {
|
|
62
|
+
expandSameRepoWaitDeps,
|
|
63
|
+
getWaitDeps,
|
|
64
|
+
} from "../../shared/environment-manager/dag.js";
|
|
65
|
+
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
66
|
+
import {
|
|
67
|
+
deriveStartTargets,
|
|
68
|
+
filterClosureForStart,
|
|
69
|
+
orderForUp,
|
|
70
|
+
} from "../../shared/environment-manager/derivation/derived-up.js";
|
|
71
|
+
import { readManifest } from "../../shared/manifest/read-manifest.js";
|
|
72
|
+
import {
|
|
73
|
+
checkRepoSecrets,
|
|
74
|
+
describeSecretsCheck,
|
|
75
|
+
seedSecretsFromSibling,
|
|
76
|
+
} from "../../shared/environment-manager/secrets-check.js";
|
|
77
|
+
import { serviceEnvProviderRepos } from "../../shared/environment-manager/service-env.js";
|
|
78
|
+
import { depService } from "../../shared/manifest/deps.js";
|
|
79
|
+
import { waitForReady } from "../../shared/environment-manager/readiness.js";
|
|
80
|
+
import {
|
|
81
|
+
readCatalog,
|
|
82
|
+
resolveRepoGhSlug,
|
|
83
|
+
upsertCatalogEntry,
|
|
84
|
+
} from "../../shared/environment-manager/catalog.js";
|
|
85
|
+
import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
|
|
86
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
87
|
+
import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
|
|
88
|
+
import {
|
|
89
|
+
GIT_REMOTE_TIMEOUT_MS,
|
|
90
|
+
DETACH_WINDOW_MS,
|
|
91
|
+
} from "../../shared/constants.js";
|
|
92
|
+
|
|
93
|
+
import { product } from "../../shared/product.js";
|
|
94
|
+
// Anchored to the den root, NOT process.cwd(): ws.directory values are
|
|
95
|
+
// stored relative to this and written back via path.relative below — a
|
|
96
|
+
// cwd-started-elsewhere server would resolve and persist garbage paths.
|
|
97
|
+
const ROOT = getDistRoot();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handle all /api/workspaces/* and /api/workspace/* routes.
|
|
101
|
+
*
|
|
102
|
+
* Same contract as the original inline handlers: uses return sendJson/send400
|
|
103
|
+
* to end the response. Caller checks res.writableEnded to know if handled.
|
|
104
|
+
*/
|
|
105
|
+
export async function handleWorkspaceRoutes(
|
|
106
|
+
req: http.IncomingMessage,
|
|
107
|
+
res: http.ServerResponse,
|
|
108
|
+
pathname: string,
|
|
109
|
+
url: URL,
|
|
110
|
+
editors: EditorManager,
|
|
111
|
+
github: GitHubService,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
// ── Workspace cleanup helpers ─────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Compute which clones can be deleted when removing `wsName`.
|
|
116
|
+
*
|
|
117
|
+
* devManaged — repos den cloned under <reposRoot>/<wsName>/ for this ws
|
|
118
|
+
* denManaged — .den-services clones exclusively owned by this workspace
|
|
119
|
+
*/
|
|
120
|
+
function computeCleanupTargets(wsName: string): {
|
|
121
|
+
devManaged: Array<{ address: string; cloneDir: string }>;
|
|
122
|
+
denManaged: Array<{ address: string; cloneDir: string }>;
|
|
123
|
+
} {
|
|
124
|
+
const wsFile = readWorkspacesFile();
|
|
125
|
+
const ws = wsFile.workspaces[wsName];
|
|
126
|
+
if (!ws) return { devManaged: [], denManaged: [] };
|
|
127
|
+
|
|
128
|
+
const catalog = readCatalog();
|
|
129
|
+
const reposRoot = getReposRoot();
|
|
130
|
+
const wsDevRoot = path.join(reposRoot, wsName);
|
|
131
|
+
|
|
132
|
+
const otherWorkspaces = Object.entries(wsFile.workspaces)
|
|
133
|
+
.filter(([n]) => n !== wsName)
|
|
134
|
+
.map(([, cfg]) => cfg);
|
|
135
|
+
|
|
136
|
+
const devManaged: Array<{ address: string; cloneDir: string }> = [];
|
|
137
|
+
const denManaged: Array<{ address: string; cloneDir: string }> = [];
|
|
138
|
+
|
|
139
|
+
for (const [address, entry] of Object.entries(ws.services)) {
|
|
140
|
+
if (entry.mode === "dev-managed") {
|
|
141
|
+
// Only include repos den cloned into <reposRoot>/<wsName>/ — not
|
|
142
|
+
// repos the engineer linked from their own existing checkout.
|
|
143
|
+
if (entry.path?.startsWith(wsDevRoot) && fs.existsSync(entry.path)) {
|
|
144
|
+
devManaged.push({ address, cloneDir: entry.path });
|
|
145
|
+
}
|
|
146
|
+
} else if (entry.mode === "managed") {
|
|
147
|
+
const { repoName } = parseAddress(address, catalog);
|
|
148
|
+
const branch = entry.branch ?? "main";
|
|
149
|
+
const cloneDir = path.join(DEN_SERVICES_DIR, repoName, branch);
|
|
150
|
+
if (!fs.existsSync(cloneDir)) continue;
|
|
151
|
+
const usedByOther = otherWorkspaces.some((other) =>
|
|
152
|
+
Object.entries(other.services).some(([addr, e]) => {
|
|
153
|
+
if (e.mode !== "managed") return false;
|
|
154
|
+
const { repoName: r } = parseAddress(addr, catalog);
|
|
155
|
+
return r === repoName && (e.branch ?? "main") === branch;
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
if (!usedByOther) denManaged.push({ address, cloneDir });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { devManaged, denManaged };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Editor capability sets ────────────────────────────────────────────────
|
|
166
|
+
const VSCODE_LIKE_EDITORS = new Set(["vscode", "cursor"]);
|
|
167
|
+
|
|
168
|
+
// ── API: workspaces ───────────────────────────────────────────────────────
|
|
169
|
+
if (pathname === "/api/workspaces") {
|
|
170
|
+
if (req.method === "POST") {
|
|
171
|
+
try {
|
|
172
|
+
const body = await readBody(req);
|
|
173
|
+
const { name, action } = JSON.parse(body) as {
|
|
174
|
+
name?: string;
|
|
175
|
+
/** activate | deactivate | focus | use (default: use = activate+focus) */
|
|
176
|
+
action?: "activate" | "deactivate" | "focus" | "use";
|
|
177
|
+
};
|
|
178
|
+
if (!name) return send400(res, "name required");
|
|
179
|
+
const act = action ?? "use";
|
|
180
|
+
let released: Awaited<ReturnType<typeof releaseWorkspaceDaemons>> = [];
|
|
181
|
+
switch (act) {
|
|
182
|
+
case "activate":
|
|
183
|
+
activateWorkspace(name);
|
|
184
|
+
break;
|
|
185
|
+
case "deactivate":
|
|
186
|
+
deactivateWorkspace(name);
|
|
187
|
+
// Release AFTER the workspace has left active[] so shared services
|
|
188
|
+
// see the post-deactivation owner set (design decision 5).
|
|
189
|
+
released = await releaseWorkspaceDaemons(name);
|
|
190
|
+
break;
|
|
191
|
+
case "focus":
|
|
192
|
+
setFocusedWorkspace(name);
|
|
193
|
+
break;
|
|
194
|
+
case "use":
|
|
195
|
+
setActiveWorkspace(name);
|
|
196
|
+
break;
|
|
197
|
+
default: {
|
|
198
|
+
const _exhaustive: never = act;
|
|
199
|
+
return send400(res, `unknown action: ${String(_exhaustive)}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const file = readWorkspacesFile();
|
|
203
|
+
return sendJson(res, {
|
|
204
|
+
ok: true,
|
|
205
|
+
active: file.active,
|
|
206
|
+
focus: file.focus,
|
|
207
|
+
released,
|
|
208
|
+
});
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (req.method === "DELETE") {
|
|
214
|
+
try {
|
|
215
|
+
const body = await readBody(req);
|
|
216
|
+
const { name, cleanupDirectory, cleanupDev, cleanupDen } = JSON.parse(
|
|
217
|
+
body,
|
|
218
|
+
) as {
|
|
219
|
+
name?: string;
|
|
220
|
+
cleanupDirectory?: boolean;
|
|
221
|
+
cleanupDev?: boolean;
|
|
222
|
+
cleanupDen?: boolean;
|
|
223
|
+
};
|
|
224
|
+
if (!name) return send400(res, "name required");
|
|
225
|
+
if (name === "default")
|
|
226
|
+
return send400(res, "cannot delete the default workspace");
|
|
227
|
+
|
|
228
|
+
// Delete workspace directory (den-workspaces/<name>)
|
|
229
|
+
if (cleanupDirectory) {
|
|
230
|
+
try {
|
|
231
|
+
deleteWorkspaceDirectory(name);
|
|
232
|
+
} catch {
|
|
233
|
+
/* best-effort */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (cleanupDev || cleanupDen) {
|
|
238
|
+
const { devManaged, denManaged } = computeCleanupTargets(name);
|
|
239
|
+
|
|
240
|
+
if (cleanupDev) {
|
|
241
|
+
// Dev-managed clones
|
|
242
|
+
for (const { cloneDir } of devManaged) {
|
|
243
|
+
try {
|
|
244
|
+
fs.rmSync(cloneDir, { recursive: true, force: true });
|
|
245
|
+
} catch {
|
|
246
|
+
/* best-effort */
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (cleanupDen) {
|
|
252
|
+
for (const { cloneDir } of denManaged) {
|
|
253
|
+
try {
|
|
254
|
+
fs.rmSync(cloneDir, { recursive: true, force: true });
|
|
255
|
+
} catch {
|
|
256
|
+
/* best-effort */
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
deleteWorkspaceConfig(name);
|
|
263
|
+
return sendJson(res, { ok: true });
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Augment with resolved absolute directories so the UI can open them.
|
|
269
|
+
// Priority: ws.directory field → den-workspaces/<name> → legacy reposRoot/<name>
|
|
270
|
+
// → reposRoot itself as last resort.
|
|
271
|
+
const wsFile = readWorkspacesFile();
|
|
272
|
+
const reposRoot = getReposRoot();
|
|
273
|
+
const resolvedDirs: Record<string, string> = {};
|
|
274
|
+
for (const [name, ws] of Object.entries(wsFile.workspaces)) {
|
|
275
|
+
if (ws.directory) {
|
|
276
|
+
resolvedDirs[name] = path.resolve(ROOT, ws.directory);
|
|
277
|
+
} else {
|
|
278
|
+
const gwDir = path.join(DEN_WORKSPACES_DIR, name);
|
|
279
|
+
const legacyDir = path.join(reposRoot, name);
|
|
280
|
+
if (fs.existsSync(gwDir)) {
|
|
281
|
+
resolvedDirs[name] = gwDir;
|
|
282
|
+
} else if (fs.existsSync(legacyDir)) {
|
|
283
|
+
resolvedDirs[name] = legacyDir;
|
|
284
|
+
} else {
|
|
285
|
+
resolvedDirs[name] = reposRoot;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const editor = readUserConfig().editor ?? "";
|
|
290
|
+
const catalog = readCatalog();
|
|
291
|
+
const codeWorkspacePaths: Record<string, string> = {};
|
|
292
|
+
|
|
293
|
+
if (VSCODE_LIKE_EDITORS.has(editor)) {
|
|
294
|
+
for (const [name, ws] of Object.entries(wsFile.workspaces)) {
|
|
295
|
+
try {
|
|
296
|
+
codeWorkspacePaths[name] = generateVSCodeWorkspace(name, ws, catalog);
|
|
297
|
+
} catch {
|
|
298
|
+
// Non-fatal: fall back to directory path in the UI
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} else if (
|
|
302
|
+
JETBRAINS_EDITORS.has(editor) ||
|
|
303
|
+
editors.isTerminalEditor(editor)
|
|
304
|
+
) {
|
|
305
|
+
// JetBrains and terminal editors both use the symlink-in-workspace-dir
|
|
306
|
+
// approach: open the workspace folder and each editor discovers repos
|
|
307
|
+
// via symlinks (JetBrains scans .git roots; vim/nvim/emacs traverse dirs).
|
|
308
|
+
syncJetBrainsWorkspaceSymlinks(wsFile);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return sendJson(res, { ...wsFile, resolvedDirs, codeWorkspacePaths });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── API: rename workspace ─────────────────────────────────────────────────
|
|
315
|
+
if (pathname === "/api/workspaces/rename" && req.method === "POST") {
|
|
316
|
+
try {
|
|
317
|
+
const { oldName, newName } = JSON.parse(await readBody(req)) as {
|
|
318
|
+
oldName?: string;
|
|
319
|
+
newName?: string;
|
|
320
|
+
};
|
|
321
|
+
if (!oldName || !newName)
|
|
322
|
+
return send400(res, "oldName and newName required");
|
|
323
|
+
// eslint-disable-next-line no-control-regex
|
|
324
|
+
if (/["'\\/<>\x00-\x1f]/.test(newName))
|
|
325
|
+
return send400(
|
|
326
|
+
res,
|
|
327
|
+
"newName must not contain quotes, slashes, or control characters",
|
|
328
|
+
);
|
|
329
|
+
const file = readWorkspacesFile();
|
|
330
|
+
if (!file.workspaces[oldName])
|
|
331
|
+
return send400(res, `Workspace "${oldName}" not found`);
|
|
332
|
+
if (file.workspaces[newName])
|
|
333
|
+
return send400(res, `Workspace "${newName}" already exists`);
|
|
334
|
+
|
|
335
|
+
// Rename workspace config entry
|
|
336
|
+
file.workspaces[newName] = file.workspaces[oldName];
|
|
337
|
+
delete file.workspaces[oldName];
|
|
338
|
+
file.active = file.active.map((n) => (n === oldName ? newName : n));
|
|
339
|
+
if (file.focus === oldName) file.focus = newName;
|
|
340
|
+
|
|
341
|
+
// Rename the local directory on disk if one exists, then update paths.
|
|
342
|
+
// Read location priority: ws.directory → den-workspaces/<oldName> → legacy reposRoot/<oldName>.
|
|
343
|
+
const ws = file.workspaces[newName];
|
|
344
|
+
const reposRoot = getReposRoot();
|
|
345
|
+
let oldDir: string;
|
|
346
|
+
if (ws.directory) {
|
|
347
|
+
oldDir = path.resolve(ROOT, ws.directory);
|
|
348
|
+
} else {
|
|
349
|
+
const gwOld = path.join(DEN_WORKSPACES_DIR, oldName);
|
|
350
|
+
const legacyOld = path.join(reposRoot, oldName);
|
|
351
|
+
oldDir = fs.existsSync(gwOld)
|
|
352
|
+
? gwOld
|
|
353
|
+
: fs.existsSync(legacyOld)
|
|
354
|
+
? legacyOld
|
|
355
|
+
: gwOld;
|
|
356
|
+
}
|
|
357
|
+
const newDir = renameWorkspaceDirectory(oldDir, newName);
|
|
358
|
+
if (newDir) {
|
|
359
|
+
if (ws.directory) {
|
|
360
|
+
ws.directory = path.relative(ROOT, newDir);
|
|
361
|
+
}
|
|
362
|
+
for (const svc of Object.values(ws.services ?? {})) {
|
|
363
|
+
const entry = svc as { path?: string };
|
|
364
|
+
if (
|
|
365
|
+
entry.path?.startsWith(oldDir + path.sep) ||
|
|
366
|
+
entry.path === oldDir
|
|
367
|
+
) {
|
|
368
|
+
entry.path = newDir + entry.path.slice(oldDir.length);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
writeWorkspacesFile(file);
|
|
374
|
+
return sendJson(res, { ok: true });
|
|
375
|
+
} catch (e) {
|
|
376
|
+
return send400(res, `Rename failed: ${(e as Error).message}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── API: workspace cleanup preview ───────────────────────────────────────
|
|
381
|
+
// Returns which clones would be deleted when removing this workspace.
|
|
382
|
+
if (pathname === "/api/workspaces/cleanup-preview" && req.method === "GET") {
|
|
383
|
+
const wsName = url.searchParams.get("name");
|
|
384
|
+
if (!wsName) return send400(res, "name required");
|
|
385
|
+
return sendJson(res, computeCleanupTargets(wsName));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── API: create workspace ─────────────────────────────────────────────────
|
|
389
|
+
if (pathname === "/api/workspaces/create" && req.method === "POST") {
|
|
390
|
+
try {
|
|
391
|
+
const body = await readBody(req);
|
|
392
|
+
const { name } = JSON.parse(body) as { name?: string };
|
|
393
|
+
if (!name) return send400(res, "name required");
|
|
394
|
+
// eslint-disable-next-line no-control-regex
|
|
395
|
+
if (/["'\\/<>\x00-\x1f]/.test(name))
|
|
396
|
+
return send400(
|
|
397
|
+
res,
|
|
398
|
+
"name must not contain quotes, slashes, or control characters",
|
|
399
|
+
);
|
|
400
|
+
const file = readWorkspacesFile();
|
|
401
|
+
if (file.workspaces[name])
|
|
402
|
+
return send400(res, `workspace "${name}" already exists`);
|
|
403
|
+
createWorkspace(name, { services: {} });
|
|
404
|
+
return sendJson(res, { ok: true, name });
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── API: GitHub branches ──────────────────────────────────────────────────
|
|
411
|
+
if (pathname === "/api/github/branches" && req.method === "GET") {
|
|
412
|
+
const gh = url.searchParams.get("gh") ?? "";
|
|
413
|
+
return sendJson(res, github.listBranches(gh));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── API: GitHub repos ─────────────────────────────────────────────────────
|
|
417
|
+
if (pathname === "/api/github/repos" && req.method === "GET") {
|
|
418
|
+
const forceRefresh = url.searchParams.get("refresh") === "1";
|
|
419
|
+
return sendJson(res, github.listOrgRepos(forceRefresh));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── API: available repos (for adding to workspace) ────────────────────────
|
|
423
|
+
// Returns two groups:
|
|
424
|
+
// local — repos discovered recursively under repos_root (dev checkouts)
|
|
425
|
+
// catalog — catalog entries not found locally (den can clone them)
|
|
426
|
+
//
|
|
427
|
+
// Each entry carries:
|
|
428
|
+
// relPath — path relative to repos_root (used as catalog key and workspace address)
|
|
429
|
+
// name — leaf directory name (display label)
|
|
430
|
+
// absPath — absolute filesystem path (stored as linkedPath for dev-managed repos)
|
|
431
|
+
if (pathname === "/api/workspaces/available-repos" && req.method === "GET") {
|
|
432
|
+
const reposRoot = getReposRoot();
|
|
433
|
+
const wsOverride = url.searchParams.get("workspace");
|
|
434
|
+
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
435
|
+
const ws =
|
|
436
|
+
wsOverride && wsFile?.workspaces[wsOverride]
|
|
437
|
+
? { config: wsFile.workspaces[wsOverride] }
|
|
438
|
+
: getActiveWorkspace();
|
|
439
|
+
const catalog = readCatalog();
|
|
440
|
+
const existingAddresses = new Set(Object.keys(ws?.config.services ?? {}));
|
|
441
|
+
|
|
442
|
+
// Build a set of gh slugs already represented in the workspace, so that
|
|
443
|
+
// alternate local paths for the same repo (e.g. TestWorkspace/shop) are
|
|
444
|
+
// correctly flagged as already-added.
|
|
445
|
+
const existingGhSlugs = new Set<string>();
|
|
446
|
+
for (const addr of existingAddresses) {
|
|
447
|
+
const { repoName } = parseAddress(addr, catalog);
|
|
448
|
+
const gh = catalog[repoName]?.gh;
|
|
449
|
+
if (gh) existingGhSlugs.add(gh.toLowerCase());
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Get git remote origin for a local path (fast, silent on failure).
|
|
453
|
+
const getRemoteGh = (absPath: string): string | null => {
|
|
454
|
+
try {
|
|
455
|
+
const remote = execSync("git remote get-url origin", {
|
|
456
|
+
cwd: absPath,
|
|
457
|
+
encoding: "utf-8",
|
|
458
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
459
|
+
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
460
|
+
}).trim();
|
|
461
|
+
// Normalise SSH and HTTPS GitHub URLs → "owner/repo"
|
|
462
|
+
const m =
|
|
463
|
+
remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ?? null;
|
|
464
|
+
return m ? m[1].toLowerCase() : null;
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
type RepoEntry = {
|
|
471
|
+
name: string;
|
|
472
|
+
relPath: string;
|
|
473
|
+
absPath: string | null;
|
|
474
|
+
hasManifest: boolean;
|
|
475
|
+
alreadyAdded: boolean;
|
|
476
|
+
source: "local" | "catalog" | "managed";
|
|
477
|
+
gh: string | null;
|
|
478
|
+
currentBranch?: string | null;
|
|
479
|
+
};
|
|
480
|
+
const entries: RepoEntry[] = [];
|
|
481
|
+
const seenRelPaths = new Set<string>();
|
|
482
|
+
|
|
483
|
+
// Recursively walk repos_root, stopping at directories that have .den.yml.
|
|
484
|
+
// We don't recurse into repo directories themselves.
|
|
485
|
+
const walkDir = (dir: string, relPrefix: string, depth: number): void => {
|
|
486
|
+
if (depth > 4) return; // safety limit
|
|
487
|
+
let dirents: fs.Dirent[];
|
|
488
|
+
try {
|
|
489
|
+
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
490
|
+
} catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
for (const d of dirents) {
|
|
495
|
+
if (!d.isDirectory() && !d.isSymbolicLink()) continue;
|
|
496
|
+
if (d.name.startsWith(".")) continue; // skip .git, .github, .DS_Store, etc.
|
|
497
|
+
const absPath = path.join(dir, d.name);
|
|
498
|
+
if (absPath === ROOT) continue; // skip den itself
|
|
499
|
+
const relPath = relPrefix ? `${relPrefix}/${d.name}` : d.name;
|
|
500
|
+
const hasManifest = fs.existsSync(
|
|
501
|
+
path.join(absPath, `${product().manifestYml}`),
|
|
502
|
+
);
|
|
503
|
+
if (hasManifest) {
|
|
504
|
+
const gh = catalog[relPath]?.gh ?? getRemoteGh(absPath);
|
|
505
|
+
// Already added if the exact address is in the workspace, OR if
|
|
506
|
+
// a repo with the same GitHub remote is already present.
|
|
507
|
+
const alreadyAdded =
|
|
508
|
+
existingAddresses.has(relPath) ||
|
|
509
|
+
(!!gh && existingGhSlugs.has(gh.toLowerCase()));
|
|
510
|
+
entries.push({
|
|
511
|
+
name: d.name,
|
|
512
|
+
relPath,
|
|
513
|
+
absPath,
|
|
514
|
+
hasManifest: true,
|
|
515
|
+
alreadyAdded,
|
|
516
|
+
source: "local",
|
|
517
|
+
gh: gh ?? null,
|
|
518
|
+
});
|
|
519
|
+
seenRelPaths.add(relPath);
|
|
520
|
+
// Don't recurse into repo directories.
|
|
521
|
+
} else {
|
|
522
|
+
// Not a repo — recurse to find repos in subdirectories.
|
|
523
|
+
walkDir(absPath, relPath, depth + 1);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
walkDir(reposRoot, "", 0);
|
|
528
|
+
|
|
529
|
+
// Managed clones live at .den-services/<repo>/<branch>/
|
|
530
|
+
// Walk two levels: repo dir → branch dir (each branch dir is a full clone).
|
|
531
|
+
// Multiple workspaces pointing to the same repo+branch share the same clone.
|
|
532
|
+
const getCurrentBranch = (absPath: string): string | null => {
|
|
533
|
+
try {
|
|
534
|
+
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
535
|
+
cwd: absPath,
|
|
536
|
+
encoding: "utf-8",
|
|
537
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
538
|
+
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
539
|
+
}).trim();
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
const repoDirs = fs.readdirSync(DEN_SERVICES_DIR, {
|
|
546
|
+
withFileTypes: true,
|
|
547
|
+
});
|
|
548
|
+
for (const repoDir of repoDirs) {
|
|
549
|
+
if (!repoDir.isDirectory()) continue;
|
|
550
|
+
const repoName = repoDir.name;
|
|
551
|
+
const repoDirPath = path.join(DEN_SERVICES_DIR, repoName);
|
|
552
|
+
let branchDirs: fs.Dirent[];
|
|
553
|
+
try {
|
|
554
|
+
branchDirs = fs.readdirSync(repoDirPath, { withFileTypes: true });
|
|
555
|
+
} catch {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
for (const branchDir of branchDirs) {
|
|
559
|
+
if (!branchDir.isDirectory()) continue;
|
|
560
|
+
const branchName = branchDir.name;
|
|
561
|
+
const absPath = path.join(repoDirPath, branchName);
|
|
562
|
+
// Only treat as a branch clone if it's actually a git repo
|
|
563
|
+
if (!fs.existsSync(path.join(absPath, ".git"))) continue;
|
|
564
|
+
const key = `${repoName}::${branchName}`; // unique per repo+branch
|
|
565
|
+
if (seenRelPaths.has(key)) continue;
|
|
566
|
+
const hasManifest = fs.existsSync(
|
|
567
|
+
path.join(absPath, `${product().manifestYml}`),
|
|
568
|
+
);
|
|
569
|
+
const gh = catalog[repoName]?.gh ?? getRemoteGh(absPath);
|
|
570
|
+
const alreadyAdded =
|
|
571
|
+
existingAddresses.has(repoName) ||
|
|
572
|
+
(!!gh && existingGhSlugs.has(gh.toLowerCase()));
|
|
573
|
+
const currentBranch = getCurrentBranch(absPath) ?? branchName;
|
|
574
|
+
entries.push({
|
|
575
|
+
name: repoName,
|
|
576
|
+
relPath: repoName, // catalog key used as workspace address
|
|
577
|
+
absPath,
|
|
578
|
+
hasManifest,
|
|
579
|
+
alreadyAdded,
|
|
580
|
+
source: "managed",
|
|
581
|
+
gh: gh ?? null,
|
|
582
|
+
currentBranch,
|
|
583
|
+
});
|
|
584
|
+
seenRelPaths.add(key);
|
|
585
|
+
seenRelPaths.add(repoName); // prevent catalog fallback from duplicating
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
/* the managed-clones dir does not exist yet */
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Catalog-only entries — repos den knows about but not found locally.
|
|
593
|
+
for (const [relPath, entry] of Object.entries(catalog)) {
|
|
594
|
+
if (seenRelPaths.has(relPath)) continue;
|
|
595
|
+
const name = relPath.includes("/")
|
|
596
|
+
? relPath.slice(relPath.lastIndexOf("/") + 1)
|
|
597
|
+
: relPath;
|
|
598
|
+
const alreadyAdded =
|
|
599
|
+
existingAddresses.has(relPath) ||
|
|
600
|
+
(!!entry.gh && existingGhSlugs.has(entry.gh.toLowerCase()));
|
|
601
|
+
entries.push({
|
|
602
|
+
name,
|
|
603
|
+
relPath,
|
|
604
|
+
absPath: null,
|
|
605
|
+
hasManifest: false,
|
|
606
|
+
alreadyAdded,
|
|
607
|
+
source: "catalog",
|
|
608
|
+
gh: entry.gh,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return sendJson(res, entries);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── API: per-system auto-start toggle ─────────────────────────────────────
|
|
616
|
+
// POST { workspace, repo, autostart } — sets `autostart: false` on the repo's
|
|
617
|
+
// workspace entry (or removes the field when true, keeping the yaml clean).
|
|
618
|
+
// The entry may be keyed by the bare repo name or by "repo/subsystem" keys.
|
|
619
|
+
if (pathname === "/api/workspaces/services/autostart") {
|
|
620
|
+
if (req.method !== "POST") {
|
|
621
|
+
res.writeHead(405);
|
|
622
|
+
res.end();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const body = await readBody(req);
|
|
627
|
+
const { workspace, repo, autostart } = JSON.parse(body) as {
|
|
628
|
+
workspace?: string;
|
|
629
|
+
repo?: string;
|
|
630
|
+
autostart?: boolean;
|
|
631
|
+
};
|
|
632
|
+
if (!workspace || !repo) return send400(res, "workspace, repo required");
|
|
633
|
+
if (typeof autostart !== "boolean")
|
|
634
|
+
return send400(res, '"autostart" boolean required');
|
|
635
|
+
const file = readWorkspacesFile();
|
|
636
|
+
const ws = file.workspaces[workspace];
|
|
637
|
+
if (!ws) return send400(res, `Unknown workspace "${workspace}"`);
|
|
638
|
+
const catalog = readCatalog();
|
|
639
|
+
const keys = Object.keys(ws.services).filter(
|
|
640
|
+
(k) => parseAddress(k, catalog).repoName === repo,
|
|
641
|
+
);
|
|
642
|
+
if (keys.length === 0)
|
|
643
|
+
return send400(res, `"${repo}" is not in workspace "${workspace}"`);
|
|
644
|
+
for (const k of keys) {
|
|
645
|
+
if (autostart) delete ws.services[k].autostart;
|
|
646
|
+
else ws.services[k].autostart = false;
|
|
647
|
+
}
|
|
648
|
+
writeWorkspacesFile(file);
|
|
649
|
+
return sendJson(res, { ok: true, repo, autostart });
|
|
650
|
+
} catch (e) {
|
|
651
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ── API: per-system mode switch (use: local / use: den) ────────────────
|
|
656
|
+
// POST { workspace, repo, mode, branch? } — web equivalent of the CLI's
|
|
657
|
+
// use-local / use-managed. Rewrites all of the repo's entries (bare + any
|
|
658
|
+
// "repo/sub" keys) with the new mode, mirroring switchRepoMode: dev-managed
|
|
659
|
+
// requires a checkout at <reposRoot>/<repo>; managed tracks `branch`
|
|
660
|
+
// (default main; the clone is ensured at up time). Preserves `autostart`.
|
|
661
|
+
if (pathname === "/api/workspaces/services/mode") {
|
|
662
|
+
if (req.method !== "POST") {
|
|
663
|
+
res.writeHead(405);
|
|
664
|
+
res.end();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const body = await readBody(req);
|
|
669
|
+
const { workspace, repo, mode, branch } = JSON.parse(body) as {
|
|
670
|
+
workspace?: string;
|
|
671
|
+
repo?: string;
|
|
672
|
+
mode?: string;
|
|
673
|
+
branch?: string;
|
|
674
|
+
};
|
|
675
|
+
if (!workspace || !repo) return send400(res, "workspace, repo required");
|
|
676
|
+
if (mode !== "dev-managed" && mode !== "managed")
|
|
677
|
+
return send400(res, 'mode must be "dev-managed" or "managed"');
|
|
678
|
+
const file = readWorkspacesFile();
|
|
679
|
+
const wsCfg = file.workspaces[workspace];
|
|
680
|
+
if (!wsCfg) return send400(res, `Unknown workspace "${workspace}"`);
|
|
681
|
+
const catalog = readCatalog();
|
|
682
|
+
const keys = Object.keys(wsCfg.services).filter(
|
|
683
|
+
(k) => parseAddress(k, catalog).repoName === repo,
|
|
684
|
+
);
|
|
685
|
+
if (keys.length === 0)
|
|
686
|
+
return send400(res, `"${repo}" is not in workspace "${workspace}"`);
|
|
687
|
+
|
|
688
|
+
let localPath: string | undefined;
|
|
689
|
+
if (mode === "dev-managed") {
|
|
690
|
+
localPath = path.join(getReposRoot(), repo);
|
|
691
|
+
if (!fs.existsSync(localPath)) {
|
|
692
|
+
return send400(
|
|
693
|
+
res,
|
|
694
|
+
`No local checkout found at ${localPath} — clone it there first, or use ${product().cli} register for a custom path`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const effectiveBranch = branch ?? "main";
|
|
699
|
+
for (const k of keys) {
|
|
700
|
+
const keepOff = wsCfg.services[k]?.autostart === false;
|
|
701
|
+
wsCfg.services[k] = {
|
|
702
|
+
mode,
|
|
703
|
+
...(mode === "dev-managed" && localPath ? { path: localPath } : {}),
|
|
704
|
+
...(mode === "managed" ? { branch: effectiveBranch } : {}),
|
|
705
|
+
...(keepOff ? { autostart: false } : {}),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
writeWorkspacesFile(file);
|
|
709
|
+
return sendJson(res, {
|
|
710
|
+
ok: true,
|
|
711
|
+
repo,
|
|
712
|
+
mode,
|
|
713
|
+
...(localPath ? { path: localPath } : {}),
|
|
714
|
+
...(mode === "managed" ? { branch: effectiveBranch } : {}),
|
|
715
|
+
});
|
|
716
|
+
} catch (e) {
|
|
717
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── API: workspace services (add / remove) ────────────────────────────────
|
|
722
|
+
if (pathname === "/api/workspaces/services") {
|
|
723
|
+
if (req.method === "POST") {
|
|
724
|
+
try {
|
|
725
|
+
const body = await readBody(req);
|
|
726
|
+
const {
|
|
727
|
+
workspace,
|
|
728
|
+
address,
|
|
729
|
+
mode,
|
|
730
|
+
linkedPath,
|
|
731
|
+
branch,
|
|
732
|
+
ghSlug,
|
|
733
|
+
subfolder,
|
|
734
|
+
newBranch,
|
|
735
|
+
} = JSON.parse(body) as {
|
|
736
|
+
workspace?: string;
|
|
737
|
+
address?: string;
|
|
738
|
+
mode?: "dev-managed" | "managed";
|
|
739
|
+
/** Absolute path to an existing local checkout (dev-managed from disk). */
|
|
740
|
+
linkedPath?: string;
|
|
741
|
+
branch?: string;
|
|
742
|
+
/** GitHub slug (owner/repo) — present when cloning from GitHub. */
|
|
743
|
+
ghSlug?: string;
|
|
744
|
+
/** Optional subfolder within the workspace clone directory (dev-managed from GitHub). */
|
|
745
|
+
subfolder?: string;
|
|
746
|
+
/**
|
|
747
|
+
* If set, clone at `branch` (base) then create this new local branch on top.
|
|
748
|
+
* The workspace entry tracks `newBranch`, not `branch`.
|
|
749
|
+
*/
|
|
750
|
+
newBranch?: string;
|
|
751
|
+
};
|
|
752
|
+
if (!address || !mode) return send400(res, "address and mode required");
|
|
753
|
+
|
|
754
|
+
// Validate branch names against injection (used in git commands)
|
|
755
|
+
const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
|
|
756
|
+
if (branch && !SAFE_BRANCH_RE.test(branch))
|
|
757
|
+
return send400(res, "invalid branch name");
|
|
758
|
+
if (newBranch && !SAFE_BRANCH_RE.test(newBranch))
|
|
759
|
+
return send400(res, "invalid newBranch name");
|
|
760
|
+
// ghSlug is interpolated into the git clone URL — reject anything that
|
|
761
|
+
// isn't a strict owner/repo slug to block shell/argument injection.
|
|
762
|
+
const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
763
|
+
if (ghSlug && !SAFE_SLUG_RE.test(ghSlug))
|
|
764
|
+
return send400(res, "invalid ghSlug");
|
|
765
|
+
// subfolder is joined into the clone target path — reject traversal and
|
|
766
|
+
// absolute paths so a clone can't escape the workspace directory.
|
|
767
|
+
if (
|
|
768
|
+
subfolder &&
|
|
769
|
+
(!/^[a-zA-Z0-9._/-]+$/.test(subfolder) ||
|
|
770
|
+
subfolder.split("/").includes("..") ||
|
|
771
|
+
path.isAbsolute(subfolder))
|
|
772
|
+
)
|
|
773
|
+
return send400(res, "invalid subfolder");
|
|
774
|
+
|
|
775
|
+
// Prefer explicit workspace name; fall back to focused workspace.
|
|
776
|
+
const file = readWorkspacesFile();
|
|
777
|
+
const wsName = workspace ?? file.focus ?? file.active[0];
|
|
778
|
+
if (!wsName || !file.workspaces[wsName])
|
|
779
|
+
return send400(res, `workspace "${wsName ?? ""}" not found`);
|
|
780
|
+
|
|
781
|
+
let catalog = readCatalog();
|
|
782
|
+
const { repoName } = parseAddress(address, catalog);
|
|
783
|
+
// For new repos, parseAddress may not resolve correctly before the catalog entry
|
|
784
|
+
// exists — fall back to using the address directly as the catalog key.
|
|
785
|
+
const catalogKey = catalog[repoName] ? repoName : address;
|
|
786
|
+
|
|
787
|
+
// Resolve where the repo lives on disk (or clone it if requested).
|
|
788
|
+
let resolvedPath = linkedPath;
|
|
789
|
+
if (!resolvedPath && ghSlug) {
|
|
790
|
+
const leafName = address.includes("/")
|
|
791
|
+
? address.slice(address.lastIndexOf("/") + 1)
|
|
792
|
+
: address;
|
|
793
|
+
if (mode === "dev-managed") {
|
|
794
|
+
// Always clone into den-workspaces/<wsName>/[subfolder/]<leafName>/
|
|
795
|
+
const parts = [DEN_WORKSPACES_DIR, wsName];
|
|
796
|
+
if (subfolder) parts.push(...subfolder.split("/").filter(Boolean));
|
|
797
|
+
parts.push(leafName);
|
|
798
|
+
const targetDir = path.join(...parts);
|
|
799
|
+
resolvedPath = ensureDevManagedClone(
|
|
800
|
+
targetDir,
|
|
801
|
+
ghSlug,
|
|
802
|
+
branch ?? "main",
|
|
803
|
+
);
|
|
804
|
+
// If the user requested a new local branch, create it on top of the clone.
|
|
805
|
+
if (newBranch && resolvedPath) {
|
|
806
|
+
execSync(`git checkout -b "${newBranch}"`, {
|
|
807
|
+
cwd: resolvedPath,
|
|
808
|
+
stdio: "ignore",
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
// Setup is handled inside ensureDevManagedClone → runSetupIfDefined,
|
|
812
|
+
// which opens a terminal window for interactive input.
|
|
813
|
+
} else {
|
|
814
|
+
// managed: clone into .den-services/<catalogKey>/
|
|
815
|
+
if (newBranch) {
|
|
816
|
+
// Clone at base branch, then create newBranch; dir is named after newBranch.
|
|
817
|
+
ensureManagedServiceCloneNewBranch(
|
|
818
|
+
catalogKey,
|
|
819
|
+
ghSlug,
|
|
820
|
+
branch ?? "main",
|
|
821
|
+
newBranch,
|
|
822
|
+
);
|
|
823
|
+
} else {
|
|
824
|
+
ensureManagedServiceClone(catalogKey, ghSlug, branch ?? "main");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (!catalog[catalogKey]) {
|
|
830
|
+
if (mode === "dev-managed" && (resolvedPath || ghSlug)) {
|
|
831
|
+
upsertCatalogEntry(catalogKey, { gh: ghSlug ?? "" });
|
|
832
|
+
catalog = readCatalog();
|
|
833
|
+
} else if (mode === "managed" && ghSlug) {
|
|
834
|
+
upsertCatalogEntry(catalogKey, { gh: ghSlug });
|
|
835
|
+
catalog = readCatalog();
|
|
836
|
+
} else {
|
|
837
|
+
return send400(
|
|
838
|
+
res,
|
|
839
|
+
`"${catalogKey}" is not in the catalog — register it first`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// If a new branch was created on top of the base, the workspace tracks it.
|
|
845
|
+
const effectiveBranch = newBranch ?? branch ?? "main";
|
|
846
|
+
// Preserve per-entry toggles (autostart) when re-adding/rewriting an
|
|
847
|
+
// existing entry — rebuilding the literal from scratch silently drops
|
|
848
|
+
// them otherwise.
|
|
849
|
+
const prevEntry = file.workspaces[wsName].services[address];
|
|
850
|
+
file.workspaces[wsName].services[address] = {
|
|
851
|
+
mode,
|
|
852
|
+
...(mode === "dev-managed" && resolvedPath
|
|
853
|
+
? { path: resolvedPath }
|
|
854
|
+
: {}),
|
|
855
|
+
...(mode === "managed" ? { branch: effectiveBranch } : {}),
|
|
856
|
+
...(prevEntry?.autostart === false ? { autostart: false } : {}),
|
|
857
|
+
};
|
|
858
|
+
writeWorkspacesFile(file);
|
|
859
|
+
|
|
860
|
+
// Check if the newly added service has depends_on not covered by the workspace
|
|
861
|
+
const missingDeps: string[] = [];
|
|
862
|
+
try {
|
|
863
|
+
const wsConfig = file.workspaces[wsName];
|
|
864
|
+
const resolved = resolveServiceSource(
|
|
865
|
+
address,
|
|
866
|
+
wsName,
|
|
867
|
+
wsConfig,
|
|
868
|
+
catalog,
|
|
869
|
+
);
|
|
870
|
+
if (resolved) {
|
|
871
|
+
const manifest = readManifest(resolved.repoPath);
|
|
872
|
+
const { subsystem } = parseAddress(address, catalog);
|
|
873
|
+
const deps = manifest?.services?.[subsystem]?.depends_on ?? [];
|
|
874
|
+
const existingAddresses = new Set(Object.keys(wsConfig.services));
|
|
875
|
+
for (const rawDep of deps) {
|
|
876
|
+
const dep = depService(rawDep);
|
|
877
|
+
const { repoName: depRepo } = parseAddress(dep, catalog);
|
|
878
|
+
if (
|
|
879
|
+
!existingAddresses.has(dep) &&
|
|
880
|
+
!existingAddresses.has(depRepo)
|
|
881
|
+
)
|
|
882
|
+
missingDeps.push(dep);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
} catch {
|
|
886
|
+
/* ignore — manifest may not exist yet for managed repos */
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Check if a setup command exists for the newly added service
|
|
890
|
+
let setupStarted = false;
|
|
891
|
+
try {
|
|
892
|
+
const addedManifest = resolvedPath
|
|
893
|
+
? readManifest(resolvedPath)
|
|
894
|
+
: null;
|
|
895
|
+
const { subsystem: addedSubsystem } = parseAddress(address, catalog);
|
|
896
|
+
const setupDef =
|
|
897
|
+
addedManifest?.services?.[addedSubsystem]?.commands?.local?.setup;
|
|
898
|
+
setupStarted = !!setupDef;
|
|
899
|
+
} catch {
|
|
900
|
+
/* ignore */
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
return sendJson(res, { ok: true, missingDeps, setupStarted });
|
|
904
|
+
} catch (e) {
|
|
905
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if (req.method === "DELETE") {
|
|
909
|
+
try {
|
|
910
|
+
const body = await readBody(req);
|
|
911
|
+
const { workspace, address } = JSON.parse(body) as {
|
|
912
|
+
workspace?: string;
|
|
913
|
+
address?: string;
|
|
914
|
+
};
|
|
915
|
+
if (!address) return send400(res, "address required");
|
|
916
|
+
|
|
917
|
+
// Prefer explicit workspace name; fall back to focused workspace.
|
|
918
|
+
const file = readWorkspacesFile();
|
|
919
|
+
const wsName = workspace ?? file.focus ?? file.active[0];
|
|
920
|
+
if (!wsName || !file.workspaces[wsName])
|
|
921
|
+
return send400(res, `workspace "${wsName ?? ""}" not found`);
|
|
922
|
+
|
|
923
|
+
const services = file.workspaces[wsName].services;
|
|
924
|
+
const catalog = readCatalog();
|
|
925
|
+
// Delete exact match, or all subsystem entries for the repo
|
|
926
|
+
// (handles both bare-key workspaces like "shop" and
|
|
927
|
+
// explicit-subsystem workspaces like "shop/api", "shop/db").
|
|
928
|
+
if (services[address] !== undefined) {
|
|
929
|
+
delete services[address];
|
|
930
|
+
} else {
|
|
931
|
+
for (const key of Object.keys(services)) {
|
|
932
|
+
if (parseAddress(key, catalog).repoName === address) {
|
|
933
|
+
delete services[key];
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
writeWorkspacesFile(file);
|
|
938
|
+
return sendJson(res, { ok: true });
|
|
939
|
+
} catch (e) {
|
|
940
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── API: workspace settings ──────────────────────────────────────────────
|
|
946
|
+
// GET /api/workspaces/settings?workspace=<name> → { editor }
|
|
947
|
+
// POST /api/workspaces/settings body: { workspace, editor }
|
|
948
|
+
if (pathname === "/api/workspaces/settings") {
|
|
949
|
+
if (req.method === "GET") {
|
|
950
|
+
const wsName =
|
|
951
|
+
url.searchParams.get("workspace") ?? getActiveWorkspace()?.name;
|
|
952
|
+
if (!wsName) return sendJson(res, { editor: null });
|
|
953
|
+
const file = readWorkspacesFile();
|
|
954
|
+
const ws = file.workspaces[wsName];
|
|
955
|
+
return sendJson(res, { editor: ws?.editor ?? null });
|
|
956
|
+
}
|
|
957
|
+
if (req.method === "POST") {
|
|
958
|
+
try {
|
|
959
|
+
const body = JSON.parse(await readBody(req)) as {
|
|
960
|
+
workspace?: string;
|
|
961
|
+
editor?: string | null;
|
|
962
|
+
};
|
|
963
|
+
const wsName = body.workspace ?? getActiveWorkspace()?.name;
|
|
964
|
+
if (!wsName) return send400(res, "workspace required");
|
|
965
|
+
const file = readWorkspacesFile();
|
|
966
|
+
const ws = file.workspaces[wsName];
|
|
967
|
+
if (!ws) return send400(res, `Workspace "${wsName}" not found`);
|
|
968
|
+
if (body.editor === null || body.editor === "") {
|
|
969
|
+
delete ws.editor;
|
|
970
|
+
} else if (body.editor !== undefined) {
|
|
971
|
+
if (!editors.isKnownEditor(body.editor))
|
|
972
|
+
return send400(res, `Unknown editor "${body.editor}"`);
|
|
973
|
+
ws.editor = body.editor;
|
|
974
|
+
}
|
|
975
|
+
writeWorkspacesFile(file);
|
|
976
|
+
return sendJson(res, { ok: true, editor: ws.editor ?? null });
|
|
977
|
+
} catch (e) {
|
|
978
|
+
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── API: workspace dependency check ──────────────────────────────────────
|
|
984
|
+
// Returns missing deps for each service in the specified (or active) workspace.
|
|
985
|
+
if (pathname === "/api/workspaces/deps-check" && req.method === "GET") {
|
|
986
|
+
const wsOverride = url.searchParams.get("workspace");
|
|
987
|
+
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
988
|
+
const ws =
|
|
989
|
+
wsOverride && wsFile?.workspaces[wsOverride]
|
|
990
|
+
? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
|
|
991
|
+
: getActiveWorkspace();
|
|
992
|
+
if (!ws) return sendJson(res, []);
|
|
993
|
+
const catalog = readCatalog();
|
|
994
|
+
return sendJson(res, computeMissingDeps(ws.config, catalog, ws.name));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ── API: derived dependency graph ─────────────────────────────────────────
|
|
998
|
+
// Returns the derived system dependency graph as JSON (same engine the CLI
|
|
999
|
+
// `den graph` and derived `up`/`down` use). Optional ?seed=<repo> returns
|
|
1000
|
+
// that repo's closure; otherwise the full known-systems graph.
|
|
1001
|
+
if (pathname === "/api/graph" && req.method === "GET") {
|
|
1002
|
+
const seed = url.searchParams.get("seed") ?? undefined;
|
|
1003
|
+
// A repo name resolves to a different checkout (or to nothing) per
|
|
1004
|
+
// workspace, so the caller says which workspace it is rendering. Any
|
|
1005
|
+
// workspace may be graphed — reading a graph doesn't require activation.
|
|
1006
|
+
const wsParam = url.searchParams.get("workspace") ?? undefined;
|
|
1007
|
+
if (wsParam && !getWorkspaceByName(wsParam))
|
|
1008
|
+
return send400(res, `unknown workspace "${wsParam}"`);
|
|
1009
|
+
try {
|
|
1010
|
+
const graph = seed
|
|
1011
|
+
? deriveGraph({ scope: "closure", seed, workspace: wsParam })
|
|
1012
|
+
: deriveGraph({ scope: "all", workspace: wsParam });
|
|
1013
|
+
// Include the canonical startup order (infra-stack first … entry repo
|
|
1014
|
+
// last) so the web can lay the graph out along the real dependency flow,
|
|
1015
|
+
// breaking soft/event back-call cycles the same way `den up` does.
|
|
1016
|
+
return sendJson(res, { ...graph, order: orderForUp(graph) });
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
return send400(res, `graph derivation failed: ${(e as Error).message}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ── API: applications ─────────────────────────────────────────────────────
|
|
1023
|
+
// Org > Application > Repo > Services for a workspace — same as
|
|
1024
|
+
// `den apps --json` — with each application's service addresses for
|
|
1025
|
+
// the UI's per-application up/down/restart. No `workspace` = the focused one.
|
|
1026
|
+
if (pathname === "/api/applications" && req.method === "GET") {
|
|
1027
|
+
const wsParam = url.searchParams.get("workspace") ?? undefined;
|
|
1028
|
+
if (wsParam && !getWorkspaceByName(wsParam))
|
|
1029
|
+
return send400(res, `unknown workspace "${wsParam}"`);
|
|
1030
|
+
try {
|
|
1031
|
+
return sendJson(res, loadApplicationTree(wsParam));
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
return send400(res, `applications failed: ${(e as Error).message}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ── API: workspace-level command (SSE) ───────────────────────────────────
|
|
1038
|
+
// Runs a command (up/down/restart/setup) on all services in the active
|
|
1039
|
+
// workspace in DERIVED dependency order, streaming labeled output as SSE events.
|
|
1040
|
+
if (pathname === "/api/workspace/exec") {
|
|
1041
|
+
const cmd = url.searchParams.get("cmd");
|
|
1042
|
+
if (!cmd) return send400(res, "cmd required");
|
|
1043
|
+
|
|
1044
|
+
res.writeHead(200, {
|
|
1045
|
+
"Content-Type": "text/event-stream",
|
|
1046
|
+
"Cache-Control": "no-cache",
|
|
1047
|
+
Connection: "keep-alive",
|
|
1048
|
+
"X-Accel-Buffering": "no",
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// Client disconnect cleanup — set when the browser closes the SSE stream
|
|
1052
|
+
// (tab close, navigation, refresh mid-operation). The flag is polled by
|
|
1053
|
+
// the sequential runner and the readiness poller so in-flight work bails
|
|
1054
|
+
// out instead of writing to a dead socket and leaking descriptors.
|
|
1055
|
+
let cancelled = false;
|
|
1056
|
+
let activeProc: ReturnType<typeof runServiceCommand>["proc"] | null = null;
|
|
1057
|
+
let activeDetachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1058
|
+
req.on("close", () => {
|
|
1059
|
+
cancelled = true;
|
|
1060
|
+
if (activeDetachTimer !== null) {
|
|
1061
|
+
clearTimeout(activeDetachTimer);
|
|
1062
|
+
activeDetachTimer = null;
|
|
1063
|
+
}
|
|
1064
|
+
if (activeProc) {
|
|
1065
|
+
try {
|
|
1066
|
+
activeProc.kill();
|
|
1067
|
+
} catch {
|
|
1068
|
+
/* already exited */
|
|
1069
|
+
}
|
|
1070
|
+
activeProc = null;
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
const emit = (obj: unknown): void => {
|
|
1075
|
+
if (cancelled) return;
|
|
1076
|
+
try {
|
|
1077
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
1078
|
+
} catch {
|
|
1079
|
+
// Socket closed mid-write — mark cancelled so subsequent loops bail.
|
|
1080
|
+
cancelled = true;
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
const wsParam = url.searchParams.get("workspace");
|
|
1085
|
+
if (wsParam && !isWorkspaceActive(wsParam)) {
|
|
1086
|
+
emit({
|
|
1087
|
+
type: "error",
|
|
1088
|
+
text: `Workspace "${wsParam}" is not active — activate it first`,
|
|
1089
|
+
});
|
|
1090
|
+
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1091
|
+
res.end();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const ws = wsParam ? getWorkspaceByName(wsParam) : getActiveWorkspace();
|
|
1095
|
+
if (!ws) {
|
|
1096
|
+
emit({ type: "error", text: "No active workspace" });
|
|
1097
|
+
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1098
|
+
res.end();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const catalog = readCatalog();
|
|
1103
|
+
// Modules and libraries have no runtime: never started or stopped.
|
|
1104
|
+
const allServices = listWorkspaceServices(
|
|
1105
|
+
ws.config,
|
|
1106
|
+
catalog,
|
|
1107
|
+
ws.name,
|
|
1108
|
+
).filter((s) => hasRuntime(repoKind(s.repoPath)));
|
|
1109
|
+
if (allServices.length === 0) {
|
|
1110
|
+
emit({ type: "error", text: "No services in active workspace" });
|
|
1111
|
+
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1112
|
+
res.end();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Optional comma-separated address filter — used by workspace switching
|
|
1117
|
+
// to target only the services that differ between workspaces.
|
|
1118
|
+
const addressFilter = url.searchParams.get("addresses");
|
|
1119
|
+
const addressSet = addressFilter
|
|
1120
|
+
? new Set(addressFilter.split(",").map((a) => a.trim()))
|
|
1121
|
+
: null;
|
|
1122
|
+
// For a workspace-wide STARTING command (up/restart, no explicit address
|
|
1123
|
+
// filter) exclude systems whose auto-start is toggled off (`autostart:
|
|
1124
|
+
// false` on the workspace entry). Down still stops them — the toggle only
|
|
1125
|
+
// governs starting. An explicit address filter is a deliberate target, so
|
|
1126
|
+
// the toggle is bypassed there.
|
|
1127
|
+
const startingCmd = cmd === "up" || cmd === "restart";
|
|
1128
|
+
const noAutostart = startingCmd
|
|
1129
|
+
? noAutostartRepos(ws.config, catalog)
|
|
1130
|
+
: new Set<string>();
|
|
1131
|
+
|
|
1132
|
+
// `seed=<repo>` — "up with deps": start that system AND its dependency
|
|
1133
|
+
// closure. Derived HERE rather than by the client sending an address list,
|
|
1134
|
+
// for two reasons: startClosure's positional rule (everything strictly
|
|
1135
|
+
// below the seed in the start order, never above) is load-bearing, and the
|
|
1136
|
+
// two behaviours this mode needs — "already healthy → skip" and the
|
|
1137
|
+
// auto-start-off toggle — are both gated on there being no explicit
|
|
1138
|
+
// address filter, so no amount of client-side expansion could reach them.
|
|
1139
|
+
const seedRepo =
|
|
1140
|
+
startingCmd || cmd === "down" ? url.searchParams.get("seed") : null;
|
|
1141
|
+
let closureRepos: Set<string> | null = null;
|
|
1142
|
+
if (seedRepo && cmd === "down") {
|
|
1143
|
+
// Symmetric with "up with deps": the SAME closure, torn down in reverse.
|
|
1144
|
+
// What you started together you stop together — the alternative (walking
|
|
1145
|
+
// to dependents instead) means the two menu items operate on different
|
|
1146
|
+
// sets, which is not what the pairing promises.
|
|
1147
|
+
//
|
|
1148
|
+
// The exec route already reverses the rank sort for `down`, so the seed
|
|
1149
|
+
// stops first and its foundations last.
|
|
1150
|
+
const graph = deriveGraph({ scope: "all", workspace: ws.name });
|
|
1151
|
+
const { order } = deriveStartTargets(graph, seedRepo);
|
|
1152
|
+
closureRepos = new Set(order);
|
|
1153
|
+
emit({
|
|
1154
|
+
type: "line",
|
|
1155
|
+
text: `${seedRepo}: stopping with dependencies — ${[...order].reverse().join(" → ")}`,
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// Stopping shared foundations can break systems OUTSIDE this closure that
|
|
1159
|
+
// are still running on them (infra-stack underpins most of the fleet).
|
|
1160
|
+
// Name them rather than letting them fall over silently.
|
|
1161
|
+
const atRisk = new Set<string>();
|
|
1162
|
+
for (const e of graph.edges) {
|
|
1163
|
+
if (e.kind === "jwks" || e.ordering === false) continue;
|
|
1164
|
+
if (!closureRepos.has(e.to) || closureRepos.has(e.from)) continue;
|
|
1165
|
+
if (allServices.some((s) => s.repoName === e.from)) atRisk.add(e.from);
|
|
1166
|
+
}
|
|
1167
|
+
if (atRisk.size > 0) {
|
|
1168
|
+
emit({
|
|
1169
|
+
type: "line",
|
|
1170
|
+
text: `⚠ also depends on what this stops: ${[...atRisk].sort().join(", ")} — stop or restart them if they misbehave`,
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const missing = order.filter(
|
|
1175
|
+
(r) => !allServices.some((s) => s.repoName === r),
|
|
1176
|
+
);
|
|
1177
|
+
if (missing.length > 0) {
|
|
1178
|
+
emit({
|
|
1179
|
+
type: "line",
|
|
1180
|
+
text: `Not in this workspace, skipping: ${missing.join(", ")}`,
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
} else if (seedRepo) {
|
|
1184
|
+
// Positional closure: everything the seed sits ON TOP OF, never anything
|
|
1185
|
+
// above it. Includes tolerant deps that rank below (an event consumer the
|
|
1186
|
+
// graph already draws underneath) and excludes tolerant deps that rank
|
|
1187
|
+
// above (a soft http call up the stack). See startClosure.
|
|
1188
|
+
const { order } = deriveStartTargets(
|
|
1189
|
+
deriveGraph({ scope: "all", workspace: ws.name }),
|
|
1190
|
+
seedRepo,
|
|
1191
|
+
);
|
|
1192
|
+
if (order.length === 0) {
|
|
1193
|
+
emit({
|
|
1194
|
+
type: "error",
|
|
1195
|
+
text: `${seedRepo} has no derived dependency closure — is it in this workspace?`,
|
|
1196
|
+
});
|
|
1197
|
+
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1198
|
+
res.end();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
// A system the user switched auto-start OFF is still skipped here — but
|
|
1202
|
+
// never the seed, which is what they just clicked. Mirrors `den up
|
|
1203
|
+
// <repo>` (cli/index.ts).
|
|
1204
|
+
const policy = filterClosureForStart(order, seedRepo, noAutostart);
|
|
1205
|
+
closureRepos = new Set(policy.start);
|
|
1206
|
+
const offInClosure = policy.skippedOff;
|
|
1207
|
+
emit({
|
|
1208
|
+
type: "line",
|
|
1209
|
+
text: `${seedRepo}: starting with dependencies — ${[...closureRepos].join(" → ")}`,
|
|
1210
|
+
});
|
|
1211
|
+
if (offInClosure.length > 0) {
|
|
1212
|
+
emit({
|
|
1213
|
+
type: "line",
|
|
1214
|
+
text: `Skipping (auto-start off): ${offInClosure.join(", ")} (toggle in the card ⋮ menu)`,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
const missing = [...closureRepos].filter(
|
|
1218
|
+
(r) => !allServices.some((s) => s.repoName === r),
|
|
1219
|
+
);
|
|
1220
|
+
if (missing.length > 0) {
|
|
1221
|
+
emit({
|
|
1222
|
+
type: "line",
|
|
1223
|
+
text: `Not in this workspace, skipping: ${missing.join(", ")}`,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const skippedOff: string[] = [];
|
|
1229
|
+
const addresses = allServices
|
|
1230
|
+
.map((s) => s.address)
|
|
1231
|
+
.filter((a) => {
|
|
1232
|
+
if (addressSet) return addressSet.has(a);
|
|
1233
|
+
const repoName = parseAddress(a, catalog).repoName;
|
|
1234
|
+
// Closure membership already applied the auto-start rule above, with
|
|
1235
|
+
// the seed exempted — don't re-apply it and drop the seed.
|
|
1236
|
+
if (closureRepos) return closureRepos.has(repoName);
|
|
1237
|
+
if (noAutostart.has(repoName)) {
|
|
1238
|
+
skippedOff.push(a);
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
return true;
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
if (skippedOff.length > 0) {
|
|
1245
|
+
emit({
|
|
1246
|
+
type: "line",
|
|
1247
|
+
text: `Skipping (auto-start off): ${skippedOff.join(", ")} (toggle in the card ⋮ menu, or start from its tab)`,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (addresses.length === 0) {
|
|
1252
|
+
emit({ type: "done", exitCode: 0, duration: 0 });
|
|
1253
|
+
res.end();
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
let ordered: string[];
|
|
1258
|
+
try {
|
|
1259
|
+
// Order by the DERIVED dependency graph (hard edges first) instead of
|
|
1260
|
+
// hand-written .den.yml depends_on — manifests are unreliable. We rank
|
|
1261
|
+
// each workspace address by its repo's position in the derived order.
|
|
1262
|
+
const rank = new Map(
|
|
1263
|
+
orderForUp(deriveGraph({ scope: "all", workspace: ws.name })).map(
|
|
1264
|
+
(repo, i) => [repo, i],
|
|
1265
|
+
),
|
|
1266
|
+
);
|
|
1267
|
+
const rankOf = (addr: string): number =>
|
|
1268
|
+
rank.get(parseAddress(addr, catalog).repoName) ??
|
|
1269
|
+
Number.MAX_SAFE_INTEGER;
|
|
1270
|
+
const sorted = [...addresses].sort((a, b) => rankOf(a) - rankOf(b));
|
|
1271
|
+
ordered = cmd === "down" ? sorted.reverse() : sorted;
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
emit({ type: "error", text: (e as Error).message });
|
|
1274
|
+
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1275
|
+
res.end();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// waitThenStart below blocks on a service's `wait: true` deps' health
|
|
1280
|
+
// before running its command — but for a SAME-REPO dep, the only thing
|
|
1281
|
+
// that makes it healthy is its own `up`. Prepend failing same-repo
|
|
1282
|
+
// wait-deps so the wait has something to wait FOR.
|
|
1283
|
+
//
|
|
1284
|
+
// This runs for whole-workspace starts too, not just explicit addresses
|
|
1285
|
+
// (#68): ranking is per REPO, so every subsystem of a repo ties and falls
|
|
1286
|
+
// back to manifest order — a manifest that lists a dependent before its
|
|
1287
|
+
// dependency deadlocked a plain `up` exactly the same way. Being correct
|
|
1288
|
+
// here rather than relying on file order costs nothing when the manifest
|
|
1289
|
+
// already reads deps-first: such deps are already queued and are filtered
|
|
1290
|
+
// out before any probe runs.
|
|
1291
|
+
if (startingCmd) {
|
|
1292
|
+
// Resolve/probe lazily and memoize — this now runs on every workspace
|
|
1293
|
+
// up, and the naive version re-read each repo's manifest once per
|
|
1294
|
+
// subsystem.
|
|
1295
|
+
const resolvedCache = new Map<string, ResolvedService | null>();
|
|
1296
|
+
const resolveCached = (addr: string): ResolvedService | null => {
|
|
1297
|
+
if (!resolvedCache.has(addr))
|
|
1298
|
+
resolvedCache.set(addr, resolveService(addr, ws.name));
|
|
1299
|
+
return resolvedCache.get(addr) ?? null;
|
|
1300
|
+
};
|
|
1301
|
+
const waitDepsCache = new Map<string, string[]>();
|
|
1302
|
+
let announced = false;
|
|
1303
|
+
try {
|
|
1304
|
+
const expansion = await expandSameRepoWaitDeps(ordered, {
|
|
1305
|
+
repoOf: (addr) => parseAddress(addr, catalog).repoName,
|
|
1306
|
+
waitDepsOf: (addr) => {
|
|
1307
|
+
if (!waitDepsCache.has(addr))
|
|
1308
|
+
waitDepsCache.set(
|
|
1309
|
+
addr,
|
|
1310
|
+
getWaitDeps(addr, catalog, ws.config, ws.name),
|
|
1311
|
+
);
|
|
1312
|
+
return waitDepsCache.get(addr) ?? [];
|
|
1313
|
+
},
|
|
1314
|
+
// Two address spellings can name ONE service (a bare repo name and
|
|
1315
|
+
// `repo/entrypoint`, or a `repo/repo` dep). Collapsing them on repo +
|
|
1316
|
+
// subsystem is what stops such a dep being treated as missing and
|
|
1317
|
+
// started a second time.
|
|
1318
|
+
identityOf: (addr) => {
|
|
1319
|
+
const r = resolveCached(addr);
|
|
1320
|
+
return r ? `${r.repoName}/${r.subsystem}` : addr;
|
|
1321
|
+
},
|
|
1322
|
+
isFailing: async (addr) => {
|
|
1323
|
+
if (cancelled) return false; // client gone — stop probing
|
|
1324
|
+
const depResolved = resolveCached(addr);
|
|
1325
|
+
if (!depResolved) return false;
|
|
1326
|
+
// Announce on the FIRST real probe only: when every manifest
|
|
1327
|
+
// already reads deps-first nothing is probed, and an unconditional
|
|
1328
|
+
// line would print on every workspace up for nothing.
|
|
1329
|
+
if (!announced) {
|
|
1330
|
+
announced = true;
|
|
1331
|
+
emit({ type: "line", text: "checking dependency health…" });
|
|
1332
|
+
}
|
|
1333
|
+
const h = await checkServiceHealth(depResolved, "local");
|
|
1334
|
+
return h.health === "error";
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
// ALWAYS take the expansion's order. It both ADDS deps and REORDERS
|
|
1338
|
+
// deps-first, and those are independent: when every failing dep was
|
|
1339
|
+
// already in the explicit list, `added` is empty while the reorder is
|
|
1340
|
+
// exactly what prevents the deadlock. Gating this on `added` would
|
|
1341
|
+
// keep the fix for one path and drop it for the other.
|
|
1342
|
+
ordered = expansion.ordered;
|
|
1343
|
+
if (expansion.added.length > 0) {
|
|
1344
|
+
// `cmd` is global to the run, so an added dep inherits it — on
|
|
1345
|
+
// `restart` it is taken DOWN and re-upped, not merely started. Say so.
|
|
1346
|
+
const verb = cmd === "restart" ? "Restarting" : "Starting";
|
|
1347
|
+
emit({
|
|
1348
|
+
type: "line",
|
|
1349
|
+
text: `${verb} required same-system dependencies first: ${expansion.added.join(", ")}`,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
// Degrade to the unexpanded order rather than killing a start the user
|
|
1354
|
+
// asked for: this block awaits manifest reads and health probes, and an
|
|
1355
|
+
// uncaught throw here would unwind past the SSE `done` event, hanging
|
|
1356
|
+
// the run pane instead of failing it.
|
|
1357
|
+
emit({
|
|
1358
|
+
type: "line",
|
|
1359
|
+
text: `⚠ could not expand dependencies (${(e as Error).message}) — starting as requested`,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Setup runs once per repo, on its entrypoint — not once per subsystem.
|
|
1365
|
+
// Several manifests declare `setup` on multiple services (e.g. shop/db AND
|
|
1366
|
+
// shop/api), so the fan-out ran `make setup` repeatedly per repo; each run
|
|
1367
|
+
// also dirties the managed clone (codegen), which blocks `git pull` on sync.
|
|
1368
|
+
// Workspace-wide (no address filter) setup also targets MANAGED
|
|
1369
|
+
// clones only — setup mutates a checkout with codegen/installs, which is
|
|
1370
|
+
// uninvited in an engineer's own dev-managed repo. An explicit address
|
|
1371
|
+
// (card ⋮ setup) still runs regardless of mode.
|
|
1372
|
+
if (cmd === "setup") {
|
|
1373
|
+
const chosenByRepo = new Map<string, string>();
|
|
1374
|
+
const skippedDev: string[] = [];
|
|
1375
|
+
for (const addr of ordered) {
|
|
1376
|
+
const r = resolveService(addr);
|
|
1377
|
+
if (!r) continue;
|
|
1378
|
+
if (!addressSet && r.mode === "dev-managed") {
|
|
1379
|
+
const repoName = parseAddress(addr, catalog).repoName;
|
|
1380
|
+
if (!skippedDev.includes(repoName)) skippedDev.push(repoName);
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const m = readManifest(r.repoPath);
|
|
1384
|
+
const hasSetup =
|
|
1385
|
+
"setup" in (m?.services?.[r.subsystem]?.commands?.local ?? {});
|
|
1386
|
+
if (!hasSetup) continue;
|
|
1387
|
+
const repoName = parseAddress(addr, catalog).repoName;
|
|
1388
|
+
const isEntry = m?.entrypoint ? r.subsystem === m.entrypoint : false;
|
|
1389
|
+
if (isEntry || !chosenByRepo.has(repoName))
|
|
1390
|
+
chosenByRepo.set(repoName, addr);
|
|
1391
|
+
}
|
|
1392
|
+
const keep = new Set(chosenByRepo.values());
|
|
1393
|
+
ordered = ordered.filter((addr) => keep.has(addr));
|
|
1394
|
+
if (skippedDev.length > 0) {
|
|
1395
|
+
emit({
|
|
1396
|
+
type: "line",
|
|
1397
|
+
text: `Skipping dev-managed (your own checkouts): ${skippedDev.join(", ")} — run setup from each card's ⋮ menu to include one`,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const startTime = Date.now();
|
|
1403
|
+
let overallExit = 0;
|
|
1404
|
+
let ranCount = 0;
|
|
1405
|
+
|
|
1406
|
+
// Run services sequentially in dependency order
|
|
1407
|
+
const runNext = (i: number): void => {
|
|
1408
|
+
if (cancelled) {
|
|
1409
|
+
// Client disconnected — drop further work silently. res.end() on a
|
|
1410
|
+
// dead socket is a no-op; don't emit anything that could retrigger
|
|
1411
|
+
// the try/catch guard in `emit`.
|
|
1412
|
+
try {
|
|
1413
|
+
res.end();
|
|
1414
|
+
} catch {
|
|
1415
|
+
/* already closed */
|
|
1416
|
+
}
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (i >= ordered.length) {
|
|
1420
|
+
if (ranCount === 0) {
|
|
1421
|
+
emit({
|
|
1422
|
+
type: "line",
|
|
1423
|
+
text: `No services in this workspace have a "${cmd}" command.`,
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
emit({
|
|
1427
|
+
type: "done",
|
|
1428
|
+
exitCode: overallExit,
|
|
1429
|
+
duration: Date.now() - startTime,
|
|
1430
|
+
});
|
|
1431
|
+
res.end();
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const address = ordered[i];
|
|
1436
|
+
|
|
1437
|
+
// Resolve service once — reused for both the skip check and the run.
|
|
1438
|
+
const resolved = resolveService(address, ws.name);
|
|
1439
|
+
const svcRepoName = resolved?.repoName ?? address;
|
|
1440
|
+
const svcSubsystem = resolved?.subsystem ?? address;
|
|
1441
|
+
|
|
1442
|
+
// Pre-check: skip services that don't have the requested command.
|
|
1443
|
+
// `down` is always attempted — it's opctl-conventional (stops running
|
|
1444
|
+
// containers with no declared command) and no-ops gracefully otherwise.
|
|
1445
|
+
// `restart` is orchestrated as down → verify → up, so it only needs `up`.
|
|
1446
|
+
if (resolved) {
|
|
1447
|
+
// A managed repo that was never cloned has no manifest, so it
|
|
1448
|
+
// fails the has-command check below and used to be dropped silently —
|
|
1449
|
+
// leaving "no services have an up command" as the only feedback on a
|
|
1450
|
+
// fresh install. Call it out and point at the control that fixes it.
|
|
1451
|
+
if (resolved.mode === "managed" && !fs.existsSync(resolved.repoPath)) {
|
|
1452
|
+
emit({
|
|
1453
|
+
type: "line",
|
|
1454
|
+
text: ` ${address}: not cloned yet — run Sync to clone it, then retry ${cmd}`,
|
|
1455
|
+
});
|
|
1456
|
+
runNext(i + 1);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
// Same gate as the sync chain: never START a service whose declared
|
|
1460
|
+
// local secrets are still unset — placeholders bake themselves into
|
|
1461
|
+
// database volumes at first init. `down` is always allowed.
|
|
1462
|
+
if (cmd === "up" || cmd === "restart") {
|
|
1463
|
+
const secrets = checkRepoSecrets(resolved.repoPath);
|
|
1464
|
+
if (secrets && !secrets.ok) {
|
|
1465
|
+
for (const line of describeSecretsCheck(
|
|
1466
|
+
resolved.repoName,
|
|
1467
|
+
secrets,
|
|
1468
|
+
)) {
|
|
1469
|
+
emit({ type: "line", text: ` ${line}` });
|
|
1470
|
+
}
|
|
1471
|
+
runNext(i + 1);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
const svcCmds = localCommandsFor(resolved, ws.name);
|
|
1476
|
+
const hasCmd =
|
|
1477
|
+
cmd === "down" ||
|
|
1478
|
+
cmd in svcCmds ||
|
|
1479
|
+
(cmd === "restart" && "up" in svcCmds);
|
|
1480
|
+
if (!hasCmd) {
|
|
1481
|
+
runNext(i + 1);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
ranCount++;
|
|
1487
|
+
|
|
1488
|
+
emit({ type: "label", text: address });
|
|
1489
|
+
|
|
1490
|
+
// detachFired: true once the detach timer fires so onClose can return early
|
|
1491
|
+
// and not emit a duplicate svc-done after the timer kills the tail proc.
|
|
1492
|
+
let detachFired = false;
|
|
1493
|
+
let detachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1494
|
+
let didRunNext = false;
|
|
1495
|
+
const safeRunNext = (n: number) => {
|
|
1496
|
+
if (!didRunNext) {
|
|
1497
|
+
didRunNext = true;
|
|
1498
|
+
runNext(n);
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
const doStart = (): void => {
|
|
1503
|
+
prewarmSudo();
|
|
1504
|
+
const startCmd = cmd === "restart" ? "up" : cmd;
|
|
1505
|
+
const { proc, detachable, interactive } = runServiceCommand({
|
|
1506
|
+
address,
|
|
1507
|
+
// restart is orchestrated: phase 1 (down + verify) already ran, so
|
|
1508
|
+
// phase 2 is a TRUE `up` — full detach/tail/readiness semantics —
|
|
1509
|
+
// instead of the old single-shell "down && up" synthesis.
|
|
1510
|
+
command: startCmd,
|
|
1511
|
+
workspace: ws.name,
|
|
1512
|
+
// Web-triggered ups always daemonize so output streams into the
|
|
1513
|
+
// browser log pane. `cli_tabs` only affects the CLI (`den up
|
|
1514
|
+
// --tabs`); opening Terminal.app from a button the user clicked in
|
|
1515
|
+
// the UI just hides the command they came here to watch.
|
|
1516
|
+
runtime: "daemon",
|
|
1517
|
+
env: getSudoEnv(),
|
|
1518
|
+
onLine: (line) => emit({ type: "line", text: line }),
|
|
1519
|
+
onClose: (exitCode) => {
|
|
1520
|
+
// Release outer-scope refs so the disconnect handler no longer
|
|
1521
|
+
// tries to kill a process/timer that's already gone.
|
|
1522
|
+
if (activeProc === proc) activeProc = null;
|
|
1523
|
+
if (detachFired) return; // detach timer already emitted svc-done for this service
|
|
1524
|
+
// Command finished before the detach window — cancel the timer and complete normally.
|
|
1525
|
+
if (detachTimer !== null) {
|
|
1526
|
+
clearTimeout(detachTimer);
|
|
1527
|
+
// Clear the shared ref BEFORE nulling the local, otherwise the
|
|
1528
|
+
// comparison is always false and activeDetachTimer stays stale.
|
|
1529
|
+
if (activeDetachTimer === detachTimer) activeDetachTimer = null;
|
|
1530
|
+
detachTimer = null;
|
|
1531
|
+
}
|
|
1532
|
+
if (exitCode !== 0) overallExit = exitCode;
|
|
1533
|
+
emit({
|
|
1534
|
+
type: "svc-done",
|
|
1535
|
+
address,
|
|
1536
|
+
exitCode,
|
|
1537
|
+
repoName: svcRepoName,
|
|
1538
|
+
subsystem: svcSubsystem,
|
|
1539
|
+
});
|
|
1540
|
+
safeRunNext(i + 1);
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
// Track current proc so req.on('close') can kill it on disconnect.
|
|
1545
|
+
activeProc = proc;
|
|
1546
|
+
|
|
1547
|
+
if (interactive) {
|
|
1548
|
+
// Can't run interactive commands over HTTP — skip with guidance
|
|
1549
|
+
emit({
|
|
1550
|
+
type: "error",
|
|
1551
|
+
text: `${address}: requires interactive terminal — run: ${product().cli} setup ${address}`,
|
|
1552
|
+
});
|
|
1553
|
+
safeRunNext(i + 1);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if (detachable && (cmd === "up" || cmd === "restart")) {
|
|
1558
|
+
detachTimer = setTimeout(() => {
|
|
1559
|
+
detachFired = true;
|
|
1560
|
+
detachTimer = null;
|
|
1561
|
+
activeDetachTimer = null;
|
|
1562
|
+
try {
|
|
1563
|
+
proc.kill();
|
|
1564
|
+
} catch {
|
|
1565
|
+
/* tail may have already exited */
|
|
1566
|
+
}
|
|
1567
|
+
emit({
|
|
1568
|
+
type: "svc-done",
|
|
1569
|
+
address,
|
|
1570
|
+
exitCode: 0,
|
|
1571
|
+
detached: true,
|
|
1572
|
+
repoName: svcRepoName,
|
|
1573
|
+
subsystem: svcSubsystem,
|
|
1574
|
+
});
|
|
1575
|
+
safeRunNext(i + 1);
|
|
1576
|
+
}, DETACH_WINDOW_MS);
|
|
1577
|
+
activeDetachTimer = detachTimer;
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
|
|
1581
|
+
const waitThenStart = (): void => {
|
|
1582
|
+
// Before starting an up/restart, block on any `wait: true` deps until
|
|
1583
|
+
// their health check is green. Default (no flagged deps) → doStart() now.
|
|
1584
|
+
if (cmd === "up" || cmd === "restart") {
|
|
1585
|
+
const waitDeps = getWaitDeps(address, catalog, ws.config, ws.name);
|
|
1586
|
+
if (waitDeps.length > 0) {
|
|
1587
|
+
void (async () => {
|
|
1588
|
+
for (const depAddr of waitDeps) {
|
|
1589
|
+
if (cancelled) return;
|
|
1590
|
+
const depResolved = resolveService(depAddr);
|
|
1591
|
+
if (!depResolved) {
|
|
1592
|
+
emit({
|
|
1593
|
+
type: "line",
|
|
1594
|
+
text: `⚠ wait dep ${depAddr} not resolvable — skipping readiness check`,
|
|
1595
|
+
});
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
emit({
|
|
1599
|
+
type: "line",
|
|
1600
|
+
text: `waiting for ${depAddr} to be ready…`,
|
|
1601
|
+
});
|
|
1602
|
+
const result = await waitForReady(depResolved, "local", {
|
|
1603
|
+
onTick: (detail) =>
|
|
1604
|
+
emit({ type: "line", text: ` ${depAddr}: ${detail}` }),
|
|
1605
|
+
isCancelled: () => cancelled,
|
|
1606
|
+
});
|
|
1607
|
+
if (cancelled) return;
|
|
1608
|
+
if (!result.ok) {
|
|
1609
|
+
emit({
|
|
1610
|
+
type: "error",
|
|
1611
|
+
text: `timed out waiting for ${depAddr} after ${Math.round(result.elapsedMs / 1000)}s: ${result.detail ?? "not ready"}`,
|
|
1612
|
+
});
|
|
1613
|
+
overallExit = 1;
|
|
1614
|
+
emit({
|
|
1615
|
+
type: "svc-done",
|
|
1616
|
+
address,
|
|
1617
|
+
exitCode: 1,
|
|
1618
|
+
repoName: svcRepoName,
|
|
1619
|
+
subsystem: svcSubsystem,
|
|
1620
|
+
});
|
|
1621
|
+
safeRunNext(i + 1);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
emit({
|
|
1625
|
+
type: "line",
|
|
1626
|
+
text: result.skipped
|
|
1627
|
+
? `${depAddr}: no health check configured — proceeding`
|
|
1628
|
+
: `${depAddr} ready (${Math.round(result.elapsedMs / 1000)}s)`,
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
if (cancelled) return;
|
|
1632
|
+
doStart();
|
|
1633
|
+
})();
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
doStart();
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
// Workspace-wide runs consult LIVE state before acting (matches the
|
|
1642
|
+
// CLI's derived-up `skipHealthyAll` behavior). An explicit address
|
|
1643
|
+
// filter is a deliberate target and bypasses these checks:
|
|
1644
|
+
// - up: a service that already answers its health check is left alone
|
|
1645
|
+
// - down: a service with no running containers and no healthy probe
|
|
1646
|
+
// is already stopped — running its down script is pointless noise
|
|
1647
|
+
if (!addressSet && resolved && (cmd === "up" || cmd === "down")) {
|
|
1648
|
+
void (async () => {
|
|
1649
|
+
if (cmd === "up") {
|
|
1650
|
+
const health = await checkServiceHealth(resolved, "local");
|
|
1651
|
+
if (cancelled) return;
|
|
1652
|
+
if (health.health === "ok") {
|
|
1653
|
+
// Skipping must not lose the refcount — this workspace is an
|
|
1654
|
+
// owner of what's running, whether den tracked it or not.
|
|
1655
|
+
const claim = claimHealthyService(resolved, ws.name);
|
|
1656
|
+
emit({
|
|
1657
|
+
type: "line",
|
|
1658
|
+
text:
|
|
1659
|
+
`${address} already healthy — skipping up` +
|
|
1660
|
+
(claim === "adopted"
|
|
1661
|
+
? " (adopted its running containers)"
|
|
1662
|
+
: claim === "attached"
|
|
1663
|
+
? ` (owned by "${ws.name}" too)`
|
|
1664
|
+
: ""),
|
|
1665
|
+
});
|
|
1666
|
+
emit({
|
|
1667
|
+
type: "svc-done",
|
|
1668
|
+
address,
|
|
1669
|
+
exitCode: 0,
|
|
1670
|
+
skipped: true,
|
|
1671
|
+
repoName: svcRepoName,
|
|
1672
|
+
subsystem: svcSubsystem,
|
|
1673
|
+
});
|
|
1674
|
+
safeRunNext(i + 1);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
} else {
|
|
1678
|
+
let running = hasRunningOpctlContainers(resolved);
|
|
1679
|
+
if (!running) {
|
|
1680
|
+
// Not opctl-visible but still responding (host-run process)?
|
|
1681
|
+
// Then it IS running — proceed with the down.
|
|
1682
|
+
const h = await checkServiceHealth(resolved, "local");
|
|
1683
|
+
running = h.health === "ok";
|
|
1684
|
+
}
|
|
1685
|
+
if (cancelled) return;
|
|
1686
|
+
if (!running) {
|
|
1687
|
+
emit({
|
|
1688
|
+
type: "line",
|
|
1689
|
+
text: `${address} already stopped — skipping down`,
|
|
1690
|
+
});
|
|
1691
|
+
emit({
|
|
1692
|
+
type: "svc-done",
|
|
1693
|
+
address,
|
|
1694
|
+
exitCode: 0,
|
|
1695
|
+
skipped: true,
|
|
1696
|
+
repoName: svcRepoName,
|
|
1697
|
+
subsystem: svcSubsystem,
|
|
1698
|
+
});
|
|
1699
|
+
safeRunNext(i + 1);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
waitThenStart();
|
|
1704
|
+
})();
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Restart = orchestrated down → VERIFY → up. Phase 1 stops the service
|
|
1709
|
+
// (opctl-aware; "nothing to stop" counts as success) and only proceeds to
|
|
1710
|
+
// the up when the down actually succeeded — the old single-shell
|
|
1711
|
+
// "down && up" synthesis required both commands declared in the manifest
|
|
1712
|
+
// and gave no verification point. An already-stopped service skips the
|
|
1713
|
+
// down phase entirely (running its manifest `down` while stopped is
|
|
1714
|
+
// pointless and often exits non-zero).
|
|
1715
|
+
if (cmd === "restart") {
|
|
1716
|
+
void (async () => {
|
|
1717
|
+
let alreadyDown = resolved
|
|
1718
|
+
? !hasRunningOpctlContainers(resolved)
|
|
1719
|
+
: false;
|
|
1720
|
+
if (alreadyDown && resolved) {
|
|
1721
|
+
// Not opctl-visible but still responding (non-opctl process)?
|
|
1722
|
+
// Then it IS running — go through the down phase.
|
|
1723
|
+
const h = await checkServiceHealth(resolved, "local");
|
|
1724
|
+
if (h.health === "ok") alreadyDown = false;
|
|
1725
|
+
}
|
|
1726
|
+
if (cancelled) return;
|
|
1727
|
+
if (alreadyDown) {
|
|
1728
|
+
emit({
|
|
1729
|
+
type: "line",
|
|
1730
|
+
text: "── already stopped — starting up ──",
|
|
1731
|
+
});
|
|
1732
|
+
waitThenStart();
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
emit({ type: "line", text: "── restart: stopping ──" });
|
|
1737
|
+
prewarmSudo();
|
|
1738
|
+
const { proc: downProc } = runServiceCommand({
|
|
1739
|
+
address,
|
|
1740
|
+
command: "down",
|
|
1741
|
+
workspace: ws.name,
|
|
1742
|
+
env: getSudoEnv(),
|
|
1743
|
+
onLine: (line) => emit({ type: "line", text: line }),
|
|
1744
|
+
onClose: (downExit) => {
|
|
1745
|
+
if (activeProc === downProc) activeProc = null;
|
|
1746
|
+
if (cancelled) {
|
|
1747
|
+
try {
|
|
1748
|
+
res.end();
|
|
1749
|
+
} catch {
|
|
1750
|
+
/* already closed */
|
|
1751
|
+
}
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (downExit !== 0) {
|
|
1755
|
+
overallExit = downExit;
|
|
1756
|
+
emit({
|
|
1757
|
+
type: "error",
|
|
1758
|
+
text: `${address}: down failed (exit ${downExit}) — aborting restart`,
|
|
1759
|
+
});
|
|
1760
|
+
emit({
|
|
1761
|
+
type: "svc-done",
|
|
1762
|
+
address,
|
|
1763
|
+
exitCode: downExit,
|
|
1764
|
+
repoName: svcRepoName,
|
|
1765
|
+
subsystem: svcSubsystem,
|
|
1766
|
+
});
|
|
1767
|
+
safeRunNext(i + 1);
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
emit({ type: "line", text: "── down ✓ — starting up ──" });
|
|
1771
|
+
waitThenStart();
|
|
1772
|
+
},
|
|
1773
|
+
});
|
|
1774
|
+
activeProc = downProc;
|
|
1775
|
+
})();
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
waitThenStart();
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
runNext(0);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// ── API: workspace sync (SSE) ─────────────────────────────────────────────
|
|
1787
|
+
/**
|
|
1788
|
+
* Run one command for a managed service, streaming its output into
|
|
1789
|
+
* the sync SSE. Resolves true on exit 0. No manifest pre-gating: the runner
|
|
1790
|
+
* itself owns command semantics (conventional `down` works with nothing
|
|
1791
|
+
* declared; a genuinely missing command surfaces as its error output).
|
|
1792
|
+
*
|
|
1793
|
+
* Detachable commands report on tail-detach rather than on health, so their
|
|
1794
|
+
* success line says "started" and the health dot carries the truth from
|
|
1795
|
+
* there. Sync itself only uses this for `down` — it never starts anything.
|
|
1796
|
+
*/
|
|
1797
|
+
async function runManagedStep(
|
|
1798
|
+
address: string,
|
|
1799
|
+
repoName: string,
|
|
1800
|
+
command: string,
|
|
1801
|
+
emit: (o: unknown) => void,
|
|
1802
|
+
isCancelled: () => boolean,
|
|
1803
|
+
): Promise<boolean> {
|
|
1804
|
+
emit({ type: "line", text: ` ${repoName}: ${command}…` });
|
|
1805
|
+
return new Promise<boolean>((resolve) => {
|
|
1806
|
+
let settled = false;
|
|
1807
|
+
let detached = false;
|
|
1808
|
+
const done = (ok: boolean): void => {
|
|
1809
|
+
if (settled) return;
|
|
1810
|
+
settled = true;
|
|
1811
|
+
resolve(ok);
|
|
1812
|
+
};
|
|
1813
|
+
try {
|
|
1814
|
+
const result = runServiceCommand({
|
|
1815
|
+
address,
|
|
1816
|
+
command,
|
|
1817
|
+
onLine: (line) => {
|
|
1818
|
+
if (!isCancelled()) emit({ type: "line", text: ` ${line}` });
|
|
1819
|
+
},
|
|
1820
|
+
onClose: (code) => {
|
|
1821
|
+
// For a detachable command the tail was likely killed by the
|
|
1822
|
+
// detach timer below — that close is bookkeeping, not a verdict.
|
|
1823
|
+
if (settled) return;
|
|
1824
|
+
if (code === 0) {
|
|
1825
|
+
emit({
|
|
1826
|
+
type: "line",
|
|
1827
|
+
text: detached
|
|
1828
|
+
? ` ${repoName}: ${command} started (running in background)`
|
|
1829
|
+
: ` ${repoName}: ${command} ✓`,
|
|
1830
|
+
});
|
|
1831
|
+
} else {
|
|
1832
|
+
emit({
|
|
1833
|
+
type: "line",
|
|
1834
|
+
text: ` ${repoName}: ${command} failed (exit ${code})`,
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
done(code === 0);
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
// onClose is never invoked synchronously (see runServiceCommand), so
|
|
1841
|
+
// this assignment always lands before the wording above is needed.
|
|
1842
|
+
detached = result.detachable;
|
|
1843
|
+
if (result.detachable) {
|
|
1844
|
+
// Daemon tails stream INDEFINITELY — that's the live-log feature the
|
|
1845
|
+
// card exec route is built on, so onClose never fires on success and
|
|
1846
|
+
// this promise would never resolve (the first live run held the sync
|
|
1847
|
+
// stream open forever). Give the tail a detach window to surface
|
|
1848
|
+
// early crash output, then declare the service started, stop the
|
|
1849
|
+
// tail, and let the health dot carry the truth from here.
|
|
1850
|
+
setTimeout(() => {
|
|
1851
|
+
if (settled) return;
|
|
1852
|
+
try {
|
|
1853
|
+
result.proc.kill();
|
|
1854
|
+
} catch {
|
|
1855
|
+
/* tail may already be gone */
|
|
1856
|
+
}
|
|
1857
|
+
emit({
|
|
1858
|
+
type: "line",
|
|
1859
|
+
text: ` ${repoName}: ${command} started (detached — output continues in the service log)`,
|
|
1860
|
+
});
|
|
1861
|
+
done(true);
|
|
1862
|
+
}, DETACH_WINDOW_MS);
|
|
1863
|
+
}
|
|
1864
|
+
} catch (e) {
|
|
1865
|
+
emit({
|
|
1866
|
+
type: "line",
|
|
1867
|
+
text: ` ${repoName}: ${command} error — ${(e as Error).message}`,
|
|
1868
|
+
});
|
|
1869
|
+
done(false);
|
|
1870
|
+
}
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
if (pathname === "/api/workspace/sync") {
|
|
1875
|
+
res.writeHead(200, {
|
|
1876
|
+
"Content-Type": "text/event-stream",
|
|
1877
|
+
"Cache-Control": "no-cache",
|
|
1878
|
+
Connection: "keep-alive",
|
|
1879
|
+
"X-Accel-Buffering": "no",
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
let cancelled = false;
|
|
1883
|
+
req.on("close", () => {
|
|
1884
|
+
cancelled = true;
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
const emit = (obj: unknown): void => {
|
|
1888
|
+
if (cancelled) return;
|
|
1889
|
+
try {
|
|
1890
|
+
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
1891
|
+
} catch {
|
|
1892
|
+
cancelled = true;
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
const ws = getActiveWorkspace();
|
|
1897
|
+
if (!ws) {
|
|
1898
|
+
emit({ type: "error", text: "No active workspace" });
|
|
1899
|
+
emit({ type: "done" });
|
|
1900
|
+
res.end();
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const catalog = readCatalog();
|
|
1905
|
+
const services = listWorkspaceServices(ws.config, catalog, ws.name);
|
|
1906
|
+
// Optional ?repo=<name> syncs a single system (the card's ⟳ sync action);
|
|
1907
|
+
// omitted syncs the whole workspace (the master ⟳ Sync button).
|
|
1908
|
+
const onlyRepo = url.searchParams.get("repo");
|
|
1909
|
+
// Only sync managed services. Deduplicate by repoPath.
|
|
1910
|
+
const seen = new Set<string>();
|
|
1911
|
+
const targets = services.filter((s) => {
|
|
1912
|
+
if (s.mode !== "managed") return false;
|
|
1913
|
+
if (onlyRepo && s.repoName !== onlyRepo) return false;
|
|
1914
|
+
if (seen.has(s.repoPath)) return false;
|
|
1915
|
+
seen.add(s.repoPath);
|
|
1916
|
+
return true;
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
if (targets.length === 0) {
|
|
1920
|
+
// Distinguish "you asked for a repo we can't sync" from "nothing to do",
|
|
1921
|
+
// so a stale card or a dev-managed repo doesn't look like a silent no-op.
|
|
1922
|
+
emit({
|
|
1923
|
+
type: "line",
|
|
1924
|
+
text: onlyRepo
|
|
1925
|
+
? `No managed repo named "${onlyRepo}" in this workspace — nothing to sync.`
|
|
1926
|
+
: "No managed services to sync.",
|
|
1927
|
+
});
|
|
1928
|
+
emit({ type: "done" });
|
|
1929
|
+
res.end();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
emit({
|
|
1934
|
+
type: "line",
|
|
1935
|
+
text: onlyRepo
|
|
1936
|
+
? `Checking ${onlyRepo} for updates…\n`
|
|
1937
|
+
: `Checking ${targets.length} managed repo(s) for updates…\n`,
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
// Repos other repos read shared config from — a failure in one of these
|
|
1941
|
+
// must stop the chain, not just skip it (see the gates below).
|
|
1942
|
+
const providerRepos = serviceEnvProviderRepos();
|
|
1943
|
+
|
|
1944
|
+
for (const svc of targets) {
|
|
1945
|
+
if (cancelled) break;
|
|
1946
|
+
emit({ type: "line", text: ` ${svc.repoName} checking…` });
|
|
1947
|
+
// A sync chain that started (pull landed) but didn't finish (setup or up
|
|
1948
|
+
// failed / was interrupted) leaves the clone new and its environment or
|
|
1949
|
+
// run-state old. Without a marker the next sync sees behind=0, says
|
|
1950
|
+
// "up to date", and the service stays broken with no way back from this
|
|
1951
|
+
// button — which is exactly what happened the first time this ran. The
|
|
1952
|
+
// marker spans the WHOLE chain: written when we commit to it, cleared
|
|
1953
|
+
// only after `up` succeeds.
|
|
1954
|
+
const pendingMarker = path.join(
|
|
1955
|
+
svc.repoPath,
|
|
1956
|
+
`.${product().name}-setup-pending`,
|
|
1957
|
+
);
|
|
1958
|
+
const chainPending = fs.existsSync(pendingMarker);
|
|
1959
|
+
// A managed repo that was never cloned can't be checked, let
|
|
1960
|
+
// alone pulled — and syncing it IS the intent, so it bootstraps below
|
|
1961
|
+
// (down → clone → setup) instead of reporting "no upstream".
|
|
1962
|
+
const cloneExists = fs.existsSync(svc.repoPath);
|
|
1963
|
+
let behind = 0;
|
|
1964
|
+
if (cloneExists) {
|
|
1965
|
+
// Async spawn-based git so the event loop stays responsive — inline
|
|
1966
|
+
// execSync here previously froze the whole server for the duration of
|
|
1967
|
+
// each `git fetch` (5-30s per repo over VPN).
|
|
1968
|
+
const chk = await checkManagedCloneBehind(
|
|
1969
|
+
svc.repoPath,
|
|
1970
|
+
() => cancelled,
|
|
1971
|
+
);
|
|
1972
|
+
if (cancelled || chk.cancelled) break;
|
|
1973
|
+
if (chk.behind === -1) {
|
|
1974
|
+
emit({
|
|
1975
|
+
type: "line",
|
|
1976
|
+
text: ` ${svc.repoName}: no upstream — skipping`,
|
|
1977
|
+
});
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
behind = chk.behind;
|
|
1981
|
+
if (behind === 0 && !chainPending) {
|
|
1982
|
+
emit({ type: "line", text: ` ${svc.repoName}: up to date ✓` });
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Something to do: explicit down → (clone|pull) → setup. Down FIRST
|
|
1988
|
+
// is the point of the ordering — the service must never observe mixed
|
|
1989
|
+
// state (new code under an old venv is how files-api died: uvicorn
|
|
1990
|
+
// hot-reloaded mid-pull into an import its environment couldn't satisfy).
|
|
1991
|
+
// Down runs against the BARE repo name: it is conventional and needs no
|
|
1992
|
+
// manifest, which matters when the clone doesn't exist yet but the
|
|
1993
|
+
// containers do (started from another checkout).
|
|
1994
|
+
emit({
|
|
1995
|
+
type: "line",
|
|
1996
|
+
text: !cloneExists
|
|
1997
|
+
? ` ${svc.repoName}: not cloned yet — bootstrapping (down → clone → setup)`
|
|
1998
|
+
: behind > 0
|
|
1999
|
+
? ` ${svc.repoName}: ${behind} commit(s) behind — down → pull → setup`
|
|
2000
|
+
: ` ${svc.repoName}: up to date, but a previous sync did not finish — resuming setup`,
|
|
2001
|
+
});
|
|
2002
|
+
if (cloneExists) {
|
|
2003
|
+
try {
|
|
2004
|
+
fs.writeFileSync(pendingMarker, `${new Date().toISOString()}\n`);
|
|
2005
|
+
} catch {
|
|
2006
|
+
/* best-effort — worst case we just don't retry next time */
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
prewarmSudo();
|
|
2010
|
+
|
|
2011
|
+
const downOk = await runManagedStep(
|
|
2012
|
+
svc.repoName,
|
|
2013
|
+
svc.repoName,
|
|
2014
|
+
"down",
|
|
2015
|
+
emit,
|
|
2016
|
+
() => cancelled,
|
|
2017
|
+
);
|
|
2018
|
+
if (cancelled) break;
|
|
2019
|
+
if (!downOk) {
|
|
2020
|
+
emit({
|
|
2021
|
+
type: "line",
|
|
2022
|
+
text: ` ${svc.repoName}: down failed — leaving this repo untouched (sync again to retry)`,
|
|
2023
|
+
});
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
if (!cloneExists) {
|
|
2028
|
+
// A catalog entry can exist with an empty `gh` (the dev-managed add
|
|
2029
|
+
// flow writes one), which used to read as "not in the catalog" and
|
|
2030
|
+
// skip the clone entirely. Derive the conventional slug instead, and
|
|
2031
|
+
// persist it so every other flow (CLI up/setup) stops hitting this.
|
|
2032
|
+
const ghSlug = resolveRepoGhSlug(svc.repoName);
|
|
2033
|
+
if (!readCatalog()[svc.repoName]?.gh) {
|
|
2034
|
+
upsertCatalogEntry(svc.repoName, { gh: ghSlug });
|
|
2035
|
+
}
|
|
2036
|
+
const branch = (svc as { branch?: string | null }).branch ?? "main";
|
|
2037
|
+
emit({
|
|
2038
|
+
type: "line",
|
|
2039
|
+
text: ` ${svc.repoName}: cloning ${ghSlug} (${branch})…`,
|
|
2040
|
+
});
|
|
2041
|
+
try {
|
|
2042
|
+
// Synchronous (same helper the workspace-add flow uses) — the server
|
|
2043
|
+
// is busy for the duration of the clone. runSetup=false: setup runs
|
|
2044
|
+
// below with prompt auto-answering and the retry marker.
|
|
2045
|
+
ensureManagedServiceClone(svc.repoName, ghSlug, branch, false);
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
emit({
|
|
2048
|
+
type: "line",
|
|
2049
|
+
text: ` ${svc.repoName}: clone failed — ${(e as Error).message}`,
|
|
2050
|
+
});
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
emit({ type: "line", text: ` ${svc.repoName}: clone ✓` });
|
|
2054
|
+
|
|
2055
|
+
// A fresh clone carries stack's placeholder secrets, which blocks its
|
|
2056
|
+
// own `make setup`. When another checkout of this same repo already
|
|
2057
|
+
// holds real values, copy them across BEFORE setup runs — that is what
|
|
2058
|
+
// the engineer would do by hand, and it makes the common case
|
|
2059
|
+
// (a sibling checkout already configured) need no prompting at all.
|
|
2060
|
+
const seeded = seedSecretsFromSibling(svc.repoName, svc.repoPath);
|
|
2061
|
+
if (seeded) {
|
|
2062
|
+
emit({
|
|
2063
|
+
type: "line",
|
|
2064
|
+
text: ` ${svc.repoName}: filled ${seeded.filled.length} local secret(s) from ${seeded.donorPath} — ${seeded.filled.join(", ")}`,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
try {
|
|
2068
|
+
fs.writeFileSync(pendingMarker, `${new Date().toISOString()}\n`);
|
|
2069
|
+
} catch {
|
|
2070
|
+
/* best-effort */
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
if (cloneExists && behind > 0) {
|
|
2075
|
+
const result = await pullManagedClone(
|
|
2076
|
+
svc.repoPath,
|
|
2077
|
+
behind,
|
|
2078
|
+
() => cancelled,
|
|
2079
|
+
);
|
|
2080
|
+
if (cancelled || result.cancelled) break;
|
|
2081
|
+
if (!result.pulled) {
|
|
2082
|
+
emit({
|
|
2083
|
+
type: "line",
|
|
2084
|
+
text: ` ${svc.repoName}: pull failed — ${result.error ?? "unknown"}; left stopped and unchanged`,
|
|
2085
|
+
});
|
|
2086
|
+
// Marker stays: the next sync retries the whole chain.
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
emit({
|
|
2090
|
+
type: "line",
|
|
2091
|
+
text: ` ${svc.repoName}: pulled ${result.behind} commit(s) ✓`,
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Single non-interactive setup authority, shared with the CLI's clone
|
|
2096
|
+
// bootstrap (runSetupHidden): env-hardened, tty-less, stdin auto-answers
|
|
2097
|
+
// "n", stall detector kills anything that still blocks. Resolves the
|
|
2098
|
+
// setup command itself (manifest first, else `make setup`) and no-ops
|
|
2099
|
+
// cleanly when there is nothing to run — so no pre-gating here. The
|
|
2100
|
+
// prompt text streams below; whatever was declined is visible.
|
|
2101
|
+
emit({
|
|
2102
|
+
type: "line",
|
|
2103
|
+
text: ` ${svc.repoName}: running setup (non-interactive — prompts auto-answered "n"; use \`${product().cli} setup ${svc.repoName}\` for interactive control)`,
|
|
2104
|
+
});
|
|
2105
|
+
const setupRes = await runSetupHidden(svc.repoPath, (line) => {
|
|
2106
|
+
if (!cancelled) emit({ type: "line", text: ` ${line}` });
|
|
2107
|
+
});
|
|
2108
|
+
if (cancelled) break;
|
|
2109
|
+
if (setupRes.stalledPrompt) {
|
|
2110
|
+
emit({
|
|
2111
|
+
type: "line",
|
|
2112
|
+
text: ` ${svc.repoName}: setup stalled on a prompt (${setupRes.stalledPrompt}) and was killed — run \`${product().cli} setup ${svc.repoName}\` interactively`,
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
if (setupRes.code !== 0) {
|
|
2116
|
+
// Starting a service whose environment no longer matches its code is
|
|
2117
|
+
// the crash-loop this chain exists to prevent — stay down, honestly.
|
|
2118
|
+
emit({
|
|
2119
|
+
type: "line",
|
|
2120
|
+
text: ` ${svc.repoName}: setup failed (exit ${setupRes.code}) — leaving stopped; sync again to retry`,
|
|
2121
|
+
});
|
|
2122
|
+
if (providerRepos.has(svc.repoName)) {
|
|
2123
|
+
emit({
|
|
2124
|
+
type: "line",
|
|
2125
|
+
text: ` ${svc.repoName}: other services read shared config from this repo — stopping the sync here rather than starting them against a half-configured dependency`,
|
|
2126
|
+
});
|
|
2127
|
+
break;
|
|
2128
|
+
}
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
emit({ type: "line", text: ` ${svc.repoName}: setup ✓` });
|
|
2132
|
+
|
|
2133
|
+
// Sync deliberately ENDS here. It brings the checkout up to date; it does
|
|
2134
|
+
// not change run state. Starting a service is an explicit action the
|
|
2135
|
+
// engineer takes from the card (▶ up), so a sync can never surprise them
|
|
2136
|
+
// by booting something — least of all against config they haven't looked
|
|
2137
|
+
// at yet. The marker spans clone/pull → setup, so clear it now that the
|
|
2138
|
+
// chain has finished.
|
|
2139
|
+
try {
|
|
2140
|
+
fs.unlinkSync(pendingMarker);
|
|
2141
|
+
} catch {
|
|
2142
|
+
/* best-effort */
|
|
2143
|
+
}
|
|
2144
|
+
emit({
|
|
2145
|
+
type: "line",
|
|
2146
|
+
text: ` ${svc.repoName}: synced ✓ — not started (use ▶ up on the card when you're ready)`,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
emit({ type: "done" });
|
|
2151
|
+
res.end();
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
}
|