@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.115

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 (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +9 -14
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
@@ -0,0 +1,285 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ REVIEW_CLI_INSTALL_TARGET_LABELS,
8
+ REVIEW_REMOTE_AGENT_IDS,
9
+ type ReviewRemoteAgentId,
10
+ } from "@dev.fast/review-protocol";
11
+ import {
12
+ type AgentTraceHookAgent,
13
+ agentTraceHomeDirectory,
14
+ } from "@dev.fast/trace-core";
15
+ import { parse as parseToml } from "smol-toml";
16
+ import { z } from "zod";
17
+
18
+ import { CONNECT_COMMANDS, type ConnectCommand } from "./connect-prompts";
19
+ import { isDirectory } from "./fs-utils";
20
+
21
+ export const AGENT_CONNECT_TARGETS: readonly (ReviewRemoteAgentId &
22
+ AgentTraceHookAgent)[] = REVIEW_REMOTE_AGENT_IDS;
23
+
24
+ export type AgentConnectTarget = ReviewRemoteAgentId;
25
+
26
+ export interface DetectedAgent {
27
+ id: AgentConnectTarget;
28
+ name: string;
29
+ connected: boolean;
30
+ manual?: true;
31
+ }
32
+
33
+ export interface ConnectedAgent {
34
+ id: AgentConnectTarget;
35
+ name: string;
36
+ connected: boolean;
37
+ output: string;
38
+ }
39
+
40
+ const OUTPUT_LIMIT = 4000;
41
+
42
+ export const AGENT_CONNECT_TIMEOUT_MS = 120_000;
43
+
44
+ interface Scope {
45
+ homeDir: string;
46
+ env: NodeJS.ProcessEnv;
47
+ }
48
+
49
+ export async function detectAgents(scope: Scope): Promise<DetectedAgent[]> {
50
+ const found: DetectedAgent[] = [];
51
+
52
+ for (const id of AGENT_CONNECT_TARGETS) {
53
+ if (
54
+ !(await isDirectory(
55
+ agentTraceHomeDirectory(id, scope.homeDir, scope.env),
56
+ ))
57
+ )
58
+ continue;
59
+
60
+ const manual = !(await onPath(CONNECT_COMMANDS[id][0].argv[0], scope.env));
61
+
62
+ found.push({
63
+ id,
64
+ name: REVIEW_CLI_INSTALL_TARGET_LABELS[id],
65
+ connected: await connected(id, scope),
66
+ ...(manual && { manual: true as const }),
67
+ });
68
+ }
69
+
70
+ return found;
71
+ }
72
+
73
+ export async function connectAgents(
74
+ input: Scope & {
75
+ agents: readonly AgentConnectTarget[];
76
+ timeoutMs?: number;
77
+ },
78
+ ): Promise<ConnectedAgent[]> {
79
+ const results: ConnectedAgent[] = [];
80
+
81
+ for (const id of input.agents) {
82
+ const deadline = Date.now() + (input.timeoutMs ?? AGENT_CONNECT_TIMEOUT_MS);
83
+ let output = "";
84
+
85
+ const commands: readonly ConnectCommand[] = CONNECT_COMMANDS[id];
86
+
87
+ for (const { argv, ifAny } of commands) {
88
+ const left = deadline - Date.now();
89
+
90
+ if (left <= 0) break;
91
+ const step = await run(argv, input.env, left);
92
+
93
+ output += `$ ${argv.join(" ")}\n${step.output}`;
94
+
95
+ if (!step.ok && !ifAny) {
96
+ output += `Stopped: ${argv.join(" ")} failed.\n`;
97
+ break;
98
+ }
99
+ }
100
+
101
+ results.push({
102
+ id,
103
+ name: REVIEW_CLI_INSTALL_TARGET_LABELS[id],
104
+ connected: await connected(id, input),
105
+ output: output.slice(-OUTPUT_LIMIT),
106
+ });
107
+ }
108
+
109
+ return results;
110
+ }
111
+
112
+ const WHITEBOARD_PLUGIN = "whiteboard@devfast";
113
+
114
+ const ClaudePluginsSchema = z.object({
115
+ plugins: z.record(z.string(), z.unknown()),
116
+ });
117
+
118
+ const CodexConfigSchema = z.object({
119
+ plugins: z.record(z.string(), z.unknown()).optional(),
120
+ });
121
+
122
+ const CodexPluginSchema = z.object({ enabled: z.boolean().optional() });
123
+
124
+ const PiSettingsSchema = z.object({
125
+ packages: z.array(
126
+ z.union([
127
+ z.string(),
128
+ z.object({ source: z.string() }).transform((entry) => entry.source),
129
+ ]),
130
+ ),
131
+ });
132
+
133
+ const PI_PACKAGE = /^npm:@dev\.fast\/pi-whiteboard(@.*)?$/;
134
+
135
+ const OPENCODE_PLUGIN =
136
+ /"plugin"\s*:\s*\[[^\]]*"@dev\.fast\/opencode-whiteboard(@[^"]*)?"/;
137
+
138
+ const OpenCodeConfigSchema = z.object({
139
+ mcp: z.object({
140
+ whiteboard: z.unknown().optional(),
141
+ servers: z.object({ whiteboard: z.unknown().optional() }).optional(),
142
+ }),
143
+ });
144
+
145
+ /** OpenCode's config is JSONC: drop comments and trailing commas, keep strings. */
146
+ const JSONC_EXTRAS =
147
+ /("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\/|,(?=\s*[}\]])/g;
148
+
149
+ function openCodeConnected(text: string): boolean {
150
+ if (OPENCODE_PLUGIN.test(text)) return true;
151
+
152
+ try {
153
+ const { mcp } = OpenCodeConfigSchema.parse(
154
+ JSON.parse(text.replace(JSONC_EXTRAS, "$1")),
155
+ );
156
+
157
+ return (
158
+ mcp.whiteboard !== undefined || mcp.servers?.whiteboard !== undefined
159
+ );
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ async function connected(
166
+ id: AgentConnectTarget,
167
+ scope: Scope,
168
+ ): Promise<boolean> {
169
+ const home = agentTraceHomeDirectory(id, scope.homeDir, scope.env);
170
+
171
+ const read = (...parts: string[]) =>
172
+ readFile(path.join(home, ...parts), "utf8");
173
+
174
+ try {
175
+ switch (id) {
176
+ case "claude": {
177
+ const installs = ClaudePluginsSchema.parse(
178
+ JSON.parse(await read("plugins", "installed_plugins.json")),
179
+ ).plugins[WHITEBOARD_PLUGIN];
180
+
181
+ return Array.isArray(installs)
182
+ ? installs.length > 0
183
+ : Boolean(installs);
184
+ }
185
+
186
+ case "codex": {
187
+ const entry = CodexConfigSchema.parse(
188
+ parseToml(await read("config.toml")),
189
+ ).plugins?.[WHITEBOARD_PLUGIN];
190
+
191
+ return (
192
+ entry !== undefined &&
193
+ CodexPluginSchema.parse(entry).enabled !== false
194
+ );
195
+ }
196
+
197
+ case "opencode": {
198
+ for (const name of ["opencode.json", "opencode.jsonc", "config.json"]) {
199
+ if (openCodeConnected(await read(name).catch(() => ""))) return true;
200
+ }
201
+
202
+ return false;
203
+ }
204
+
205
+ case "pi":
206
+ return PiSettingsSchema.parse(
207
+ JSON.parse(await read("agent", "settings.json")),
208
+ ).packages.some((entry) => PI_PACKAGE.test(entry));
209
+ }
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ async function onPath(
216
+ command: string,
217
+ env: NodeJS.ProcessEnv,
218
+ ): Promise<string | undefined> {
219
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
220
+ if (!path.isAbsolute(directory)) continue;
221
+ const candidate = path.join(directory, command);
222
+
223
+ try {
224
+ await access(candidate, constants.X_OK);
225
+
226
+ return candidate;
227
+ } catch {}
228
+ }
229
+
230
+ return undefined;
231
+ }
232
+
233
+ async function run(
234
+ argv: readonly string[],
235
+ env: NodeJS.ProcessEnv,
236
+ timeoutMs: number,
237
+ ): Promise<{ output: string; ok: boolean }> {
238
+ const [command = "", ...args] = argv;
239
+ const file = await onPath(command, env);
240
+
241
+ if (!file)
242
+ return { output: `${command} was not found on PATH.\n`, ok: false };
243
+
244
+ return new Promise((resolve) => {
245
+ let output = "";
246
+
247
+ const keep = (chunk: Buffer) => {
248
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_LIMIT);
249
+ };
250
+
251
+ const child = spawn(file, args, {
252
+ env,
253
+ stdio: ["ignore", "pipe", "pipe"],
254
+ detached: true,
255
+ });
256
+
257
+ const timer = setTimeout(() => {
258
+ output += `\n${command} did not finish within ${Math.round(timeoutMs / 1000)} seconds.\n`;
259
+
260
+ try {
261
+ process.kill(-child.pid!, "SIGKILL");
262
+ } catch {
263
+ child.kill("SIGKILL");
264
+ }
265
+ }, timeoutMs);
266
+
267
+ child.stdout.on("data", keep);
268
+ child.stderr.on("data", keep);
269
+ child.once("error", (error) => {
270
+ clearTimeout(timer);
271
+ resolve({ output: `${output}${error.message}\n`, ok: false });
272
+ });
273
+ child.once("close", (code) => {
274
+ clearTimeout(timer);
275
+ resolve(
276
+ code === 0
277
+ ? { output, ok: true }
278
+ : {
279
+ output: `${output}${output.endsWith("\n") || !output ? "" : "\n"}(exit ${code ?? "signal"})\n`,
280
+ ok: false,
281
+ },
282
+ );
283
+ });
284
+ });
285
+ }
@@ -89,6 +89,64 @@ export const CLAUDE_WINDOWS_MCP_ADD = `claude mcp add -s user whiteboard -- ${WI
89
89
  /** Copilot CLI loads the Claude plugin, so on Windows it too registers the server directly. */
90
90
  export const COPILOT_WINDOWS_MCP_ADD = `copilot mcp add whiteboard -- ${WINDOWS_MCP_LAUNCH.command} ${WINDOWS_MCP_LAUNCH.args.join(" ")}`;
91
91
 
92
+ export interface ConnectCommand {
93
+ argv: readonly string[];
94
+ ifAny?: true;
95
+ }
96
+
97
+ export const CONNECT_COMMANDS = {
98
+ claude: [
99
+ {
100
+ argv: ["claude", "plugin", "marketplace", "add", "devdotfast/whiteboard"],
101
+ },
102
+ {
103
+ argv: [
104
+ "claude",
105
+ "plugin",
106
+ "install",
107
+ "whiteboard@devfast",
108
+ "--scope",
109
+ "user",
110
+ ],
111
+ },
112
+ {
113
+ argv: ["claude", "mcp", "remove", "-s", "user", "whiteboard"],
114
+ ifAny: true,
115
+ },
116
+ ],
117
+ codex: [
118
+ {
119
+ argv: ["codex", "plugin", "marketplace", "add", "devdotfast/whiteboard"],
120
+ },
121
+ { argv: ["codex", "plugin", "add", "whiteboard@devfast"] },
122
+ { argv: ["codex", "mcp", "remove", "whiteboard"], ifAny: true },
123
+ ],
124
+ opencode: [
125
+ {
126
+ argv: [
127
+ "opencode",
128
+ "mcp",
129
+ "add",
130
+ "--global",
131
+ "whiteboard",
132
+ "--",
133
+ REVIEW_MCP_LAUNCH.command,
134
+ ...REVIEW_MCP_LAUNCH.args,
135
+ ],
136
+ },
137
+ ],
138
+ pi: [{ argv: ["pi", "install", PI_WHITEBOARD_PACKAGE] }],
139
+ } as const satisfies Record<string, readonly ConnectCommand[]>;
140
+
141
+ function runBlock(commands: readonly ConnectCommand[]): string {
142
+ const lines = commands.map(
143
+ ({ argv, ifAny }) =>
144
+ `${argv.join(" ")}${ifAny ? " # old manual registration, if any" : ""}`,
145
+ );
146
+
147
+ return `Run:\n\n\`\`\`sh\n${lines.join("\n")}\n\`\`\``;
148
+ }
149
+
92
150
  function pluginSteps(
93
151
  target: Exclude<InstallTarget, "cursor">,
94
152
  platform: NodeJS.Platform,
@@ -100,13 +158,9 @@ function pluginSteps(
100
158
  `Run:\n\n\`\`\`sh\nclaude plugin uninstall whiteboard@devfast # its launch cannot start on Windows, if installed\nclaude mcp remove -s user whiteboard # old registration, if any\n${CLAUDE_WINDOWS_MCP_ADD}\n\`\`\``,
101
159
  ];
102
160
 
103
- return [
104
- "Run:\n\n```sh\nclaude plugin marketplace add devdotfast/whiteboard\nclaude plugin install whiteboard@devfast --scope user\nclaude mcp remove -s user whiteboard # old manual registration, if any\n```",
105
- ];
161
+ return [runBlock(CONNECT_COMMANDS.claude)];
106
162
  case "codex":
107
- return [
108
- "Run:\n\n```sh\ncodex plugin marketplace add devdotfast/whiteboard\ncodex plugin add whiteboard@devfast\ncodex mcp remove whiteboard # old manual registration, if any\n```",
109
- ];
163
+ return [runBlock(CONNECT_COMMANDS.codex)];
110
164
  case "opencode": {
111
165
  const launch = reviewMcpLaunch(true, platform);
112
166
 
@@ -11,8 +11,10 @@ import {
11
11
  parseJsonText,
12
12
  parseReviewDesktopDiscovery,
13
13
  } from "@dev.fast/review-protocol";
14
+ import { processIsAlive } from "@dev.fast/trace-core";
14
15
 
15
16
  import {
17
+ DEV_REVIEW_HOME_ENV,
16
18
  reviewDefaultInstancePath,
17
19
  reviewDevInstanceKey,
18
20
  reviewInstancesDir,
@@ -183,6 +185,28 @@ export interface ReviewInstanceDependencies {
183
185
  warn?: (message: string) => void;
184
186
  }
185
187
 
188
+ /** Any live Desktop holds the store, attached window or not. Only a gone
189
+ * process counts as stopped: a paused or busy one may not answer /health. */
190
+ export async function desktopUsesHome(
191
+ home: string,
192
+ env: NodeJS.ProcessEnv = process.env,
193
+ ) {
194
+ const desktops = await readReviewInstances({
195
+ env: { ...env, [DEV_REVIEW_HOME_ENV]: home },
196
+ });
197
+
198
+ const [problem] = desktops.broken.values();
199
+
200
+ if (problem)
201
+ throw new Error(
202
+ `Cannot tell whether a Whiteboard Desktop is using ${home}: ${problem.message}`,
203
+ );
204
+
205
+ return desktops.instances.some(({ discovery }) =>
206
+ [discovery.appPid, discovery.serverPid].some((pid) => processIsAlive(pid)),
207
+ );
208
+ }
209
+
186
210
  /** Records by key, plus the files that could not be read, by key. */
187
211
  export async function readReviewInstances(
188
212
  dependencies: ReviewInstanceDependencies,
@@ -0,0 +1,118 @@
1
+ // Plain Node and zod only: apps/review-desktop/scripts imports this file directly.
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream, createWriteStream } from "node:fs";
4
+ import { mkdir, rename, rm } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { Readable } from "node:stream";
7
+ import { pipeline } from "node:stream/promises";
8
+ import type { ReadableStream as WebReadableStream } from "node:stream/web";
9
+
10
+ import { z } from "zod";
11
+
12
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
13
+
14
+ interface DownloadOptions {
15
+ size?: number;
16
+ timeoutMs?: number;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ /** Streams `url` into `file` through a temporary file and returns its sha256. */
21
+ export async function downloadFile(
22
+ url: string,
23
+ file: string,
24
+ { size, timeoutMs = DOWNLOAD_TIMEOUT_MS, signal }: DownloadOptions = {},
25
+ ) {
26
+ const { host } = new URL(url);
27
+ const partial = `${file}.${process.pid}.part`;
28
+ let response: Response;
29
+
30
+ await mkdir(path.dirname(file), { recursive: true });
31
+
32
+ try {
33
+ response = await fetch(url, {
34
+ signal: AbortSignal.any([
35
+ AbortSignal.timeout(timeoutMs),
36
+ ...(signal ? [signal] : []),
37
+ ]),
38
+ });
39
+ } catch (error) {
40
+ throw new Error(
41
+ `Network error reaching ${host}: ${error instanceof Error ? networkCause(error) : String(error)}`,
42
+ );
43
+ }
44
+
45
+ if (!response.ok || !response.body)
46
+ throw new Error(`${host} answered ${response.status} for ${url}`);
47
+
48
+ const hash = createHash("sha256");
49
+ let received = 0;
50
+
51
+ const tooLarge = new Error(`${url} is larger than its pinned ${size} bytes`);
52
+
53
+ try {
54
+ await pipeline(
55
+ // SAFETY: Node's fetch body is its own web stream; the DOM type only
56
+ // names the same object.
57
+ Readable.fromWeb(response.body as WebReadableStream),
58
+ async function* (chunks: AsyncIterable<Buffer>) {
59
+ for await (const chunk of chunks) {
60
+ received += chunk.length;
61
+
62
+ if (size !== undefined && received > size) throw tooLarge;
63
+
64
+ hash.update(chunk);
65
+ yield chunk;
66
+ }
67
+ },
68
+ createWriteStream(partial),
69
+ );
70
+ await rename(partial, file);
71
+ } catch (error) {
72
+ await rm(partial, { force: true });
73
+
74
+ if (error === tooLarge) throw error;
75
+
76
+ throw new Error(
77
+ `Network error downloading from ${host}: ${error instanceof Error ? networkCause(error) : String(error)}`,
78
+ );
79
+ }
80
+
81
+ return hash.digest("hex");
82
+ }
83
+
84
+ /** Leaves a matching `file` in place; otherwise downloads it and checks the pin. */
85
+ export async function downloadPinned(
86
+ pin: { url: string; sha256: string; size?: number },
87
+ file: string,
88
+ options: Omit<DownloadOptions, "size"> = {},
89
+ ) {
90
+ if ((await sha256File(file).catch(() => undefined)) === pin.sha256) return;
91
+
92
+ const actual = await downloadFile(pin.url, file, {
93
+ ...options,
94
+ size: pin.size,
95
+ });
96
+
97
+ if (actual !== pin.sha256) {
98
+ await rm(file, { force: true });
99
+ throw new Error(
100
+ `Checksum mismatch for ${pin.url}: expected ${pin.sha256}, got ${actual}. The download was deleted.`,
101
+ );
102
+ }
103
+ }
104
+
105
+ export async function sha256File(file: string) {
106
+ const hash = createHash("sha256");
107
+
108
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
109
+
110
+ return hash.digest("hex");
111
+ }
112
+
113
+ const networkCauseSchema = z.object({ code: z.string() });
114
+
115
+ const networkCause = (error: Error) =>
116
+ error.name === "TimeoutError"
117
+ ? "timed out"
118
+ : (networkCauseSchema.safeParse(error.cause).data?.code ?? error.message);
@@ -0,0 +1,130 @@
1
+ import { gt as greaterVersion, valid as validVersion } from "semver";
2
+
3
+ import { readReviewPackageVersion } from "./package-paths";
4
+ import {
5
+ type EnsureRemoteLanguageServerInput,
6
+ ensureRemoteLanguageServer,
7
+ } from "./remote-language-server";
8
+ import { missingToolchains } from "./remote-toolchains";
9
+ import { readReviewServerHealth, serverNotReady } from "./server-discovery";
10
+ import {
11
+ type EnsureBackgroundServerInput,
12
+ ensureBackgroundServer,
13
+ recordedBackgroundServer,
14
+ stopBackgroundServer,
15
+ } from "./server/background-server";
16
+
17
+ export async function remoteAttach(input: {
18
+ stateDir: string;
19
+ env: NodeJS.ProcessEnv;
20
+ packageRoot?: string;
21
+ cli?: EnsureBackgroundServerInput["cli"];
22
+ groups?: string[];
23
+ ensureExtensions?: EnsureRemoteLanguageServerInput["ensure"];
24
+ installTimeoutMs?: number;
25
+ replace?: boolean;
26
+ version?: string;
27
+ }) {
28
+ const extensions = new AbortController();
29
+
30
+ const language = ensureRemoteLanguageServer({
31
+ env: input.env,
32
+ packageRoot: input.packageRoot,
33
+ groups: input.groups,
34
+ signal: extensions.signal,
35
+ ensure: input.ensureExtensions,
36
+ installTimeoutMs: input.installTimeoutMs,
37
+ cli: input.cli,
38
+ });
39
+
40
+ const toolchains = missingToolchains(input.groups ?? [], input.env);
41
+
42
+ let server: Awaited<ReturnType<typeof ensureBackgroundServer>>;
43
+ let previousVersion: string | undefined;
44
+
45
+ let incompatibleRunning:
46
+ | { version: string; pid: number; startedBy: "user" | "cli" | "desktop" }
47
+ | undefined;
48
+
49
+ try {
50
+ const version = input.version ?? readReviewPackageVersion(import.meta.url);
51
+
52
+ const other = input.replace
53
+ ? await otherVersionRunning(input.stateDir, version)
54
+ : undefined;
55
+
56
+ if (other && (other.startedBy === "user" || newer(other.version, version)))
57
+ incompatibleRunning = other;
58
+ else if (other) {
59
+ await stopBackgroundServer({ serverPid: other.pid });
60
+ previousVersion = other.version;
61
+ }
62
+
63
+ server = await ensureBackgroundServer({
64
+ stateDir: input.stateDir,
65
+ env: input.env,
66
+ startedBy: "desktop",
67
+ cli: input.cli,
68
+ });
69
+ } catch (error) {
70
+ extensions.abort();
71
+ await Promise.all([language, toolchains]);
72
+ throw error;
73
+ }
74
+
75
+ const { discovery, started } = server;
76
+
77
+ const {
78
+ languageServer,
79
+ languageServerDetail,
80
+ languageServerPending,
81
+ languageGroups,
82
+ } = await language;
83
+
84
+ const missing = await toolchains;
85
+
86
+ const health = await readReviewServerHealth(discovery);
87
+
88
+ if (!health) throw serverNotReady(input.stateDir);
89
+
90
+ return {
91
+ event: "remote.attach" as const,
92
+ version: health.version ?? null,
93
+ commit: health.commit ?? null,
94
+ serverId: health.serverId ?? null,
95
+ url: discovery.url,
96
+ token: discovery.token,
97
+ startedServer: started,
98
+ languageServer,
99
+ ...(languageServerDetail !== undefined && { languageServerDetail }),
100
+ ...(languageServerPending && { languageServerPending }),
101
+ languageGroups: languageGroups.map(({ group, installed, detail }) => {
102
+ const details = [detail, missing.get(group)].filter(Boolean).join("; ");
103
+
104
+ return { group, installed, ...(details && { detail: details }) };
105
+ }),
106
+ ...(previousVersion !== undefined && { replaced: true, previousVersion }),
107
+ ...(incompatibleRunning && { incompatibleRunning }),
108
+ };
109
+ }
110
+
111
+ const newer = (running: string, own: string) =>
112
+ validVersion(running) !== null &&
113
+ validVersion(own) !== null &&
114
+ greaterVersion(running, own);
115
+
116
+ async function otherVersionRunning(stateDir: string, version: string) {
117
+ const recorded = await recordedBackgroundServer(stateDir);
118
+
119
+ if (!recorded) return undefined;
120
+ const { discovery, health } = recorded;
121
+ const running = health.version ?? "unknown";
122
+
123
+ return running === version
124
+ ? undefined
125
+ : {
126
+ version: running,
127
+ pid: discovery.serverPid,
128
+ startedBy: discovery.startedBy,
129
+ };
130
+ }