@vellumai/assistant 0.4.37 → 0.4.41

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 (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -53,10 +53,14 @@ import type {
53
53
  ExtensionResponse,
54
54
  } from "../browser-extension-relay/protocol.js";
55
55
  import { extensionRelayServer } from "../browser-extension-relay/server.js";
56
- import { isSigningKeyInitialized } from "../runtime/auth/token-service.js";
56
+ import {
57
+ initAuthSigningKey,
58
+ isSigningKeyInitialized,
59
+ loadOrCreateSigningKey,
60
+ } from "../runtime/auth/token-service.js";
57
61
  import { gatewayPost } from "../runtime/gateway-internal-client.js";
58
62
  import type { ExtractedCredential } from "../tools/browser/network-recording-types.js";
59
- import { type AmazonSession, loadSession } from "./session.js";
63
+ import { type AmazonSession, loadSession, saveSession } from "./session.js";
60
64
 
61
65
  export const AMAZON_BASE = "https://www.amazon.com";
62
66
 
@@ -81,12 +85,10 @@ export async function sendRelayCommand(
81
85
 
82
86
  // Fall back to HTTP relay endpoint via the gateway.
83
87
  // The gateway validates edge JWTs (aud=vellum-gateway) and mints an
84
- // exchange token for the runtime. Without the signing key (CLI
85
- // out-of-process), we cannot mint JWTs at all.
88
+ // exchange token for the runtime. In CLI out-of-process contexts the
89
+ // signing key may not be initialized yet — load it from disk.
86
90
  if (!isSigningKeyInitialized()) {
87
- throw new Error(
88
- "Auth signing key not initialized — browser-relay commands require the daemon to be running",
89
- );
91
+ initAuthSigningKey(loadOrCreateSigningKey());
90
92
  }
91
93
 
92
94
  const { data } = await gatewayPost<ExtensionResponse>(
@@ -358,6 +360,86 @@ export interface PlaceOrderResult {
358
360
  estimatedDelivery?: string;
359
361
  }
360
362
 
363
+ // ---------------------------------------------------------------------------
364
+ // Session refresh via browser extension relay
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Refresh the Amazon session by grabbing cookies directly from Chrome
369
+ * via the browser extension relay's `get_cookies` action.
370
+ *
371
+ * Much faster than the CDP-based Ride Shotgun flow — no separate Chrome
372
+ * instance, no recording. Requires the extension to be loaded and connected.
373
+ */
374
+ export async function refreshSessionFromExtension(): Promise<AmazonSession> {
375
+ const resp = await sendRelayCommand({
376
+ action: "get_cookies",
377
+ domain: "amazon.com",
378
+ });
379
+
380
+ if (!resp.success) {
381
+ throw new Error(
382
+ `Failed to get cookies from browser extension: ${resp.error ?? "unknown error"}`,
383
+ );
384
+ }
385
+
386
+ const chromeCookies = resp.result as Array<{
387
+ name: string;
388
+ value: string;
389
+ domain: string;
390
+ path: string;
391
+ httpOnly: boolean;
392
+ secure: boolean;
393
+ expirationDate?: number;
394
+ }>;
395
+
396
+ if (!chromeCookies?.length) {
397
+ throw new Error(
398
+ "No Amazon cookies found in Chrome. " +
399
+ "Make sure you are signed into Amazon in Chrome.",
400
+ );
401
+ }
402
+
403
+ const cookies: ExtractedCredential[] = chromeCookies.map((c) => ({
404
+ name: c.name,
405
+ value: c.value,
406
+ domain: c.domain,
407
+ path: c.path || "/",
408
+ httpOnly: c.httpOnly,
409
+ secure: c.secure,
410
+ expires: c.expirationDate ? Math.floor(c.expirationDate) : undefined,
411
+ }));
412
+
413
+ // Validate required cookies
414
+ const cookieNames = new Set(cookies.map((c) => c.name));
415
+ if (!cookieNames.has("session-id")) {
416
+ throw new Error(
417
+ "Chrome cookies are missing required Amazon cookie: session-id. " +
418
+ "Make sure you are signed into Amazon in Chrome.",
419
+ );
420
+ }
421
+ if (!cookieNames.has("ubid-main")) {
422
+ throw new Error(
423
+ "Chrome cookies are missing required Amazon cookie: ubid-main. " +
424
+ "Make sure you are signed into Amazon in Chrome.",
425
+ );
426
+ }
427
+ if (!cookieNames.has("at-main") && !cookieNames.has("x-main")) {
428
+ throw new Error(
429
+ "Chrome cookies are missing required Amazon auth cookie (at-main or x-main). " +
430
+ "Make sure you are fully signed into Amazon in Chrome.",
431
+ );
432
+ }
433
+
434
+ const session: AmazonSession = {
435
+ cookies,
436
+ importedAt: new Date().toISOString(),
437
+ };
438
+
439
+ saveSession(session);
440
+ return session;
441
+ }
442
+
361
443
  // ---------------------------------------------------------------------------
362
444
  // Re-export public API from submodules
363
445
  // ---------------------------------------------------------------------------
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { answerCall } from "../calls/call-domain.js";
15
15
  import { getGatewayInternalBaseUrl } from "../config/env.js";
16
- import { upsertMember } from "../contacts/contacts-write.js";
16
+ import { upsertContactChannel } from "../contacts/contacts-write.js";
17
17
  import {
18
18
  type CanonicalGuardianRequest,
19
19
  getCanonicalGuardianRequest,
@@ -459,7 +459,7 @@ const accessRequestResolver: GuardianRequestResolver = {
459
459
  // relay server's in-call wait loop will detect the approved status.
460
460
  if (channel === "voice") {
461
461
  try {
462
- upsertMember({
462
+ upsertContactChannel({
463
463
  sourceChannel: "voice",
464
464
  externalUserId: requesterExternalUserId,
465
465
  externalChatId: requesterChatId,
@@ -14,9 +14,10 @@ import { extname, join } from "node:path";
14
14
  import archiver from "archiver";
15
15
  import JSZip from "jszip";
16
16
 
17
- import { getApp, getAppsDir } from "../memory/app-store.js";
17
+ import { getApp, getAppsDir, isMultifileApp } from "../memory/app-store.js";
18
18
  import { computeContentId } from "../util/content-id.js";
19
19
  import { getLogger } from "../util/logger.js";
20
+ import { compileApp } from "./app-compiler.js";
20
21
  import type { SigningCallback } from "./bundle-signer.js";
21
22
  import { signBundle } from "./bundle-signer.js";
22
23
  import type { AppManifest } from "./manifest.js";
@@ -194,13 +195,15 @@ export async function packageApp(
194
195
  throw new Error(`App not found: ${appId}`);
195
196
  }
196
197
 
198
+ const multifile = isMultifileApp(app);
199
+
197
200
  // Build manifest
198
201
  const createdBy = `vellum-assistant/${PACKAGE_VERSION}`;
199
202
  const version = app.version ?? "1.0.0";
200
203
  const contentId = computeContentId(app.name);
201
204
 
202
205
  const manifest: AppManifest = {
203
- format_version: 1,
206
+ format_version: multifile ? 2 : 1,
204
207
  name: app.name,
205
208
  ...(app.description ? { description: app.description } : {}),
206
209
  ...(app.icon ? { icon: app.icon } : {}),
@@ -213,28 +216,63 @@ export async function packageApp(
213
216
  content_id: contentId,
214
217
  };
215
218
 
216
- // Fetch remote assets and rewrite HTML to reference local copies
217
- const { rewrittenHtml, assets: fetchedAssets } = await materializeAssets(
218
- app.htmlDefinition,
219
- );
220
-
221
- // Also materialize assets in additional pages
219
+ // For multifile apps, compile first then bundle the dist/ output.
220
+ // For legacy apps, materialize remote assets and bundle the HTML directly.
221
+ let rewrittenHtml = "";
222
+ let allAssets: FetchedAsset[] = [];
222
223
  const rewrittenPages: Record<string, string> = {};
223
- const pageAssets: FetchedAsset[] = [];
224
- if (app.pages) {
225
- for (const [filename, content] of Object.entries(app.pages)) {
226
- const result = await materializeAssets(content);
227
- rewrittenPages[filename] = result.rewrittenHtml;
228
- pageAssets.push(...result.assets);
224
+ const compiledFiles: { name: string; data: Buffer }[] = [];
225
+
226
+ if (multifile) {
227
+ const appDir = join(getAppsDir(), appId);
228
+ const compileResult = await compileApp(appDir);
229
+ if (!compileResult.ok) {
230
+ const messages = compileResult.errors
231
+ .map((e) => {
232
+ const loc = e.location
233
+ ? ` (${e.location.file}:${e.location.line}:${e.location.column})`
234
+ : "";
235
+ return `${e.text}${loc}`;
236
+ })
237
+ .join("\n");
238
+ throw new Error(`Compilation failed for app "${app.name}":\n${messages}`);
239
+ }
240
+
241
+ const distDir = join(appDir, "dist");
242
+ const indexHtml = await readFile(join(distDir, "index.html"), "utf-8");
243
+ const mainJs = await readFile(join(distDir, "main.js"));
244
+
245
+ compiledFiles.push({ name: "index.html", data: Buffer.from(indexHtml) });
246
+ compiledFiles.push({ name: "main.js", data: mainJs });
247
+
248
+ // main.css is optional — only produced when the app imports CSS
249
+ const cssPath = join(distDir, "main.css");
250
+ if (existsSync(cssPath)) {
251
+ compiledFiles.push({ name: "main.css", data: await readFile(cssPath) });
252
+ }
253
+ } else {
254
+ // Legacy path: fetch remote assets and rewrite HTML
255
+ const materialized = await materializeAssets(app.htmlDefinition);
256
+ rewrittenHtml = materialized.rewrittenHtml;
257
+ const fetchedAssets = materialized.assets;
258
+
259
+ // Also materialize assets in additional pages
260
+ const pageAssets: FetchedAsset[] = [];
261
+ if (app.pages) {
262
+ for (const [filename, content] of Object.entries(app.pages)) {
263
+ const result = await materializeAssets(content);
264
+ rewrittenPages[filename] = result.rewrittenHtml;
265
+ pageAssets.push(...result.assets);
266
+ }
229
267
  }
230
- }
231
268
 
232
- // Deduplicate assets by archive path
233
- const allAssetsMap = new Map<string, FetchedAsset>();
234
- for (const asset of [...fetchedAssets, ...pageAssets]) {
235
- allAssetsMap.set(asset.archivePath, asset);
269
+ // Deduplicate assets by archive path
270
+ const allAssetsMap = new Map<string, FetchedAsset>();
271
+ for (const asset of [...fetchedAssets, ...pageAssets]) {
272
+ allAssetsMap.set(asset.archivePath, asset);
273
+ }
274
+ allAssets = [...allAssetsMap.values()];
236
275
  }
237
- const allAssets = [...allAssetsMap.values()];
238
276
 
239
277
  // Create the zip archive
240
278
  const safeName = app.name.replace(/[/\\:*?"<>|]/g, "_").trim() || "App";
@@ -264,20 +302,27 @@ export async function packageApp(
264
302
  // Add manifest.json at root level
265
303
  archive.append(serializeManifest(manifest), { name: "manifest.json" });
266
304
 
267
- // Add index.html at root level
268
- archive.append(rewrittenHtml, { name: "index.html" });
269
-
270
- // Add additional pages alongside index.html (with rewritten asset URLs)
271
- if (app.pages) {
272
- for (const filename of Object.keys(app.pages)) {
273
- const content = rewrittenPages[filename] ?? app.pages[filename];
274
- archive.append(content, { name: filename });
305
+ if (multifile) {
306
+ // Add compiled dist/ files
307
+ for (const file of compiledFiles) {
308
+ archive.append(file.data, { name: file.name });
309
+ }
310
+ } else {
311
+ // Add index.html at root level
312
+ archive.append(rewrittenHtml, { name: "index.html" });
313
+
314
+ // Add additional pages alongside index.html (with rewritten asset URLs)
315
+ if (app.pages) {
316
+ for (const filename of Object.keys(app.pages)) {
317
+ const content = rewrittenPages[filename] ?? app.pages[filename];
318
+ archive.append(content, { name: filename });
319
+ }
275
320
  }
276
- }
277
321
 
278
- // Add fetched remote assets
279
- for (const asset of allAssets) {
280
- archive.append(asset.data, { name: asset.archivePath });
322
+ // Add fetched remote assets
323
+ for (const asset of allAssets) {
324
+ archive.append(asset.data, { name: asset.archivePath });
325
+ }
281
326
  }
282
327
 
283
328
  // Include app icon if one was generated
@@ -0,0 +1,195 @@
1
+ /**
2
+ * esbuild wrapper for compiling multi-file TSX apps.
3
+ *
4
+ * Compiles src/main.tsx → dist/main.js, copies index.html with
5
+ * script/style tag injection, and returns structured diagnostics.
6
+ */
7
+
8
+ import { existsSync, rmSync } from "node:fs";
9
+ 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";
13
+
14
+ import { getLogger } from "../util/logger.js";
15
+ import {
16
+ ALLOWED_PACKAGES,
17
+ getCacheDir,
18
+ isBareImport,
19
+ packageName,
20
+ resolvePackage,
21
+ } from "./package-resolver.js";
22
+
23
+ const log = getLogger("app-compiler");
24
+
25
+ export interface CompileDiagnostic {
26
+ text: string;
27
+ location?: { file: string; line: number; column: number };
28
+ }
29
+
30
+ export interface CompileResult {
31
+ ok: boolean;
32
+ errors: CompileDiagnostic[];
33
+ warnings: CompileDiagnostic[];
34
+ durationMs: number;
35
+ }
36
+
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
+ },
47
+ }
48
+ : {}),
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Compile a TSX app from appDir/src/ into appDir/dist/.
54
+ *
55
+ * Expects appDir/src/main.tsx as the entry point and appDir/src/index.html
56
+ * as the HTML shell. Produces appDir/dist/main.js and appDir/dist/index.html
57
+ * (with script and optional stylesheet tags injected).
58
+ */
59
+ export async function compileApp(appDir: string): Promise<CompileResult> {
60
+ const start = performance.now();
61
+ const srcDir = join(appDir, "src");
62
+ const distDir = join(appDir, "dist");
63
+ const entryPoint = join(srcDir, "main.tsx");
64
+
65
+ // Clear stale dist/ output so removed assets (e.g. CSS) don't persist
66
+ if (existsSync(distDir)) {
67
+ rmSync(distDir, { recursive: true, force: true });
68
+ }
69
+ await mkdir(distDir, { recursive: true });
70
+
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;
115
+ 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
+ };
148
+ 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 };
153
+ }
154
+
155
+ const errors = mapDiagnostics(result.errors);
156
+ const warnings = mapDiagnostics(result.warnings);
157
+
158
+ if (errors.length > 0) {
159
+ const durationMs = Math.round(performance.now() - start);
160
+ log.info({ durationMs, errorCount: errors.length }, "Build failed");
161
+ return { ok: false, errors, warnings, durationMs };
162
+ }
163
+
164
+ // Copy index.html and inject script/style tags
165
+ const htmlSrc = join(srcDir, "index.html");
166
+ if (existsSync(htmlSrc)) {
167
+ let html = await readFile(htmlSrc, "utf-8");
168
+
169
+ // Check if CSS output was produced
170
+ const distFiles = await readdir(distDir);
171
+ const hasCss = distFiles.some((f) => f.endsWith(".css"));
172
+
173
+ // Inject stylesheet link into <head> if CSS exists and not already present
174
+ if (hasCss && !html.includes('href="main.css"')) {
175
+ html = html.replace(
176
+ "</head>",
177
+ ' <link rel="stylesheet" href="main.css">\n </head>',
178
+ );
179
+ }
180
+
181
+ // Inject script tag before </body> if not already present
182
+ if (!html.includes('src="main.js"')) {
183
+ html = html.replace(
184
+ "</body>",
185
+ ' <script type="module" src="main.js"></script>\n </body>',
186
+ );
187
+ }
188
+
189
+ await writeFile(join(distDir, "index.html"), html);
190
+ }
191
+
192
+ const durationMs = Math.round(performance.now() - start);
193
+ log.info({ durationMs }, "Build succeeded");
194
+ return { ok: true, errors, warnings, durationMs };
195
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  export interface AppManifest {
6
- format_version: number; // always 1
6
+ format_version: number; // 1 = legacy single-HTML; 2 = multi-file TSX (future PR)
7
7
  name: string;
8
8
  description?: string;
9
9
  icon?: string; // single emoji
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Third-party package resolver with an allowlist for app builds.
3
+ *
4
+ * Maintains a shared cache at ~/.vellum/package-cache/ so packages are
5
+ * installed once and reused across all app compilations.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { mkdir, stat } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { getLogger } from "../util/logger.js";
14
+
15
+ const log = getLogger("package-resolver");
16
+
17
+ /** Packages the model is likely to use and that we trust in sandboxed apps. */
18
+ export const ALLOWED_PACKAGES: readonly string[] = [
19
+ "date-fns",
20
+ "chart.js",
21
+ "lodash-es",
22
+ "zod",
23
+ "clsx",
24
+ "lucide",
25
+ ] as const;
26
+
27
+ const INSTALL_TIMEOUT_MS = 10_000;
28
+ const MAX_PACKAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
29
+
30
+ /** In-flight install promises keyed by package name, to deduplicate concurrent requests. */
31
+ const inflight = new Map<string, Promise<string | null>>();
32
+
33
+ /** Where all cached packages live on disk. */
34
+ export function getCacheDir(): string {
35
+ return join(homedir(), ".vellum", "package-cache");
36
+ }
37
+
38
+ /**
39
+ * Return true when `name` is a bare specifier that our plugin should handle
40
+ * (i.e. not a relative/absolute path, not preact/react which are aliased).
41
+ */
42
+ export function isBareImport(name: string): boolean {
43
+ if (name.startsWith(".") || name.startsWith("/")) return false;
44
+ if (
45
+ name.startsWith("preact") ||
46
+ name.startsWith("react") ||
47
+ name.startsWith("react-dom")
48
+ ) {
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+
54
+ /** Get the top-level package name from a specifier (handles scoped pkgs). */
55
+ export function packageName(specifier: string): string {
56
+ if (specifier.startsWith("@")) {
57
+ const parts = specifier.split("/");
58
+ return parts.slice(0, 2).join("/");
59
+ }
60
+ return specifier.split("/")[0];
61
+ }
62
+
63
+ /**
64
+ * Resolve a third-party package from the shared cache.
65
+ *
66
+ * Returns the path to the package inside node_modules, or null if the
67
+ * package is not allowed or installation failed.
68
+ */
69
+ export async function resolvePackage(name: string): Promise<string | null> {
70
+ const pkg = packageName(name);
71
+
72
+ if (!ALLOWED_PACKAGES.includes(pkg)) {
73
+ return null;
74
+ }
75
+
76
+ const cacheDir = getCacheDir();
77
+ const nodeModulesDir = join(cacheDir, "node_modules");
78
+ const pkgDir = join(nodeModulesDir, pkg);
79
+
80
+ // Already cached — skip install
81
+ if (existsSync(pkgDir)) {
82
+ return nodeModulesDir;
83
+ }
84
+
85
+ // Deduplicate concurrent install requests for the same package
86
+ const existing = inflight.get(pkg);
87
+ if (existing) {
88
+ return existing;
89
+ }
90
+
91
+ const promise = installPackage(pkg, cacheDir, nodeModulesDir, pkgDir);
92
+ inflight.set(pkg, promise);
93
+ try {
94
+ return await promise;
95
+ } finally {
96
+ inflight.delete(pkg);
97
+ }
98
+ }
99
+
100
+ async function installPackage(
101
+ pkg: string,
102
+ cacheDir: string,
103
+ nodeModulesDir: string,
104
+ pkgDir: string,
105
+ ): Promise<string | null> {
106
+ // Ensure cache directory exists with a package.json so bun install works
107
+ await mkdir(cacheDir, { recursive: true });
108
+ const pkgJsonPath = join(cacheDir, "package.json");
109
+ if (!existsSync(pkgJsonPath)) {
110
+ const { writeFile } = await import("node:fs/promises");
111
+ await writeFile(
112
+ pkgJsonPath,
113
+ JSON.stringify({ name: "vellum-pkg-cache", private: true }),
114
+ );
115
+ }
116
+
117
+ log.info({ pkg }, "Installing package into shared cache");
118
+
119
+ try {
120
+ const proc = Bun.spawn(["bun", "install", "--no-save", pkg], {
121
+ cwd: cacheDir,
122
+ stdout: "pipe",
123
+ stderr: "pipe",
124
+ env: {
125
+ ...process.env,
126
+ PATH: `${homedir()}/.bun/bin:${process.env.PATH}`,
127
+ },
128
+ });
129
+
130
+ // Race against timeout
131
+ const exited = proc.exited;
132
+ let timer: ReturnType<typeof setTimeout>;
133
+ const timeout = new Promise<"timeout">((resolve) => {
134
+ timer = setTimeout(() => resolve("timeout"), INSTALL_TIMEOUT_MS);
135
+ });
136
+
137
+ const raceResult = await Promise.race([exited, timeout]);
138
+ clearTimeout(timer!);
139
+
140
+ if (raceResult === "timeout") {
141
+ proc.kill();
142
+ log.warn({ pkg }, "Package install timed out");
143
+ return null;
144
+ }
145
+
146
+ if (raceResult !== 0) {
147
+ const stderr = await new Response(proc.stderr).text();
148
+ log.warn({ pkg, stderr }, "Package install failed");
149
+ return null;
150
+ }
151
+
152
+ // Enforce max size
153
+ if (existsSync(pkgDir)) {
154
+ const size = await dirSize(pkgDir);
155
+ if (size > MAX_PACKAGE_SIZE_BYTES) {
156
+ log.warn({ pkg, size }, "Package exceeds size limit, removing");
157
+ const { rm } = await import("node:fs/promises");
158
+ await rm(pkgDir, { recursive: true, force: true });
159
+ return null;
160
+ }
161
+ }
162
+
163
+ return existsSync(pkgDir) ? nodeModulesDir : null;
164
+ } catch (err) {
165
+ log.warn({ pkg, err }, "Package resolution failed");
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** Recursively sum file sizes under a directory. */
171
+ async function dirSize(dir: string): Promise<number> {
172
+ const { readdir } = await import("node:fs/promises");
173
+ const entries = await readdir(dir, { withFileTypes: true });
174
+ let total = 0;
175
+ for (const entry of entries) {
176
+ const full = join(dir, entry.name);
177
+ if (entry.isDirectory()) {
178
+ total += await dirSize(full);
179
+ } else {
180
+ const s = await stat(full);
181
+ total += s.size;
182
+ }
183
+ }
184
+ return total;
185
+ }