@vellumai/assistant 0.4.41 → 0.4.42

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.4.41",
3
+ "version": "0.4.42",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "assistant": "./src/index.ts"
@@ -1,19 +1,21 @@
1
1
  /**
2
- * esbuild wrapper for compiling multi-file TSX apps.
2
+ * Compiler for multi-file TSX apps.
3
3
  *
4
- * Compiles src/main.tsx dist/main.js, copies index.html with
5
- * script/style tag injection, and returns structured diagnostics.
4
+ * Shells out to the esbuild CLI binary (JIT-downloaded on first use) to
5
+ * compile src/main.tsx -> dist/main.js, then copies index.html with
6
+ * script/style tag injection.
7
+ *
8
+ * This avoids importing esbuild's JS API (which caches its native binary
9
+ * path at module load time and breaks inside bun --compile's /$bunfs/).
6
10
  */
7
11
 
8
12
  import { existsSync, rmSync } from "node:fs";
9
13
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
10
- import { join, resolve } from "node:path";
11
-
12
- import { build, type Message, type Plugin } from "esbuild";
14
+ import { dirname, join } from "node:path";
13
15
 
14
16
  import { getLogger } from "../util/logger.js";
17
+ import { ensureCompilerTools } from "./compiler-tools.js";
15
18
  import {
16
- ALLOWED_PACKAGES,
17
19
  getCacheDir,
18
20
  isBareImport,
19
21
  packageName,
@@ -34,19 +36,70 @@ export interface CompileResult {
34
36
  durationMs: number;
35
37
  }
36
38
 
37
- function mapDiagnostics(messages: Message[]): CompileDiagnostic[] {
38
- return messages.map((msg) => ({
39
- text: msg.text,
40
- ...(msg.location
41
- ? {
42
- location: {
43
- file: msg.location.file,
44
- line: msg.location.line,
45
- column: msg.location.column,
46
- },
39
+ /**
40
+ * Parse esbuild CLI stderr into structured diagnostics.
41
+ * esbuild outputs errors like:
42
+ * ✘ [ERROR] Could not resolve "foo"
43
+ * src/main.tsx:3:7:
44
+ */
45
+ function parseEsbuildStderr(stderr: string): {
46
+ errors: CompileDiagnostic[];
47
+ warnings: CompileDiagnostic[];
48
+ } {
49
+ const errors: CompileDiagnostic[] = [];
50
+ const warnings: CompileDiagnostic[] = [];
51
+ const lines = stderr.split("\n");
52
+
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const errorMatch = lines[i].match(/✘ \[ERROR\] (.+)/);
55
+ const warnMatch = lines[i].match(/▲ \[WARNING\] (.+)/);
56
+
57
+ if (errorMatch || warnMatch) {
58
+ const text = (errorMatch ?? warnMatch)![1];
59
+ const diag: CompileDiagnostic = { text };
60
+
61
+ // Next non-empty line may have location: " file:line:col:"
62
+ const locLine = lines[i + 1]?.trim();
63
+ if (locLine) {
64
+ const locMatch = locLine.match(/^(.+):(\d+):(\d+):?$/);
65
+ if (locMatch) {
66
+ diag.location = {
67
+ file: locMatch[1],
68
+ line: parseInt(locMatch[2], 10),
69
+ column: parseInt(locMatch[3], 10),
70
+ };
47
71
  }
48
- : {}),
49
- }));
72
+ }
73
+
74
+ if (errorMatch) errors.push(diag);
75
+ else warnings.push(diag);
76
+ }
77
+ }
78
+
79
+ return { errors, warnings };
80
+ }
81
+
82
+ /**
83
+ * Scan source files for bare import specifiers and pre-install any
84
+ * allowlisted packages into the shared cache so esbuild can resolve them.
85
+ */
86
+ async function resolveAppImports(srcDir: string): Promise<void> {
87
+ const importRe = /(?:import|from)\s+["']([^"'.][^"']*)["']/g;
88
+ const seen = new Set<string>();
89
+
90
+ const files = await readdir(srcDir, { recursive: true });
91
+ for (const file of files) {
92
+ if (!/\.[jt]sx?$/.test(String(file))) continue;
93
+ const content = await readFile(join(srcDir, String(file)), "utf-8");
94
+ for (const match of content.matchAll(importRe)) {
95
+ const specifier = match[1];
96
+ if (!isBareImport(specifier)) continue;
97
+ const pkg = packageName(specifier);
98
+ if (seen.has(pkg)) continue;
99
+ seen.add(pkg);
100
+ await resolvePackage(pkg);
101
+ }
102
+ }
50
103
  }
51
104
 
52
105
  /**
@@ -68,95 +121,70 @@ export async function compileApp(appDir: string): Promise<CompileResult> {
68
121
  }
69
122
  await mkdir(distDir, { recursive: true });
70
123
 
71
- // Resolve preact from the assistant's own node_modules so per-app
72
- // directories don't need their own copy.
73
- const preactDir = resolve(
74
- import.meta.dirname ?? __dirname,
75
- "../../node_modules/preact",
76
- );
77
-
78
- // Plugin that resolves bare third-party imports against the allowlist
79
- const resolvePlugin: Plugin = {
80
- name: "vellum-package-resolver",
81
- setup(pluginBuild) {
82
- pluginBuild.onResolve({ filter: /.*/ }, async (args) => {
83
- // Only intercept bare specifiers (not relative, not preact/react aliases)
84
- if (
85
- args.kind !== "import-statement" &&
86
- args.kind !== "dynamic-import"
87
- ) {
88
- return undefined;
89
- }
90
- if (!isBareImport(args.path)) return undefined;
91
-
92
- const pkg = packageName(args.path);
93
- const nodeModulesDir = await resolvePackage(pkg);
94
-
95
- if (nodeModulesDir) {
96
- // Let esbuild resolve normally — nodePaths will pick it up
97
- return undefined;
98
- }
99
-
100
- // Not allowed — produce a clear error
101
- return {
102
- errors: [
103
- {
104
- text: `Package '${pkg}' is not in the allowed list. Allowed: ${ALLOWED_PACKAGES.join(", ")}`,
105
- },
106
- ],
107
- };
108
- });
109
- },
110
- };
111
-
112
- const cacheNodeModules = join(getCacheDir(), "node_modules");
113
-
114
- let result;
124
+ // JIT download esbuild binary + preact on first use
125
+ let tools;
115
126
  try {
116
- result = await build({
117
- entryPoints: [entryPoint],
118
- bundle: true,
119
- minify: true,
120
- sourcemap: false,
121
- outdir: distDir,
122
- format: "esm",
123
- target: ["es2022"],
124
- jsx: "automatic",
125
- jsxImportSource: "preact",
126
- loader: {
127
- ".tsx": "tsx",
128
- ".ts": "ts",
129
- ".jsx": "jsx",
130
- ".js": "js",
131
- ".css": "css",
132
- },
133
- alias: {
134
- react: "preact/compat",
135
- "react-dom": "preact/compat",
136
- },
137
- plugins: [resolvePlugin],
138
- // Point esbuild at assistant's preact and at the shared package cache
139
- nodePaths: [resolve(preactDir, ".."), cacheNodeModules],
140
- logLevel: "silent",
141
- });
142
- } catch (err: unknown) {
143
- // esbuild throws on hard failures (e.g. syntax errors) with .errors/.warnings
144
- const esbuildErr = err as {
145
- errors?: Message[];
146
- warnings?: Message[];
147
- };
127
+ tools = await ensureCompilerTools();
128
+ } catch (err) {
148
129
  const durationMs = Math.round(performance.now() - start);
149
- const errors = mapDiagnostics(esbuildErr.errors ?? []);
150
- const warnings = mapDiagnostics(esbuildErr.warnings ?? []);
151
- log.info({ durationMs, errorCount: errors.length }, "Build failed");
152
- return { ok: false, errors, warnings, durationMs };
130
+ const text = err instanceof Error ? err.message : String(err);
131
+ log.error({ err, durationMs }, "Failed to ensure compiler tools");
132
+ return {
133
+ ok: false,
134
+ errors: [{ text: `Compiler setup failed: ${text}` }],
135
+ warnings: [],
136
+ durationMs,
137
+ };
153
138
  }
