@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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,905 @@
1
+ /**
2
+ * Workspace Directory Manager
3
+ *
4
+ * Handles git operations for managed repos:
5
+ * - .den-services/<repo>/ long-lived branch-tracking clones
6
+ * - den-workspaces/<ws>/<repo>/ feature workspace clones
7
+ *
8
+ * Dev-managed repos (mode: "dev-managed") are never touched here — the developer
9
+ * owns those checkouts entirely.
10
+ */
11
+
12
+ import { execSync, spawnSync } from "child_process";
13
+ import * as fs from "fs";
14
+ import * as os from "os";
15
+ import * as path from "path";
16
+ import { DEN_WORKSPACES_DIR, DEN_SERVICES_DIR } from "./workspaces.js";
17
+ import { readManifest } from "../manifest/read-manifest.js";
18
+ import { readUserConfig } from "../den-config.js";
19
+ import {
20
+ spawnInteractive,
21
+ spawnStreaming,
22
+ } from "../process/spawn-streaming.js";
23
+ import { buildServiceEnv } from "./service-env.js";
24
+ import { assertSafeCloneDestination } from "./clone-guard.js";
25
+ import type { CommandDef } from "../types/index.js";
26
+
27
+ // ── Terminal resolution (macOS) ──────────────────────────────────────────────
28
+
29
+ const KNOWN_TERMINALS: ReadonlyArray<{
30
+ key: string;
31
+ appName: string;
32
+ appPaths: readonly string[];
33
+ }> = [
34
+ {
35
+ key: "terminal",
36
+ appName: "Terminal",
37
+ appPaths: ["/System/Applications/Utilities/Terminal.app"],
38
+ },
39
+ { key: "iterm2", appName: "iTerm", appPaths: ["/Applications/iTerm.app"] },
40
+ {
41
+ key: "ghostty",
42
+ appName: "Ghostty",
43
+ appPaths: [
44
+ "/Applications/Ghostty.app",
45
+ path.join(os.homedir(), "Applications/Ghostty.app"),
46
+ ],
47
+ },
48
+ { key: "warp", appName: "Warp", appPaths: ["/Applications/Warp.app"] },
49
+ {
50
+ key: "kitty",
51
+ appName: "kitty",
52
+ appPaths: [
53
+ "/Applications/kitty.app",
54
+ path.join(os.homedir(), "Applications/kitty.app"),
55
+ ],
56
+ },
57
+ {
58
+ key: "alacritty",
59
+ appName: "Alacritty",
60
+ appPaths: [
61
+ "/Applications/Alacritty.app",
62
+ path.join(os.homedir(), "Applications/Alacritty.app"),
63
+ ],
64
+ },
65
+ { key: "hyper", appName: "Hyper", appPaths: ["/Applications/Hyper.app"] },
66
+ ];
67
+
68
+ function resolveTerminalAppName(key: string): string | null {
69
+ const terminal = KNOWN_TERMINALS.find((t) => t.key === key);
70
+ if (!terminal) return null;
71
+ for (const p of terminal.appPaths) {
72
+ if (fs.existsSync(p)) return terminal.appName;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // ── Git helpers ───────────────────────────────────────────────────────────────
78
+
79
+ function git(cmd: string, cwd?: string): void {
80
+ execSync(`git -c credential.helper="!gh auth git-credential" ${cmd}`, {
81
+ cwd,
82
+ stdio: "inherit",
83
+ });
84
+ }
85
+
86
+ function gitCapture(cmd: string, cwd?: string): string {
87
+ return execSync(cmd, {
88
+ cwd,
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ encoding: "utf-8",
91
+ }).trim();
92
+ }
93
+
94
+ function ghCloneUrl(ghSlug: string): string {
95
+ return `https://github.com/${ghSlug}.git`;
96
+ }
97
+
98
+ // ── Injection guards ──────────────────────────────────────────────────────────
99
+ //
100
+ // git() runs through a shell (execSync), so branch names and GitHub slugs
101
+ // interpolated into clone/checkout commands must be validated first. The HTTP
102
+ // boundary already checks these (workspace-routes.ts uses the same anchored
103
+ // patterns), but CLI callers pass config-sourced values (workspaces.yml,
104
+ // service-catalog.yml) straight in — validating here makes workspace-manager
105
+ // the single choke point for every clone path.
106
+
107
+ export const SAFE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
108
+ export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
109
+
110
+ function assertSafeBranch(branch: string): void {
111
+ if (!SAFE_BRANCH_RE.test(branch)) {
112
+ throw new Error(
113
+ `Invalid branch name "${branch}" — only letters, digits, ".", "_", "/" and "-" are allowed`,
114
+ );
115
+ }
116
+ }
117
+
118
+ function assertSafeSlug(ghSlug: string): void {
119
+ if (!SAFE_SLUG_RE.test(ghSlug)) {
120
+ throw new Error(
121
+ `Invalid GitHub slug "${ghSlug}" — expected "owner/repo" using only letters, digits, ".", "_" and "-"`,
122
+ );
123
+ }
124
+ }
125
+
126
+ // ── Post-clone setup ──────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Attempt to run the repo's setup command after a fresh clone.
130
+ *
131
+ * Resolution order:
132
+ * 1. If .den.yml defines commands.local.setup on any service → run that command
133
+ * 2. Else if a Makefile exists at the repo root → run `make setup`
134
+ *
135
+ * Failures are logged as warnings but never thrown — a broken setup
136
+ * should not prevent the workspace from being usable.
137
+ */
138
+ /**
139
+ * Resolve the setup command for a repo: the first `setup` command declared in
140
+ * its manifest (entrypoint service first), else `make setup` when a Makefile
141
+ * exists. Returns null when there is nothing to run.
142
+ */
143
+ function resolveSetupCommand(repoPath: string): string | null {
144
+ const manifest = readManifest(repoPath);
145
+ if (manifest?.services) {
146
+ const entryKey = manifest.entrypoint;
147
+ const serviceKeys = entryKey
148
+ ? [
149
+ entryKey,
150
+ ...Object.keys(manifest.services).filter((k) => k !== entryKey),
151
+ ]
152
+ : Object.keys(manifest.services);
153
+ for (const key of serviceKeys) {
154
+ const def: CommandDef | undefined =
155
+ manifest.services[key]?.commands?.local?.["setup"];
156
+ if (def) return typeof def === "string" ? def : def.cmd;
157
+ }
158
+ }
159
+ if (fs.existsSync(path.join(repoPath, "Makefile"))) return "make setup";
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Environment that lets `make setup` run unattended (mirrors how the web UX runs
165
+ * setup behind a hidden process): skip husky hook-install prompts, make git fail
166
+ * instead of prompting for credentials, and wire `gh` as git's credential helper
167
+ * so in-setup fetches (e.g. common-iac) authenticate without a terminal.
168
+ */
169
+ export const NON_INTERACTIVE_SETUP_ENV: NodeJS.ProcessEnv = {
170
+ CI: "1",
171
+ HUSKY: "0",
172
+ GIT_TERMINAL_PROMPT: "0",
173
+ GIT_CONFIG_COUNT: "1",
174
+ GIT_CONFIG_KEY_0: "credential.helper",
175
+ GIT_CONFIG_VALUE_0: "!gh auth git-credential",
176
+ };
177
+
178
+ /**
179
+ * Run a repo's setup command non-interactively, streaming output via `onLine`,
180
+ * and resolve to the exit code (0 when there is nothing to set up). Unlike
181
+ * `runSetupIfDefined` this never opens a Terminal window — `den up` uses it so
182
+ * a freshly-cloned managed repo is prepared without ambushing the user with
183
+ * interactive windows.
184
+ */
185
+ export interface HiddenSetupResult {
186
+ code: number;
187
+ /**
188
+ * Set when the setup emitted an interactive-looking prompt and then went
189
+ * silent — den killed it. Callers should point the user at running the
190
+ * setup interactively (`den setup <repo>`).
191
+ */
192
+ stalledPrompt?: string;
193
+ }
194
+
195
+ /** Lines that look like a blocking question: `[y/N]`, `(yes/no)`, `password:`. */
196
+ const INTERACTIVE_PROMPT_RE =
197
+ /(\[\s*[yY](es)?\s*\/\s*[nN]o?\s*\]|\[\s*[nN]o?\s*\/\s*[yY](es)?\s*\]|\(\s*[yY](es)?\s*\/\s*[nN]o?\s*\)|password\s*:?\s*$|\?\s*$)/;
198
+
199
+ /** How long a prompt-looking line may sit with no further output before we kill. */
200
+ const PROMPT_STALL_MS = 15_000;
201
+
202
+ export function runSetupHidden(
203
+ repoPath: string,
204
+ onLine: (line: string) => void,
205
+ ): Promise<HiddenSetupResult> {
206
+ const setupCmd = resolveSetupCommand(repoPath);
207
+ if (!setupCmd) return Promise.resolve({ code: 0 });
208
+ return new Promise<HiddenSetupResult>((resolve) => {
209
+ let stallTimer: ReturnType<typeof setTimeout> | null = null;
210
+ let stalledPrompt: string | undefined;
211
+
212
+ // The child runs in its OWN SESSION (detached) so it has no controlling
213
+ // terminal: installers that prompt via /dev/tty (e.g. `curl atlasgo.sh |
214
+ // sh` asking to install a binary) fail that read immediately instead of
215
+ // silently blocking the spinner forever waiting for keystrokes den
216
+ // will never forward.
217
+ const killGroup = (): void => {
218
+ try {
219
+ if (proc.pid) process.kill(-proc.pid, "SIGTERM");
220
+ } catch {
221
+ /* already gone */
222
+ }
223
+ };
224
+ // Detached child ⇒ Ctrl+C on den would no longer reach it — reap it
225
+ // ourselves, then exit with the conventional SIGINT status. (Registered
226
+ // AFTER the spawn below so the handler can never observe `proc` in TDZ.)
227
+ const onSigint = (): void => {
228
+ killGroup();
229
+ process.exit(130);
230
+ };
231
+
232
+ const clearStall = (): void => {
233
+ if (stallTimer) {
234
+ clearTimeout(stallTimer);
235
+ stallTimer = null;
236
+ }
237
+ };
238
+
239
+ // Defense layers against interactive setups, outermost first:
240
+ // 1. NON_INTERACTIVE_SETUP_ENV (CI/HUSKY/git) — template-guarded prompts
241
+ // ([ -t 0 ] blocks, husky installs) skip themselves.
242
+ // 2. Detached own-session spawn — /dev/tty prompts fail fast (no
243
+ // controlling terminal), see killGroup rationale above.
244
+ // 3. `yes n` on stdin — unguarded `read` prompts are DECLINED and the
245
+ // setup continues, instead of erroring on a failed read. The prompt
246
+ // text still streams to onLine, so whatever was declined is visible;
247
+ // "n" is the safe default for a background run.
248
+ // 4. The stall detector below — anything that still blocks is killed and
249
+ // reported instead of hanging forever.
250
+ const guardedCmd = `yes n 2>/dev/null | ( ${setupCmd} )`;
251
+ const proc = spawnStreaming(
252
+ guardedCmd,
253
+ [],
254
+ { ...process.env, ...buildServiceEnv(), ...NON_INTERACTIVE_SETUP_ENV },
255
+ (line) => {
256
+ // Insurance for tools that still manage to block: a prompt-looking
257
+ // line followed by silence means nobody can ever answer — kill and
258
+ // report instead of hanging the whole `up`.
259
+ clearStall();
260
+ if (INTERACTIVE_PROMPT_RE.test(line.trim())) {
261
+ const prompt = line.trim();
262
+ stallTimer = setTimeout(() => {
263
+ stalledPrompt = prompt;
264
+ killGroup();
265
+ }, PROMPT_STALL_MS);
266
+ }
267
+ onLine(line);
268
+ },
269
+ (code) => {
270
+ clearStall();
271
+ process.removeListener("SIGINT", onSigint);
272
+ resolve({
273
+ code: stalledPrompt && code === 0 ? 1 : code,
274
+ stalledPrompt,
275
+ });
276
+ },
277
+ repoPath,
278
+ true, // own session — see killGroup/tty rationale above
279
+ );
280
+ process.once("SIGINT", onSigint);
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Re-run a repo's setup in THE CURRENT terminal (inherited stdio) so the user
286
+ * can answer the prompt a hidden setup stalled on. Deliberately omits
287
+ * NON_INTERACTIVE_SETUP_ENV (CI=1, GIT_TERMINAL_PROMPT=0) — prompting the
288
+ * user is the point. Resolves to the exit code (0 when the repo declares no
289
+ * setup command).
290
+ */
291
+ export function runSetupInteractive(repoPath: string): Promise<number> {
292
+ const setupCmd = resolveSetupCommand(repoPath);
293
+ if (!setupCmd) return Promise.resolve(0);
294
+ return new Promise<number>((resolve) => {
295
+ spawnInteractive(
296
+ setupCmd,
297
+ { ...process.env, ...buildServiceEnv() },
298
+ resolve,
299
+ repoPath,
300
+ );
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Den records a per-clone marker once setup has completed successfully, so
306
+ * `den up` can tell a fully-prepared clone from one that was cloned but whose
307
+ * setup failed (or never ran) — and re-run setup instead of starting a
308
+ * half-prepared repo. The marker lives BESIDE the clone dir (not inside the
309
+ * repo) so it never shows up as a dirty working-tree file.
310
+ */
311
+ export function setupMarkerPath(repoPath: string): string {
312
+ return path.join(
313
+ path.dirname(repoPath),
314
+ `.${path.basename(repoPath)}.setup-ok`,
315
+ );
316
+ }
317
+
318
+ export function hasSetupSucceeded(repoPath: string): boolean {
319
+ return fs.existsSync(setupMarkerPath(repoPath));
320
+ }
321
+
322
+ export function markSetupSucceeded(repoPath: string): void {
323
+ try {
324
+ fs.writeFileSync(
325
+ setupMarkerPath(repoPath),
326
+ `${new Date().toISOString()}\n`,
327
+ );
328
+ } catch {
329
+ /* the marker is an optimization — ignore write failures */
330
+ }
331
+ }
332
+
333
+ /** Whether the repo actually has a setup command to run (else nothing to do). */
334
+ export function hasSetupCommand(repoPath: string): boolean {
335
+ return resolveSetupCommand(repoPath) !== null;
336
+ }
337
+
338
+ function runSetupIfDefined(repoPath: string): void {
339
+ try {
340
+ const setupCmd = resolveSetupCommand(repoPath);
341
+ if (!setupCmd) return;
342
+
343
+ const repoName = path.basename(repoPath);
344
+ console.log(` ⚙ Opening terminal for setup in ${repoName}: ${setupCmd}`);
345
+
346
+ // Write a .command script so macOS opens it in a terminal window,
347
+ // allowing the user to interact (enter tokens, passwords, etc.).
348
+ const safePath = repoPath.replace(/'/g, "'\\''");
349
+ const tmp = path.join(
350
+ os.tmpdir(),
351
+ `${product().cli}-setup-${repoName}-${Date.now()}.command`,
352
+ );
353
+ // A .command opens a fresh login shell that does NOT inherit den's env, so
354
+ // export the den-resolved shared env (the org config's service_env) into it too.
355
+ const exportLines = Object.entries(buildServiceEnv()).map(
356
+ ([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`,
357
+ );
358
+ fs.writeFileSync(
359
+ tmp,
360
+ [
361
+ "#!/bin/bash",
362
+ `echo "${product().title} — running setup for ${repoName}"`,
363
+ `echo " ${setupCmd}"`,
364
+ `echo ""`,
365
+ ...exportLines,
366
+ `cd '${safePath}'`,
367
+ setupCmd,
368
+ `EXIT_CODE=$?`,
369
+ `echo ""`,
370
+ `if [ $EXIT_CODE -eq 0 ]; then echo "✓ Setup complete."; else echo "✗ Setup failed (exit $EXIT_CODE)."; fi`,
371
+ `echo "Press enter to close this window."`,
372
+ `read`,
373
+ "",
374
+ ].join("\n"),
375
+ { mode: 0o755 },
376
+ );
377
+
378
+ // Use the user's preferred terminal if configured, otherwise the OS default.
379
+ const userTerminal = readUserConfig().terminal;
380
+ const appName = userTerminal ? resolveTerminalAppName(userTerminal) : null;
381
+ const openArgs = appName ? ["-a", appName, tmp] : [tmp];
382
+ spawnSync("open", openArgs, { timeout: 5_000 });
383
+
384
+ // Best-effort cleanup after the terminal has had time to read the file.
385
+ // unref() so this fire-and-forget timer never keeps the process (or a
386
+ // jest worker) alive — otherwise it pins the event loop for 5s as an
387
+ // open handle and hangs CI test runs (jest waits on workers to exit).
388
+ const cleanupTimer = setTimeout(() => {
389
+ try {
390
+ fs.unlinkSync(tmp);
391
+ } catch {
392
+ /* ignore */
393
+ }
394
+ }, 5_000);
395
+ cleanupTimer.unref();
396
+ } catch (err) {
397
+ console.warn(
398
+ ` ⚠ Setup failed for ${path.basename(repoPath)}: ${err instanceof Error ? err.message : String(err)}`,
399
+ );
400
+ }
401
+ }
402
+
403
+ // ── Managed service clones (.den-services/) ────────────────────────────────
404
+
405
+ /**
406
+ * Ensure a managed service clone exists at .den-services/<repoName>/<branch>/.
407
+ * Different branches of the same repo live side-by-side and are shared across workspaces.
408
+ * Clones from GitHub if missing; fast-forwards if already present.
409
+ * Returns the absolute clone path.
410
+ */
411
+ export function ensureManagedServiceClone(
412
+ repoName: string,
413
+ ghSlug: string,
414
+ branch = "main",
415
+ /**
416
+ * Run the interactive (Terminal-window) setup after a first clone. Default
417
+ * true preserves the register/web behavior; `den up` passes false and runs
418
+ * setup itself via the hidden, non-interactive `runSetupHidden`.
419
+ */
420
+ runSetup = true,
421
+ ): string {
422
+ assertSafeSlug(ghSlug);
423
+ assertSafeBranch(branch);
424
+ const cloneDir = path.join(DEN_SERVICES_DIR, repoName, branch);
425
+
426
+ if (!fs.existsSync(cloneDir)) {
427
+ assertSafeCloneDestination(cloneDir, repoName);
428
+ console.log(
429
+ ` ⬇ Cloning ${ghSlug}@${branch} → ${product().servicesDir}/${repoName}/${branch}/`,
430
+ );
431
+ fs.mkdirSync(path.join(DEN_SERVICES_DIR, repoName), {
432
+ recursive: true,
433
+ });
434
+ git(
435
+ `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
436
+ );
437
+ if (runSetup) runSetupIfDefined(cloneDir);
438
+ } else {
439
+ git("pull --ff-only", cloneDir);
440
+ }
441
+
442
+ return cloneDir;
443
+ }
444
+
445
+ /**
446
+ * Clone a managed repo from a base branch, then create a new local
447
+ * branch on top of it. The clone lives at `.den-services/<repoName>/<newBranch>/`
448
+ * so managed branch tracking works correctly.
449
+ *
450
+ * Returns the absolute path to the clone directory.
451
+ */
452
+ export function ensureManagedServiceCloneNewBranch(
453
+ repoName: string,
454
+ ghSlug: string,
455
+ baseBranch: string,
456
+ newBranch: string,
457
+ ): string {
458
+ assertSafeSlug(ghSlug);
459
+ assertSafeBranch(baseBranch);
460
+ assertSafeBranch(newBranch);
461
+ const cloneDir = path.join(DEN_SERVICES_DIR, repoName, newBranch);
462
+ if (!fs.existsSync(cloneDir)) {
463
+ assertSafeCloneDestination(cloneDir, repoName);
464
+ console.log(
465
+ ` ⬇ Cloning ${ghSlug}@${baseBranch} → ${product().servicesDir}/${repoName}/${newBranch}/ (new branch: ${newBranch})`,
466
+ );
467
+ fs.mkdirSync(path.dirname(cloneDir), { recursive: true });
468
+ git(
469
+ `clone --filter=blob:none --branch "${baseBranch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
470
+ );
471
+ git(`checkout -b "${newBranch}"`, cloneDir);
472
+ runSetupIfDefined(cloneDir);
473
+ }
474
+ return cloneDir;
475
+ }
476
+
477
+ // ── Dev-managed clones (repos_root/<workspace>/<repo>/) ──────────────────────
478
+
479
+ /**
480
+ * Clone a GitHub repo to the given absolute targetDir so the developer
481
+ * can manage it directly (dev-managed mode).
482
+ *
483
+ * The checkout is a normal git clone — den does NOT auto-pull or manage
484
+ * the branch after the initial clone. The developer owns it.
485
+ *
486
+ * Returns the absolute path to the clone directory.
487
+ */
488
+ export function ensureDevManagedClone(
489
+ targetDir: string,
490
+ ghSlug: string,
491
+ branch: string,
492
+ ): string {
493
+ assertSafeSlug(ghSlug);
494
+ assertSafeBranch(branch);
495
+ if (!fs.existsSync(targetDir)) {
496
+ const leafName = path.basename(targetDir);
497
+ assertSafeCloneDestination(targetDir, leafName);
498
+ console.log(` ⬇ Cloning ${ghSlug}@${branch} → ${targetDir}/`);
499
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
500
+ try {
501
+ git(
502
+ `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${targetDir}"`,
503
+ );
504
+ } catch {
505
+ // Branch may not exist — clone default branch and checkout/create the branch
506
+ git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${targetDir}"`);
507
+ try {
508
+ git(`checkout "${branch}"`, targetDir);
509
+ } catch {
510
+ git(`checkout -b "${branch}"`, targetDir);
511
+ console.log(` ✓ Created new branch ${branch} in ${leafName}`);
512
+ }
513
+ }
514
+ runSetupIfDefined(targetDir);
515
+ }
516
+ return targetDir;
517
+ }
518
+
519
+ // ── Feature workspace clones (den-workspaces/) ───────────────────────────
520
+
521
+ /**
522
+ * Ensure a feature workspace clone exists at den-workspaces/<ws>/<repo>/.
523
+ * Creates the branch if it doesn't exist on the remote (branching from main).
524
+ * Returns the absolute clone path.
525
+ */
526
+ export function ensureWorkspaceClone(
527
+ workspaceName: string,
528
+ repoName: string,
529
+ ghSlug: string,
530
+ branch: string,
531
+ ): string {
532
+ assertSafeSlug(ghSlug);
533
+ assertSafeBranch(branch);
534
+ const wsDir = path.join(DEN_WORKSPACES_DIR, workspaceName);
535
+ const cloneDir = path.join(wsDir, repoName);
536
+
537
+ if (!fs.existsSync(cloneDir)) {
538
+ assertSafeCloneDestination(cloneDir, repoName);
539
+ console.log(
540
+ ` ⬇ Cloning ${ghSlug}@${branch} → ${product().workspacesDir}/${workspaceName}/${repoName}/`,
541
+ );
542
+ fs.mkdirSync(wsDir, { recursive: true });
543
+
544
+ try {
545
+ git(
546
+ `clone --filter=blob:none --branch "${branch}" "${ghCloneUrl(ghSlug)}" "${cloneDir}"`,
547
+ );
548
+ } catch {
549
+ // Branch doesn't exist yet — clone main and create the branch
550
+ git(`clone --filter=blob:none "${ghCloneUrl(ghSlug)}" "${cloneDir}"`);
551
+ git(`checkout -b "${branch}"`, cloneDir);
552
+ console.log(` ✓ Created new branch ${branch} in ${repoName}`);
553
+ }
554
+ runSetupIfDefined(cloneDir);
555
+ }
556
+
557
+ return cloneDir;
558
+ }
559
+
560
+ /**
561
+ * Delete the workspace directory at den-workspaces/<workspaceName>/.
562
+ * Warns (but does not block) if repos inside have unpushed commits.
563
+ */
564
+ export function deleteWorkspaceDirectory(workspaceName: string): void {
565
+ const wsDir = path.join(DEN_WORKSPACES_DIR, workspaceName);
566
+ if (!fs.existsSync(wsDir)) return;
567
+
568
+ // Warn about unpushed commits in any clones
569
+ for (const entry of fs.readdirSync(wsDir, { withFileTypes: true })) {
570
+ if (!entry.isDirectory()) continue;
571
+ const repoDir = path.join(wsDir, entry.name);
572
+ try {
573
+ const unpushed = gitCapture(
574
+ "git log --oneline @{u}..HEAD 2>/dev/null | wc -l",
575
+ repoDir,
576
+ );
577
+ if (parseInt(unpushed, 10) > 0) {
578
+ console.warn(
579
+ ` ⚠ ${entry.name} has unpushed commits — push before deleting`,
580
+ );
581
+ }
582
+ } catch {
583
+ // No upstream set or not a git repo — skip
584
+ }
585
+ }
586
+
587
+ fs.rmSync(wsDir, { recursive: true, force: true });
588
+ }
589
+
590
+ /**
591
+ * Rename the on-disk workspace directory from oldName to newName.
592
+ *
593
+ * - If den-workspaces/<newName> already exists it is moved aside to
594
+ * den-workspaces/<newName>-backup-<timestamp> (no data loss).
595
+ * - The <oldName>.code-workspace file inside the directory is renamed to
596
+ * <newName>.code-workspace so VS Code picks it up correctly.
597
+ *
598
+ * Returns the new absolute directory path, or null if the old directory
599
+ * did not exist (nothing to rename).
600
+ */
601
+ export function renameWorkspaceDirectory(
602
+ oldDir: string,
603
+ newName: string,
604
+ ): string | null {
605
+ if (!fs.existsSync(oldDir)) return null;
606
+
607
+ const oldName = path.basename(oldDir);
608
+ const newDir = path.join(DEN_WORKSPACES_DIR, newName);
609
+
610
+ if (fs.existsSync(newDir)) {
611
+ const backupDir = path.join(
612
+ DEN_WORKSPACES_DIR,
613
+ `${newName}-backup-${Date.now()}`,
614
+ );
615
+ fs.renameSync(newDir, backupDir);
616
+ }
617
+
618
+ fs.mkdirSync(DEN_WORKSPACES_DIR, { recursive: true });
619
+ fs.renameSync(oldDir, newDir);
620
+
621
+ const oldCodeWs = path.join(newDir, `${oldName}.code-workspace`);
622
+ const newCodeWs = path.join(newDir, `${newName}.code-workspace`);
623
+ if (fs.existsSync(oldCodeWs)) {
624
+ fs.renameSync(oldCodeWs, newCodeWs);
625
+ }
626
+
627
+ return newDir;
628
+ }
629
+
630
+ // ── Sync helpers ──────────────────────────────────────────────────────────────
631
+
632
+ /**
633
+ * Return how many commits the local repo is behind its upstream.
634
+ * Returns -1 if the check fails (e.g. no upstream configured).
635
+ */
636
+ export function checkBehindCount(repoPath: string): number {
637
+ try {
638
+ gitCapture("git fetch --quiet", repoPath);
639
+ const behind = gitCapture("git rev-list --count HEAD..@{u}", repoPath);
640
+ return parseInt(behind, 10);
641
+ } catch {
642
+ return -1;
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Pull the latest commits (fast-forward only) in a MANAGED clone.
648
+ * (Sole caller is `den sync`, which skips dev-managed checkouts.)
649
+ *
650
+ * Discards local tracked changes first: `make up`/`setup` codegen dirties
651
+ * managed clones (the OpenAPI generator datestamps gen/openapi/README.md,
652
+ * regenerates internal/apis/deps.go, …), and a dirty tree makes the ff-only
653
+ * pull abort with "your local changes would be overwritten". Nothing in a
654
+ * managed clone is worth preserving — den owns it.
655
+ */
656
+ export function pullRepo(repoPath: string): void {
657
+ try {
658
+ git("restore .", repoPath);
659
+ } catch {
660
+ /* best-effort — a genuinely diverged clone still errors on the pull */
661
+ }
662
+ git("pull --ff-only", repoPath);
663
+ }
664
+
665
+ /**
666
+ * Fetch (with gh credentials, so private repos authenticate) and fast-forward a
667
+ * managed clone, synchronously, returning a structured result. CLI-only —
668
+ * execSync blocks the event loop, so never call this from the web server (use
669
+ * the async `syncManagedClone` there). Fast-forward-only never clobbers local
670
+ * work; a diverged clone surfaces as an error instead of a silent overwrite.
671
+ *
672
+ * behind -1 : no upstream configured, or the fetch failed
673
+ * (network unreachable / auth) — caller should skip.
674
+ * behind 0 : already current.
675
+ * behind >0, pulled : fast-forwarded that many commits.
676
+ * behind >0, !pulled : diverged or local changes blocked the fast-forward
677
+ * (error populated); the clone is left untouched.
678
+ */
679
+ export function fetchAndFastForward(repoPath: string): {
680
+ behind: number;
681
+ pulled: boolean;
682
+ error?: string;
683
+ } {
684
+ const GH_CRED = `-c credential.helper="!gh auth git-credential"`;
685
+ try {
686
+ execSync(`git ${GH_CRED} fetch --quiet`, {
687
+ cwd: repoPath,
688
+ stdio: ["ignore", "pipe", "pipe"],
689
+ });
690
+ } catch (e) {
691
+ return { behind: -1, pulled: false, error: (e as Error).message };
692
+ }
693
+ let behind: number;
694
+ try {
695
+ // Local comparison against the just-fetched upstream — no network/creds.
696
+ behind = parseInt(
697
+ gitCapture("git rev-list --count HEAD..@{u}", repoPath),
698
+ 10,
699
+ );
700
+ } catch {
701
+ return { behind: -1, pulled: false }; // no upstream tracking ref
702
+ }
703
+ if (Number.isNaN(behind)) return { behind: -1, pulled: false };
704
+ if (behind === 0) return { behind: 0, pulled: false };
705
+ try {
706
+ // Managed clones accumulate `make up`/`setup` codegen (datestamped
707
+ // gen/openapi/README.md etc.) — discard it so the ff merge isn't blocked
708
+ // by a dirty tree. Nothing in a managed clone is worth preserving.
709
+ execSync("git restore .", {
710
+ cwd: repoPath,
711
+ stdio: ["ignore", "pipe", "pipe"],
712
+ });
713
+ } catch {
714
+ /* best-effort */
715
+ }
716
+ try {
717
+ execSync(`git ${GH_CRED} merge --ff-only @{u}`, {
718
+ cwd: repoPath,
719
+ stdio: ["ignore", "pipe", "pipe"],
720
+ });
721
+ return { behind, pulled: true };
722
+ } catch (e) {
723
+ return { behind, pulled: false, error: (e as Error).message };
724
+ }
725
+ }
726
+
727
+ // ── Async git helpers (don't block the event loop) ───────────────────────────
728
+ //
729
+ // The synchronous `git` / `gitCapture` above use execSync, which pauses
730
+ // Node.js for the full duration of the command. That's fine for CLI paths
731
+ // but catastrophic inside HTTP request handlers: a single `git fetch` over
732
+ // VPN can freeze the whole server for 10+ seconds. Use these async variants
733
+ // anywhere a git call runs inside the server event loop (e.g. workspace
734
+ // sync SSE handler).
735
+
736
+ import { spawn } from "child_process";
737
+
738
+ import { product } from "../product.js";
739
+ function runGitAsync(
740
+ args: string[],
741
+ cwd: string | undefined,
742
+ isCancelled: (() => boolean) | undefined,
743
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
744
+ return new Promise((resolve, reject) => {
745
+ const proc = spawn("git", args, {
746
+ cwd,
747
+ stdio: ["ignore", "pipe", "pipe"],
748
+ });
749
+ let stdout = "";
750
+ let stderr = "";
751
+ proc.stdout.on("data", (chunk: Buffer) => {
752
+ stdout += chunk.toString();
753
+ });
754
+ proc.stderr.on("data", (chunk: Buffer) => {
755
+ stderr += chunk.toString();
756
+ });
757
+ // Poll the cancellation flag; kill the git process if it flips.
758
+ const cancelTimer = isCancelled
759
+ ? setInterval(() => {
760
+ if (isCancelled()) {
761
+ if (cancelTimer) clearInterval(cancelTimer);
762
+ try {
763
+ proc.kill();
764
+ } catch {
765
+ /* already exited */
766
+ }
767
+ }
768
+ }, 500)
769
+ : null;
770
+ proc.on("close", (code) => {
771
+ if (cancelTimer) clearInterval(cancelTimer);
772
+ resolve({ stdout, stderr, code: code ?? -1 });
773
+ });
774
+ proc.on("error", (err) => {
775
+ if (cancelTimer) clearInterval(cancelTimer);
776
+ reject(err);
777
+ });
778
+ });
779
+ }
780
+
781
+ /**
782
+ * Fetch + check behind + fast-forward in a single async call. Replaces the
783
+ * three synchronous git calls that blocked the event loop during workspace
784
+ * sync (checkBehindCount's `git fetch` + `git rev-list`, then
785
+ * ensureManagedServiceClone's `git pull`).
786
+ *
787
+ * `isCancelled` is polled between ops so the SSE handler can abort cleanly
788
+ * when the client disconnects mid-sync.
789
+ */
790
+ export interface SyncResult {
791
+ /** -1: no upstream / unreachable. 0: already up to date. >0: commits pulled. */
792
+ behind: number;
793
+ /** True when a pull actually happened (behind > 0 and ff succeeded). */
794
+ pulled: boolean;
795
+ /** Populated on pull failure; empty on success. */
796
+ error?: string;
797
+ /** True if we bailed out early because isCancelled() returned true. */
798
+ cancelled?: boolean;
799
+ }
800
+
801
+ /**
802
+ * Read-only half of a sync: fetch and count how far the clone is behind its
803
+ * upstream. Split out from syncManagedClone so the workspace sync can decide
804
+ * whether a (disruptive) down → pull → setup chain is warranted BEFORE
805
+ * touching a running service — a sync with nothing to pull must not stop it.
806
+ */
807
+ export async function checkManagedCloneBehind(
808
+ repoPath: string,
809
+ isCancelled?: () => boolean,
810
+ ): Promise<{ behind: number; cancelled?: boolean }> {
811
+ // 1. fetch
812
+ try {
813
+ const { code } = await runGitAsync(
814
+ ["fetch", "--quiet"],
815
+ repoPath,
816
+ isCancelled,
817
+ );
818
+ if (isCancelled?.()) {
819
+ return { behind: 0, cancelled: true };
820
+ }
821
+ if (code !== 0) return { behind: -1 };
822
+ } catch {
823
+ return { behind: -1 };
824
+ }
825
+
826
+ // 2. count-behind
827
+ try {
828
+ const { stdout, code } = await runGitAsync(
829
+ ["rev-list", "--count", "HEAD..@{u}"],
830
+ repoPath,
831
+ isCancelled,
832
+ );
833
+ if (isCancelled?.()) {
834
+ return { behind: 0, cancelled: true };
835
+ }
836
+ if (code !== 0) return { behind: -1 };
837
+ const behind = parseInt(stdout.trim(), 10);
838
+ if (Number.isNaN(behind)) return { behind: -1 };
839
+ return { behind };
840
+ } catch {
841
+ return { behind: -1 };
842
+ }
843
+ }
844
+
845
+ /**
846
+ * Mutating half of a sync: discard local noise and fast-forward. Callers have
847
+ * already established `behind > 0` via checkManagedCloneBehind (a second fetch
848
+ * here would just double the network round-trips).
849
+ */
850
+ export async function pullManagedClone(
851
+ repoPath: string,
852
+ behind: number,
853
+ isCancelled?: () => boolean,
854
+ ): Promise<SyncResult> {
855
+ // 2.5 discard local changes. Managed clones accumulate uncommitted codegen
856
+ // from `make setup` (e.g. gen/openapi/README.md, internal/apis/deps.go); the
857
+ // tree is behind but NOT diverged, so `git pull --ff-only` would abort on the
858
+ // dirty tree. There's nothing to preserve in a managed clone, so reset
859
+ // tracked changes before pulling. (Best-effort — a real failure surfaces below.)
860
+ try {
861
+ await runGitAsync(["restore", "."], repoPath, isCancelled);
862
+ if (isCancelled?.()) {
863
+ return { behind, pulled: false, cancelled: true };
864
+ }
865
+ } catch {
866
+ /* best-effort */
867
+ }
868
+
869
+ // 3. fast-forward pull
870
+ try {
871
+ const { code, stderr } = await runGitAsync(
872
+ ["pull", "--ff-only"],
873
+ repoPath,
874
+ isCancelled,
875
+ );
876
+ if (isCancelled?.()) {
877
+ return { behind, pulled: false, cancelled: true };
878
+ }
879
+ if (code !== 0) {
880
+ const detail = stderr.trim();
881
+ return {
882
+ behind,
883
+ pulled: false,
884
+ error: detail ? `pull failed: ${detail}` : `pull exited ${code}`,
885
+ };
886
+ }
887
+ return { behind, pulled: true };
888
+ } catch (e) {
889
+ return { behind, pulled: false, error: (e as Error).message };
890
+ }
891
+ }
892
+
893
+ /** fetch → count → pull as one call, for callers with nothing to do between
894
+ * the check and the pull. Composition of the two halves above. */
895
+ export async function syncManagedClone(
896
+ repoPath: string,
897
+ isCancelled?: () => boolean,
898
+ ): Promise<SyncResult> {
899
+ const chk = await checkManagedCloneBehind(repoPath, isCancelled);
900
+ if (chk.cancelled) {
901
+ return { behind: chk.behind, pulled: false, cancelled: true };
902
+ }
903
+ if (chk.behind <= 0) return { behind: chk.behind, pulled: false };
904
+ return pullManagedClone(repoPath, chk.behind, isCancelled);
905
+ }