@vellumai/assistant 0.11.6-dev.202608271738.c4a1b33 → 0.11.6-dev.202608271936.fef1c19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.6-dev.202608271738.c4a1b33",
3
+ "version": "0.11.6-dev.202608271936.fef1c19",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@ import {
36
36
  writeFileSync,
37
37
  } from "node:fs";
38
38
  import { tmpdir } from "node:os";
39
- import { dirname, join, resolve, sep } from "node:path";
39
+ import { delimiter, dirname, join, resolve, sep } from "node:path";
40
40
  import { promisify } from "node:util";
41
41
 
42
42
  import {
@@ -46,7 +46,12 @@ import {
46
46
  stripPreservedUserState,
47
47
  } from "../../plugins/plugin-tree-walk.js";
48
48
  import { ensureBun } from "../../util/bun-runtime.js";
49
- import { getWorkspacePluginsDir } from "../../util/platform.js";
49
+ import {
50
+ addToPathEnv,
51
+ getExtraToolPathDirs,
52
+ getWorkspacePluginsDir,
53
+ isWindows,
54
+ } from "../../util/platform.js";
50
55
  import type { FetchLike } from "./fetch-like.js";
51
56
  import {
52
57
  type DependencyInstaller,
@@ -1258,19 +1263,18 @@ export const defaultPostinstallRunner: PostinstallRunner = async ({
1258
1263
  };
1259
1264
 
1260
1265
  function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
1266
+ const systemDirs = isWindows()
1267
+ ? [process.env.SystemRoot ?? "C:\\Windows"]
1268
+ : [...getExtraToolPathDirs(), "/usr/bin", "/bin"];
1261
1269
  const env: NodeJS.ProcessEnv = {
1262
- PATH: [
1263
- dirname(bun),
1264
- "/opt/homebrew/bin",
1265
- "/usr/local/bin",
1266
- "/usr/bin",
1267
- "/bin",
1268
- ]
1269
- .filter(Boolean)
1270
- .join(":"),
1270
+ PATH: [dirname(bun), ...systemDirs].join(delimiter),
1271
1271
  };
1272
- if (process.env.HOME) {
1273
- env.HOME = process.env.HOME;
1272
+ for (const key of isWindows()
1273
+ ? ["USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "SystemRoot"]
1274
+ : ["HOME"]) {
1275
+ if (process.env[key]) {
1276
+ env[key] = process.env[key];
1277
+ }
1274
1278
  }
1275
1279
  return env;
1276
1280
  }
@@ -1385,12 +1389,7 @@ function pluginGitEnv(): NodeJS.ProcessEnv {
1385
1389
  }
1386
1390
  }
1387
1391
  env.GIT_TERMINAL_PROMPT = "0";
1388
- const extraPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
1389
- const current = (env.PATH ?? "").split(":").filter(Boolean);
1390
- const missing = extraPaths.filter((p) => !current.includes(p));
1391
- if (missing.length > 0) {
1392
- env.PATH = [...current, ...missing].join(":");
1393
- }
1392
+ addToPathEnv(env, getExtraToolPathDirs(), "back");
1394
1393
  return env;
1395
1394
  }
1396
1395
 
@@ -59,6 +59,7 @@ import { promisify } from "node:util";
59
59
 
60
60
  import { ensureBun } from "../../util/bun-runtime.js";
61
61
  import { getLogger } from "../../util/logger.js";
62
+ import { addToPathEnv } from "../../util/platform.js";
62
63
 
63
64
  const execFileAsync = promisify(execFile);
64
65
 
@@ -331,10 +332,6 @@ export const defaultDependencyInstaller: DependencyInstaller = async ({
331
332
  */
332
333
  function dependencyInstallEnv(bun: string): NodeJS.ProcessEnv {
333
334
  const env: NodeJS.ProcessEnv = { ...process.env };
334
- const bunDir = dirname(bun);
335
- const current = (env.PATH ?? "").split(":").filter(Boolean);
336
- if (!current.includes(bunDir)) {
337
- env.PATH = [bunDir, ...current].join(":");
338
- }
335
+ addToPathEnv(env, [dirname(bun)]);
339
336
  return env;
340
337
  }
@@ -412,6 +412,14 @@
412
412
  "label": "monday.com Integration",
413
413
  "description": "Gates the seeded monday.com OAuth provider. When off, the provider is hidden from GET /v1/oauth/providers, its get-by-id route, and the connect/update routes, so it cannot be listed or connected from the CLI, gateway, or web integrations list. The row is still seeded into oauth_providers on startup; only its visibility changes.",
414
414
  "defaultEnabled": false
415
+ },
416
+ {
417
+ "id": "chatgpt-device-code-login",
418
+ "scope": "client",
419
+ "key": "chatgpt-device-code-login",
420
+ "label": "ChatGPT Device Code Login",
421
+ "description": "Gates the device-code sign-in as the primary ChatGPT subscription connect flow in the web client. Off: the redirect-and-paste flow is the only flow the connect section shows, with no device code UI and no other-sign-in-options disclosure. On: device code leads, and the redirect-and-paste flow stays reachable behind that disclosure and takes the section over when the assistant has no device-auth route.",
422
+ "defaultEnabled": false
415
423
  }
416
424
  ]
417
425
  }
@@ -3,6 +3,7 @@ import {
3
3
  beforeEach,
4
4
  describe,
5
5
  expect,
6
+ jest,
6
7
  mock,
7
8
  setSystemTime,
8
9
  test,
@@ -66,14 +67,30 @@ function makeQueue(
66
67
  return queue;
67
68
  }
68
69
 
70
+ const FROZEN_NOW = new Date("2026-01-01T00:00:00.000Z").getTime();
71
+
72
+ /**
73
+ * Flush already-queued microtasks. Fake timers stop the clock but not the
74
+ * promise chain `enqueue` appends onto, so the PATCH work a timer kicks off
75
+ * lands here.
76
+ */
69
77
  async function settle(): Promise<void> {
70
- for (let i = 0; i < 10; i++) {
71
- await new Promise((r) => setTimeout(r, 5));
78
+ for (let i = 0; i < 20; i += 1) {
79
+ await Promise.resolve();
72
80
  }
73
81
  }
74
82
 
83
+ /** Advance `Date.now()` and the queue's `setTimeout` handles together. */
84
+ async function elapse(ms: number): Promise<void> {
85
+ setSystemTime(new Date(Date.now() + ms));
86
+ jest.advanceTimersByTime(ms);
87
+ await settle();
88
+ }
89
+
75
90
  describe("createPlatformPatchQueue", () => {
76
91
  beforeEach(() => {
92
+ jest.useFakeTimers();
93
+ setSystemTime(new Date(FROZEN_NOW));
77
94
  patches = [];
78
95
  builds = 0;
79
96
  respond = () => new Response("{}", { status: 200 });
@@ -85,6 +102,8 @@ describe("createPlatformPatchQueue", () => {
85
102
  for (const queue of queues) {
86
103
  queue.dispose();
87
104
  }
105
+ jest.useRealTimers();
106
+ setSystemTime();
88
107
  });
89
108
 
90
109
  test("PATCHes once and dedups an unchanged key", async () => {
@@ -203,21 +222,16 @@ describe("createPlatformPatchQueue", () => {
203
222
  saveSyncedKey: (synced) => saved.push(synced),
204
223
  });
205
224
  const start = Date.now();
206
- setSystemTime(new Date(start));
207
- try {
208
- queue.enqueue("a");
209
- await settle();
210
- setSystemTime(new Date(start + 999));
211
- queue.enqueue("a");
212
- await settle();
213
- setSystemTime(new Date(start + 1000));
214
- queue.enqueue("a");
215
- await settle();
216
- queue.enqueue("a");
217
- await settle();
218
- } finally {
219
- setSystemTime();
220
- }
225
+ queue.enqueue("a");
226
+ await settle();
227
+ setSystemTime(new Date(start + 999));
228
+ queue.enqueue("a");
229
+ await settle();
230
+ setSystemTime(new Date(start + 1000));
231
+ queue.enqueue("a");
232
+ await settle();
233
+ queue.enqueue("a");
234
+ await settle();
221
235
 
222
236
  expect(patches).toHaveLength(2);
223
237
  expect(saved.map((s) => s.syncedAt)).toEqual([start, start + 1000]);
@@ -243,14 +257,12 @@ describe("createPlatformPatchQueue", () => {
243
257
  await settle();
244
258
  expect(patches).toHaveLength(1);
245
259
 
246
- await new Promise((r) => setTimeout(r, 300));
247
- await settle();
260
+ await elapse(300);
248
261
  expect(patches).toHaveLength(2);
249
262
  expect(builds).toBe(2);
250
263
 
251
264
  queue.dispose();
252
- await new Promise((r) => setTimeout(r, 300));
253
- await settle();
265
+ await elapse(300);
254
266
  expect(patches).toHaveLength(2);
255
267
  });
256
268
 
@@ -266,8 +278,7 @@ describe("createPlatformPatchQueue", () => {
266
278
  await settle();
267
279
  expect(patches).toHaveLength(0);
268
280
 
269
- await new Promise((r) => setTimeout(r, 300));
270
- await settle();
281
+ await elapse(300);
271
282
  expect(patches).toHaveLength(1);
272
283
  });
273
284
 
@@ -279,12 +290,10 @@ describe("createPlatformPatchQueue", () => {
279
290
  expect(patches).toHaveLength(1);
280
291
 
281
292
  respond = () => new Response("{}", { status: 200 });
282
- await new Promise((r) => setTimeout(r, 100));
283
- await settle();
293
+ await elapse(100);
284
294
  expect(patches).toHaveLength(2);
285
295
 
286
- await new Promise((r) => setTimeout(r, 100));
287
- await settle();
296
+ await elapse(100);
288
297
  queue.enqueue("a");
289
298
  await settle();
290
299
  expect(patches).toHaveLength(2);
@@ -301,8 +310,7 @@ describe("createPlatformPatchQueue", () => {
301
310
  queue.enqueue("a");
302
311
  await settle();
303
312
  mockClient = makeClient();
304
- await new Promise((r) => setTimeout(r, 100));
305
- await settle();
313
+ await elapse(100);
306
314
 
307
315
  expect(patches).toHaveLength(1);
308
316
  });
@@ -312,14 +320,11 @@ describe("createPlatformPatchQueue", () => {
312
320
  respond = () => new Response("nope", { status: 500 });
313
321
  queue.enqueue("a");
314
322
  await settle();
315
- await new Promise((r) => setTimeout(r, 50));
316
- await settle();
317
- await new Promise((r) => setTimeout(r, 50));
318
- await settle();
323
+ await elapse(50);
324
+ await elapse(50);
319
325
  expect(patches).toHaveLength(3);
320
326
 
321
- await new Promise((r) => setTimeout(r, 100));
322
- await settle();
327
+ await elapse(100);
323
328
  expect(patches).toHaveLength(3);
324
329
  });
325
330
 
@@ -332,12 +337,10 @@ describe("createPlatformPatchQueue", () => {
332
337
  await settle();
333
338
  expect(patches.map((p) => p.body.value)).toEqual(["a", "b"]);
334
339
 
335
- await new Promise((r) => setTimeout(r, 100));
336
- await settle();
340
+ await elapse(100);
337
341
  expect(patches.map((p) => p.body.value)).toEqual(["a", "b", "b"]);
338
342
 
339
- await new Promise((r) => setTimeout(r, 100));
340
- await settle();
343
+ await elapse(100);
341
344
  expect(patches).toHaveLength(3);
342
345
  });
343
346
 
@@ -347,8 +350,7 @@ describe("createPlatformPatchQueue", () => {
347
350
  queue.enqueue("a");
348
351
  await settle();
349
352
  queue.dispose();
350
- await new Promise((r) => setTimeout(r, 300));
351
- await settle();
353
+ await elapse(300);
352
354
 
353
355
  expect(patches).toHaveLength(1);
354
356
  });
@@ -0,0 +1,74 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
+
6
+ import {
7
+ _listSharedAppsForTests,
8
+ _resolveSharedAppDirForTests,
9
+ } from "../app-management-routes.js";
10
+
11
+ let rootDir: string;
12
+ let canonicalDir: string;
13
+ let legacyDir: string;
14
+
15
+ function writeSharedApp(dir: string, uuid: string, name: string): void {
16
+ mkdirSync(join(dir, uuid), { recursive: true });
17
+ writeFileSync(
18
+ join(dir, `${uuid}-meta.json`),
19
+ JSON.stringify({
20
+ uuid,
21
+ name,
22
+ entry: "index.html",
23
+ trustTier: "verified",
24
+ installedAt: "2026-01-01T00:00:00.000Z",
25
+ }),
26
+ );
27
+ writeFileSync(
28
+ join(dir, uuid, "manifest.json"),
29
+ JSON.stringify({ version: "1.0.0", content_id: `content-${uuid}` }),
30
+ );
31
+ }
32
+
33
+ beforeEach(() => {
34
+ rootDir = mkdtempSync(join(tmpdir(), "shared-app-roots-"));
35
+ canonicalDir = join(rootDir, "canonical");
36
+ legacyDir = join(rootDir, "legacy");
37
+ });
38
+
39
+ afterEach(() => {
40
+ rmSync(rootDir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe("shared app roots", () => {
44
+ test("lists canonical and legacy apps after the canonical directory exists", () => {
45
+ writeSharedApp(canonicalDir, "app-new", "New App");
46
+ writeSharedApp(legacyDir, "app-legacy", "Legacy App");
47
+
48
+ const apps = _listSharedAppsForTests([canonicalDir, legacyDir]);
49
+
50
+ expect(apps.map((app) => app.uuid)).toEqual(["app-new", "app-legacy"]);
51
+ });
52
+
53
+ test("prefers the canonical copy when both roots contain the same app", () => {
54
+ writeSharedApp(canonicalDir, "app-shared", "Canonical App");
55
+ writeSharedApp(legacyDir, "app-shared", "Legacy App");
56
+
57
+ const apps = _listSharedAppsForTests([canonicalDir, legacyDir]);
58
+
59
+ expect(apps).toHaveLength(1);
60
+ expect(apps[0]?.name).toBe("Canonical App");
61
+ expect(
62
+ _resolveSharedAppDirForTests("app-shared", [canonicalDir, legacyDir]),
63
+ ).toBe(canonicalDir);
64
+ });
65
+
66
+ test("resolves a legacy app when it is absent from the canonical root", () => {
67
+ mkdirSync(canonicalDir, { recursive: true });
68
+ writeSharedApp(legacyDir, "app-legacy", "Legacy App");
69
+
70
+ expect(
71
+ _resolveSharedAppDirForTests("app-legacy", [canonicalDir, legacyDir]),
72
+ ).toBe(legacyDir);
73
+ });
74
+ });
@@ -54,6 +54,7 @@ import { verifyBundleSignature } from "../../bundler/signature-verifier.js";
54
54
  import { compareSemver } from "../../daemon/handlers/shared.js";
55
55
  import { computeContentId } from "../../util/content-id.js";
56
56
  import { getLogger } from "../../util/logger.js";
57
+ import { getUserAppDataDir } from "../../util/platform.js";
57
58
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
58
59
  import {
59
60
  getOriginClientId,
@@ -73,14 +74,26 @@ const log = getLogger("app-management-routes");
73
74
  // Helpers
74
75
  // ---------------------------------------------------------------------------
75
76
 
76
- function getSharedAppsDir(): string {
77
- return join(
77
+ function getSharedAppsDirs(): string[] {
78
+ const canonical = join(
79
+ getUserAppDataDir(),
80
+ "vellum-assistant",
81
+ "shared-apps",
82
+ );
83
+ const legacy = join(
78
84
  homedir(),
79
85
  "Library",
80
86
  "Application Support",
81
87
  "vellum-assistant",
82
88
  "shared-apps",
83
89
  );
90
+ return legacy === canonical ? [canonical] : [canonical, legacy];
91
+ }
92
+
93
+ function resolveSharedAppDir(appUuid: string, dirs: string[]): string | null {
94
+ return (
95
+ dirs.find((dir) => existsSync(join(dir, `${appUuid}-meta.json`))) ?? null
96
+ );
84
97
  }
85
98
 
86
99
  // ---------------------------------------------------------------------------
@@ -218,13 +231,9 @@ function getAppDataResult(
218
231
  }
219
232
  }
220
233
 
221
- function listSharedApps(): Array<Record<string, unknown>> {
222
- const dir = getSharedAppsDir();
223
- if (!existsSync(dir)) {
224
- return [];
225
- }
226
-
227
- const files = readdirSync(dir).filter((f) => f.endsWith("-meta.json"));
234
+ function listSharedApps(
235
+ dirs = getSharedAppsDirs(),
236
+ ): Array<Record<string, unknown>> {
228
237
  const apps: Array<{
229
238
  uuid: string;
230
239
  name: string;
@@ -240,43 +249,54 @@ function listSharedApps(): Array<Record<string, unknown>> {
240
249
  contentId?: string;
241
250
  forked?: boolean;
242
251
  }> = [];
252
+ const seen = new Set<string>();
243
253
 
244
- for (const file of files) {
245
- try {
246
- const raw = readFileSync(join(dir, file), "utf-8");
247
- const meta = JSON.parse(raw);
248
-
249
- let version: string | undefined;
250
- let contentId: string | undefined;
251
- const manifestPath = join(dir, meta.uuid, "manifest.json");
252
- if (existsSync(manifestPath)) {
253
- try {
254
- const manifestRaw = readFileSync(manifestPath, "utf-8");
255
- const manifest = JSON.parse(manifestRaw);
256
- version = manifest.version;
257
- contentId = manifest.content_id;
258
- } catch {
259
- // ignore malformed manifest
254
+ for (const dir of dirs) {
255
+ if (!existsSync(dir)) {
256
+ continue;
257
+ }
258
+ const files = readdirSync(dir).filter((f) => f.endsWith("-meta.json"));
259
+ for (const file of files) {
260
+ try {
261
+ const raw = readFileSync(join(dir, file), "utf-8");
262
+ const meta = JSON.parse(raw);
263
+ if (seen.has(meta.uuid)) {
264
+ continue;
260
265
  }
261
- }
262
266
 
263
- apps.push({
264
- uuid: meta.uuid,
265
- name: meta.name,
266
- description: meta.description,
267
- icon: meta.icon,
268
- preview: meta.preview,
269
- entry: meta.entry,
270
- trustTier: meta.trustTier,
271
- signerDisplayName: meta.signerDisplayName,
272
- bundleSizeBytes: meta.bundleSizeBytes ?? 0,
273
- installedAt: meta.installedAt,
274
- version,
275
- contentId,
276
- forked: meta.forked,
277
- });
278
- } catch {
279
- log.warn({ file }, "Failed to read shared app metadata file");
267
+ let version: string | undefined;
268
+ let contentId: string | undefined;
269
+ const manifestPath = join(dir, meta.uuid, "manifest.json");
270
+ if (existsSync(manifestPath)) {
271
+ try {
272
+ const manifestRaw = readFileSync(manifestPath, "utf-8");
273
+ const manifest = JSON.parse(manifestRaw);
274
+ version = manifest.version;
275
+ contentId = manifest.content_id;
276
+ } catch {
277
+ // ignore malformed manifest
278
+ }
279
+ }
280
+
281
+ apps.push({
282
+ uuid: meta.uuid,
283
+ name: meta.name,
284
+ description: meta.description,
285
+ icon: meta.icon,
286
+ preview: meta.preview,
287
+ entry: meta.entry,
288
+ trustTier: meta.trustTier,
289
+ signerDisplayName: meta.signerDisplayName,
290
+ bundleSizeBytes: meta.bundleSizeBytes ?? 0,
291
+ installedAt: meta.installedAt,
292
+ version,
293
+ contentId,
294
+ forked: meta.forked,
295
+ });
296
+ seen.add(meta.uuid);
297
+ } catch {
298
+ log.warn({ file }, "Failed to read shared app metadata file");
299
+ }
280
300
  }
281
301
  }
282
302
 
@@ -314,6 +334,7 @@ function listSharedApps(): Array<Record<string, unknown>> {
314
334
 
315
335
  function forkSharedApp(
316
336
  appUuid: string,
337
+ dirs = getSharedAppsDirs(),
317
338
  ):
318
339
  | { success: true; appId: string; name: string }
319
340
  | { success: false; error: string } {
@@ -326,12 +347,11 @@ function forkSharedApp(
326
347
  return { success: false, error: "Invalid UUID" };
327
348
  }
328
349
 
329
- const dir = getSharedAppsDir();
330
- const metaFile = join(dir, `${appUuid}-meta.json`);
331
-
332
- if (!existsSync(metaFile)) {
350
+ const dir = resolveSharedAppDir(appUuid, dirs);
351
+ if (!dir) {
333
352
  return { success: false, error: "Shared app not found" };
334
353
  }
354
+ const metaFile = join(dir, `${appUuid}-meta.json`);
335
355
 
336
356
  const metaRaw = readFileSync(metaFile, "utf-8");
337
357
  const meta = JSON.parse(metaRaw);
@@ -392,6 +412,19 @@ function forkSharedApp(
392
412
  return { success: true, appId: newApp.id, name: newApp.name };
393
413
  }
394
414
 
415
+ export function _listSharedAppsForTests(
416
+ dirs: string[],
417
+ ): Array<Record<string, unknown>> {
418
+ return listSharedApps(dirs);
419
+ }
420
+
421
+ export function _resolveSharedAppDirForTests(
422
+ appUuid: string,
423
+ dirs: string[],
424
+ ): string | null {
425
+ return resolveSharedAppDir(appUuid, dirs);
426
+ }
427
+
395
428
  async function openBundle(filePath: string): Promise<Record<string, unknown>> {
396
429
  const fileStat = await stat(filePath);
397
430
  const bundleSizeBytes = fileStat.size;
@@ -22,7 +22,7 @@ import { arch, homedir, platform } from "node:os";
22
22
  import { basename, join } from "node:path";
23
23
 
24
24
  import { getLogger } from "./logger.js";
25
- import { getBinDir } from "./platform.js";
25
+ import { getBinDir, isWindows } from "./platform.js";
26
26
 
27
27
  const log = getLogger("bun-runtime");
28
28
 
@@ -81,19 +81,31 @@ export function findBun(): string | undefined {
81
81
  return process.execPath;
82
82
  }
83
83
 
84
+ const exe = isWindows() ? "bun.exe" : "bun";
85
+
84
86
  // 2. Previously downloaded copy
85
- const downloaded = join(getBinDir(), "bun");
87
+ const downloaded = join(getBinDir(), exe);
86
88
  if (existsSync(downloaded)) {
87
89
  return downloaded;
88
90
  }
89
91
 
90
92
  // 3. Common install locations
91
93
  const home = homedir();
92
- for (const p of [
93
- join(home, ".bun", "bin", "bun"),
94
- "/opt/homebrew/bin/bun",
95
- "/usr/local/bin/bun",
96
- ]) {
94
+ const candidates = isWindows()
95
+ ? [
96
+ join(home, ".bun", "bin", exe),
97
+ join(
98
+ process.env.LOCALAPPDATA ?? join(home, "AppData", "Local"),
99
+ "bun",
100
+ exe,
101
+ ),
102
+ ]
103
+ : [
104
+ join(home, ".bun", "bin", exe),
105
+ "/opt/homebrew/bin/bun",
106
+ "/usr/local/bin/bun",
107
+ ];
108
+ for (const p of candidates) {
97
109
  if (existsSync(p)) {
98
110
  return p;
99
111
  }
@@ -108,13 +120,20 @@ export function findBun(): string | undefined {
108
120
  return undefined;
109
121
  }
110
122
 
123
+ /** Escape a string for a PowerShell single-quoted literal. */
124
+ function psQuote(value: string): string {
125
+ return value.replace(/'/g, "''");
126
+ }
127
+
111
128
  /**
112
129
  * Download a pinned bun release into the given directory.
113
130
  * Returns the absolute path to the downloaded binary.
114
131
  */
115
132
  async function downloadBun(installDir: string): Promise<string> {
116
- const os = platform();
117
- const cpu = arch() === "arm64" ? "aarch64" : arch();
133
+ const win = isWindows();
134
+ const os = win ? "windows" : platform();
135
+ // Bun ships no Windows ARM64 build; the x64 one runs under emulation.
136
+ const cpu = win ? "x64" : arch() === "arm64" ? "aarch64" : arch();
118
137
  const target = `${os}-${cpu}`;
119
138
  const url = `https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-${target}.zip`;
120
139
 
@@ -135,7 +154,15 @@ async function downloadBun(installDir: string): Promise<string> {
135
154
 
136
155
  try {
137
156
  const proc = Bun.spawn({
138
- cmd: ["unzip", "-o", tmpZip, "-d", installDir],
157
+ cmd: win
158
+ ? [
159
+ "powershell.exe",
160
+ "-NoProfile",
161
+ "-NonInteractive",
162
+ "-Command",
163
+ `Expand-Archive -LiteralPath '${psQuote(tmpZip)}' -DestinationPath '${psQuote(installDir)}' -Force`,
164
+ ]
165
+ : ["unzip", "-o", tmpZip, "-d", installDir],
139
166
  stdout: "ignore",
140
167
  stderr: "pipe",
141
168
  });
@@ -146,8 +173,9 @@ async function downloadBun(installDir: string): Promise<string> {
146
173
  }
147
174
 
148
175
  // Move binary from bun-{target}/bun to installDir/bun
149
- const extractedBun = join(installDir, `bun-${target}`, "bun");
150
- const finalPath = join(installDir, "bun");
176
+ const exe = win ? "bun.exe" : "bun";
177
+ const extractedBun = join(installDir, `bun-${target}`, exe);
178
+ const finalPath = join(installDir, exe);
151
179
  if (existsSync(extractedBun)) {
152
180
  // Remove old binary first to avoid "Text file busy" on some systems
153
181
  if (existsSync(finalPath)) {
@@ -164,7 +192,9 @@ async function downloadBun(installDir: string): Promise<string> {
164
192
  );
165
193
  }
166
194
 
167
- chmodSync(finalPath, 0o755);
195
+ if (!win) {
196
+ chmodSync(finalPath, 0o755);
197
+ }
168
198
 
169
199
  log.info({ path: finalPath }, "Bun binary downloaded successfully");
170
200
  return finalPath;
@@ -1,6 +1,14 @@
1
1
  import { chmodSync, existsSync, mkdirSync, realpathSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import {
4
+ basename,
5
+ delimiter,
6
+ dirname,
7
+ isAbsolute,
8
+ join,
9
+ relative,
10
+ sep,
11
+ } from "node:path";
4
12
 
5
13
  import {
6
14
  AVATAR_IMAGE_FILENAME,
@@ -54,6 +62,62 @@ export function isWindows(): boolean {
54
62
  return process.platform === "win32";
55
63
  }
56
64
 
65
+ /**
66
+ * Per-user application data root: `~/Library/Application Support` on macOS,
67
+ * `%APPDATA%` on Windows, `$XDG_DATA_HOME` (default `~/.local/share`) elsewhere.
68
+ */
69
+ export function getUserAppDataDir(): string {
70
+ if (isMacOS()) {
71
+ return join(homedir(), "Library", "Application Support");
72
+ }
73
+ if (isWindows()) {
74
+ return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
75
+ }
76
+ return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
77
+ }
78
+
79
+ /**
80
+ * Extra directories to prepend to PATH so daemon-spawned tools are found
81
+ * even when launched from a minimal environment (macOS .app bundle). Empty
82
+ * on Windows, where PATH already carries the installer-managed entries.
83
+ */
84
+ export function getExtraToolPathDirs(): string[] {
85
+ if (isWindows()) {
86
+ return [];
87
+ }
88
+ return [
89
+ "/opt/homebrew/bin",
90
+ "/usr/local/bin",
91
+ join(homedir(), ".local", "bin"),
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * Add missing `dirs` to the PATH entry of `env` in place, using the platform
97
+ * delimiter. Windows keys the variable as `Path`, so the existing key is
98
+ * matched case-insensitively rather than assuming `PATH`.
99
+ */
100
+ export function addToPathEnv(
101
+ env: Record<string, string | undefined>,
102
+ dirs: string[],
103
+ position: "front" | "back" = "front",
104
+ ): void {
105
+ if (dirs.length === 0) {
106
+ return;
107
+ }
108
+ const key =
109
+ Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH";
110
+ const entries = (env[key] ?? "").split(delimiter).filter(Boolean);
111
+ const missing = dirs.filter((d) => !entries.includes(d));
112
+ if (missing.length === 0) {
113
+ return;
114
+ }
115
+ env[key] =
116
+ position === "front"
117
+ ? [...missing, ...entries].join(delimiter)
118
+ : [...entries, ...missing].join(delimiter);
119
+ }
120
+
57
121
  /**
58
122
  * Returns the raw platform string from Node.js (e.g. 'darwin', 'linux', 'win32').
59
123
  * Prefer this over accessing process.platform directly so all platform
@@ -21,6 +21,10 @@
21
21
  */
22
22
 
23
23
  import { existsSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { isWindows } from "./platform.js";
24
28
 
25
29
  let cachedSqlite3Path: string | undefined | null;
26
30
 
@@ -37,11 +41,27 @@ export function findSqlite3(): string | undefined {
37
41
  // 1. Common install locations. We check these before `Bun.which` so we
38
42
  // pick a deterministic, predictable binary even when PATH is
39
43
  // unusual (e.g. a daemon launched with a stripped env).
40
- for (const p of [
41
- "/usr/bin/sqlite3",
42
- "/usr/local/bin/sqlite3",
43
- "/opt/homebrew/bin/sqlite3",
44
- ]) {
44
+ const candidates = isWindows()
45
+ ? [
46
+ join(
47
+ process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"),
48
+ "Programs",
49
+ "sqlite",
50
+ "sqlite3.exe",
51
+ ),
52
+ join(
53
+ process.env.ProgramFiles ?? "C:\\Program Files",
54
+ "SQLite",
55
+ "sqlite3.exe",
56
+ ),
57
+ "C:\\sqlite\\sqlite3.exe",
58
+ ]
59
+ : [
60
+ "/usr/bin/sqlite3",
61
+ "/usr/local/bin/sqlite3",
62
+ "/opt/homebrew/bin/sqlite3",
63
+ ];
64
+ for (const p of candidates) {
45
65
  if (existsSync(p)) {
46
66
  cachedSqlite3Path = p;
47
67
  return p;
@@ -15,6 +15,7 @@ import { getConfig } from "../config/loader.js";
15
15
  import { isFileUnheld } from "../util/file-use.js";
16
16
  import { getLogger } from "../util/logger.js";
17
17
  import { Mutex } from "../util/mutex.js";
18
+ import { addToPathEnv, getExtraToolPathDirs } from "../util/platform.js";
18
19
  import { PromiseGuard } from "../util/promise-guard.js";
19
20
 
20
21
  const execFileAsync = promisify(execFile);
@@ -41,18 +42,7 @@ function cleanGitEnv(workspaceDir: string): Record<string, string> {
41
42
  }
42
43
  env.GIT_CEILING_DIRECTORIES = workspaceDir;
43
44
 
44
- const home = process.env.HOME ?? "";
45
- const extraDirs = [
46
- "/opt/homebrew/bin",
47
- "/usr/local/bin",
48
- `${home}/.local/bin`,
49
- ];
50
- const currentPath = env.PATH ?? "";
51
- const pathDirs = currentPath.split(":");
52
- const missing = extraDirs.filter((d) => !pathDirs.includes(d));
53
- if (missing.length > 0) {
54
- env.PATH = [...missing, currentPath].filter(Boolean).join(":");
55
- }
45
+ addToPathEnv(env, getExtraToolPathDirs());
56
46
 
57
47
  return env;
58
48
  }