154
139
 
155
- const errors = mapDiagnostics(result.errors);
156
- const warnings = mapDiagnostics(result.warnings);
140
+ // Scan source files for bare imports and JIT-install allowed packages
141
+ await resolveAppImports(srcDir);
157
142
 
158
- if (errors.length > 0) {
143
+ // Build NODE_PATH: preact parent dir + shared package cache
144
+ const preactParent = dirname(tools.preactDir);
145
+ const cacheNodeModules = join(getCacheDir(), "node_modules");
146
+ const nodePath = [preactParent, cacheNodeModules]
147
+ .filter((p) => existsSync(p))
148
+ .join(":");
149
+
150
+ // Shell out to esbuild CLI
151
+ const args = [
152
+ entryPoint,
153
+ "--bundle",
154
+ "--minify",
155
+ `--outdir=${distDir}`,
156
+ "--format=esm",
157
+ "--target=es2022",
158
+ "--jsx=automatic",
159
+ "--jsx-import-source=preact",
160
+ "--alias:react=preact/compat",
161
+ "--alias:react-dom=preact/compat",
162
+ "--loader:.tsx=tsx",
163
+ "--loader:.ts=ts",
164
+ "--loader:.jsx=jsx",
165
+ "--loader:.js=js",
166
+ "--loader:.css=css",
167
+ "--log-level=warning",
168
+ ];
169
+
170
+ const proc = Bun.spawn({
171
+ cmd: [tools.esbuildBin, ...args],
172
+ cwd: appDir,
173
+ stdout: "pipe",
174
+ stderr: "pipe",
175
+ env: { ...process.env, NODE_PATH: nodePath },
176
+ });
177
+
178
+ await proc.exited;
179
+ const stderr = await new Response(proc.stderr).text();
180
+
181
+ if (proc.exitCode !== 0) {
159
182
  const durationMs = Math.round(performance.now() - start);
183
+ const { errors, warnings } = parseEsbuildStderr(stderr);
184
+ // If parsing found nothing, use raw stderr as the error
185
+ if (errors.length === 0 && stderr.trim()) {
186
+ errors.push({ text: stderr.trim() });
187
+ }
160
188
  log.info({ durationMs, errorCount: errors.length }, "Build failed");
161
189
  return { ok: false, errors, warnings, durationMs };
162
190
  }
@@ -191,5 +219,5 @@ export async function compileApp(appDir: string): Promise<CompileResult> {
191
219
 
192
220
  const durationMs = Math.round(performance.now() - start);
193
221
  log.info({ durationMs }, "Build succeeded");
194
- return { ok: true, errors, warnings, durationMs };
222
+ return { ok: true, errors: [], warnings: [], durationMs };
195
223
  }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * JIT download and cache of esbuild binary + preact for app compilation.
3
+ *
4
+ * Instead of shipping these in the .app bundle (~11 MB), we download them
5
+ * on first compile to ~/.vellum/workspace/compiler-tools/. Follows the
6
+ * same pattern as EmbeddingRuntimeManager.
7
+ */
8
+
9
+ import {
10
+ chmodSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { arch, homedir, platform } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import { getLogger } from "../util/logger.js";
21
+ import { PromiseGuard } from "../util/promise-guard.js";
22
+
23
+ const log = getLogger("compiler-tools");
24
+
25
+ // Pinned versions matching assistant/bun.lock
26
+ const ESBUILD_VERSION = "0.24.2";
27
+ const PREACT_VERSION = "10.28.4";
28
+
29
+ const TOOLS_VERSION = `esbuild-${ESBUILD_VERSION}_preact-${PREACT_VERSION}`;
30
+
31
+ export interface CompilerTools {
32
+ esbuildBin: string;
33
+ preactDir: string;
34
+ }
35
+
36
+ interface VersionManifest {
37
+ toolsVersion: string;
38
+ esbuildVersion: string;
39
+ preactVersion: string;
40
+ platform: string;
41
+ arch: string;
42
+ installedAt: string;
43
+ }
44
+
45
+ function getToolsDir(): string {
46
+ return join(homedir(), ".vellum", "workspace", "compiler-tools");
47
+ }
48
+
49
+ const installGuard = new PromiseGuard<void>();
50
+
51
+ function npmTarballUrl(pkg: string, version: string): string {
52
+ const encoded = pkg.replace("/", "%2f");
53
+ const basename = pkg.startsWith("@") ? pkg.split("/")[1] : pkg;
54
+ return `https://registry.npmjs.org/${encoded}/-/${basename}-${version}.tgz`;
55
+ }
56
+
57
+ async function downloadAndExtract(
58
+ url: string,
59
+ targetDir: string,
60
+ ): Promise<void> {
61
+ log.info({ url, targetDir }, "Downloading npm package");
62
+
63
+ const response = await fetch(url);
64
+ if (!response.ok) {
65
+ throw new Error(
66
+ `Failed to download ${url}: ${response.status} ${response.statusText}`,
67
+ );
68
+ }
69
+
70
+ const tarball = await response.arrayBuffer();
71
+ mkdirSync(targetDir, { recursive: true });
72
+
73
+ const tmpTar = join(targetDir, `download-${Date.now()}.tgz`);
74
+ writeFileSync(tmpTar, Buffer.from(tarball));
75
+
76
+ try {
77
+ const proc = Bun.spawn({
78
+ cmd: ["tar", "xzf", tmpTar, "-C", targetDir, "--strip-components=1"],
79
+ stdout: "ignore",
80
+ stderr: "pipe",
81
+ });
82
+ await proc.exited;
83
+ if (proc.exitCode !== 0) {
84
+ const stderr = await new Response(proc.stderr).text();
85
+ throw new Error(`Failed to extract ${url}: ${stderr}`);
86
+ }
87
+ } finally {
88
+ try {
89
+ rmSync(tmpTar);
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }
94
+ }
95
+
96
+ function readManifest(baseDir: string): VersionManifest | null {
97
+ const manifestPath = join(baseDir, "version.json");
98
+ if (!existsSync(manifestPath)) return null;
99
+ try {
100
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function isReady(baseDir: string): boolean {
107
+ const manifest = readManifest(baseDir);
108
+ if (!manifest || manifest.toolsVersion !== TOOLS_VERSION) return false;
109
+ return (
110
+ existsSync(join(baseDir, "bin", "esbuild")) &&
111
+ existsSync(join(baseDir, "node_modules", "preact"))
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Ensure esbuild binary + preact are downloaded and cached.
117
+ * Safe to call concurrently — deduplicates via PromiseGuard.
118
+ */
119
+ export async function ensureCompilerTools(): Promise<CompilerTools> {
120
+ const baseDir = getToolsDir();
121
+
122
+ if (!isReady(baseDir)) {
123
+ await installGuard.run(() => install(baseDir));
124
+ if (!isReady(baseDir)) {
125
+ installGuard.reset();
126
+ throw new Error("Compiler tools installation failed");
127
+ }
128
+ }
129
+
130
+ return {
131
+ esbuildBin: join(baseDir, "bin", "esbuild"),
132
+ preactDir: join(baseDir, "node_modules", "preact"),
133
+ };
134
+ }
135
+
136
+ async function install(baseDir: string): Promise<void> {
137
+ if (isReady(baseDir)) return;
138
+
139
+ const os = platform();
140
+ const cpu = arch();
141
+ log.info(
142
+ { os, cpu, toolsVersion: TOOLS_VERSION },
143
+ "Installing compiler tools",
144
+ );
145
+
146
+ mkdirSync(baseDir, { recursive: true });
147
+
148
+ // Lock file to prevent concurrent cross-process installs
149
+ const lockPath = join(baseDir, ".downloading");
150
+ if (existsSync(lockPath)) {
151
+ try {
152
+ const lockPid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
153
+ if (!isNaN(lockPid) && lockPid !== process.pid) {
154
+ try {
155
+ process.kill(lockPid, 0);
156
+ log.info(
157
+ { lockPid },
158
+ "Another process is installing compiler tools, waiting",
159
+ );
160
+ // Wait up to 60s for the other process to finish
161
+ for (let i = 0; i < 60; i++) {
162
+ await new Promise((r) => setTimeout(r, 1000));
163
+ if (isReady(baseDir)) return;
164
+ }
165
+ log.warn("Timed out waiting for other installer, proceeding");
166
+ } catch {
167
+ log.info({ lockPid }, "Cleaning up stale compiler tools lock");
168
+ }
169
+ }
170
+ } catch {
171
+ // Can't read lock, proceed
172
+ }
173
+ }
174
+
175
+ writeFileSync(lockPath, String(process.pid));
176
+
177
+ const tmpDir = join(baseDir, `.installing-${Date.now()}`);
178
+ mkdirSync(tmpDir, { recursive: true });
179
+
180
+ try {
181
+ // Determine esbuild platform package name
182
+ const esbuildPlatform =
183
+ os === "darwin"
184
+ ? cpu === "arm64"
185
+ ? "darwin-arm64"
186
+ : "darwin-x64"
187
+ : cpu === "arm64"
188
+ ? "linux-arm64"
189
+ : "linux-x64";
190
+
191
+ // Download esbuild binary + preact in parallel
192
+ await Promise.all([
193
+ downloadAndExtract(
194
+ npmTarballUrl(`@esbuild/${esbuildPlatform}`, ESBUILD_VERSION),
195
+ join(tmpDir, "esbuild-pkg"),
196
+ ),
197
+ downloadAndExtract(
198
+ npmTarballUrl("preact", PREACT_VERSION),
199
+ join(tmpDir, "node_modules", "preact"),
200
+ ),
201
+ ]);
202
+
203
+ // Move esbuild binary to bin/
204
+ const esbuildBinSrc = join(tmpDir, "esbuild-pkg", "bin", "esbuild");
205
+ const binDir = join(tmpDir, "bin");
206
+ mkdirSync(binDir, { recursive: true });
207
+ const { renameSync } = await import("node:fs");
208
+ renameSync(esbuildBinSrc, join(binDir, "esbuild"));
209
+ chmodSync(join(binDir, "esbuild"), 0o755);
210
+ rmSync(join(tmpDir, "esbuild-pkg"), { recursive: true, force: true });
211
+
212
+ // Write version manifest
213
+ const manifest: VersionManifest = {
214
+ toolsVersion: TOOLS_VERSION,
215
+ esbuildVersion: ESBUILD_VERSION,
216
+ preactVersion: PREACT_VERSION,
217
+ platform: os,
218
+ arch: cpu,
219
+ installedAt: new Date().toISOString(),
220
+ };
221
+ writeFileSync(
222
+ join(tmpDir, "version.json"),
223
+ JSON.stringify(manifest, null, 2) + "\n",
224
+ );
225
+
226
+ // Atomic swap: clear old install, move new files in
227
+ const { readdirSync } = await import("node:fs");
228
+ for (const entry of readdirSync(baseDir)) {
229
+ if (entry.startsWith(".") || entry === tmpDir.split("/").pop()) continue;
230
+ rmSync(join(baseDir, entry), { recursive: true, force: true });
231
+ }
232
+ for (const entry of readdirSync(tmpDir)) {
233
+ renameSync(join(tmpDir, entry), join(baseDir, entry));
234
+ }
235
+
236
+ log.info({ toolsVersion: TOOLS_VERSION }, "Compiler tools installed");
237
+ } catch (err) {
238
+ log.error({ err }, "Failed to install compiler tools");
239
+ throw err;
240
+ } finally {
241
+ rmSync(tmpDir, { recursive: true, force: true });
242
+ try {
243
+ rmSync(lockPath);
244
+ } catch {
245
+ /* ignore */
246
+ }
247
+ }
248
+ }