Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const ctx = await esbuild.context({
outExtension: {
'.js': '.cjs',
},
loader: { '.ts': 'ts' },
loader: { '.ts': 'ts', '.tsx': 'tsx' },
external: ['vscode'],
platform: 'node',
sourcemap: !minify,
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,14 @@
"@redocly/cli": "^2.15.0",
"agentlang": "^0.10.2",
"better-sqlite3": "^12.6.2",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"commander": "^14.0.2",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"esbuild": "^0.27.2",
"express": "^5.2.1",
"fs-extra": "^11.3.3",
"ink": "^6.8.0",
"langium": "^4.1.3",
"mammoth": "^1.11.0",
"multer": "^2.0.2",
Expand All @@ -75,6 +75,7 @@
"openapi-to-postmanv2": "^5.8.0",
"ora": "^9.1.0",
"pdf-parse": "^2.4.5",
"react": "^19.2.4",
"simple-git": "^3.30.0",
"sqlite-vec": "0.1.7-alpha.2",
"tmp": "^0.2.5",
Expand All @@ -91,6 +92,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^25.0.10",
"@types/openapi-to-postmanv2": "^5.0.0",
"@types/react": "^19.2.14",
"@types/tmp": "^0.2.6",
"eslint": "^9.39.2",
"prettier": "^3.8.1",
Expand Down
340 changes: 321 additions & 19 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

484 changes: 208 additions & 276 deletions src/main.ts

Large diffs are not rendered by default.

194 changes: 87 additions & 107 deletions src/repl.ts

Large diffs are not rendered by default.

83 changes: 42 additions & 41 deletions src/studio.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,60 @@
/* eslint-disable no-console */
import express from 'express';
import cors from 'cors';
import path from 'path';
import chalk from 'chalk';
import ora from 'ora';
import { ui } from './ui/index.js';
import open from 'open';
import { getWorkspaceRoot, findLStudioPath } from './studio/utils.js';
import { FileService } from './studio/services/FileService.js';
import { StudioServer } from './studio/services/StudioServer.js';
import { createRoutes } from './studio/routes.js';

export async function startStudio(projectPath = '.', studioPort = 4000, serverOnly = false): Promise<void> {
const spinner = ora(serverOnly ? 'Starting Studio backend server...' : 'Starting Agent Studio...').start();
const inputDir = path.resolve(process.cwd(), projectPath);

// Smart Parent Detection: Determine workspace root and initial app
const { workspaceRoot, initialAppPath } = getWorkspaceRoot(inputDir);

// ── Startup banner ──────────────────────────────────────────────────────────
ui.blank();
ui.banner(serverOnly ? 'Agent Studio' : 'Agent Studio', serverOnly ? 'Backend Server' : undefined);
ui.blank();

if (initialAppPath) {
ui.label('Project', path.basename(initialAppPath), 'cyan');
}
ui.label('Workspace', workspaceRoot);
ui.label('Port', String(studioPort), 'cyan');
ui.blank();

// ── Initialize ──────────────────────────────────────────────────────────────
const spinner = ora({
text: ui.format.dim('Initializing...'),
spinner: 'dots',
}).start();

// Initialize Services with workspace root
const fileService = new FileService(workspaceRoot);
const studioServer = new StudioServer(workspaceRoot, initialAppPath, fileService);

// Always use Dashboard Mode
if (initialAppPath) {
// Launched from inside a project - show workspace with initial app highlighted
console.log(chalk.blue(`ℹ Detected project: ${path.basename(initialAppPath)}`));
console.log(chalk.blue(`ℹ Workspace root: ${workspaceRoot}`));
console.log(chalk.dim(' Auto-launching app...'));

// Auto-launch the detected app
// This sets the current app in StudioServer, which the frontend can detect via /workspace or /apps
spinner.text = ui.format.dim(`Launching ${path.basename(initialAppPath)}...`);
await studioServer.launchApp(initialAppPath);
} else {
// Launched from a workspace directory
console.log(chalk.blue(`ℹ Workspace root: ${workspaceRoot}`));
console.log(chalk.dim(' Starting in Dashboard Mode. Select an app from the UI to launch it.'));
spinner.text = ui.format.dim('Starting Dashboard Mode...');
}
spinner.succeed(chalk.green('Studio Dashboard Ready'));

spinner.succeed(ui.format.success('Studio initialized'));

// Find @agentlang/lstudio (skip in server-only mode)
// Try to find it in the input directory first (for projects with local lstudio)
// then fall back to workspace root
let lstudioPath: string | null = null;
if (!serverOnly) {
lstudioPath = findLStudioPath(inputDir);
if (!lstudioPath && inputDir !== workspaceRoot) {
lstudioPath = findLStudioPath(workspaceRoot);
}
if (!lstudioPath) {
// Only error if we really can't find it, but in dev mode it might differ.
// Warn instead of exit in case we are just using API
console.warn(chalk.yellow('Warning: Could not find @agentlang/lstudio UI files.'));
ui.warn('Could not find @agentlang/lstudio UI files.');
}
}

Expand All @@ -64,12 +68,9 @@ export async function startStudio(projectPath = '.', studioPort = 4000, serverOn

// Serve static files from @agentlang/lstudio/dist (skip in server-only mode)
if (!serverOnly && lstudioPath) {
// Serve static files with fallthrough disabled - if file not found, continue to next middleware
app.use(express.static(lstudioPath, { fallthrough: true }));

// Handle client-side routing - serve index.html for all non-API, non-static-file routes
app.use((req, res, next) => {
// Skip if this is an API route (already handled by createRoutes)
if (
req.path.startsWith('/files') ||
req.path.startsWith('/file') ||
Expand All @@ -78,16 +79,14 @@ export async function startStudio(projectPath = '.', studioPort = 4000, serverOn
req.path.startsWith('/branch') ||
req.path.startsWith('/install') ||
req.path.startsWith('/env-config.js') ||
req.path.startsWith('/workspace') || // workspace info
req.path.startsWith('/workspace') ||
req.path.startsWith('/apps') ||
req.path.startsWith('/app/') ||
req.path.startsWith('/documents') // document upload routes
req.path.startsWith('/documents')
) {
return next();
}

// Check if this is a request for a static file with a known extension
// express.static would have already served it if it existed
const staticFileExtensions = [
'.js',
'.css',
Expand All @@ -107,13 +106,10 @@ export async function startStudio(projectPath = '.', studioPort = 4000, serverOn
];
const hasStaticExtension = staticFileExtensions.some(ext => req.path.toLowerCase().endsWith(ext));

// If it's a static file request that express.static didn't handle, return 404
if (hasStaticExtension) {
return res.status(404).send('File not found');
}

// For all other GET requests, serve index.html
// (client-side routing will handle the rest, including routes with dots in them)
if (req.method === 'GET' && lstudioPath) {
res.sendFile(path.join(lstudioPath, 'index.html'));
} else {
Expand All @@ -122,29 +118,34 @@ export async function startStudio(projectPath = '.', studioPort = 4000, serverOn
});
}

// Start server
// ── Start server ────────────────────────────────────────────────────────────
await new Promise<void>(resolve => {
app.listen(studioPort, () => {
spinner.succeed(chalk.green(`Studio server is running on http://localhost:${studioPort}`));
const studioUrl = `http://localhost:${studioPort}`;

ui.blank();
ui.divider(50);
ui.success('Studio is ready');
ui.blank();

if (serverOnly) {
console.log(chalk.blue(`Backend API available at: ${studioUrl}`));
console.log(chalk.dim('Endpoints: /files, /file, /info, /branch, /test, /workspace, /apps, /app/launch'));
ui.label('API', studioUrl, 'cyan');
ui.dim(' Endpoints: /files, /file, /info, /branch, /test, /workspace, /apps, /app/launch');
} else {
console.log(chalk.blue(`Studio UI is available at: ${studioUrl}`));
ui.label('Local', studioUrl, 'cyan');
}

// Open browser automatically (skip in server-only mode)
ui.blank();
ui.divider(50);
ui.blank();

if (!serverOnly) {
void open(studioUrl).catch(() => {
// Ignore errors when opening browser
});
void open(studioUrl);
}

// Handle cleanup on exit
const cleanup = () => {
console.log(chalk.yellow('\nShutting down...'));
ui.blank();
ui.warn('Shutting down...');
studioServer.stopApp(false);
process.exit(0);
};
Expand Down
29 changes: 7 additions & 22 deletions src/studio/services/AppManagementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import path from 'path';
import { existsSync } from 'fs';
import { rm } from 'fs/promises';
import chalk from 'chalk';
import { ui } from '../../ui/index.js';
import { initializeProject } from '../../utils/projectInitializer.js';
import { forkApp as forkAppUtil, type ForkOptions } from '../../utils/forkApp.js';
import { AppInfo } from '../types.js';
Expand All @@ -12,30 +12,15 @@ export class AppManagementService {

async createApp(name: string): Promise<AppInfo> {
const appPath = path.join(this.workspaceRoot, name);
console.log(`[AppManagement] createApp: Creating app "${name}" at path: ${appPath}`);

try {
// Use the shared initialization logic
console.log(`[AppManagement] createApp: Starting project initialization for "${name}"`);
const initStartTime = Date.now();

await initializeProject(appPath, name, {
silent: false, // Enable logging for better visibility
skipInstall: false, // Install dependencies so the app is ready to run
skipGit: false, // Initialize git repo
silent: false,
skipInstall: false,
skipGit: false,
});

const initDuration = Date.now() - initStartTime;
console.log(`[AppManagement] createApp: Project initialization completed for "${name}" in ${initDuration}ms`);

const appInfo = {
name,
path: appPath,
isInitialApp: false,
};

console.log(`[AppManagement] createApp: Successfully created app "${name}"`);
return appInfo;
return { name, path: appPath, isInitialApp: false };
} catch (error) {
console.error(`[AppManagement] createApp: Failed to create app "${name}":`, error);
throw error;
Expand Down Expand Up @@ -73,8 +58,8 @@ export class AppManagementService {
}

// Delete the app directory
console.log(chalk.yellow(`Deleting app: ${appPath}`));
ui.warn(`Deleting app: ${appPath}`);
await rm(appPath, { recursive: true, force: true });
console.log(chalk.green(`Successfully deleted app: ${appPath}`));
ui.success(`Successfully deleted app: ${appPath}`);
}
}
29 changes: 14 additions & 15 deletions src/studio/services/AppRuntimeService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable no-console */
import path from 'path';
import { existsSync } from 'fs';
import { spawn, ChildProcess, execSync } from 'child_process';
import chalk from 'chalk';
import { ui, ansi } from '../../ui/index.js';
import { FileService } from './FileService.js';
import { runPreInitTasks } from '../runtime.js';
import { fileURLToPath } from 'url';
Expand All @@ -17,7 +16,7 @@ export class AppRuntimeService {
constructor(private fileService: FileService) {}

async launchApp(appPath: string): Promise<void> {
console.log(chalk.blue(`\nLaunching app: ${appPath}`));
ui.dim(` Starting ${path.basename(appPath)}...`);

// 1. Stop existing app if any
this.stopApp(false); // false = don't reset to root yet, we are switching
Expand All @@ -30,10 +29,10 @@ export class AppRuntimeService {
try {
const preInitSuccess = await runPreInitTasks();
if (!preInitSuccess) {
console.warn(chalk.yellow('Warning: Failed to initialize Agentlang runtime'));
ui.warn('Failed to initialize Agentlang runtime');
}
} catch (error) {
console.warn(chalk.yellow('Warning: Runtime initialization error:'), error);
ui.warn(`Runtime initialization error: ${String(error)}`);
}

// 4. Check and install dependencies if needed
Expand All @@ -44,7 +43,7 @@ export class AppRuntimeService {
const needsInstall = !existsSync(nodeModulesPath) || !existsSync(path.join(nodeModulesPath, 'sqlite3'));

if (needsInstall) {
console.log(chalk.yellow('📦 Dependencies not found. Installing...'));
ui.warn('Dependencies not found. Installing...');
try {
execSync('npm install', {
cwd: appPath,
Expand All @@ -55,9 +54,9 @@ export class AppRuntimeService {
GIT_TERMINAL_PROMPT: '0',
},
});
console.log(chalk.green('✓ Dependencies installed'));
ui.success('Dependencies installed');
} catch (error) {
console.error(chalk.red('Failed to install dependencies:'), error);
ui.error(`Failed to install dependencies: ${String(error)}`);
throw new Error('Failed to install dependencies. Please run "npm install" manually in the app directory.');
}
}
Expand All @@ -67,7 +66,7 @@ export class AppRuntimeService {
try {
await this.fileService.loadProject();
} catch (error) {
console.error(chalk.red('Failed to load project runtime:'), error);
ui.error(`Failed to load project runtime: ${String(error)}`);
// Continue anyway to allow editing
}

Expand Down Expand Up @@ -107,30 +106,30 @@ export class AppRuntimeService {

if (this.agentProcess) {
this.agentProcess.stdout?.on('data', (data: Buffer) => {
process.stdout.write(chalk.dim(`[Agent ${path.basename(appPath)}] ${data.toString()}`));
process.stdout.write(ansi.dim(`[Agent ${path.basename(appPath)}] ${data.toString()}`));
});
this.agentProcess.stderr?.on('data', (data: Buffer) => {
process.stderr.write(chalk.dim(`[Agent ${path.basename(appPath)}] ${data.toString()}`));
process.stderr.write(ansi.dim(`[Agent ${path.basename(appPath)}] ${data.toString()}`));
});

console.log(chalk.green(`✓ Agent process started (PID: ${this.agentProcess.pid})`));
ui.success(`Agent process started (PID: ${this.agentProcess.pid})`);
}
} catch (error) {
console.error(chalk.red('Failed to spawn agent process:'), error);
ui.error(`Failed to spawn agent process: ${String(error)}`);
}
}

stopApp(resetToRoot = true, workspaceRoot?: string): void {
if (this.agentProcess) {
console.log(chalk.yellow('\nStopping active app...'));
ui.warn('Stopping active app...');
this.agentProcess.kill();
this.agentProcess = null;
}

if (resetToRoot && workspaceRoot) {
this.currentAppPath = null;
this.fileService.setTargetDir(workspaceRoot);
console.log(chalk.green('✓ Returned to Dashboard Mode'));
ui.success('Returned to Dashboard Mode');
} else if (resetToRoot) {
this.currentAppPath = null;
}
Expand Down
Loading