diff --git a/bun.lock b/bun.lock index 50ed8bbf2..6055cf357 100644 --- a/bun.lock +++ b/bun.lock @@ -530,6 +530,19 @@ "typescript": "^6.0.2", }, }, + "packages/vite": { + "name": "@agentuity/vite", + "version": "3.0.0-beta.6", + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.0.0", + "typescript": "^6.0.2", + "vite": "^7.0.0", + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + }, + }, "packages/vscode": { "name": "agentuity-vscode", "version": "3.0.0-beta.6", @@ -592,6 +605,7 @@ }, "devDependencies": { "@agentuity/cli": "workspace:*", + "@agentuity/vite": "workspace:*", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.17.1", "@sveltejs/vite-plugin-svelte": "^5.0.3", @@ -624,6 +638,7 @@ }, "devDependencies": { "@agentuity/cli": "workspace:*", + "@agentuity/vite": "workspace:*", "@tailwindcss/typography": "^0.5.16", "@tanstack/devtools-vite": "latest", "@testing-library/dom": "^10.4.1", @@ -859,6 +874,8 @@ "@agentuity/vector": ["@agentuity/vector@workspace:packages/vector"], + "@agentuity/vite": ["@agentuity/vite@workspace:packages/vite"], + "@agentuity/webhook": ["@agentuity/webhook@workspace:packages/webhook"], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.79", "", { "dependencies": { "@ai-sdk/provider": "2.0.3", "@ai-sdk/provider-utils": "3.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-K0U09FPDO1kmLPjRLXFcNSvmnKHJBMARCb8r3Ulw7wU6/+Zh9djWcFDiPPNsklg6yAezcdLTcYPszgWJJ6iOTA=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 1733aa269..0bdf89e87 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,10 +13,6 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" - }, - "./vite-plugin": { - "import": "./dist/cmd/build/vite/index.js", - "types": "./dist/cmd/build/vite/index.d.ts" } }, "files": [ diff --git a/packages/cli/src/api.ts b/packages/cli/src/api.ts index f9c7f368b..61177720c 100644 --- a/packages/cli/src/api.ts +++ b/packages/cli/src/api.ts @@ -92,3 +92,23 @@ export function getAppBaseURL(config?: Config | null): string { const overrides = config?.overrides as { app_url?: string } | undefined; return baseGetAppBaseURL(config?.name, overrides); } + +/** + * URL the gravity tunnel binary connects to. + * + * Profile overrides win, then a hardcoded `local` profile target, + * then the default production endpoint. Region is currently unused + * because the platform exposes a single global devmode endpoint, but + * the parameter is accepted so callers can pass it forward without + * branching. + */ +export function getGravityDevModeURL(_region: string, config?: Config | null): string { + const overrides = config?.overrides as { gravity_url?: string } | undefined; + if (overrides?.gravity_url) { + return overrides.gravity_url; + } + if (config?.name === 'local') { + return 'grpc://gravity.agentuity.io:443'; + } + return 'grpc://devmode-us.agentuity.com'; +} diff --git a/packages/cli/src/cmd/dev/api.ts b/packages/cli/src/cmd/dev/api.ts new file mode 100644 index 000000000..533b2aa3d --- /dev/null +++ b/packages/cli/src/cmd/dev/api.ts @@ -0,0 +1,69 @@ +import { createPublicKey } from 'node:crypto'; +import { APIResponseSchema } from '@agentuity/server'; +import { z } from 'zod'; +import { StructuredError } from '@agentuity/core'; +import type { APIClient } from '../../api.ts'; + +const DevmodeRequestSchema = z.object({ + hostname: z.string().optional().describe('the hostname for the endpoint'), + publicKey: z.string().optional().describe('the public key PEM for the endpoint'), +}); + +type DevmodeRequest = z.infer; + +function extractPublicKeyPEM(privateKeyPEM: string): string | undefined { + try { + const publicKey = createPublicKey(privateKeyPEM); + return publicKey.export({ type: 'spki', format: 'pem' }) as string; + } catch { + return undefined; + } +} + +const DevmodeResponseSchema = z.object({ + id: z.string(), + hostname: z.string(), + privateKey: z.string().optional(), +}); +export type DevmodeResponse = z.infer; + +const DevmodeResponseAPISchema = APIResponseSchema(DevmodeResponseSchema); +type DevmodeResponseAPI = z.infer; + +const DevmodeEndpointError = StructuredError('DevmodeEndpointError'); + +/** + * Reserve (or re-use) an Agentuity devmode endpoint for the current + * project. The platform returns a hostname plus a private key the + * gravity binary uses to authenticate when it dials the public-URL + * tunnel. Re-passing a previously-issued private key keeps the same + * hostname stable across dev sessions on the same machine. + * + * KNOWN PLATFORM BUG: as of 2026-05-07 this endpoint routes hostnames + * by the caller's `User-Agent` and v3-shaped UAs (`Agentuity CLI/3.x`) + * receive hostnames under `*.agentuity.live`, which has no wildcard + * DNS configured — so the URL never resolves. v2 UAs and curl get + * `*.agentuity-us.live`, which works. Tracking in agentuity/infra#210. + */ +export async function generateEndpoint( + apiClient: APIClient, + projectId: string, + hostname?: string, + privateKey?: string +): Promise { + const publicKey = privateKey ? extractPublicKeyPEM(privateKey) : undefined; + + const resp = await apiClient.request( + 'POST', + `/cli/devmode/3/${projectId}`, + DevmodeResponseAPISchema, + { hostname, publicKey }, + DevmodeRequestSchema + ); + + if (!resp.success) { + throw new DevmodeEndpointError({ message: resp.message }); + } + + return resp.data; +} diff --git a/packages/cli/src/cmd/dev/download.ts b/packages/cli/src/cmd/dev/download.ts new file mode 100644 index 000000000..975b7c0dc --- /dev/null +++ b/packages/cli/src/cmd/dev/download.ts @@ -0,0 +1,149 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir, platform } from 'node:os'; +import { join, dirname } from 'node:path'; +import * as tar from 'tar'; +import { StructuredError } from '@agentuity/core'; +import { spinner } from '../../tui.ts'; + +interface GravityClient { + filename: string; + version: string; +} + +/** + * Remove previously downloaded gravity version directories after a + * newer version has started successfully. + * + * Safety guard: only removes sibling directories that contain a + * gravity binary, leaving any unrelated files/folders untouched. + */ +export function sweepOldGravityVersions(gravityDir: string, currentVersion: string): string[] { + if (!existsSync(gravityDir)) { + return []; + } + + const removed: string[] = []; + for (const entry of readdirSync(gravityDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === currentVersion) { + continue; + } + + const candidateDir = join(gravityDir, entry.name); + const candidateBinary = join(candidateDir, 'gravity'); + if (!existsSync(candidateBinary)) { + continue; + } + + rmSync(candidateDir, { recursive: true, force: true }); + removed.push(candidateDir); + } + + return removed; +} + +const GravityVersionError = StructuredError('GravityVersionError')<{ + status: number; + statusText: string; +}>(); +const GravityDownloadError = StructuredError('GravityDownloadError')<{ + status: number; + statusText: string; +}>(); +const GravityExtractionError = StructuredError('GravityExtractionError')<{ + path: string; +}>(); + +function getBaseURL(): string { + return process.env.AGENTUITY_SH_URL || 'https://agentuity.sh'; +} + +/** + * Resolve the latest gravity version, download (or re-use) the + * binary for the host platform, and extract it to + * `//gravity`. + */ +export async function download(gravityDir: string): Promise { + const baseURL = getBaseURL(); + + // Step 1: Get the latest version from agentuity.sh + const tag = (await spinner({ + message: 'Checking Agentuity Gravity', + callback: async () => { + const resp = await fetch(`${baseURL}/release/gravity/version`, { + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { + throw new GravityVersionError({ + status: resp.status, + statusText: resp.statusText, + }); + } + const text = (await resp.text()).trim(); + return text.startsWith('v') ? text : `v${text}`; + }, + clearOnSuccess: true, + })) as string; + + const version = tag.startsWith('v') ? tag.slice(1) : tag; + const releaseFilename = join(gravityDir, version, 'gravity'); + + // Step 2: Check if already downloaded + if (existsSync(releaseFilename)) { + return { filename: releaseFilename, version }; + } + + // Step 3: Download the binary from agentuity.sh + const os = platform(); + let arch: string = process.arch; + if (arch === 'x64') { + arch = 'x86_64'; + } + + const tmpFile = join(tmpdir(), `${randomUUID()}.tar.gz`); + + try { + await spinner({ + message: `Downloading Gravity ${version}`, + callback: async () => { + const resp = await fetch(`${baseURL}/release/gravity/${tag}/${os}/${arch}`, { + signal: AbortSignal.timeout(60_000), + }); + if (!resp.ok) { + throw new GravityDownloadError({ + status: resp.status, + statusText: resp.statusText, + }); + } + const buffer = await resp.arrayBuffer(); + writeFileSync(tmpFile, Buffer.from(buffer)); + }, + clearOnSuccess: true, + }); + + // Step 4: Extract the tarball + await spinner({ + message: 'Extracting release', + callback: async () => { + const downloadDir = dirname(releaseFilename); + if (!existsSync(downloadDir)) { + mkdirSync(downloadDir, { recursive: true }); + } + await tar.x({ file: tmpFile, cwd: downloadDir, chmod: true }); + }, + clearOnSuccess: true, + }); + } finally { + // Clean up temp file regardless of success or failure + if (existsSync(tmpFile)) { + rmSync(tmpFile); + } + } + + // Step 5: Verify the binary was extracted + if (!existsSync(releaseFilename)) { + throw new GravityExtractionError({ path: releaseFilename }); + } + + return { filename: releaseFilename, version }; +} diff --git a/packages/cli/src/cmd/dev/gravity.ts b/packages/cli/src/cmd/dev/gravity.ts new file mode 100644 index 000000000..05bcd2f0e --- /dev/null +++ b/packages/cli/src/cmd/dev/gravity.ts @@ -0,0 +1,298 @@ +/** + * Gravity tunnel orchestration for `agentuity dev --public`. + * + * Wraps the lifecycle of the gravity binary that the CLI spawns to + * front the user's local dev server with a public HTTPS URL: + * + * 1. Spawns gravity with the project + endpoint args we got from + * the `/cli/devmode/3/` API call. + * 2. Tails stdout for the `HEARTBEAT_PORT=` line gravity prints + * shortly after startup, then POSTs `/heartbeat` to that port + * every 5 seconds so gravity knows we're still alive. (The + * tunnel auto-tears-down if heartbeats stop.) + * 3. Forwards stderr to the CLI logger so connection issues are + * visible. + * 4. Exposes a `stop()` that kills the entire gravity process tree + * (gravity spawns helper children) and clears the heartbeat + * interval. + * + * Node-compatible: uses `node:child_process.spawn` with a detached + * process group so we can deliver SIGTERM to the whole tree on + * shutdown. No `Bun.spawn`. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process'; +import type { Logger } from '../../types.ts'; + +/** + * Kill any lingering `gravity` processes left over from a previous + * dev session for this project. Scoped by `--project-id` so other + * concurrent sessions in different repos aren't affected. + * + * No-op on Windows; `pkill` not being available is treated as + * success (we just don't have an old session to clean up). + */ +export function killLingeringGravityProcesses( + logger: { debug: (msg: string, ...args: unknown[]) => void }, + projectId?: string +): void { + if (process.platform === 'win32') return; + try { + const pattern = projectId ? `gravity.*--project-id.*${projectId}` : 'gravity.*--endpoint-id'; + const result = spawnSync('pkill', ['-f', pattern], { + stdio: 'ignore', + }); + if (result.status === 0) { + logger.debug( + 'Killed lingering gravity processes%s from previous session', + projectId ? ` (project ${projectId})` : '' + ); + } else if (result.status === 1) { + logger.debug('no lingering gravity processes found'); + } + } catch { + // pkill not present — nothing to clean up, continue. + } +} + +export interface GravityStartOptions { + /** Path to the gravity binary on disk. */ + binary: string; + /** Endpoint id returned by the CLI devmode API. */ + endpointId: string; + /** Local port gravity should forward to (the user's dev server). */ + targetPort: number; + /** Region-specific gravity gRPC URL. */ + gravityURL: string; + /** Project owner org id. */ + orgId: string; + /** Project id. */ + projectId: string; + /** + * Base64-encoded private key PEM gravity uses to authenticate + * with the platform. + */ + privateKeyB64: string; + /** Working directory for the spawn (defaults to process cwd). */ + cwd?: string; + logger: Logger; +} + +export interface GravityHandle { + /** Underlying child process so callers can attach extra listeners. */ + readonly process: ChildProcess; + /** Resolves when the gravity child exits. */ + readonly exited: Promise<{ exitCode: number | null }>; + /** + * Resolves the first time gravity reports a heartbeat port ("the + * tunnel is actually up"). Useful for sweeping old binaries only + * after the new one has confirmed it works. + */ + readonly ready: Promise; + /** + * Stop the tunnel: clears the heartbeat interval and SIGTERMs the + * gravity process group. Safe to call multiple times. + */ + stop(): Promise; + /** + * Synchronous best-effort SIGKILL of the gravity process group. + * Intended for `process.on('exit', ...)` handlers where async + * operations cannot run — use `stop()` for normal shutdown. + */ + forceKillSync(): void; +} + +const HEARTBEAT_INTERVAL_MS = 5_000; +const HEARTBEAT_TIMEOUT_MS = 2_000; + +/** + * Spawn gravity and wire up heartbeats + log forwarding. Returns + * once the child has been spawned (heartbeats begin async when the + * binary prints its `HEARTBEAT_PORT=` line). + */ +export function startGravity(opts: GravityStartOptions): GravityHandle { + const { logger } = opts; + + // Strip PORT from the inherited env so gravity doesn't accidentally + // pick up the dev-server port and start serving HTTP itself. + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.PORT; + + const args = [ + '--endpoint-id', + opts.endpointId, + '--port', + String(opts.targetPort), + '--url', + opts.gravityURL, + '--log-level', + process.env.AGENTUITY_GRAVITY_LOG_LEVEL ?? 'error', + '--org-id', + opts.orgId, + '--project-id', + opts.projectId, + '--private-key', + opts.privateKeyB64, + '--health-check', + ]; + + const child = spawn(opts.binary, args, { + cwd: opts.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + // Detach so the child becomes its own process-group leader and + // kill(-pid) can reach helper grandchildren when we tear down. + detached: true, + }); + + logger.debug('Gravity tunnel spawned (pid %d, target port %d)', child.pid, opts.targetPort); + + let heartbeatInterval: ReturnType | null = null; + let heartbeatPort: number | null = null; + let stopped = false; + let readyResolve: () => void = () => {}; + const ready = new Promise((resolve) => { + readyResolve = resolve; + }); + + const sendHeartbeat = async (port: number) => { + try { + await fetch(`http://127.0.0.1:${port}/heartbeat`, { + method: 'POST', + signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS), + }); + } catch { + // Heartbeat failures are recoverable — gravity will tear the + // tunnel down on its own if they stop entirely. + } + }; + + // Tail stdout for the HEARTBEAT_PORT line. Everything else goes to + // debug-level logs; gravity emits routine connection chatter that + // we don't want surfacing in the CLI's normal output. + (async () => { + const stdout = child.stdout; + if (!stdout) return; + stdout.setEncoding('utf-8'); + let buffer = ''; + try { + for await (const chunk of stdout) { + buffer += chunk; + let newline: number; + // biome-ignore lint/suspicious/noAssignInExpressions: classic line-buffer drain pattern + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + + const match = line.match(/^HEARTBEAT_PORT=(\d+)$/); + if (match?.[1]) { + heartbeatPort = parseInt(match[1], 10); + logger.debug('Gravity heartbeat port: %d', heartbeatPort); + readyResolve(); + if (!heartbeatInterval && !stopped) { + void sendHeartbeat(heartbeatPort); + heartbeatInterval = setInterval( + () => void sendHeartbeat(heartbeatPort!), + HEARTBEAT_INTERVAL_MS + ); + } + } else { + logger.debug('[gravity] %s', line); + } + } + } + } catch (err) { + logger.debug('gravity stdout reader exited: %s', err); + } + })(); + + (async () => { + const stderr = child.stderr; + if (!stderr) return; + stderr.setEncoding('utf-8'); + try { + for await (const chunk of stderr) { + const text = String(chunk).trim(); + if (text) { + logger.warn('[gravity] %s', text); + } + } + } catch (err) { + logger.debug('gravity stderr reader exited: %s', err); + } + })(); + + const exited = new Promise<{ exitCode: number | null }>((resolve) => { + child.once('close', (code) => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + resolve({ exitCode: code }); + }); + }); + + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + const pid = child.pid; + if (!pid || child.exitCode !== null) return; + + // detached:true makes pid a process-group leader, so we kill the + // whole tree with `kill(-pid, signal)`. Falls back to direct + // kill on EPERM (rare; helper children may already be gone). + try { + process.kill(-pid, 'SIGTERM'); + logger.debug('Sent SIGTERM to gravity process group -%d', pid); + } catch { + try { + child.kill('SIGTERM'); + logger.debug('Sent SIGTERM to gravity pid %d (direct)', pid); + } catch { + // Already gone. + } + } + + // Give the child up to 2s to exit gracefully, then SIGKILL. + await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 2_000))]); + + if (child.exitCode === null) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // Already gone. + } + } + } + }; + + const forceKillSync = (): void => { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + const pid = child.pid; + if (!pid || pid <= 1 || child.exitCode !== null) return; + try { + process.kill(-pid, 'SIGKILL'); + } catch { + try { + child.kill('SIGKILL'); + } catch { + // Already gone. + } + } + }; + + return { process: child, exited, ready, stop, forceKillSync }; +} diff --git a/packages/cli/src/cmd/dev/index.ts b/packages/cli/src/cmd/dev/index.ts index 5d03757bc..0f0bb1dd9 100644 --- a/packages/cli/src/cmd/dev/index.ts +++ b/packages/cli/src/cmd/dev/index.ts @@ -1,25 +1,51 @@ /** - * Dev command — runs the project's own dev script. + * Dev command — runs the project's own dev script and (optionally) + * exposes it through an Agentuity gravity tunnel as a public HTTPS + * URL. * * Detects the package manager (bun/npm/pnpm/yarn) from the project, * then runs ` run dev`. Before spawning, injects Agentuity AI * Gateway environment variables so LLM SDK calls (OpenAI, Anthropic, * Groq) are automatically routed through the gateway when the user * has an AGENTUITY_SDK_KEY configured. + * + * When `--public` is enabled (saved per-project, prompted on first + * run), the CLI also reserves a devmode endpoint, downloads the + * gravity tunnel binary if needed, spawns it pointing at the user's + * dev port, and exports `AGENTUITY_DEVMODE_HOSTNAME` / + * `AGENTUITY_DEVMODE_URL` so framework plugins (e.g. + * `@agentuity/vite`) can configure themselves for the tunnel. */ import { spawn } from 'node:child_process'; -import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import { z } from 'zod'; +import { APIClient, getAPIBaseURL, getGravityDevModeURL } from '../../api.ts'; +import { isTTY } from '../../auth.ts'; import { getCommand } from '../../command-prefix.ts'; -import { getAuth, loadConfig, loadProjectSDKKey } from '../../config.ts'; +import { + getAuth, + getDefaultConfigDir, + loadConfig, + loadProjectConfig, + loadProjectSDKKey, + saveConfig, + updateProjectConfig, +} from '../../config.ts'; import { ErrorCode } from '../../errors.ts'; +import { validateGravityRequiresUpgrade } from '../../runtime.ts'; import * as tui from '../../tui.ts'; import { createCommand } from '../../types.ts'; +import type { Config, Logger, ProjectConfig } from '../../types.ts'; import { detectFrameworkWithPackageJson } from '../build/detect/index.ts'; import { detectPackageManager, getRunCommand } from '../build/detect/util.ts'; +import { generateEndpoint, type DevmodeResponse } from './api.ts'; +import { download, sweepOldGravityVersions } from './download.ts'; +import { killLingeringGravityProcesses, startGravity, type GravityHandle } from './gravity.ts'; const DEFAULT_PORT = 3000; +const GRAVITY_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h export const command = createCommand({ name: 'dev', @@ -30,6 +56,14 @@ export const command = createCommand({ examples: [ { command: getCommand('dev'), description: 'Start development server' }, { command: getCommand('dev --port 8080'), description: 'Specify custom port' }, + { + command: getCommand('dev --public'), + description: 'Expose dev server through a public URL (gravity tunnel)', + }, + { + command: getCommand('dev --no-public'), + description: 'Run without a public URL even if one was previously enabled', + }, ], schema: { options: z.object({ @@ -43,6 +77,7 @@ export const command = createCommand({ .string() .optional() .describe('Custom script name to run instead of "dev" (e.g., "dev:web")'), + public: z.boolean().optional().describe('Expose dev server via gravity public-URL tunnel'), }), }, @@ -108,7 +143,7 @@ export const command = createCommand({ } // Load profile config to get transport URL for gateway routing - const config = await loadConfig(); + let config = await loadConfig(); if (config?.overrides?.transport_url && !env.AGENTUITY_TRANSPORT_URL) { env.AGENTUITY_TRANSPORT_URL = config.overrides.transport_url; } @@ -128,23 +163,84 @@ export const command = createCommand({ ); } - // Run the dev command, inheriting stdio for full interactivity. - // We use child_process.spawn directly here (rather than the - // spawnInherit shim) so we can forward signals to the child. - const [command, ...args] = cmd; - const proc = spawn(command!, args, { + // ──────────────────────────────────────────────────────────── + // Public URL (gravity tunnel) setup + // ──────────────────────────────────────────────────────────── + + const project = await tryLoadProjectConfig(rootDir, config); + const publicEnabled = await resolvePublicMode(opts.public, project, rootDir, config, logger); + + let gravity: GravityHandle | null = null; + let publicUrl: string | undefined; + + if (publicEnabled) { + const result = await setupPublicTunnel({ + rootDir, + port, + project, + config, + logger, + env, + packageJson, + }); + gravity = result.gravity; + publicUrl = result.publicUrl; + config = result.config; + } + + // ──────────────────────────────────────────────────────────── + // Banner — show local + public URLs + // ──────────────────────────────────────────────────────────── + + printDevBanner(port, publicUrl); + + // ──────────────────────────────────────────────────────────── + // Spawn user's dev server (inherits stdio for full interactivity) + // ──────────────────────────────────────────────────────────── + + const [bin, ...args] = cmd; + const proc = spawn(bin!, args, { cwd: rootDir, env: { ...process.env, ...env }, stdio: 'inherit', }); - // Forward signals + // Forward signals to BOTH the framework and the gravity tunnel. + // Killing them in parallel matches the v2 procManager behavior — + // without it the tunnel would keep running until the framework + // finished its (potentially slow) graceful shutdown. + let shuttingDown = false; const signalHandler = (signal: NodeJS.Signals) => { - proc.kill(signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM'); + if (shuttingDown) return; + shuttingDown = true; + const forwarded = signal === 'SIGINT' ? 'SIGINT' : 'SIGTERM'; + try { + proc.kill(forwarded); + } catch { + // already exited + } + if (gravity) { + void gravity.stop(); + } }; process.on('SIGINT', signalHandler); process.on('SIGTERM', signalHandler); + // Last-resort synchronous SIGKILL: if Node tears down before our + // async cleanup finishes (uncaught exception, parent abandoned us) + // the process.on('exit') handler is the final chance to avoid + // orphaning gravity. async work is not allowed here. + const exitHandler = () => { + if (gravity) { + try { + gravity.forceKillSync(); + } catch { + // best effort + } + } + }; + process.on('exit', exitHandler); + const exitCode = await new Promise((resolve) => { proc.once('close', (code) => resolve(code)); }); @@ -152,6 +248,12 @@ export const command = createCommand({ process.off('SIGINT', signalHandler); process.off('SIGTERM', signalHandler); + if (gravity) { + await gravity.stop(); + } + + process.off('exit', exitHandler); + if (exitCode !== 0 && exitCode !== 130) { // 130 = SIGINT (Ctrl+C), which is normal logger.debug('Dev server exited with code %d', exitCode); @@ -161,6 +263,290 @@ export const command = createCommand({ }, }); +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function tryLoadProjectConfig( + rootDir: string, + config: Config | null +): Promise { + try { + return await loadProjectConfig(rootDir, config); + } catch { + return undefined; + } +} + +/** + * Decide whether the public URL should be enabled for this run. + * + * Precedence: + * 1. Explicit `--public` / `--no-public` flag. + * 2. Saved per-project preference (`agentuity.json` `devmode.public`). + * 3. Interactive prompt (only when stdin is a TTY); default no. + * 4. Non-TTY default: off. + * + * Saves the chosen value back to `agentuity.json` when the user is + * prompted, so subsequent runs honor the choice silently. + */ +async function resolvePublicMode( + explicit: boolean | undefined, + project: ProjectConfig | undefined, + rootDir: string, + config: Config | null, + logger: Logger +): Promise { + if (explicit !== undefined) { + // Persist if the user opted in/out from the CLI and we have a + // project config to save it to. + if (project && project.devmode?.public !== explicit) { + try { + await updateProjectConfig( + rootDir, + { devmode: { ...project.devmode, public: explicit } }, + config + ); + } catch (err) { + logger.debug('Could not persist devmode.public preference: %s', err); + } + } + return explicit; + } + + if (project?.devmode?.public !== undefined) { + return project.devmode.public; + } + + if (!project || !isTTY()) { + return false; + } + + tui.newline(); + const enabled = await tui.confirm( + 'Expose this dev server through a public URL (gravity tunnel)?', + false + ); + tui.newline(); + + try { + await updateProjectConfig( + rootDir, + { devmode: { ...project.devmode, public: enabled } }, + config + ); + } catch (err) { + logger.debug('Could not persist devmode.public preference: %s', err); + } + + return enabled; +} + +interface SetupTunnelArgs { + rootDir: string; + port: number; + project: ProjectConfig | undefined; + config: Config | null; + logger: Logger; + env: Record; + packageJson: { dependencies?: Record; devDependencies?: Record }; +} + +interface SetupTunnelResult { + gravity: GravityHandle | null; + publicUrl?: string; + config: Config | null; +} + +async function setupPublicTunnel(args: SetupTunnelArgs): Promise { + const { rootDir, port, project, logger, env, packageJson } = args; + let config = args.config; + + // Best-effort: clear any orphaned gravity tunnels left over from a + // previous dev session for this project. Without this, the platform + // can briefly refuse the new tunnel because the old endpoint hasn't + // timed out yet. + killLingeringGravityProcesses(logger, project?.projectId); + + // Public URL needs both a registered project and a valid auth. + if (!project) { + tui.fatal( + `Public URL requires a registered project.\n` + + `Run ${tui.bold(getCommand('project import'))} to link this directory to an Agentuity project, ` + + `or re-run with ${tui.bold('--no-public')}.`, + ErrorCode.PROJECT_NOT_FOUND + ); + } + + const auth = await getAuth(); + if (!auth || auth.expires <= new Date()) { + tui.fatal( + `Public URL requires authentication.\n` + + `Run ${tui.bold(getCommand('auth login'))} to log in, or re-run with ${tui.bold('--no-public')}.`, + ErrorCode.AUTH_REQUIRED + ); + } + + // Friendly heads-up when the project uses Vite but hasn't installed + // the @agentuity/vite plugin (Vite blocks unknown hosts in dev). + checkVitePluginInstalled(rootDir, packageJson, logger); + + // Reserve (or refresh) the devmode endpoint with the platform. + const apiClient = new APIClient(getAPIBaseURL(config), logger, auth.apiKey, config); + + const savedPrivateKey = config?.devmode?.privateKey + ? Buffer.from(config.devmode.privateKey, 'base64').toString('utf-8') + : undefined; + + let endpoint: DevmodeResponse; + try { + endpoint = await tui.spinner({ + message: 'Connecting to Gravity', + callback: () => + generateEndpoint( + apiClient, + project.projectId, + config?.devmode?.hostname, + savedPrivateKey + ), + clearOnSuccess: true, + }); + } catch (err) { + tui.fatal( + `Failed to reserve devmode endpoint: ${err instanceof Error ? err.message : String(err)}`, + ErrorCode.NETWORK_ERROR + ); + } + + // Stash the hostname/private key so the same URL persists across + // dev sessions on this machine. + const updatedPrivateKey = endpoint.privateKey ?? savedPrivateKey; + const updatedConfig: Config = { + ...(config ?? ({ name: 'default' } as Config)), + devmode: { + hostname: endpoint.hostname, + privateKey: updatedPrivateKey + ? Buffer.from(updatedPrivateKey).toString('base64') + : undefined, + }, + }; + await saveConfig(updatedConfig); + config = updatedConfig; + + // Resolve gravity binary — re-use cached copy if recent enough, + // otherwise download. + const gravityDir = join(getDefaultConfigDir(), 'gravity'); + let gravityBin: string | undefined; + let sweepTarget: { gravityDir: string; version: string } | null = null; + + const cached = config.gravity; + if ( + cached?.version && + existsSync(join(gravityDir, cached.version, 'gravity')) && + cached.checked && + Date.now() - cached.checked < GRAVITY_CHECK_INTERVAL_MS && + !validateGravityRequiresUpgrade(cached.version) + ) { + gravityBin = join(gravityDir, cached.version, 'gravity'); + } else { + const previousVersion = cached?.version; + const res = await download(gravityDir); + gravityBin = res.filename; + if (previousVersion && previousVersion !== res.version) { + sweepTarget = { gravityDir, version: res.version }; + } + const refreshed: Config = { + ...config, + gravity: { checked: Date.now(), version: res.version }, + }; + await saveConfig(refreshed); + config = refreshed; + } + + // Spawn gravity, pointing at the user's dev port. + const privateKeyPEM = endpoint.privateKey ?? savedPrivateKey; + if (!privateKeyPEM) { + tui.fatal( + 'No private key returned for devmode endpoint. Re-run to generate a fresh key.', + ErrorCode.INTERNAL_ERROR + ); + } + + const gravityURL = getGravityDevModeURL(project.region, config); + const handle = startGravity({ + binary: gravityBin, + endpointId: endpoint.id, + targetPort: port, + gravityURL, + orgId: project.orgId, + projectId: project.projectId, + privateKeyB64: Buffer.from(privateKeyPEM).toString('base64'), + cwd: rootDir, + logger, + }); + + if (sweepTarget) { + // Wait for the first heartbeat (= tunnel up) before sweeping the + // previous gravity binary; if the new version doesn't connect we + // don't want to lose the working fallback. Failure is non-fatal. + void handle.ready.then(() => { + try { + const removed = sweepOldGravityVersions(sweepTarget!.gravityDir, sweepTarget!.version); + if (removed.length > 0) { + logger.debug('Swept %d old gravity version dir(s)', removed.length); + } + } catch (err) { + logger.debug('sweep of old gravity versions failed: %s', err); + } + }); + } + + const publicUrl = `https://${endpoint.hostname}`; + env.AGENTUITY_DEVMODE_URL = publicUrl; + env.AGENTUITY_DEVMODE_HOSTNAME = endpoint.hostname; + + return { gravity: handle, publicUrl, config }; +} + +function checkVitePluginInstalled( + _rootDir: string, + packageJson: { dependencies?: Record; devDependencies?: Record }, + logger: Logger +): void { + const allDeps = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + }; + const usesVite = 'vite' in allDeps; + const hasPlugin = '@agentuity/vite' in allDeps; + if (usesVite && !hasPlugin) { + logger.debug('Vite project detected without @agentuity/vite plugin'); + tui.warning( + 'This project uses Vite but does not have @agentuity/vite installed. ' + + 'Vite will reject requests from the public URL with "Blocked request". ' + + 'Install with: ' + + tui.bold('bun add -d @agentuity/vite') + + ' and add ' + + tui.bold('agentuity()') + + ' to vite.config plugins.' + ); + } +} + +function printDevBanner(port: number, publicUrl?: string): void { + const padding = 12; + const lines = [ + tui.muted(tui.padRight('Local:', padding)) + tui.link(`http://127.0.0.1:${port}`), + ]; + if (publicUrl) { + lines.push(tui.muted(tui.padRight('Public:', padding)) + tui.link(publicUrl)); + } + tui.banner('⨺ Agentuity DevMode', lines.join('\n'), { + padding: 2, + topSpacer: false, + bottomSpacer: false, + centerTitle: false, + }); +} + // ─── AI Gateway Env Injection ───────────────────────────────────────────────── interface GatewayProvider { diff --git a/packages/cli/src/cmd/project/frameworks.ts b/packages/cli/src/cmd/project/frameworks.ts index 9eeb95143..0afdbb59b 100644 --- a/packages/cli/src/cmd/project/frameworks.ts +++ b/packages/cli/src/cmd/project/frameworks.ts @@ -197,8 +197,14 @@ export const frameworkCatalog: FrameworkScaffold[] = [ // Swap sv's default `@sveltejs/adapter-auto` (which can't detect // our runtime) for `@sveltejs/adapter-node`, which emits a // self-listening Node server at `build/index.js`. The overlay - // drops a matching svelte.config.js. - devDependencies: ['@sveltejs/adapter-node', '@tailwindcss/vite', 'tailwindcss'], + // drops a matching svelte.config.js. @agentuity/vite configures + // Vite dev/HMR for `agentuity dev --public`. + devDependencies: [ + '@agentuity/vite', + '@sveltejs/adapter-node', + '@tailwindcss/vite', + 'tailwindcss', + ], scripts: { deploy: 'agentuity deploy', start: 'node build/index.js', @@ -226,8 +232,9 @@ export const frameworkCatalog: FrameworkScaffold[] = [ // Astro defaults to a static SPA build. We swap to SSR via // `@astrojs/node` (standalone mode) so the deploy can host // server-rendered pages and API routes. The overlay drops a - // matching `astro.config.mjs`. - devDependencies: ['@astrojs/node', '@tailwindcss/vite', 'tailwindcss'], + // matching `astro.config.mjs`. @agentuity/vite configures Vite + // dev/HMR for `agentuity dev --public`. + devDependencies: ['@agentuity/vite', '@astrojs/node', '@tailwindcss/vite', 'tailwindcss'], scripts: { deploy: 'agentuity deploy', start: 'node ./dist/server/entry.mjs', diff --git a/packages/cli/src/cmd/project/templates/astro/astro.config.mjs b/packages/cli/src/cmd/project/templates/astro/astro.config.mjs index 3633e37e9..40acafdbf 100644 --- a/packages/cli/src/cmd/project/templates/astro/astro.config.mjs +++ b/packages/cli/src/cmd/project/templates/astro/astro.config.mjs @@ -6,6 +6,7 @@ // a static SPA that doesn't run any user-side server code. import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; +import agentuity from '@agentuity/vite'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ @@ -14,7 +15,7 @@ export default defineConfig({ mode: 'standalone', }), vite: { - plugins: [tailwindcss()], + plugins: [agentuity(), tailwindcss()], ssr: { noExternal: ['pg'], }, diff --git a/packages/cli/src/cmd/project/templates/sveltekit/vite.config.ts b/packages/cli/src/cmd/project/templates/sveltekit/vite.config.ts index 8abd67e9d..6322545b7 100644 --- a/packages/cli/src/cmd/project/templates/sveltekit/vite.config.ts +++ b/packages/cli/src/cmd/project/templates/sveltekit/vite.config.ts @@ -1,9 +1,10 @@ +import agentuity from '@agentuity/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], + plugins: [agentuity(), tailwindcss(), sveltekit()], ssr: { noExternal: ['pg'], }, diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index e45692bb5..54409dc1c 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -3,11 +3,21 @@ import { runtimeKind, runtimeVersion } from './node-compat/runtime-info.ts'; const MIN_BUN_VERSION = '>=1.3.3'; const MIN_NODE_VERSION = '>=24.0.0'; +const MIN_GRAVITY_VERSION = '>=1.0.6'; export function isBun(): boolean { return runtimeKind() === 'bun'; } +/** + * Returns true when the locally cached gravity binary is older than + * the minimum version this CLI knows how to drive. Callers use this + * to force-redownload before spawning the tunnel. + */ +export function validateGravityRequiresUpgrade(version: string): boolean { + return satisfies(version, MIN_GRAVITY_VERSION) === false; +} + /** * Validate that the host runtime is recent enough. * diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index ba253b198..1040cb31b 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -567,6 +567,15 @@ export const ProjectSchema = zod.object({ .optional() .describe('whether to skip the git integration setup prompt during deploy'), template: TemplateSchema.optional().describe('template metadata and requirements'), + devmode: zod + .object({ + public: zod + .boolean() + .optional() + .describe('whether `agentuity dev` should expose a public URL via the gravity tunnel'), + }) + .optional() + .describe('per-project devmode preferences'), }); export const BuildMetadataSchema = ServerBuildMetadataSchema; diff --git a/packages/vite/README.md b/packages/vite/README.md new file mode 100644 index 000000000..c9ff7bb67 --- /dev/null +++ b/packages/vite/README.md @@ -0,0 +1,39 @@ +# @agentuity/vite + +Vite plugin for the Agentuity public-URL devmode tunnel. + +When `agentuity dev --public` is active, the CLI exports +`AGENTUITY_DEVMODE_HOSTNAME` so this plugin can configure Vite for the +gravity tunnel: it adds the public hostname to `server.allowedHosts` +and points `server.hmr` at `wss://:443` so HMR works for +users browsing the public URL. + +The plugin is a no-op when the env var isn't set, so it's safe to +keep in `vite.config.ts` permanently. + +## Install + +```bash +bun add -d @agentuity/vite +``` + +## Usage + +```ts +// vite.config.ts +import { defineConfig } from 'vite'; +import agentuity from '@agentuity/vite'; + +export default defineConfig({ + plugins: [agentuity()], +}); +``` + +For SvelteKit / Astro / similar — the plugin works in any Vite-based +config; just add it to the `plugins` array. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `hostname` | `string` | `process.env.AGENTUITY_DEVMODE_HOSTNAME` | Override the public hostname the plugin reacts to. Useful for testing. | diff --git a/packages/vite/package.json b/packages/vite/package.json new file mode 100644 index 000000000..174298e75 --- /dev/null +++ b/packages/vite/package.json @@ -0,0 +1,47 @@ +{ + "name": "@agentuity/vite", + "version": "3.0.0-beta.6", + "description": "Vite plugin for the Agentuity dev public-URL tunnel", + "license": "Apache-2.0", + "author": "Agentuity employees and contributors", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "AGENTS.md", + "README.md", + "src", + "dist" + ], + "scripts": { + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "build": "tsgo --build --force", + "typecheck": "tsgo --noEmit", + "prepublishOnly": "bun run clean && bun run build", + "test": "bun test" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.0.0", + "typescript": "^6.0.2", + "vite": "^7.0.0" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/agentuity/sdk.git", + "directory": "packages/vite" + } +} diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts new file mode 100644 index 000000000..3fdbdee25 --- /dev/null +++ b/packages/vite/src/index.ts @@ -0,0 +1,77 @@ +/** + * Agentuity Vite plugin. + * + * Wires Vite's dev server up to a gravity public-URL tunnel that the + * Agentuity CLI may be running. When `agentuity dev --public` is + * active, the CLI exports `AGENTUITY_DEVMODE_HOSTNAME` (and + * `AGENTUITY_DEVMODE_URL`) into the user's framework process. + * + * This plugin reads that hostname and: + * + * 1. Adds it to `server.allowedHosts` so Vite stops rejecting + * requests with "Blocked request. This host is not allowed." + * 2. Configures `server.hmr` so the HMR WebSocket connects back + * through the tunnel using `wss://:443`. Without this, + * the browser tries to dial Vite directly on the local port and + * HMR silently fails for users browsing the public URL. + * + * The plugin is a no-op when the env var is absent, so it's safe to + * always include in `vite.config.ts`. + */ + +import type { Plugin, UserConfig } from 'vite'; + +const ENV_HOSTNAME = 'AGENTUITY_DEVMODE_HOSTNAME'; + +export interface AgentuityVitePluginOptions { + /** + * Override the hostname the plugin reacts to. When omitted, the + * plugin reads `process.env.AGENTUITY_DEVMODE_HOSTNAME`. + */ + hostname?: string; +} + +/** + * Vite plugin: enables Agentuity public-URL devmode (gravity tunnel). + * + * @example + * ```ts + * // vite.config.ts + * import { defineConfig } from 'vite'; + * import agentuity from '@agentuity/vite'; + * + * export default defineConfig({ + * plugins: [agentuity()], + * }); + * ``` + */ +export default function agentuity(options: AgentuityVitePluginOptions = {}): Plugin { + return { + name: 'agentuity:devmode', + // Only apply during dev. Production builds don't need this. + apply: 'serve', + config(): UserConfig | undefined { + const hostname = options.hostname ?? process.env[ENV_HOSTNAME]; + if (!hostname) { + return undefined; + } + return { + server: { + // Vite requires the hostname to be in this list — strings + // are matched exactly, regex entries can broaden matches. + allowedHosts: [hostname], + // HMR comes in over the gravity TLS tunnel on port 443. + // Without this Vite tells the browser to dial localhost + // directly, which fails for clients hitting the public URL. + hmr: { + host: hostname, + clientPort: 443, + protocol: 'wss', + }, + }, + }; + }, + }; +} + +export { agentuity }; diff --git a/packages/vite/test/index.test.ts b/packages/vite/test/index.test.ts new file mode 100644 index 000000000..405cb0cb2 --- /dev/null +++ b/packages/vite/test/index.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import agentuity from '../src/index.ts'; + +const ENV_HOSTNAME = 'AGENTUITY_DEVMODE_HOSTNAME'; +const previousHostname = process.env[ENV_HOSTNAME]; + +afterEach(() => { + if (previousHostname === undefined) { + delete process.env[ENV_HOSTNAME]; + } else { + process.env[ENV_HOSTNAME] = previousHostname; + } +}); + +describe('@agentuity/vite', () => { + test('is a serve-only Vite plugin', () => { + const plugin = agentuity(); + + expect(plugin.name).toBe('agentuity:devmode'); + expect(plugin.apply).toBe('serve'); + }); + + test('does nothing without a devmode hostname', () => { + delete process.env[ENV_HOSTNAME]; + const plugin = agentuity(); + + expect(typeof plugin.config).toBe('function'); + if (typeof plugin.config !== 'function') { + throw new Error('expected config hook'); + } + + expect(plugin.config()).toBeUndefined(); + }); + + test('configures allowed hosts and HMR for the devmode hostname', () => { + const plugin = agentuity({ hostname: 'example.agentuity-us.live' }); + + expect(typeof plugin.config).toBe('function'); + if (typeof plugin.config !== 'function') { + throw new Error('expected config hook'); + } + + expect(plugin.config()).toEqual({ + server: { + allowedHosts: ['example.agentuity-us.live'], + hmr: { + host: 'example.agentuity-us.live', + clientPort: 443, + protocol: 'wss', + }, + }, + }); + }); +}); diff --git a/packages/vite/tsconfig.json b/packages/vite/tsconfig.json new file mode 100644 index 000000000..2956f6448 --- /dev/null +++ b/packages/vite/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/tests/frameworks/svelte-web/package.json b/tests/frameworks/svelte-web/package.json index 4f3c68d2f..c777030f7 100644 --- a/tests/frameworks/svelte-web/package.json +++ b/tests/frameworks/svelte-web/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@agentuity/cli": "workspace:*", + "@agentuity/vite": "workspace:*", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "^2.17.1", "@sveltejs/vite-plugin-svelte": "^5.0.3", diff --git a/tests/frameworks/svelte-web/tests/structure.test.ts b/tests/frameworks/svelte-web/tests/structure.test.ts index 96ef9e37b..194e5cf75 100644 --- a/tests/frameworks/svelte-web/tests/structure.test.ts +++ b/tests/frameworks/svelte-web/tests/structure.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test'; describe('svelte-web', () => { test('svelte.config.js uses adapter-node', async () => { const config = await import('../svelte.config.js'); - expect(config.default.kit.adapter).toBeDefined(); + expect(config.default.kit?.adapter).toBeDefined(); }); test('app.html contains SvelteKit placeholders', async () => { diff --git a/tests/frameworks/svelte-web/vite.config.ts b/tests/frameworks/svelte-web/vite.config.ts index fce9e8704..b1eeb4c93 100644 --- a/tests/frameworks/svelte-web/vite.config.ts +++ b/tests/frameworks/svelte-web/vite.config.ts @@ -1,6 +1,7 @@ +import agentuity from '@agentuity/vite'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [agentuity(), sveltekit()], }); diff --git a/tests/frameworks/tanstack-start/package.json b/tests/frameworks/tanstack-start/package.json index 8a3e98165..4db86d147 100644 --- a/tests/frameworks/tanstack-start/package.json +++ b/tests/frameworks/tanstack-start/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@agentuity/cli": "workspace:*", + "@agentuity/vite": "workspace:*", "@tailwindcss/typography": "^0.5.16", "@tanstack/devtools-vite": "latest", "@testing-library/dom": "^10.4.1", diff --git a/tests/frameworks/tanstack-start/vite.config.ts b/tests/frameworks/tanstack-start/vite.config.ts index f02b7cb82..e1605c14f 100644 --- a/tests/frameworks/tanstack-start/vite.config.ts +++ b/tests/frameworks/tanstack-start/vite.config.ts @@ -1,3 +1,4 @@ +import agentuity from '@agentuity/vite'; import { defineConfig } from 'vite'; import { devtools } from '@tanstack/devtools-vite'; import { nitro } from 'nitro/vite'; @@ -10,6 +11,7 @@ import tailwindcss from '@tailwindcss/vite'; const config = defineConfig({ plugins: [ + agentuity(), devtools(), tsconfigPaths({ projects: ['./tsconfig.json'] }), tailwindcss(), diff --git a/tsconfig.json b/tsconfig.json index 8bee9bf07..9b01aa895 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ { "path": "./packages/stream" }, { "path": "./packages/task" }, { "path": "./packages/vector" }, + { "path": "./packages/vite" }, { "path": "./packages/vscode" }, { "path": "./packages/webhook" } ],