Skip to content
Closed
20 changes: 12 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions packages/cli/src/cmd/build/adapters/agentuity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Agentuity native build adapter.
*
* This adapter delegates to the existing viteBundle pipeline for native
* Agentuity projects (app.ts + @agentuity/runtime). It preserves all
* existing behavior: agent discovery, route discovery, Bun.build,
* db-rewrite, LLM patches, metadata generation, etc.
*
* This is the bridge between the new framework-agnostic build system
* and the existing Agentuity-specific build pipeline.
*/

import { join } from 'node:path';
import type { BuildAdapter, BuildAdapterOptions, BuildResult } from './types';

export const agentuityAdapter: BuildAdapter = {
name: 'agentuity',

async build(options: BuildAdapterOptions): Promise<BuildResult> {
const {
projectDir,
logger,
collector,
dev,
projectId,
orgId,
region,
deploymentId,
deploymentOptions,
deploymentConfig,
} = options;
const started = Date.now();
const logs: string[] = [];

// Delegate to the existing viteBundle pipeline
const { viteBundle } = await import('../vite-bundler');

const bundleResult = await viteBundle({
rootDir: projectDir,
dev: dev || false,
projectId,
orgId,
region: region ?? 'local',
deploymentId,
logger,
deploymentOptions,
deploymentConfig,
collector,
});

logs.push(...bundleResult.output);

return {
outputDir: join(projectDir, '.agentuity'),
startCommand: 'bun app.js',
serverEntry: 'app.js',
port: 3500,
duration: Date.now() - started,
logs,
};
},
};
239 changes: 239 additions & 0 deletions packages/cli/src/cmd/build/adapters/generic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/**
* Generic build adapter.
*
* Handles any JS framework by:
* 1. Installing dependencies
* 2. Running the project's build script
* 3. Copying the build output to the output directory
*
* This is the fallback for frameworks without a specific adapter,
* and is also the base logic that specific adapters build on.
*/

import { basename, join, resolve, relative } from 'node:path';
import { cpSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import type { BuildAdapter, BuildAdapterOptions, BuildResult } from './types';
import { getRunCommand } from '../detect/util';

/**
* Run a shell command and return exit code.
*/
async function runCommand(
cmd: string[],
cwd: string,
env?: Record<string, string>
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const proc = Bun.spawn(cmd, {
cwd,
env: { ...process.env, ...env },
stdout: 'pipe',
stderr: 'pipe',
});

const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);

await proc.exited;

return {
exitCode: proc.exitCode ?? 1,
stdout,
stderr,
};
}

/**
* Install dependencies using the detected package manager.
*/
export async function installDependencies(
projectDir: string,
packageManager: string,
logger: { debug: (...args: unknown[]) => void }
): Promise<void> {
let cmd: string[];
switch (packageManager) {
case 'bun':
cmd = ['bun', 'install'];
break;
case 'pnpm':
cmd = ['pnpm', 'install', '--frozen-lockfile'];
break;
case 'yarn':
cmd = ['yarn', 'install', '--frozen-lockfile'];
break;
default:
cmd = ['npm', 'ci'];
break;
}

logger.debug(`Installing dependencies with: ${cmd.join(' ')}`);

const result = await runCommand(cmd, projectDir);
if (result.exitCode !== 0) {
throw new Error(
`Dependency installation failed (exit ${result.exitCode}):\n${result.stderr}`
);
}
}

/**
* Run the framework's build command.
*/
export async function runBuildCommand(
projectDir: string,
buildCommand: string,
packageManager: string,
buildEnv?: Record<string, string>,
logger?: { debug: (...args: unknown[]) => void }
): Promise<{ stdout: string; stderr: string }> {
// If it's a package.json script name, use the package manager's run command
// If it contains spaces or special chars, it's likely a direct command
const isScriptName = /^[a-zA-Z0-9_:-]+$/.test(buildCommand);

let cmd: string[];
if (isScriptName && buildCommand !== '__agentuity_internal__') {
const runCmd = getRunCommand(packageManager as 'bun' | 'npm' | 'pnpm' | 'yarn');
cmd = runCmd.split(' ').concat(buildCommand);
} else {
cmd = ['sh', '-c', buildCommand];
}

logger?.debug(`Running build command: ${cmd.join(' ')}`);

const result = await runCommand(cmd, projectDir, buildEnv);
if (result.exitCode !== 0) {
throw new Error(`Build failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`);
}

return { stdout: result.stdout, stderr: result.stderr };
}

