@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import {
8
+ createEditorPidRegistry,
9
+ isDashboardOwnedCodeServer,
10
+ type PersistedEditorEntry,
11
+ } from "../editor-pid-registry.js";
12
+
13
+ function tempPidFile(): string {
14
+ const dir = mkdtempSync(join(tmpdir(), "editor-pid-reg-"));
15
+ return join(dir, "editor-pids.json");
16
+ }
17
+
18
+ function readEntries(file: string): PersistedEditorEntry[] {
19
+ if (!existsSync(file)) return [];
20
+ return JSON.parse(readFileSync(file, "utf-8")).entries ?? [];
21
+ }
22
+
23
+ const VALID_CMDLINE = `/usr/local/bin/code-server --auth none --bind-addr 127.0.0.1:63584 --user-data-dir ${path.join(os.homedir(), ".pi", "dashboard", "editors", "abc123def456")} /Users/me/project`;
24
+
25
+ const UNRELATED_CMDLINE = "/usr/local/bin/code-server --user-data-dir /Users/me/.config/Code";
26
+
27
+ describe("isDashboardOwnedCodeServer", () => {
28
+ it("returns true for a dashboard-owned code-server cmdline", () => {
29
+ expect(isDashboardOwnedCodeServer(VALID_CMDLINE)).toBe(true);
30
+ });
31
+
32
+ it("returns false for an unrelated code-server", () => {
33
+ expect(isDashboardOwnedCodeServer(UNRELATED_CMDLINE)).toBe(false);
34
+ });
35
+
36
+ it("returns false for null cmdline", () => {
37
+ expect(isDashboardOwnedCodeServer(null)).toBe(false);
38
+ });
39
+
40
+ it("returns false when --user-data-dir is missing", () => {
41
+ expect(isDashboardOwnedCodeServer("/usr/local/bin/code-server --bind-addr 127.0.0.1:1234")).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("createEditorPidRegistry — register/remove/persist", () => {
46
+ it("register writes an entry to the JSON file", () => {
47
+ const file = tempPidFile();
48
+ const reg = createEditorPidRegistry({ pidFilePath: file });
49
+ reg.register({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
50
+ expect(reg.size()).toBe(1);
51
+ const entries = readEntries(file);
52
+ expect(entries).toHaveLength(1);
53
+ expect(entries[0]).toMatchObject({ id: "editor-aaa", pid: 5961, port: 63584, cwd: "/projects/app", dataDir: "/data" });
54
+ expect(entries[0].spawnedAt).toBeDefined();
55
+ });
56
+
57
+ it("remove deletes the entry from the JSON file", () => {
58
+ const file = tempPidFile();
59
+ const reg = createEditorPidRegistry({ pidFilePath: file });
60
+ reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" });
61
+ reg.register({ id: "editor-bbb", pid: 2, port: 2, cwd: "/b", dataDir: "/d2" });
62
+ reg.remove("editor-aaa");
63
+ expect(reg.size()).toBe(1);
64
+ const entries = readEntries(file);
65
+ expect(entries).toHaveLength(1);
66
+ expect(entries[0].id).toBe("editor-bbb");
67
+ });
68
+
69
+ it("persistence write failure does not throw from register", () => {
70
+ // Portable way to force writeJsonFile to fail: make the target path a
71
+ // directory so fs.writeFileSync (to path + ".tmp") succeeds but rename()
72
+ // onto a directory fails with EISDIR/EPERM on every platform.
73
+ const file = tempPidFile();
74
+ mkdirSync(file, { recursive: true }); // target exists as a directory
75
+ const reg = createEditorPidRegistry({ pidFilePath: file });
76
+ expect(() => reg.register({ id: "editor-aaa", pid: 1, port: 1, cwd: "/a", dataDir: "/d" })).not.toThrow();
77
+ // In-memory entry still tracked even when disk write failed.
78
+ expect(reg.size()).toBe(1);
79
+ });
80
+ });
81
+
82
+ describe("createEditorPidRegistry — cleanupOrphans", () => {
83
+ it("returns without throwing when file does not exist", async () => {
84
+ const file = join(mkdtempSync(join(tmpdir(), "missing-")), "nope.json");
85
+ const reg = createEditorPidRegistry({ pidFilePath: file });
86
+ await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
87
+ });
88
+
89
+ it("returns without throwing when file is corrupt", async () => {
90
+ const file = tempPidFile();
91
+ writeFileSync(file, "{not json");
92
+ const reg = createEditorPidRegistry({ pidFilePath: file });
93
+ await expect(reg.cleanupOrphans()).resolves.toBeUndefined();
94
+ });
95
+
96
+ it("skips dead PIDs", async () => {
97
+ const file = tempPidFile();
98
+ writeFileSync(file, JSON.stringify({
99
+ entries: [{ id: "editor-x", pid: 999999, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
100
+ }));
101
+ const killed: Array<{ pid: number; sig: string }> = [];
102
+ const reg = createEditorPidRegistry({
103
+ pidFilePath: file,
104
+ isProcessAlive: () => false,
105
+ getCmdline: () => VALID_CMDLINE,
106
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
107
+ graceMs: 1,
108
+ });
109
+ await reg.cleanupOrphans();
110
+ expect(killed).toEqual([]);
111
+ });
112
+
113
+ it("does NOT signal a live PID whose cmdline doesn't match (PID reuse)", async () => {
114
+ const file = tempPidFile();
115
+ writeFileSync(file, JSON.stringify({
116
+ entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
117
+ }));
118
+ const killed: Array<{ pid: number; sig: string }> = [];
119
+ const reg = createEditorPidRegistry({
120
+ pidFilePath: file,
121
+ isProcessAlive: () => true,
122
+ getCmdline: () => UNRELATED_CMDLINE,
123
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
124
+ graceMs: 1,
125
+ });
126
+ await reg.cleanupOrphans();
127
+ expect(killed).toEqual([]);
128
+ });
129
+
130
+ it("does NOT signal when cmdline lookup fails (cannot verify)", async () => {
131
+ const file = tempPidFile();
132
+ writeFileSync(file, JSON.stringify({
133
+ entries: [{ id: "editor-x", pid: 1234, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
134
+ }));
135
+ const killed: Array<{ pid: number; sig: string }> = [];
136
+ const reg = createEditorPidRegistry({
137
+ pidFilePath: file,
138
+ isProcessAlive: () => true,
139
+ getCmdline: () => null,
140
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
141
+ graceMs: 1,
142
+ });
143
+ await reg.cleanupOrphans();
144
+ expect(killed).toEqual([]);
145
+ });
146
+
147
+ it("SIGTERMs verified live orphans then SIGKILLs survivors", async () => {
148
+ const file = tempPidFile();
149
+ writeFileSync(file, JSON.stringify({
150
+ entries: [
151
+ { id: "editor-x", pid: 5961, port: 63584, cwd: "/a", dataDir: "/d1", spawnedAt: new Date().toISOString() },
152
+ { id: "editor-y", pid: 5962, port: 63585, cwd: "/b", dataDir: "/d2", spawnedAt: new Date().toISOString() },
153
+ ],
154
+ }));
155
+ const killed: Array<{ pid: number; sig: string }> = [];
156
+ // First call: alive. After SIGTERM grace, simulate that 5961 died, 5962 survived.
157
+ let phase: "before" | "after" = "before";
158
+ const reg = createEditorPidRegistry({
159
+ pidFilePath: file,
160
+ isProcessAlive: (pid) => phase === "before" ? true : pid === 5962,
161
+ getCmdline: () => VALID_CMDLINE,
162
+ kill: (pid, sig) => { killed.push({ pid, sig }); return true; },
163
+ graceMs: 1,
164
+ });
165
+ // Toggle phase right after SIGTERMs are sent.
166
+ const origSetTimeout = setTimeout;
167
+ const promise = reg.cleanupOrphans();
168
+ // microtask flip
169
+ queueMicrotask(() => { phase = "after"; });
170
+ await promise;
171
+
172
+ expect(killed.filter((k) => k.sig === "SIGTERM").map((k) => k.pid).sort()).toEqual([5961, 5962]);
173
+ expect(killed.filter((k) => k.sig === "SIGKILL").map((k) => k.pid)).toEqual([5962]);
174
+ });
175
+
176
+ it("rewrites the registry file empty after sweep", async () => {
177
+ const file = tempPidFile();
178
+ writeFileSync(file, JSON.stringify({
179
+ entries: [{ id: "editor-x", pid: 5961, port: 1, cwd: "/a", dataDir: "/d", spawnedAt: new Date().toISOString() }],
180
+ }));
181
+ const reg = createEditorPidRegistry({
182
+ pidFilePath: file,
183
+ isProcessAlive: () => true,
184
+ getCmdline: () => VALID_CMDLINE,
185
+ kill: () => true,
186
+ graceMs: 1,
187
+ });
188
+ await reg.cleanupOrphans();
189
+ expect(readEntries(file)).toEqual([]);
190
+ });
191
+ });
@@ -1,12 +1,18 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
 
3
- const { mockedExecSync } = vi.hoisted(() => ({
3
+ const { mockedExecSync, mockedSpawnSync } = vi.hoisted(() => ({
4
4
  mockedExecSync: vi.fn(),
5
+ mockedSpawnSync: vi.fn(),
5
6
  }));
6
7
 
8
+ // platform/process.ts and platform/tools.ts now use spawnSync via whereAllLines
9
+ // and isProcessRunning; both must be mocked to exercise the detection path in
10
+ // isolation. Default return is status:1 (not found) so each test explicitly
11
+ // overrides what it needs.
7
12
  vi.mock("node:child_process", () => ({
8
- default: { execSync: mockedExecSync },
13
+ default: { execSync: mockedExecSync, spawnSync: mockedSpawnSync },
9
14
  execSync: mockedExecSync,
15
+ spawnSync: mockedSpawnSync,
10
16
  }));
11
17
 
12
18
  import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type DetectedEditor } from "../editor-registry.js";
@@ -14,6 +20,9 @@ import { detectEditors, isProcessRunning, isProcessRunningWin32, EDITORS, type D
14
20
  describe("editor-registry", () => {
15
21
  beforeEach(() => {
16
22
  vi.resetAllMocks();
23
+ // Default: spawnSync reports not-found so any un-overridden test still
24
+ // sees clean state rather than stale returns from previous tests.
25
+ mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
17
26
  });
18
27
 
19
28
  describe("EDITORS", () => {
@@ -74,19 +83,22 @@ describe("editor-registry", () => {
74
83
 
75
84
  describe("detectEditors", () => {
76
85
  it("should return editor when process is running AND CLI is available", () => {
86
+ // platform/process.ts isProcessRunning uses execSync (pgrep) internally.
77
87
  mockedExecSync.mockImplementation((cmd) => {
78
88
  const s = String(cmd);
79
89
  if (s.includes("pgrep")) {
80
- // Only Zed is running
81
- if (s.includes("Zed")) return Buffer.from("12345\n");
82
- throw new Error("not found");
83
- }
84
- if (s.includes("which")) {
85
- if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
90
+ if (s.includes("Zed") || s.includes("zed")) return Buffer.from("12345\n");
86
91
  throw new Error("not found");
87
92
  }
88
93
  throw new Error("unexpected command");
89
94
  });
95
+ // ToolResolver.which uses spawnSync for `which`/`where` lookup.
96
+ mockedSpawnSync.mockImplementation((cmd, args) => {
97
+ if ((cmd === "which" || cmd === "where") && args?.[0] === "zed") {
98
+ return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
99
+ }
100
+ return { status: 1, stdout: "", stderr: "" };
101
+ });
90
102
 
91
103
  const result = detectEditors("/some/project");
92
104
  expect(result).toEqual([{ id: "zed", name: "Zed" }]);
@@ -96,9 +108,9 @@ describe("editor-registry", () => {
96
108
  mockedExecSync.mockImplementation((cmd) => {
97
109
  const s = String(cmd);
98
110
  if (s.includes("pgrep")) throw new Error("not found");
99
- if (s.includes("which")) return Buffer.from("/usr/local/bin/zed\n");
100
111
  throw new Error("unexpected command");
101
112
  });
113
+ mockedSpawnSync.mockImplementation(() => ({ status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" }));
102
114
 
103
115
  const result = detectEditors("/some/project");
104
116
  expect(result).toEqual([]);
@@ -108,9 +120,9 @@ describe("editor-registry", () => {
108
120
  mockedExecSync.mockImplementation((cmd) => {
109
121
  const s = String(cmd);
110
122
  if (s.includes("pgrep")) return Buffer.from("12345\n");
111
- if (s.includes("which")) throw new Error("not found");
112
123
  throw new Error("unexpected command");
113
124
  });
125
+ mockedSpawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" });
114
126
 
115
127
  const result = detectEditors("/some/project");
116
128
  expect(result).toEqual([]);
@@ -124,13 +136,15 @@ describe("editor-registry", () => {
124
136
  if (s.includes("Visual Studio Code") || s.includes("code")) return Buffer.from("67890\n");
125
137
  throw new Error("not found");
126
138
  }
127
- if (s.includes("which")) {
128
- if (s.includes("zed")) return Buffer.from("/usr/local/bin/zed\n");
129
- if (s.includes("code")) return Buffer.from("/usr/local/bin/code\n");
130
- throw new Error("not found");
131
- }
132
139
  throw new Error("unexpected command");
133
140
  });
141
+ mockedSpawnSync.mockImplementation((cmd, args) => {
142
+ if (cmd === "which" || cmd === "where") {
143
+ if (args?.[0] === "zed") return { status: 0, stdout: "/usr/local/bin/zed\n", stderr: "" };
144
+ if (args?.[0] === "code") return { status: 0, stdout: "/usr/local/bin/code\n", stderr: "" };
145
+ }
146
+ return { status: 1, stdout: "", stderr: "" };
147
+ });
134
148
 
135
149
  const result = detectEditors("/some/project");
136
150
  expect(result).toEqual([
@@ -30,7 +30,11 @@ describe("findBundledExtension - AppImage guard", () => {
30
30
  });
31
31
 
32
32
  it("returns null when extension does not exist", () => {
33
- expect(findBundledExtension(tmpDir)).toBeNull();
33
+ // Disable Strategy 2 (node-resolver fallback) so this test exercises
34
+ // the AppImage guard path in isolation.
35
+ expect(
36
+ findBundledExtension(tmpDir, { resolvePackage: () => null }),
37
+ ).toBeNull();
34
38
  });
35
39
 
36
40
  // Note: We can't easily test the /tmp/.mount_ guard with real paths
@@ -26,7 +26,9 @@ describe("bridge extension registration (server context)", () => {
26
26
  });
27
27
 
28
28
  it("findBundledExtension returns null when extension dir does not exist", () => {
29
- const result = findBundledExtension(tmpDir);
29
+ // Strategy 2 (require.resolve) would find the monorepo extension;
30
+ // disable it for this test so we exercise Strategy 1 in isolation.
31
+ const result = findBundledExtension(tmpDir, { resolvePackage: () => null });
30
32
  expect(result).toBeNull();
31
33
  });
32
34
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for cross-platform port-holder detection.
3
+ * See change: fix-windows-server-parity.
4
+ */
5
+ import { describe, it, expect, vi } from "vitest";
6
+ import { findPortHolders, parseNetstatListeners } from "../cli.js";
7
+
8
+ describe("parseNetstatListeners", () => {
9
+ const selfPid = 99999;
10
+
11
+ it("parses Windows netstat -ano output for a listening PID", () => {
12
+ const output = [
13
+ "Active Connections",
14
+ "",
15
+ " Proto Local Address Foreign Address State PID",
16
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345",
17
+ " TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4",
18
+ " TCP 127.0.0.1:8000 127.0.0.1:54321 ESTABLISHED 23456",
19
+ ].join("\r\n");
20
+
21
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([12345]);
22
+ });
23
+
24
+ it("excludes non-LISTENING rows (ESTABLISHED, TIME_WAIT)", () => {
25
+ const output = [
26
+ " TCP 0.0.0.0:9999 0.0.0.0:0 ESTABLISHED 11111",
27
+ " TCP 0.0.0.0:9999 0.0.0.0:0 TIME_WAIT 22222",
28
+ ].join("\n");
29
+ expect(parseNetstatListeners(output, 9999, selfPid)).toEqual([]);
30
+ });
31
+
32
+ it("excludes the current process PID", () => {
33
+ const output = ` TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING ${selfPid}`;
34
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([]);
35
+ });
36
+
37
+ it("only matches the requested port (suffix-based)", () => {
38
+ const output = [
39
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 1111",
40
+ " TCP 0.0.0.0:18000 0.0.0.0:0 LISTENING 2222",
41
+ " TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 3333",
42
+ ].join("\n");
43
+ expect(parseNetstatListeners(output, 8000, selfPid).sort()).toEqual([1111]);
44
+ });
45
+
46
+ it("returns empty array for empty or garbage input", () => {
47
+ expect(parseNetstatListeners("", 8000, selfPid)).toEqual([]);
48
+ expect(parseNetstatListeners("not a netstat output\nblah", 8000, selfPid)).toEqual([]);
49
+ });
50
+
51
+ it("handles IPv6 listening addresses", () => {
52
+ const output = " TCP [::]:8000 [::]:0 LISTENING 7777";
53
+ expect(parseNetstatListeners(output, 8000, selfPid)).toEqual([7777]);
54
+ });
55
+ });
56
+
57
+ describe("findPortHolders", () => {
58
+ it("uses netstat on Windows (via injected exec)", () => {
59
+ const originalPlatform = process.platform;
60
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
61
+ try {
62
+ const exec = vi.fn().mockReturnValue(
63
+ " TCP 0.0.0.0:8000 0.0.0.0:0 LISTENING 12345\n",
64
+ );
65
+ const result = findPortHolders(8000, exec as any);
66
+ expect(exec).toHaveBeenCalledTimes(1);
67
+ expect(exec.mock.calls[0][0]).toMatch(/netstat/i);
68
+ expect(exec.mock.calls[0][0]).not.toMatch(/lsof/i);
69
+ expect(result).toEqual([12345]);
70
+ } finally {
71
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
72
+ }
73
+ });
74
+
75
+ it("uses lsof on Unix (via injected exec)", () => {
76
+ const originalPlatform = process.platform;
77
+ Object.defineProperty(process, "platform", { value: "linux", configurable: true });
78
+ try {
79
+ const exec = vi.fn().mockReturnValue("12345\n67890\n");
80
+ const result = findPortHolders(8000, exec as any);
81
+ expect(exec).toHaveBeenCalledTimes(1);
82
+ expect(exec.mock.calls[0][0]).toMatch(/lsof/);
83
+ expect(exec.mock.calls[0][0]).toContain(":8000");
84
+ expect(result.sort()).toEqual([12345, 67890].sort());
85
+ } finally {
86
+ Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
87
+ }
88
+ });
89
+
90
+ it("returns empty array on exec failure (best-effort)", () => {
91
+ const exec = vi.fn().mockImplementation(() => { throw new Error("boom"); });
92
+ expect(findPortHolders(8000, exec as any)).toEqual([]);
93
+ });
94
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Regression test for the postinstall `fix-pty-permissions.cjs` script.
3
+ *
4
+ * Ensures that after `npm install` the native `spawn-helper` binary in the
5
+ * current platform's `node-pty` prebuild directory has at least one execute
6
+ * bit set. Without this, `pty.spawn(...)` fails with "posix_spawnp failed."
7
+ * and the dashboard's "New Terminal" button appears dead.
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { existsSync, statSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { createRequire } from "node:module";
13
+
14
+ describe("fix-pty-permissions", () => {
15
+ it.skipIf(process.platform === "win32")(
16
+ "spawn-helper for current platform is executable",
17
+ () => {
18
+ const require = createRequire(import.meta.url);
19
+ const ptyPkg = require.resolve("node-pty/package.json");
20
+ const ptyRoot = dirname(ptyPkg);
21
+ const platformDir =
22
+ process.platform === "darwin"
23
+ ? process.arch === "arm64"
24
+ ? "darwin-arm64"
25
+ : "darwin-x64"
26
+ : process.arch === "arm64"
27
+ ? "linux-arm64"
28
+ : "linux-x64";
29
+
30
+ // node-pty ships pre-packed binaries under `prebuilds/<platform>-<arch>/`
31
+ // when the tarball includes a prebuild for the host platform (macOS,
32
+ // Windows, some Linux builds). Otherwise node-pty's install script
33
+ // falls back to `node-gyp rebuild`, producing artifacts under
34
+ // `build/Release/` instead. Accept either location so this test is
35
+ // stable across local dev (prebuilt) and Linux CI (built from source).
36
+ const candidates = [
37
+ join(ptyRoot, "prebuilds", platformDir, "spawn-helper"),
38
+ join(ptyRoot, "build", "Release", "spawn-helper"),
39
+ ];
40
+ const helper = candidates.find((p) => existsSync(p));
41
+
42
+ if (!helper) {
43
+ // No spawn-helper anywhere means node-pty's install step did not
44
+ // produce the binary on this host (e.g. missing build toolchain).
45
+ // The fix-permissions script is defensive — it silently skips when
46
+ // the prebuild dir is absent — so skipping here matches runtime
47
+ // behavior rather than masking a real regression.
48
+ console.warn(
49
+ `[fix-pty-permissions.test] no spawn-helper found at any of: ${candidates.join(", ")} — skipping`,
50
+ );
51
+ return;
52
+ }
53
+
54
+ const mode = statSync(helper).mode;
55
+ // At least one execute bit (owner/group/other) must be set.
56
+ expect(mode & 0o111).not.toBe(0);
57
+ },
58
+ );
59
+ });
@@ -1,9 +1,30 @@
1
1
  /**
2
2
  * Tests for handleForceKill in session-action-handler.
3
+ *
4
+ * Kill-path routing (see change: route-kill-paths-through-platform):
5
+ * we verify that the handler delegates to the platform `killProcess`
6
+ * helper rather than calling `process.kill(...)` directly. Cross-OS
7
+ * behavior of `killProcess` itself is covered in
8
+ * `packages/shared/src/__tests__/platform-process.test.ts`.
3
9
  */
4
10
  import { describe, it, expect, vi, beforeEach } from "vitest";
5
- import { handleForceKill } from "../browser-handlers/session-action-handler.js";
6
- import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
11
+
12
+ // Spy on the platform module so we can assert the handler routes through it.
13
+ const killProcessSpy = vi.fn(async (_pid: number, _opts?: any) => ({ ok: true, forced: false }));
14
+ const isProcessAliveSpy = vi.fn((_pid: number) => false);
15
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", async () => {
16
+ const actual = await vi.importActual<typeof import("@blackbelt-technology/pi-dashboard-shared/platform/process.js")>(
17
+ "@blackbelt-technology/pi-dashboard-shared/platform/process.js",
18
+ );
19
+ return {
20
+ ...actual,
21
+ killProcess: (pid: number, opts?: any) => killProcessSpy(pid, opts),
22
+ isProcessAlive: (pid: number) => isProcessAliveSpy(pid),
23
+ };
24
+ });
25
+
26
+ const { handleForceKill } = await import("../browser-handlers/session-action-handler.js");
27
+ type BrowserHandlerContext = import("../browser-handlers/handler-context.js").BrowserHandlerContext;
7
28
 
8
29
  function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandlerContext & { sent: any[]; broadcasts: any[] } {
9
30
  const sent: any[] = [];
@@ -44,7 +65,10 @@ function createMockContext(sessionOverrides?: Record<string, any>): BrowserHandl
44
65
 
45
66
  describe("handleForceKill", () => {
46
67
  beforeEach(() => {
47
- vi.restoreAllMocks();
68
+ killProcessSpy.mockClear();
69
+ killProcessSpy.mockImplementation(async () => ({ ok: true, forced: false }));
70
+ isProcessAliveSpy.mockClear();
71
+ isProcessAliveSpy.mockReturnValue(false);
48
72
  });
49
73
 
50
74
  it("should close bridge WebSocket and mark session ended when no PID", async () => {
@@ -61,19 +85,44 @@ describe("handleForceKill", () => {
61
85
  expect(result.message).toContain("no PID");
62
86
  });
63
87
 
64
- it("should send SIGTERM and mark session ended for valid PID", async () => {
65
- // Use a PID that doesn't exist so SIGTERM throws
66
- const ctx = createMockContext({ pid: 2147483647 });
88
+ it("should delegate termination to platform killProcess with 2s timeout", async () => {
89
+ const ctx = createMockContext({ pid: 12345 });
67
90
 
68
91
  await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
69
92
 
93
+ expect(killProcessSpy).toHaveBeenCalledTimes(1);
94
+ expect(killProcessSpy).toHaveBeenCalledWith(12345, expect.objectContaining({ timeoutMs: 2000 }));
95
+
70
96
  expect(ctx.piGateway.closeSession).toHaveBeenCalledWith("sess-1");
71
97
  expect(ctx.sessionManager.update).toHaveBeenCalledWith("sess-1", expect.objectContaining({ status: "ended" }));
72
-
98
+
99
+ const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
100
+ expect(result).toBeDefined();
101
+ expect(result.success).toBe(true);
102
+ });
103
+
104
+ it("should report already-exited when killProcess reports pid not alive", async () => {
105
+ killProcessSpy.mockResolvedValueOnce({ ok: false, forced: false });
106
+ const ctx = createMockContext({ pid: 2147483647 });
107
+
108
+ await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
109
+
73
110
  const result = ctx.sent.find((m: any) => m.type === "force_kill_result");
74
111
  expect(result).toBeDefined();
75
112
  expect(result.success).toBe(true);
76
- expect(result.message).toContain("already exited");
113
+ });
114
+
115
+ it("should not call process.kill directly (must route through platform)", async () => {
116
+ const processKillSpy = vi.spyOn(process, "kill");
117
+ const ctx = createMockContext({ pid: 12345 });
118
+
119
+ await handleForceKill({ type: "force_kill", sessionId: "sess-1" }, ctx);
120
+
121
+ // handleForceKill must NOT invoke process.kill; all termination goes
122
+ // through killProcess from the platform module.
123
+ expect(processKillSpy).not.toHaveBeenCalled();
124
+ expect(killProcessSpy).toHaveBeenCalledOnce();
125
+ processKillSpy.mockRestore();
77
126
  });
78
127
 
79
128
  it("should broadcast session_updated with ended status", async () => {
@@ -18,7 +18,9 @@ function git(cmd: string, cwd: string) {
18
18
 
19
19
  function makeRepo(): string {
20
20
  const dir = mkdtempSync(join(tmpdir(), "git-ops-test-"));
21
- git("init", dir);
21
+ // Force `main` as the default branch so tests are deterministic regardless
22
+ // of the host user's `init.defaultBranch` config.
23
+ git("-c init.defaultBranch=main init", dir);
22
24
  git("config user.email test@test.com", dir);
23
25
  git("config user.name Test", dir);
24
26
  // Initial commit so we have a branch
@@ -122,7 +124,7 @@ describe("git-operations", () => {
122
124
  writeFileSync(join(repo, "remote.txt"), "data");
123
125
  git("add .", repo);
124
126
  git("commit -m remote-only", repo);
125
- git("checkout master", repo);
127
+ git("checkout main", repo);
126
128
 
127
129
  // Fetch in clone
128
130
  git("fetch origin", clone);
@@ -151,7 +153,7 @@ describe("git-operations", () => {
151
153
  describe("checkoutBranch", () => {
152
154
  it("checks out a local branch on clean repo", () => {
153
155
  git("checkout -b feature-x", repo);
154
- git("checkout master", repo);
156
+ git("checkout main", repo);
155
157
  const result = checkoutBranch(repo, "feature-x", false);
156
158
  expect(result.success).toBe(true);
157
159
  const head = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repo, encoding: "utf-8" }).trim();
@@ -160,7 +162,7 @@ describe("git-operations", () => {
160
162
 
161
163
  it("returns dirty when working tree is dirty and stash=false", () => {
162
164
  git("checkout -b feature-y", repo);
163
- git("checkout master", repo);
165
+ git("checkout main", repo);
164
166
  writeFileSync(join(repo, "README.md"), "dirty");
165
167
  const result = checkoutBranch(repo, "feature-y", false);
166
168
  expect(result.success).toBe(false);
@@ -172,7 +174,7 @@ describe("git-operations", () => {
172
174
 
173
175
  it("stashes and checks out when stash=true", () => {
174
176
  git("checkout -b feature-z", repo);
175
- git("checkout master", repo);
177
+ git("checkout main", repo);
176
178
  writeFileSync(join(repo, "README.md"), "dirty");
177
179
  const result = checkoutBranch(repo, "feature-z", true);
178
180
  expect(result.success).toBe(true);
@@ -184,7 +186,7 @@ describe("git-operations", () => {
184
186
  });
185
187
 
186
188
  it("returns success when already on target branch", () => {
187
- const result = checkoutBranch(repo, "master", false);
189
+ const result = checkoutBranch(repo, "main", false);
188
190
  expect(result.success).toBe(true);
189
191
  });
190
192
 
@@ -194,7 +196,7 @@ describe("git-operations", () => {
194
196
  writeFileSync(join(repo, "r.txt"), "data");
195
197
  git("add .", repo);
196
198
  git("commit -m r", repo);
197
- git("checkout master", repo);
199
+ git("checkout main", repo);
198
200
 
199
201
  const clone = mkdtempSync(join(tmpdir(), "git-ops-clone-"));
200
202
  try {