@dev.fast/whiteboard 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,1041 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, chmod, readFile, rm, stat } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ import {
9
+ type ReviewCliInstallStamp,
10
+ ReviewCliInstallStampSchema,
11
+ type ReviewCliInstallStatus,
12
+ } from "@dev.fast/review-protocol";
13
+ import {
14
+ AGENT_TRACE_HOOK_AGENTS,
15
+ type TraceCredentialsInput,
16
+ configureTraceMachine,
17
+ describeTraceHookOwners,
18
+ devReviewHome,
19
+ disableAllTraceRepositories,
20
+ disableTraceMachine,
21
+ enableTraceRepository,
22
+ installHarnessHooks,
23
+ listTraceRepositoryRoots,
24
+ removeAgentTraceHook,
25
+ traceMachineEnabled,
26
+ traceMachineStatus,
27
+ traceRepositoryStatus,
28
+ traceScope,
29
+ withFileLock,
30
+ writeFileAtomicAsync,
31
+ writePrivateJsonAtomic,
32
+ } from "@dev.fast/trace-core";
33
+
34
+ import {
35
+ CLAUDE_WINDOWS_MCP_ADD,
36
+ COPILOT_WINDOWS_MCP_ADD,
37
+ connectSetupPrompts,
38
+ launchCommand,
39
+ reviewMcpLaunch,
40
+ } from "./connect-prompts";
41
+ import { cursorInstallDeeplink } from "./cursor-deeplink";
42
+ import { isDirectory, isFile } from "./fs-utils";
43
+ import { removeLegacySkills, scanLegacySkills } from "./legacy-skills";
44
+ import { readReviewPackageVersion } from "./package-paths";
45
+ import { reviewDesktopStateDir } from "./review-home-paths";
46
+ import {
47
+ WINDOWS_MACHINE_ENVIRONMENT_KEY,
48
+ updateWindowsUserPath,
49
+ windowsCliShim,
50
+ windowsUserPathContains,
51
+ } from "./windows-cli";
52
+
53
+ const installErrors = new Map<string, string>();
54
+
55
+ const SHIM_MARKER = "Managed by Whiteboard";
56
+
57
+ function hasManagedShimMarker(source: string): boolean {
58
+ return (
59
+ source.includes(SHIM_MARKER) ||
60
+ source.includes("Managed by Review Desktop") ||
61
+ source.includes("Managed by Whiteboard Desktop")
62
+ );
63
+ }
64
+
65
+ const PROFILE_MARKER =
66
+ "# Managed by Review Desktop: review command PATH. Do not edit.";
67
+
68
+ const PROFILE_EXPORT = 'export PATH="$HOME/.local/bin:$PATH"';
69
+
70
+ const PROFILE_BLOCK = `\n${PROFILE_MARKER}\n${PROFILE_EXPORT}\n`;
71
+
72
+ const SHELL_PROFILE_NAMES = [".zprofile", ".bash_profile"] as const;
73
+
74
+ type ApplyResult = {
75
+ code: number;
76
+ output: string;
77
+ shimPath?: string;
78
+ userPath?: string;
79
+ };
80
+
81
+ export function cliInstallStampPath(
82
+ env: NodeJS.ProcessEnv = process.env,
83
+ ): string {
84
+ return path.join(reviewDesktopStateDir(env), "cli-install.json");
85
+ }
86
+
87
+ /**
88
+ * Present once this build's setup is current. It sits beside the stamp, not in
89
+ * it, because older builds parse the stamp strictly and would drop a stamp
90
+ * carrying a new key.
91
+ */
92
+ export function cliInstallUpdateMarkerPath(
93
+ env: NodeJS.ProcessEnv = process.env,
94
+ ): string {
95
+ return path.join(reviewDesktopStateDir(env), "cli-install-updated");
96
+ }
97
+
98
+ export function pathShimPath(homeDir = os.homedir()): string {
99
+ return path.join(
100
+ homeDir,
101
+ ".local",
102
+ "bin",
103
+ process.platform === "win32" ? "whiteboard.cmd" : "whiteboard",
104
+ );
105
+ }
106
+
107
+ export async function resolveCliInstallStatus(input: {
108
+ packageRoot: string;
109
+ homeDir?: string;
110
+ env?: NodeJS.ProcessEnv;
111
+ }): Promise<ReviewCliInstallStatus> {
112
+ const homeDir = input.homeDir ?? os.homedir();
113
+ const env = input.env ?? process.env;
114
+ const shimPath = pathShimPath(homeDir);
115
+ const cliPath = path.join(input.packageRoot, "dist", "cli.js");
116
+
117
+ const [fingerprint, stamp, updated, trace, legacySkills, cliBuilt, hasShim] =
118
+ await Promise.all([
119
+ installFingerprint(input.packageRoot),
120
+ readCliInstallStamp(cliInstallStampPath(env)),
121
+ isFile(cliInstallUpdateMarkerPath(env)),
122
+ traceMachineStatus({ homeDir, env }),
123
+ scanLegacySkills(homeDir),
124
+ isFile(cliPath),
125
+ isOwnedShim(shimPath),
126
+ ]);
127
+
128
+ const granted = stamp?.consent === "granted";
129
+
130
+ const installerCommand = hasShim
131
+ ? undefined
132
+ : await windowsInstallerCommand(input.packageRoot, env);
133
+
134
+ const hasCommand = hasShim || installerCommand !== undefined;
135
+
136
+ const status: ReviewCliInstallStatus = {
137
+ fingerprint,
138
+ stamp,
139
+ stale: granted && stamp.fingerprint !== fingerprint,
140
+ updateNeeded: legacySkills.length > 0 || (granted && !updated),
141
+ shim: installerCommand ?? {
142
+ path: shimPath,
143
+ installed: hasShim,
144
+ profileConfigured:
145
+ process.platform === "win32"
146
+ ? (hasShim &&
147
+ stamp?.userPath !== undefined &&
148
+ sameWindowsPath(stamp.userPath, path.dirname(shimPath))) ||
149
+ (await windowsUserPathContains(path.dirname(shimPath)))
150
+ : await isShellProfileConfigured(homeDir),
151
+ onPath: pathContainsDirectory(env.PATH, path.dirname(shimPath)),
152
+ },
153
+ trace,
154
+ cli: cliBuilt
155
+ ? {
156
+ path: cliPath,
157
+ version: readReviewPackageVersion(pathToFileURL(cliPath).href),
158
+ }
159
+ : null,
160
+ connect: {
161
+ ...reviewMcpLaunch(hasCommand),
162
+ prompts: connectSetupPrompts(),
163
+ plugins: connectPlugins(hasCommand),
164
+ },
165
+ legacySkills: legacySkills.map((skillPath) => ({
166
+ path: homeRelative(homeDir, skillPath),
167
+ })),
168
+ };
169
+
170
+ const error = installErrors.get(homeDir);
171
+
172
+ if (error) status.error = error;
173
+
174
+ return status;
175
+ }
176
+
177
+ /**
178
+ * The Windows installer's "Add to PATH" task puts <install dir>\bin, which
179
+ * holds a whiteboard.cmd, on the user or machine PATH.
180
+ */
181
+ export async function windowsInstallerCommand(
182
+ packageRoot: string,
183
+ env: NodeJS.ProcessEnv,
184
+ ): Promise<ReviewCliInstallStatus["shim"] | undefined> {
185
+ if (process.platform !== "win32") return undefined;
186
+
187
+ // packageRoot is <install dir>\resources\app\review-runtime.
188
+ const bin = path.resolve(packageRoot, "..", "..", "..", "bin");
189
+ const command = path.join(bin, "whiteboard.cmd");
190
+
191
+ if (!(await isOwnedShim(command))) return undefined;
192
+
193
+ const profileConfigured =
194
+ (await windowsUserPathContains(bin)) ||
195
+ (await windowsUserPathContains(bin, WINDOWS_MACHINE_ENVIRONMENT_KEY));
196
+
197
+ const onPath = pathContainsDirectory(env.PATH, bin);
198
+
199
+ if (!profileConfigured && !onPath) return undefined;
200
+
201
+ return {
202
+ path: command,
203
+ installed: true,
204
+ profileConfigured,
205
+ onPath,
206
+ installer: true,
207
+ };
208
+ }
209
+
210
+ interface ApplyCliInstallInput {
211
+ packageRoot: string;
212
+ shim?: boolean;
213
+ autoUpdate?: boolean;
214
+ trace?: true | TraceCredentialsInput;
215
+ cliPath?: string;
216
+ cliRuntimePath?: string;
217
+ homeDir?: string;
218
+ env?: NodeJS.ProcessEnv;
219
+ }
220
+
221
+ export async function applyCliInstall(
222
+ input: ApplyCliInstallInput,
223
+ ): Promise<ApplyResult> {
224
+ if (!input.autoUpdate && input.shim !== true && input.trace === undefined)
225
+ return { code: 0, output: "" };
226
+
227
+ const homeDir = input.homeDir ?? os.homedir();
228
+
229
+ try {
230
+ const result = await withDesktopInstallLock(input.env, () =>
231
+ input.autoUpdate
232
+ ? resyncCliInstallUnlocked(input)
233
+ : applyCliInstallUnlocked(input),
234
+ );
235
+
236
+ if (result.code === 0) installErrors.delete(homeDir);
237
+ else installErrors.set(homeDir, result.output);
238
+
239
+ return result;
240
+ } catch (error) {
241
+ const output = error instanceof Error ? error.message : String(error);
242
+ installErrors.set(homeDir, output);
243
+
244
+ return { code: 1, output };
245
+ }
246
+ }
247
+
248
+ async function withDesktopInstallLock<T>(
249
+ env: NodeJS.ProcessEnv = process.env,
250
+ operation: () => Promise<T>,
251
+ ): Promise<T> {
252
+ const outcome = await withFileLock(
253
+ `${cliInstallStampPath(env)}.lock`,
254
+ { retryMs: 50, timeoutMs: 30_000, staleMs: 300_000, unownedGraceMs: 5_000 },
255
+ operation,
256
+ );
257
+
258
+ if (!outcome.acquired)
259
+ throw new Error(
260
+ "Another Whiteboard setup operation is running. Retry shortly.",
261
+ );
262
+
263
+ return outcome.result;
264
+ }
265
+
266
+ /**
267
+ * Silent app-update resync: rewrites the shim for the new build. Consent is
268
+ * re-read under the lock, so a stale UI snapshot never reinstalls a command
269
+ * the user has since removed or declined.
270
+ */
271
+ async function resyncCliInstallUnlocked(
272
+ input: ApplyCliInstallInput,
273
+ ): Promise<ApplyResult> {
274
+ const env = input.env ?? process.env;
275
+ const stamp = await readCliInstallStamp(cliInstallStampPath(env));
276
+ const fingerprint = await installFingerprint(input.packageRoot);
277
+
278
+ if (stamp?.consent !== "granted" || stamp.fingerprint === fingerprint)
279
+ return { code: 0, output: "" };
280
+
281
+ const chunks: string[] = [];
282
+ let shimPath: string | undefined;
283
+ let userPath = stamp.userPath;
284
+
285
+ if (stamp.shimPath && !stamp.commandDisabled) {
286
+ const installed = await installShim(input, chunks);
287
+
288
+ if (installed.code !== 0) return installed;
289
+ shimPath = installed.shimPath;
290
+ userPath = installed.userPath ?? userPath;
291
+ }
292
+
293
+ const next: ReviewCliInstallStamp = {
294
+ ...stamp,
295
+ fingerprint,
296
+ updatedAt: new Date().toISOString(),
297
+ };
298
+
299
+ if (userPath) next.userPath = userPath;
300
+
301
+ // The update marker stays as it was: an upgrader keeps `updateNeeded` until
302
+ // Done.
303
+ await writePrivateJsonAtomic(cliInstallStampPath(env), next);
304
+
305
+ return withShimPath({ code: 0, output: chunks.join("") }, shimPath);
306
+ }
307
+
308
+ async function applyCliInstallUnlocked(
309
+ input: ApplyCliInstallInput,
310
+ ): Promise<ApplyResult> {
311
+ const homeDir = input.homeDir ?? os.homedir();
312
+ const env = input.env ?? process.env;
313
+ const previous = await readCliInstallStamp(cliInstallStampPath(env));
314
+ const granted = previous?.consent === "granted";
315
+ const chunks: string[] = [];
316
+ let traceEnabled = false;
317
+
318
+ // Configure the machine before any other mutation, so a request with
319
+ // missing credentials fails without a partial install.
320
+ if (input.trace !== undefined) {
321
+ try {
322
+ const status = await configureTraceMachine({
323
+ homeDir,
324
+ env,
325
+ credentials: input.trace === true ? undefined : input.trace,
326
+ });
327
+
328
+ traceEnabled = status.enabled;
329
+ chunks.push(`[ok] trace capture -> ${status.envPath}\n`);
330
+
331
+ if (status.error)
332
+ chunks.push(`Trace storage check failed: ${status.error}\n`);
333
+ } catch (cause) {
334
+ chunks.push(
335
+ `${cause instanceof Error ? cause.message : String(cause)}\n`,
336
+ );
337
+
338
+ return { code: 1, output: chunks.join("") };
339
+ }
340
+ }
341
+
342
+ let shimPath: string | undefined;
343
+ let userPath: string | undefined;
344
+
345
+ if (input.shim === true) {
346
+ const installed = await installShim(input, chunks);
347
+
348
+ if (installed.code !== 0) return installed;
349
+ shimPath = installed.shimPath;
350
+ userPath = installed.userPath;
351
+ }
352
+
353
+ if (traceEnabled) {
354
+ const executable = (await isOwnedShim(pathShimPath(homeDir)))
355
+ ? pathShimPath(homeDir)
356
+ : undefined;
357
+
358
+ const hooks = await installHarnessHooks({ homeDir, env, executable });
359
+
360
+ for (const hook of hooks.installed)
361
+ chunks.push(`[ok] ${hook.agent} trace hook -> ${hook.path}\n`);
362
+ }
363
+
364
+ const stamp: ReviewCliInstallStamp = {
365
+ consent: "granted",
366
+ fingerprint: await installFingerprint(input.packageRoot),
367
+ updatedAt: new Date().toISOString(),
368
+ };
369
+
370
+ const stampShimPath = shimPath ?? (granted ? previous.shimPath : undefined);
371
+
372
+ if (stampShimPath) stamp.shimPath = stampShimPath;
373
+
374
+ if (granted && previous.commandDisabled && input.shim !== true)
375
+ stamp.commandDisabled = true;
376
+
377
+ if (input.trace !== undefined || (granted && previous.traceManaged))
378
+ stamp.traceManaged = true;
379
+
380
+ const stampUserPath =
381
+ userPath ?? (granted && !shimPath ? previous.userPath : undefined);
382
+
383
+ if (stampUserPath) stamp.userPath = stampUserPath;
384
+
385
+ await writeCurrentStamp(env, stamp);
386
+
387
+ return withShimPath({ code: 0, output: chunks.join("") }, shimPath);
388
+ }
389
+
390
+ async function installShim(
391
+ input: ApplyCliInstallInput,
392
+ chunks: string[],
393
+ ): Promise<ApplyResult> {
394
+ if (!input.cliPath) {
395
+ chunks.push("This server has no built CLI to install the command from.\n");
396
+
397
+ return { code: 1, output: chunks.join("") };
398
+ }
399
+
400
+ const installed = await installReviewCommand({
401
+ cliPath: input.cliPath,
402
+ cliRuntimePath: input.cliRuntimePath,
403
+ homeDir: input.homeDir,
404
+ env: input.env,
405
+ });
406
+
407
+ chunks.push(installed.output);
408
+
409
+ const result: ApplyResult = {
410
+ code: 0,
411
+ output: "",
412
+ shimPath: installed.shimPath,
413
+ };
414
+
415
+ if (installed.userPath) result.userPath = installed.userPath;
416
+
417
+ return result;
418
+ }
419
+
420
+ function withShimPath(
421
+ result: ApplyResult,
422
+ shimPath: string | undefined,
423
+ ): ApplyResult {
424
+ if (shimPath) result.shimPath = shimPath;
425
+
426
+ return result;
427
+ }
428
+
429
+ /** The published plugin per harness; Cursor's link needs the shim it launches. */
430
+ function connectPlugins(
431
+ hasShim: boolean,
432
+ ): ReviewCliInstallStatus["connect"]["plugins"] {
433
+ return {
434
+ claude:
435
+ process.platform === "win32"
436
+ ? {
437
+ label: "Add the Claude Code MCP server",
438
+ command: CLAUDE_WINDOWS_MCP_ADD,
439
+ }
440
+ : {
441
+ label: "Install the Claude Code plugin",
442
+ command:
443
+ "/plugin marketplace add devdotfast/whiteboard\n/plugin install whiteboard@devfast",
444
+ },
445
+ codex: {
446
+ label: "Install the Codex plugin",
447
+ command:
448
+ "codex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast",
449
+ },
450
+ cursor: hasShim
451
+ ? {
452
+ label: "Install in Cursor",
453
+ url: cursorInstallDeeplink(reviewMcpLaunch(true)),
454
+ }
455
+ : { label: "Install in Cursor" },
456
+ opencode: {
457
+ label: "Add the OpenCode MCP server (OpenCode 2+)",
458
+ command: `opencode mcp add --global whiteboard -- ${launchCommand(reviewMcpLaunch(hasShim))}`,
459
+ },
460
+ pi: {
461
+ label: "Add the Pi MCP server (Pi 0.99+)",
462
+ command: `pi mcp add whiteboard -- ${launchCommand(reviewMcpLaunch(hasShim))}`,
463
+ },
464
+ omp: {
465
+ label: "Add the oh-my-pi MCP server",
466
+ command: `Add ${JSON.stringify({ whiteboard: reviewMcpLaunch(hasShim) })}\nto "mcpServers" in ~/.omp/agent/mcp.json, then run /mcp reload.`,
467
+ },
468
+ copilot:
469
+ process.platform === "win32"
470
+ ? {
471
+ label: "Add the Copilot CLI MCP server",
472
+ command: COPILOT_WINDOWS_MCP_ADD,
473
+ }
474
+ : {
475
+ label: "Install the Copilot CLI plugin",
476
+ command:
477
+ "copilot plugin marketplace add devdotfast/whiteboard\ncopilot plugin install whiteboard@devfast",
478
+ },
479
+ };
480
+ }
481
+
482
+ /** Removes the skills Whiteboard Desktop installed before it connected over MCP. */
483
+ export async function removeLegacyReviewSkills(
484
+ input: { homeDir?: string; env?: NodeJS.ProcessEnv } = {},
485
+ ): Promise<{ removed: string[] }> {
486
+ return withDesktopInstallLock(input.env, () =>
487
+ removeLegacySkills(input.homeDir ?? os.homedir()),
488
+ );
489
+ }
490
+
491
+ /** Records setup completion; remaining legacy skills still require the update screen. */
492
+ export async function finishCliInstallUpdate(
493
+ env: NodeJS.ProcessEnv = process.env,
494
+ ): Promise<void> {
495
+ await withDesktopInstallLock(env, async () => {
496
+ const stamp = await readCliInstallStamp(cliInstallStampPath(env));
497
+
498
+ if (stamp?.consent !== "granted") return;
499
+ await writeUpdateMarker(env);
500
+ });
501
+ }
502
+
503
+ export async function declineCliInstall(
504
+ env: NodeJS.ProcessEnv = process.env,
505
+ ): Promise<void> {
506
+ await withDesktopInstallLock(env, () =>
507
+ writeCurrentStamp(env, {
508
+ consent: "declined",
509
+ updatedAt: new Date().toISOString(),
510
+ }),
511
+ );
512
+ }
513
+
514
+ export async function skipCliInstall(
515
+ env: NodeJS.ProcessEnv = process.env,
516
+ ): Promise<void> {
517
+ await withDesktopInstallLock(env, async () => {
518
+ const stampPath = cliInstallStampPath(env);
519
+
520
+ if (await readCliInstallStamp(stampPath)) return;
521
+ await writeCurrentStamp(env, {
522
+ consent: "skipped",
523
+ updatedAt: new Date().toISOString(),
524
+ });
525
+ });
526
+ }
527
+
528
+ /** Removes the stamp entirely, so the next app launch prompts again. */
529
+ export async function resetCliInstall(
530
+ env: NodeJS.ProcessEnv = process.env,
531
+ ): Promise<void> {
532
+ await withDesktopInstallLock(env, async () => {
533
+ await rm(cliInstallStampPath(env), { force: true });
534
+ await rm(cliInstallUpdateMarkerPath(env), { force: true });
535
+ });
536
+ }
537
+
538
+ /** Writes a stamp this build decided, which also makes the setup current. */
539
+ async function writeCurrentStamp(
540
+ env: NodeJS.ProcessEnv,
541
+ stamp: ReviewCliInstallStamp,
542
+ ): Promise<void> {
543
+ await writePrivateJsonAtomic(cliInstallStampPath(env), stamp);
544
+ await writeUpdateMarker(env);
545
+ }
546
+
547
+ async function writeUpdateMarker(env: NodeJS.ProcessEnv): Promise<void> {
548
+ await writeFileAtomicAsync(cliInstallUpdateMarkerPath(env), "", {
549
+ encoding: "utf8",
550
+ mode: 0o600,
551
+ });
552
+ }
553
+
554
+ interface RemoveCliInstallInput {
555
+ shim?: boolean;
556
+ trace?: boolean;
557
+ homeDir?: string;
558
+ env?: NodeJS.ProcessEnv;
559
+ }
560
+
561
+ export async function removeCliInstall(
562
+ input: RemoveCliInstallInput,
563
+ ): Promise<{ output: string }> {
564
+ return withDesktopInstallLock(input.env, () =>
565
+ removeCliInstallUnlocked(input),
566
+ );
567
+ }
568
+
569
+ async function removeCliInstallUnlocked(
570
+ input: RemoveCliInstallInput,
571
+ ): Promise<{ output: string }> {
572
+ const homeDir = input.homeDir ?? os.homedir();
573
+ const env = input.env ?? process.env;
574
+ const chunks: string[] = [];
575
+
576
+ const expectedTraceCommand = (await isOwnedShim(pathShimPath(homeDir)))
577
+ ? pathShimPath(homeDir)
578
+ : "";
579
+
580
+ const previous = await readCliInstallStamp(cliInstallStampPath(env));
581
+
582
+ if (input.shim) {
583
+ const shimPath = pathShimPath(homeDir);
584
+ // Only ever delete a command file this app wrote; a hand-made file at
585
+ // the same path stays untouched.
586
+ const contents = await readTextIfExists(shimPath);
587
+
588
+ if (contents.includes(SHIM_MARKER)) {
589
+ await rm(shimPath, { force: true });
590
+
591
+ if (process.platform === "win32") {
592
+ const bashShim = shimPath.replace(/\.cmd$/i, "");
593
+
594
+ if (await isOwnedShim(bashShim)) await rm(bashShim, { force: true });
595
+ }
596
+
597
+ chunks.push(`[ok] removed whiteboard command ${shimPath}\n`);
598
+ } else if (contents) {
599
+ chunks.push(
600
+ `${shimPath} was not installed by Whiteboard Desktop; left in place.\n`,
601
+ );
602
+ }
603
+
604
+ for (const profilePath of await removeShellProfilePath(homeDir)) {
605
+ chunks.push(`[ok] removed Whiteboard PATH entry from ${profilePath}\n`);
606
+ }
607
+ }
608
+
609
+ if (input.trace) {
610
+ const { kept } = await disableAllTraceRepositories(
611
+ traceScope({ homeDir, env }),
612
+ expectedTraceCommand,
613
+ );
614
+
615
+ for (const agent of AGENT_TRACE_HOOK_AGENTS)
616
+ await removeAgentTraceHook(agent, homeDir, env, expectedTraceCommand);
617
+
618
+ const remaining = await describeTraceHookOwners(homeDir, env);
619
+
620
+ if (kept.length || Object.values(remaining).some(Boolean)) {
621
+ chunks.push(
622
+ "[skip] kept trace capture for another Whiteboard installation\n",
623
+ );
624
+ } else {
625
+ await disableTraceMachine({ homeDir, env });
626
+ chunks.push("[ok] disabled Whiteboard trace capture\n");
627
+ }
628
+ }
629
+
630
+ if (previous?.consent === "granted") {
631
+ const stamp: ReviewCliInstallStamp = {
632
+ consent: "granted",
633
+ updatedAt: new Date().toISOString(),
634
+ };
635
+
636
+ if (previous.fingerprint) stamp.fingerprint = previous.fingerprint;
637
+
638
+ if (!input.shim && previous.shimPath) stamp.shimPath = previous.shimPath;
639
+
640
+ if (input.shim || previous.commandDisabled) stamp.commandDisabled = true;
641
+
642
+ if (!input.trace && previous.traceManaged) stamp.traceManaged = true;
643
+
644
+ if (!input.shim && previous.userPath) stamp.userPath = previous.userPath;
645
+ await writeCurrentStamp(env, stamp);
646
+ }
647
+
648
+ return { output: chunks.join("") };
649
+ }
650
+
651
+ /**
652
+ * Fingerprint of the package manifest and built CLI. Content-based so it
653
+ * works identically in a dev checkout and a packaged review-runtime, with no
654
+ * build-time stamping.
655
+ */
656
+ export async function installFingerprint(packageRoot: string): Promise<string> {
657
+ const hash = createHash("sha256");
658
+ hash.update(await readTextIfExists(path.join(packageRoot, "package.json")));
659
+ hash.update("dist/cli.js\0");
660
+ hash.update(await readTextIfExists(path.join(packageRoot, "dist", "cli.js")));
661
+
662
+ return hash.digest("hex").slice(0, 20);
663
+ }
664
+
665
+ export async function readCliInstallStamp(
666
+ stampPath: string,
667
+ ): Promise<ReviewCliInstallStamp | null> {
668
+ let value: unknown;
669
+
670
+ try {
671
+ value = JSON.parse(await readFile(stampPath, "utf8"));
672
+ } catch {
673
+ return null;
674
+ }
675
+
676
+ const parsed = ReviewCliInstallStampSchema.safeParse(value);
677
+
678
+ return parsed.success ? parsed.data : null;
679
+ }
680
+
681
+ export async function isOwnedShim(shimPath: string): Promise<boolean> {
682
+ return hasManagedShimMarker(await readTextIfExists(shimPath));
683
+ }
684
+
685
+ /**
686
+ * The shim is POSIX sh, so running `review` needs no Node.js at all to start.
687
+ * It prefers the CLI and runtime the running Whiteboard Desktop advertises in its
688
+ * discovery file, falls back to the paths baked in by the app that wrote it,
689
+ * and runs the CLI under the app's Electron binary as Node
690
+ * (ELECTRON_RUN_AS_NODE) — the exact runtime the server uses. System Node is
691
+ * the last resort and gets a clear version check instead of a cryptic crash.
692
+ */
693
+ export async function writePathShim(
694
+ shimPath: string,
695
+ cliPath: string,
696
+ runtimePath: string | undefined,
697
+ devHome: string,
698
+ ): Promise<void> {
699
+ if (process.platform === "win32") {
700
+ await writeFileAtomicAsync(
701
+ shimPath,
702
+ windowsCliShim(cliPath, runtimePath ?? process.execPath, devHome),
703
+ { replaceSymlink: true },
704
+ );
705
+
706
+ // Git Bash and agent plugins use the POSIX launcher beside the .cmd file.
707
+ shimPath = shimPath.replace(/\.cmd$/i, "");
708
+ }
709
+
710
+ const source = `#!/bin/sh
711
+ # Managed by Whiteboard Desktop ("Whiteboard: Install CLI in PATH"). Do not edit.
712
+ FALLBACK_CLI=${shSingleQuote(cliPath)}
713
+ FALLBACK_RUNTIME=${shSingleQuote(runtimePath ?? "")}
714
+ DEFAULT_HOME=${shSingleQuote(devHome)}
715
+ export DEV_REVIEW_HOME="\${DEV_REVIEW_HOME:-$DEFAULT_HOME}"
716
+ DESKTOP="$DEV_REVIEW_HOME/review-desktop"
717
+
718
+ # A record counts only while its server process is alive.
719
+ live() {
720
+ [ -f "$1" ] || return 1
721
+ pid=$(sed -n 's/.*"serverPid"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' "$1" | head -n 1)
722
+ [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
723
+ }
724
+
725
+ # Same order as the CLI: DEV_REVIEW_INSTANCE, the machine default, the only
726
+ # live Desktop, then stable. A stable Desktop that predates instances wrote
727
+ # only server.json; it never stands in for any other key.
728
+ LEGACY="$DESKTOP/server.json"
729
+ STABLE="$DESKTOP/instances/stable.json"
730
+ [ -f "$STABLE" ] || STABLE="$LEGACY"
731
+ key="\${DEV_REVIEW_INSTANCE:-}"
732
+ if [ -z "$key" ] && [ -f "$DESKTOP/default-instance" ]; then
733
+ key=$(head -n 1 "$DESKTOP/default-instance" | tr -d '[:space:]')
734
+ fi
735
+ DISCOVERY=""
736
+ case "$key" in
737
+ # Keys name files; the CLI rejects anything else.
738
+ *[!A-Za-z0-9_.-]*) ;;
739
+ stable) DISCOVERY="$STABLE" ;;
740
+ ?*) DISCOVERY="$DESKTOP/instances/$key.json" ;;
741
+ *)
742
+ for record in "$DESKTOP"/instances/*.json "$LEGACY"; do
743
+ if [ "$record" = "$LEGACY" ] && [ "$STABLE" != "$LEGACY" ]; then continue; fi
744
+ if live "$record"; then
745
+ if [ -n "$DISCOVERY" ]; then DISCOVERY=""; break; fi
746
+ DISCOVERY="$record"
747
+ fi
748
+ done
749
+ [ -n "$DISCOVERY" ] || DISCOVERY="$STABLE"
750
+ ;;
751
+ esac
752
+
753
+ cli=""
754
+ runtime=""
755
+ delegated=""
756
+ manage_instances=""
757
+ for arg in "$@"; do
758
+ if [ "$arg" = "instances" ]; then manage_instances=1; break; fi
759
+ done
760
+ if [ -z "$manage_instances" ] && [ -z "\${DEV_FAST_REVIEW_CLI_NO_DELEGATE:-}" ] && live "$DISCOVERY"; then
761
+ cli=$(sed -n 's/.*"cliPath"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$DISCOVERY" | head -n 1)
762
+ delegated="1"
763
+ runtime=$(sed -n 's/.*"cliRuntimePath"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$DISCOVERY" | head -n 1)
764
+ fi
765
+ if [ -z "$cli" ] || [ ! -f "$cli" ] || { [ -n "$runtime" ] && [ ! -x "$runtime" ]; }; then
766
+ delegated=""
767
+ cli="$FALLBACK_CLI"
768
+ runtime="$FALLBACK_RUNTIME"
769
+ fi
770
+
771
+ if [ ! -f "$cli" ]; then
772
+ echo "Whiteboard CLI not found at $cli. Start Whiteboard Desktop, or run npx @dev.fast/whiteboard instead." >&2
773
+ exit 1
774
+ fi
775
+
776
+ # Prevent bootstrap from overriding this selection.
777
+ export DEV_FAST_REVIEW_CLI_NO_DELEGATE=1
778
+ export DEV_FAST_REVIEW_CLI_DELEGATED="$delegated"
779
+
780
+ # The app's Electron binary runs as plain Node.js and matches the server's
781
+ # runtime exactly; no system Node is required on this path.
782
+ if [ -n "$runtime" ] && [ -x "$runtime" ]; then
783
+ export ELECTRON_RUN_AS_NODE=1
784
+ exec "$runtime" "$cli" "$@"
785
+ fi
786
+
787
+ if ! command -v node >/dev/null 2>&1; then
788
+ echo "Whiteboard needs Node.js 24 or newer and none was found. Install Node 24, or install Whiteboard Desktop." >&2
789
+ exit 1
790
+ fi
791
+ major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
792
+ case "$major" in *[!0-9]*) major=0;; esac
793
+ if [ "$major" -lt 24 ]; then
794
+ echo "Whiteboard needs Node.js 24 or newer; found $(node -v 2>/dev/null). Update Node, or install Whiteboard Desktop." >&2
795
+ exit 1
796
+ fi
797
+ exec node "$cli" "$@"
798
+ `;
799
+
800
+ await writeFileAtomicAsync(shimPath, source, {
801
+ encoding: "utf8",
802
+ mode: 0o755,
803
+ replaceSymlink: true,
804
+ });
805
+ await chmod(shimPath, 0o755);
806
+ }
807
+
808
+ export async function installReviewCommand(input: {
809
+ cliPath: string;
810
+ cliRuntimePath?: string;
811
+ homeDir?: string;
812
+ env?: NodeJS.ProcessEnv;
813
+ }): Promise<{ shimPath: string; output: string; userPath?: string }> {
814
+ const homeDir = input.homeDir ?? os.homedir();
815
+ const env = input.env ?? process.env;
816
+ const shimPath = pathShimPath(homeDir);
817
+
818
+ if ((await isFile(shimPath)) && !(await isOwnedShim(shimPath))) {
819
+ return {
820
+ shimPath,
821
+ output: `[skip] kept the existing whiteboard command at ${shimPath}\n`,
822
+ };
823
+ }
824
+
825
+ const shadowingCommand = await resolvePathCommand(
826
+ "whiteboard",
827
+ shimPath,
828
+ env,
829
+ );
830
+
831
+ await writePathShim(
832
+ shimPath,
833
+ input.cliPath,
834
+ input.cliRuntimePath,
835
+ devReviewHome(env, homeDir),
836
+ );
837
+
838
+ if (await traceMachineEnabled({ homeDir, env })) {
839
+ const scope = traceScope({ homeDir, env });
840
+
841
+ for (const cwd of await listTraceRepositoryRoots(homeDir)) {
842
+ if (!(await isDirectory(cwd))) continue;
843
+
844
+ if ((await traceRepositoryStatus(cwd)).enabled)
845
+ await enableTraceRepository({
846
+ cwd,
847
+ scope,
848
+ reviewCommand: shimPath,
849
+ replaceCommand: true,
850
+ });
851
+ }
852
+ }
853
+
854
+ const legacyShim = path.join(path.dirname(shimPath), "review");
855
+
856
+ if (await isOwnedShim(legacyShim)) await rm(legacyShim, { force: true });
857
+ const profileOutput = await ensureShellProfilePath({ homeDir, env });
858
+
859
+ const shadowingOutput = shadowingCommand
860
+ ? `Warning: ${shadowingCommand} currently shadows ${shimPath}. Remove that PATH entry or put ${path.dirname(shimPath)} before it.\n`
861
+ : "";
862
+
863
+ const output = `[ok] whiteboard command -> ${shimPath}\n${profileOutput}${shadowingOutput}`;
864
+
865
+ // ensureShellProfilePath throws when the Windows PATH write fails, so
866
+ // reaching here means new terminals will find the command.
867
+ return process.platform === "win32"
868
+ ? { shimPath, output, userPath: path.dirname(shimPath) }
869
+ : { shimPath, output };
870
+ }
871
+
872
+ export async function ensureShellProfilePath(input: {
873
+ homeDir: string;
874
+ env: NodeJS.ProcessEnv;
875
+ }): Promise<string> {
876
+ const shimDirectory = path.dirname(pathShimPath(input.homeDir));
877
+
878
+ if (pathContainsDirectory(input.env.PATH, shimDirectory)) return "";
879
+
880
+ if (process.platform === "win32") {
881
+ await updateWindowsUserPath(shimDirectory);
882
+
883
+ return `[ok] added ${shimDirectory} to your user PATH; open a new terminal\n`;
884
+ }
885
+
886
+ const shell = path.basename(input.env.SHELL?.trim() ?? "");
887
+ let profileName: (typeof SHELL_PROFILE_NAMES)[number] | undefined;
888
+
889
+ if (shell === "bash") {
890
+ profileName = ".bash_profile";
891
+ } else if (
892
+ shell === "zsh" ||
893
+ (shell !== "fish" && process.platform === "darwin")
894
+ ) {
895
+ profileName = ".zprofile";
896
+ }
897
+
898
+ if (!profileName) {
899
+ return "Whiteboard did not update PATH for this shell. Add ~/.local/bin to PATH. Fish users can run: fish_add_path ~/.local/bin\n";
900
+ }
901
+
902
+ const profilePath = path.join(input.homeDir, profileName);
903
+ const source = await readTextIfExists(profilePath);
904
+
905
+ if (source.includes(PROFILE_MARKER) || source.includes(".local/bin")) {
906
+ return "";
907
+ }
908
+
909
+ await writeTextAtomic(profilePath, `${source}${PROFILE_BLOCK}`);
910
+
911
+ return `[ok] added ${shimDirectory} to PATH in ${profilePath}\n`;
912
+ }
913
+
914
+ export async function removeShellProfilePath(
915
+ homeDir: string,
916
+ ): Promise<string[]> {
917
+ if (process.platform === "win32") {
918
+ await updateWindowsUserPath(path.dirname(pathShimPath(homeDir)), true);
919
+
920
+ return ["user PATH"];
921
+ }
922
+
923
+ const removed: string[] = [];
924
+
925
+ for (const profileName of SHELL_PROFILE_NAMES) {
926
+ const profilePath = path.join(homeDir, profileName);
927
+ const source = await readTextIfExists(profilePath);
928
+
929
+ if (!source.includes(PROFILE_BLOCK)) continue;
930
+ await writeTextAtomic(profilePath, source.replaceAll(PROFILE_BLOCK, ""));
931
+ removed.push(profilePath);
932
+ }
933
+
934
+ return removed;
935
+ }
936
+
937
+ function shSingleQuote(value: string): string {
938
+ return `'${value.replaceAll("'", "'\\''")}'`;
939
+ }
940
+
941
+ async function resolvePathCommand(
942
+ command: string,
943
+ shimPath: string,
944
+ env: NodeJS.ProcessEnv,
945
+ ): Promise<string | undefined> {
946
+ const entries = (env.PATH ?? "").split(path.delimiter);
947
+ const shimDirectory = path.resolve(path.dirname(shimPath));
948
+
949
+ const shimIndex = entries.findIndex(
950
+ (entry) => path.resolve(entry || ".") === shimDirectory,
951
+ );
952
+
953
+ for (let index = 0; index < entries.length; index += 1) {
954
+ const candidate = path.join(entries[index] || ".", command);
955
+
956
+ if (!(await isExecutableFile(candidate))) continue;
957
+
958
+ if ((await readTextIfExists(candidate)).includes(SHIM_MARKER)) {
959
+ return undefined;
960
+ }
961
+
962
+ return shimIndex === -1 || index < shimIndex
963
+ ? path.resolve(candidate)
964
+ : undefined;
965
+ }
966
+
967
+ return undefined;
968
+ }
969
+
970
+ function sameWindowsPath(left: string, right: string): boolean {
971
+ return (
972
+ path.win32.resolve(left).toLowerCase() ===
973
+ path.win32.resolve(right).toLowerCase()
974
+ );
975
+ }
976
+
977
+ function pathContainsDirectory(
978
+ pathValue: string | undefined,
979
+ directory: string,
980
+ ): boolean {
981
+ return (pathValue ?? "")
982
+ .split(path.delimiter)
983
+ .some(
984
+ (entry) =>
985
+ entry.length > 0 && path.resolve(entry) === path.resolve(directory),
986
+ );
987
+ }
988
+
989
+ async function isShellProfileConfigured(homeDir: string): Promise<boolean> {
990
+ const profiles = await Promise.all(
991
+ SHELL_PROFILE_NAMES.map((profileName) =>
992
+ readTextIfExists(path.join(homeDir, profileName)),
993
+ ),
994
+ );
995
+
996
+ return profiles.some((source) => source.includes(PROFILE_MARKER));
997
+ }
998
+
999
+ async function writeTextAtomic(
1000
+ filePath: string,
1001
+ source: string,
1002
+ ): Promise<void> {
1003
+ let mode = 0o644;
1004
+
1005
+ try {
1006
+ mode = (await stat(filePath)).mode & 0o777;
1007
+ } catch {
1008
+ // Use the default profile mode for a new file.
1009
+ }
1010
+
1011
+ await writeFileAtomicAsync(filePath, source, { encoding: "utf8", mode });
1012
+ }
1013
+
1014
+ async function readTextIfExists(filePath: string): Promise<string> {
1015
+ try {
1016
+ return await readFile(filePath, "utf8");
1017
+ } catch {
1018
+ return "";
1019
+ }
1020
+ }
1021
+
1022
+ async function isExecutableFile(target: string): Promise<boolean> {
1023
+ if (!(await isFile(target))) return false;
1024
+
1025
+ try {
1026
+ await access(target, constants.X_OK);
1027
+
1028
+ return true;
1029
+ } catch {
1030
+ return false;
1031
+ }
1032
+ }
1033
+
1034
+ /** `~/…` for paths under the home directory; the card shows these, removal rescans. */
1035
+ function homeRelative(homeDir: string, target: string): string {
1036
+ const relative = path.relative(homeDir, target);
1037
+
1038
+ return relative.startsWith("..") || path.isAbsolute(relative)
1039
+ ? target
1040
+ : `~/${relative.split(path.sep).join("/")}`;
1041
+ }