export const genericAdapter: BuildAdapter = {
name: 'generic',

async build(options: BuildAdapterOptions): Promise<BuildResult> {
const { projectDir, framework, outputDir, logger } = options;
const started = Date.now();
const logs: string[] = [];

// Step 1: Install dependencies
logger.debug('Installing dependencies...');
const installStart = Date.now();
await installDependencies(projectDir, framework.packageManager, logger);
logs.push(`✓ Dependencies installed in ${Date.now() - installStart}ms`);

// Step 2: Run the build command
if (framework.buildCommand && framework.buildCommand !== '__agentuity_internal__') {
logger.debug(`Running build: ${framework.buildCommand}`);
const buildStart = Date.now();
await runBuildCommand(
projectDir,
framework.buildCommand,
framework.packageManager,
framework.buildEnv,
logger
);
logs.push(`✓ Build completed in ${Date.now() - buildStart}ms`);
}

// Step 3: Copy build output to output directory
const buildOutputPath = resolve(projectDir, framework.buildOutput);
const resolvedOutputDir = resolve(outputDir);

// Copy build output to the output directory when they differ.
// When buildOutput is '.' (project root), the output dir is a subdirectory
// of the source, so we iterate entries to avoid cpSync's self-copy check.
const shouldCopy = existsSync(buildOutputPath) && buildOutputPath !== resolvedOutputDir;

if (shouldCopy) {
logger.debug(`Copying build output from ${buildOutputPath} to ${resolvedOutputDir}`);
mkdirSync(resolvedOutputDir, { recursive: true });

// Skip directories that shouldn't be deployed
const skipEntries = new Set([
'node_modules',
'.git',
'.env',
basename(resolvedOutputDir), // e.g., '.agentuity'
]);

const entries = readdirSync(buildOutputPath);
for (const entry of entries) {
if (skipEntries.has(entry)) continue;
const srcPath = join(buildOutputPath, entry);
const dstPath = join(resolvedOutputDir, entry);
cpSync(srcPath, dstPath, { recursive: true });
}
} else {
// Ensure output dir exists even when we skip the copy
mkdirSync(resolvedOutputDir, { recursive: true });
}

// Step 4: Determine start command — inject static file server if none exists
let { startCommand, serverEntry } = framework;

if (!startCommand) {
// No start command (static-only build) — inject a minimal file server
const { injectStaticServer } = await import('./static-server');
const injected = injectStaticServer(outputDir);
startCommand = injected.startCommand;
serverEntry = injected.serverEntry;
logs.push('✓ Injected static file server (no start script found)');
}

// Step 5: Copy package.json and node_modules for runtime
const pkgJsonSrc = join(projectDir, 'package.json');
const pkgJsonDst = join(outputDir, 'package.json');
if (existsSync(pkgJsonSrc) && !existsSync(pkgJsonDst)) {
cpSync(pkgJsonSrc, pkgJsonDst);
}

const nodeModulesSrc = join(projectDir, 'node_modules');
const nodeModulesDst = join(outputDir, 'node_modules');
if (existsSync(nodeModulesSrc) && !existsSync(nodeModulesDst)) {
logger.debug('Copying node_modules for runtime dependencies...');
cpSync(nodeModulesSrc, nodeModulesDst, { recursive: true });
}

// Step 6: Resolve static asset directory for CDN upload
// staticDir is relative to the project root (set by framework detection).
// If it matches buildOutput, the files are already in outputDir from the copy.
// Otherwise, copy the static assets into the output so deploy can find them.
let resolvedStaticDir: string | undefined;

if (framework.staticDir) {
const staticSrcPath = resolve(projectDir, framework.staticDir);
const buildOutputPath = resolve(projectDir, framework.buildOutput);

// Check if the static dir is inside the build output (already copied)
if (staticSrcPath.startsWith(buildOutputPath + '/') || staticSrcPath === buildOutputPath) {
// Static assets are within the copied build output
const relativeToOutput = relative(buildOutputPath, staticSrcPath);
resolvedStaticDir = relativeToOutput
? join(resolvedOutputDir, relativeToOutput)
: resolvedOutputDir;
} else if (existsSync(staticSrcPath)) {
// Static assets are outside the build output — copy them into the output
const staticDstPath = join(resolvedOutputDir, framework.staticDir);
logger.debug(`Copying static assets from ${staticSrcPath} to ${staticDstPath}`);
mkdirSync(staticDstPath, { recursive: true });
cpSync(staticSrcPath, staticDstPath, { recursive: true });
resolvedStaticDir = staticDstPath;
logs.push(`✓ Copied static assets from ${framework.staticDir}`);
}
}

return {
outputDir,
startCommand,
serverEntry,
staticDir:
resolvedStaticDir && existsSync(resolvedStaticDir) ? resolvedStaticDir : undefined,
port: framework.port,
duration: Date.now() - started,
logs,
};
},
};
Loading
Loading