mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-05 00:13:07 +00:00
Add a Node.js CLI wrapper that allows installing AutoForge globally via `npm install -g autoforge-ai` and running it with a single `autoforge` command. The CLI handles Python detection, venv management, config loading, and uvicorn server lifecycle automatically. New files: - package.json: npm package config with bin entry, files whitelist, and prepublishOnly script that builds the UI - bin/autoforge.js: thin entry point that imports lib/cli.js - lib/cli.js: main CLI module (~790 lines) with cross-platform Python 3.11+ detection, composite venv marker for smart invalidation (requirements hash + Python version + path), .env config management at ~/.autoforge/.env, server startup with PID file and port detection, and signal handling with process tree cleanup - requirements-prod.txt: runtime-only deps (excludes ruff, mypy, pytest) - .npmignore: excludes dev files, tests, __pycache__, UI source Modified files: - ui/package.json: rename to autoforge-ui to avoid confusion with root - .gitignore: add *.tgz for npm pack output - README.md: add npm install as primary quick start method, document CLI commands, add Ollama/Vertex AI config sections, new troubleshooting entries for Python/venv issues - GettingStarted.tsx: add Installation, Quick Start, and CLI Commands sections to in-app documentation with command reference table - docsData.ts: add installation and cli-commands sidebar entries Published as autoforge-ai@0.1.0 on npm. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
792 lines
23 KiB
JavaScript
792 lines
23 KiB
JavaScript
/**
|
|
* AutoForge CLI
|
|
* =============
|
|
*
|
|
* Main CLI module for the AutoForge npm global package.
|
|
* Handles Python detection, virtual environment management,
|
|
* config loading, and uvicorn server lifecycle.
|
|
*
|
|
* Uses only Node.js built-in modules -- no external dependencies.
|
|
*/
|
|
|
|
import { execFileSync, spawn, execSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, copyFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { createServer } from 'node:net';
|
|
import { homedir, platform } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Root of the autoforge npm package (one level up from lib/) */
|
|
const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
/** User config home: ~/.autoforge/ */
|
|
const CONFIG_HOME = join(homedir(), '.autoforge');
|
|
|
|
/** Virtual-environment directory managed by the CLI */
|
|
const VENV_DIR = join(CONFIG_HOME, 'venv');
|
|
|
|
/** Composite marker written after a successful pip install */
|
|
const DEPS_MARKER = join(VENV_DIR, '.deps-installed');
|
|
|
|
/** PID file for the running server */
|
|
const PID_FILE = join(CONFIG_HOME, 'server.pid');
|
|
|
|
/** Path to the production requirements file inside the package */
|
|
const REQUIREMENTS_FILE = join(PKG_DIR, 'requirements-prod.txt');
|
|
|
|
/** Path to the .env example shipped with the package */
|
|
const ENV_EXAMPLE = join(PKG_DIR, '.env.example');
|
|
|
|
/** User .env config file */
|
|
const ENV_FILE = join(CONFIG_HOME, '.env');
|
|
|
|
const IS_WIN = platform() === 'win32';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Package version (read lazily via createRequire)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { version: VERSION } = require(join(PKG_DIR, 'package.json'));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Indented console output matching the spec format. */
|
|
function log(msg = '') {
|
|
console.log(` ${msg}`);
|
|
}
|
|
|
|
/** Print a fatal error and exit. */
|
|
function die(msg) {
|
|
console.error(`\n Error: ${msg}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Parse a Python version string like "Python 3.13.6" and return
|
|
* { major, minor, patch, raw } or null on failure.
|
|
*/
|
|
function parsePythonVersion(raw) {
|
|
const m = raw.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
|
|
if (!m) return null;
|
|
return {
|
|
major: Number(m[1]),
|
|
minor: Number(m[2]),
|
|
patch: Number(m[3]),
|
|
raw: `${m[1]}.${m[2]}.${m[3]}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Try a single Python candidate. Returns { exe, version } or null.
|
|
* `candidate` is either a bare name or an array of args (e.g. ['py', '-3']).
|
|
*/
|
|
function tryPythonCandidate(candidate) {
|
|
const args = Array.isArray(candidate) ? candidate : [candidate];
|
|
const exe = args[0];
|
|
const extraArgs = args.slice(1);
|
|
|
|
try {
|
|
const out = execFileSync(exe, [...extraArgs, '--version'], {
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const ver = parsePythonVersion(out);
|
|
if (!ver) return null;
|
|
|
|
// Require 3.11+
|
|
if (ver.major < 3 || (ver.major === 3 && ver.minor < 11)) {
|
|
return { exe: args.join(' '), version: ver, tooOld: true };
|
|
}
|
|
|
|
return { exe: args.join(' '), version: ver, tooOld: false };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Python detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find a suitable Python >= 3.11 interpreter.
|
|
*
|
|
* Search order is platform-dependent:
|
|
* Windows: python -> py -3 -> python3
|
|
* macOS/Linux: python3 -> python
|
|
*
|
|
* The AUTOFORGE_PYTHON env var overrides automatic detection.
|
|
*
|
|
* After finding a candidate we also verify that the venv module is
|
|
* available (Debian/Ubuntu strip it out of the base package).
|
|
*/
|
|
function findPython() {
|
|
// Allow explicit override via environment variable
|
|
const override = process.env.AUTOFORGE_PYTHON;
|
|
if (override) {
|
|
const result = tryPythonCandidate(override);
|
|
if (!result) {
|
|
die(`AUTOFORGE_PYTHON is set to "${override}" but it could not be executed.`);
|
|
}
|
|
if (result.tooOld) {
|
|
die(
|
|
`Python ${result.version.raw} found (via AUTOFORGE_PYTHON), but 3.11+ required.\n` +
|
|
' Install Python 3.11+ from https://python.org'
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Platform-specific candidate order
|
|
const candidates = IS_WIN
|
|
? ['python', ['py', '-3'], 'python3']
|
|
: ['python3', 'python'];
|
|
|
|
let bestTooOld = null;
|
|
|
|
for (const candidate of candidates) {
|
|
const result = tryPythonCandidate(candidate);
|
|
if (!result) continue;
|
|
|
|
if (result.tooOld) {
|
|
// Remember the first "too old" result for a better error message
|
|
if (!bestTooOld) bestTooOld = result;
|
|
continue;
|
|
}
|
|
|
|
// Verify venv module is available (Debian/Ubuntu may need python3-venv)
|
|
try {
|
|
const exeParts = result.exe.split(' ');
|
|
execFileSync(exeParts[0], [...exeParts.slice(1), '-c', 'import ensurepip'], {
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
} catch {
|
|
die(
|
|
`Python venv module not available.\n` +
|
|
` Run: sudo apt install python3.${result.version.minor}-venv`
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Provide the most helpful error message we can
|
|
if (bestTooOld) {
|
|
die(
|
|
`Python ${bestTooOld.version.raw} found, but 3.11+ required.\n` +
|
|
' Install Python 3.11+ from https://python.org'
|
|
);
|
|
}
|
|
die(
|
|
'Python 3.11+ required but not found.\n' +
|
|
' Install from https://python.org'
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Venv management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Return the path to the Python executable inside the venv. */
|
|
function venvPython() {
|
|
return IS_WIN
|
|
? join(VENV_DIR, 'Scripts', 'python.exe')
|
|
: join(VENV_DIR, 'bin', 'python');
|
|
}
|
|
|
|
/** SHA-256 hash of the requirements-prod.txt file contents. */
|
|
function requirementsHash() {
|
|
const content = readFileSync(REQUIREMENTS_FILE, 'utf8');
|
|
return createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Read the composite deps marker. Returns the parsed JSON object
|
|
* or null if the file is missing / corrupt.
|
|
*/
|
|
function readMarker() {
|
|
try {
|
|
return JSON.parse(readFileSync(DEPS_MARKER, 'utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure the virtual environment exists and dependencies are installed.
|
|
* Returns true if all setup steps were already satisfied (fast path).
|
|
*
|
|
* @param {object} python - The result of findPython()
|
|
* @param {boolean} forceRecreate - If true, delete and recreate the venv
|
|
*/
|
|
function ensureVenv(python, forceRecreate) {
|
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
|
|
const marker = readMarker();
|
|
const reqHash = requirementsHash();
|
|
const pyExe = venvPython();
|
|
|
|
// Determine if the venv itself needs to be (re)created
|
|
let needsCreate = forceRecreate || !existsSync(pyExe);
|
|
|
|
if (!needsCreate && marker) {
|
|
// Recreate if Python major.minor changed
|
|
const markerMinor = marker.python_version;
|
|
const currentMinor = `${python.version.major}.${python.version.minor}`;
|
|
if (markerMinor && markerMinor !== currentMinor) {
|
|
needsCreate = true;
|
|
}
|
|
|
|
// Recreate if the recorded python path no longer exists
|
|
if (marker.python_path && !existsSync(marker.python_path)) {
|
|
needsCreate = true;
|
|
}
|
|
}
|
|
|
|
let depsUpToDate = false;
|
|
if (!needsCreate && marker && marker.requirements_hash === reqHash) {
|
|
depsUpToDate = true;
|
|
}
|
|
|
|
// Fast path: nothing to do
|
|
if (!needsCreate && depsUpToDate) {
|
|
return true;
|
|
}
|
|
|
|
// --- Slow path: show setup progress ---
|
|
|
|
log('[2/3] Setting up environment...');
|
|
|
|
if (needsCreate) {
|
|
if (existsSync(VENV_DIR)) {
|
|
log(' Removing old virtual environment...');
|
|
rmSync(VENV_DIR, { recursive: true, force: true });
|
|
}
|
|
|
|
log(` Creating virtual environment at ~/.autoforge/venv/`);
|
|
const exeParts = python.exe.split(' ');
|
|
try {
|
|
execFileSync(exeParts[0], [...exeParts.slice(1), '-m', 'venv', VENV_DIR], {
|
|
encoding: 'utf8',
|
|
timeout: 120_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
} catch (err) {
|
|
die(`Failed to create virtual environment: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Install / update dependencies
|
|
log(' Installing dependencies...');
|
|
try {
|
|
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], {
|
|
encoding: 'utf8',
|
|
timeout: 300_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '-r', REQUIREMENTS_FILE], {
|
|
encoding: 'utf8',
|
|
timeout: 600_000,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
} catch (err) {
|
|
die(`Failed to install dependencies: ${err.message}`);
|
|
}
|
|
|
|
// Write marker only after pip succeeds to prevent partial state
|
|
const markerData = {
|
|
requirements_hash: reqHash,
|
|
python_version: `${python.version.major}.${python.version.minor}`,
|
|
python_path: pyExe,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
writeFileSync(DEPS_MARKER, JSON.stringify(markerData, null, 2), 'utf8');
|
|
|
|
log(' Done');
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config (.env) management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse a .env file into a plain object.
|
|
* Handles comments, blank lines, and quoted values.
|
|
*/
|
|
function parseEnvFile(filePath) {
|
|
const env = {};
|
|
if (!existsSync(filePath)) return env;
|
|
|
|
const lines = readFileSync(filePath, 'utf8').split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx === -1) continue;
|
|
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
let value = trimmed.slice(eqIdx + 1).trim();
|
|
|
|
// Strip matching quotes (single or double)
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
if (key) {
|
|
env[key] = value;
|
|
}
|
|
}
|
|
return env;
|
|
}
|
|
|
|
/**
|
|
* Ensure ~/.autoforge/.env exists. On first run, copy .env.example
|
|
* from the package directory and print a notice.
|
|
*
|
|
* Returns true if the file was newly created.
|
|
*/
|
|
function ensureEnvFile() {
|
|
if (existsSync(ENV_FILE)) return false;
|
|
|
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
|
|
if (existsSync(ENV_EXAMPLE)) {
|
|
copyFileSync(ENV_EXAMPLE, ENV_FILE);
|
|
} else {
|
|
// Fallback: create a minimal placeholder
|
|
writeFileSync(ENV_FILE, '# AutoForge configuration\n# See documentation for available options.\n', 'utf8');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Port detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find an available TCP port starting from `start`.
|
|
* Tries by actually binding a socket (most reliable cross-platform approach).
|
|
*/
|
|
function findAvailablePort(start = 8888, maxAttempts = 20) {
|
|
for (let port = start; port < start + maxAttempts; port++) {
|
|
try {
|
|
const server = createServer();
|
|
// Use a synchronous-like approach: try to listen, then close immediately
|
|
const result = new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(port, '127.0.0.1', () => {
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
// We cannot await here (sync context), so use the blocking approach:
|
|
// Try to bind synchronously using a different technique.
|
|
server.close();
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
// Synchronous fallback: try to connect; if connection refused, port is free.
|
|
for (let port = start; port < start + maxAttempts; port++) {
|
|
try {
|
|
execFileSync(process.execPath, [
|
|
'-e',
|
|
`const s=require("net").createServer();` +
|
|
`s.listen(${port},"127.0.0.1",()=>{s.close();process.exit(0)});` +
|
|
`s.on("error",()=>process.exit(1))`,
|
|
], { timeout: 3000, stdio: 'pipe' });
|
|
return port;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
die(`No available ports found in range ${start}-${start + maxAttempts - 1}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PID file management
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Read PID from the PID file. Returns the PID number or null. */
|
|
function readPid() {
|
|
try {
|
|
const content = readFileSync(PID_FILE, 'utf8').trim();
|
|
const pid = Number(content);
|
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Check whether a process with the given PID is still running. */
|
|
function isProcessAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0); // signal 0 = existence check
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Write the PID file. */
|
|
function writePid(pid) {
|
|
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
writeFileSync(PID_FILE, String(pid), 'utf8');
|
|
}
|
|
|
|
/** Remove the PID file. */
|
|
function removePid() {
|
|
try {
|
|
unlinkSync(PID_FILE);
|
|
} catch {
|
|
// Ignore -- file may already be gone
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Browser opening
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Open a URL in the user's default browser (best-effort). */
|
|
function openBrowser(url) {
|
|
try {
|
|
if (IS_WIN) {
|
|
// "start" is a cmd built-in; the empty title string avoids
|
|
// issues when the URL contains special characters.
|
|
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
} else if (platform() === 'darwin') {
|
|
execFileSync('open', [url], { stdio: 'ignore' });
|
|
} else {
|
|
// Linux: only attempt if a display server is available and
|
|
// we are not in an SSH session.
|
|
const hasDisplay = process.env.DISPLAY || process.env.WAYLAND_DISPLAY;
|
|
const isSSH = !!process.env.SSH_TTY;
|
|
if (hasDisplay && !isSSH) {
|
|
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-fatal: user can open the URL manually
|
|
}
|
|
}
|
|
|
|
/** Detect headless / CI environments where opening a browser is pointless. */
|
|
function isHeadless() {
|
|
if (process.env.CI) return true;
|
|
if (process.env.CODESPACES) return true;
|
|
if (process.env.SSH_TTY) return true;
|
|
// Linux without a display server
|
|
if (!IS_WIN && platform() !== 'darwin' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Kill a process tree. On Windows uses taskkill; elsewhere sends SIGTERM. */
|
|
function killProcess(pid) {
|
|
try {
|
|
if (IS_WIN) {
|
|
execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' });
|
|
} else {
|
|
process.kill(pid, 'SIGTERM');
|
|
}
|
|
} catch {
|
|
// Process may already be gone
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function printVersion() {
|
|
console.log(`autoforge v${VERSION}`);
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`
|
|
AutoForge v${VERSION}
|
|
Autonomous coding agent with web UI
|
|
|
|
Usage:
|
|
autoforge Start the server (default)
|
|
autoforge config Open ~/.autoforge/.env in $EDITOR
|
|
autoforge config --path Print config file path
|
|
autoforge config --show Show effective configuration
|
|
|
|
Options:
|
|
--port PORT Custom port (default: auto from 8888)
|
|
--host HOST Custom host (default: 127.0.0.1)
|
|
--no-browser Don't auto-open browser
|
|
--repair Delete and recreate virtual environment
|
|
--dev Development mode (requires cloned repo)
|
|
--version Print version
|
|
--help Show this help
|
|
`);
|
|
}
|
|
|
|
function handleConfig(args) {
|
|
ensureEnvFile();
|
|
|
|
if (args.includes('--path')) {
|
|
console.log(ENV_FILE);
|
|
return;
|
|
}
|
|
|
|
if (args.includes('--show')) {
|
|
if (!existsSync(ENV_FILE)) {
|
|
log('No configuration file found.');
|
|
return;
|
|
}
|
|
const lines = readFileSync(ENV_FILE, 'utf8').split('\n');
|
|
const active = lines.filter(l => {
|
|
const t = l.trim();
|
|
return t && !t.startsWith('#');
|
|
});
|
|
if (active.length === 0) {
|
|
log('No active configuration. All lines are commented out.');
|
|
log(`Edit: ${ENV_FILE}`);
|
|
} else {
|
|
for (const line of active) {
|
|
console.log(line);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Open in editor
|
|
const editor = process.env.EDITOR || process.env.VISUAL || (IS_WIN ? 'notepad' : 'vi');
|
|
try {
|
|
execFileSync(editor, [ENV_FILE], { stdio: 'inherit' });
|
|
} catch {
|
|
log(`Could not open editor "${editor}".`);
|
|
log(`Edit the file manually: ${ENV_FILE}`);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main server start
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function startServer(opts) {
|
|
const { port: requestedPort, host, noBrowser, repair } = opts;
|
|
|
|
// Step 1: Find Python
|
|
const fastPath = !repair && existsSync(venvPython()) && readMarker()?.requirements_hash === requirementsHash();
|
|
|
|
let python;
|
|
if (fastPath) {
|
|
// Skip the Python search header on fast path -- we already have a working venv
|
|
python = null;
|
|
} else {
|
|
log(`[1/3] Checking Python...`);
|
|
python = findPython();
|
|
log(` Found Python ${python.version.raw} at ${python.exe}`);
|
|
}
|
|
|
|
// Step 2: Ensure venv and deps
|
|
if (!python) {
|
|
// Fast path still needs a python reference for potential repair
|
|
python = findPython();
|
|
}
|
|
const wasAlreadyReady = ensureVenv(python, repair);
|
|
|
|
// Step 3: Config file
|
|
const configCreated = ensureEnvFile();
|
|
|
|
// Load .env into process.env for the spawned server
|
|
const dotenvVars = parseEnvFile(ENV_FILE);
|
|
|
|
// Determine port
|
|
const port = requestedPort || findAvailablePort();
|
|
|
|
// Check for already-running instance
|
|
const existingPid = readPid();
|
|
if (existingPid && isProcessAlive(existingPid)) {
|
|
log(`AutoForge is already running at http://${host}:${port}`);
|
|
log('Opening browser...');
|
|
if (!noBrowser && !isHeadless()) {
|
|
openBrowser(`http://${host}:${port}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clean up stale PID file
|
|
if (existingPid) {
|
|
removePid();
|
|
}
|
|
|
|
// Show server startup step only on slow path
|
|
if (!wasAlreadyReady) {
|
|
log('[3/3] Starting server...');
|
|
}
|
|
|
|
if (configCreated) {
|
|
log(` Created config file: ~/.autoforge/.env`);
|
|
log(' Edit this file to configure API providers (Ollama, Vertex AI, z.ai)');
|
|
log('');
|
|
}
|
|
|
|
// Security warning for non-localhost host
|
|
if (host !== '127.0.0.1') {
|
|
console.log('');
|
|
console.log(' !! SECURITY WARNING !!');
|
|
console.log(` Remote access enabled on host: ${host}`);
|
|
console.log(' The AutoForge UI will be accessible from other machines.');
|
|
console.log(' Ensure you understand the security implications.');
|
|
console.log('');
|
|
}
|
|
|
|
// Build environment for uvicorn
|
|
const serverEnv = { ...process.env, ...dotenvVars, PYTHONPATH: PKG_DIR };
|
|
|
|
// Enable remote access flag for the FastAPI server
|
|
if (host !== '127.0.0.1') {
|
|
serverEnv.AUTOFORGE_ALLOW_REMOTE = '1';
|
|
}
|
|
|
|
// Spawn uvicorn
|
|
const pyExe = venvPython();
|
|
const child = spawn(
|
|
pyExe,
|
|
[
|
|
'-m', 'uvicorn',
|
|
'server.main:app',
|
|
'--host', host,
|
|
'--port', String(port),
|
|
],
|
|
{
|
|
cwd: PKG_DIR,
|
|
env: serverEnv,
|
|
stdio: 'inherit',
|
|
}
|
|
);
|
|
|
|
writePid(child.pid);
|
|
|
|
// Open browser after a short delay to let the server start
|
|
if (!noBrowser && !isHeadless()) {
|
|
setTimeout(() => openBrowser(`http://${host}:${port}`), 2000);
|
|
}
|
|
|
|
const url = `http://${host}:${port}`;
|
|
console.log('');
|
|
log(`Server running at ${url}`);
|
|
log('Press Ctrl+C to stop');
|
|
|
|
// Graceful shutdown handlers
|
|
const cleanup = () => {
|
|
killProcess(child.pid);
|
|
removePid();
|
|
};
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('');
|
|
cleanup();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit(0);
|
|
});
|
|
|
|
// If the child exits on its own, clean up and propagate the exit code
|
|
child.on('exit', (code) => {
|
|
removePid();
|
|
process.exit(code ?? 1);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Main CLI entry point.
|
|
*
|
|
* @param {string[]} args - Command-line arguments (process.argv.slice(2))
|
|
*/
|
|
export function run(args) {
|
|
// --version / -v
|
|
if (args.includes('--version') || args.includes('-v')) {
|
|
printVersion();
|
|
return;
|
|
}
|
|
|
|
// --help / -h
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
// --dev guard: this only works from a cloned repository
|
|
if (args.includes('--dev')) {
|
|
die(
|
|
'Dev mode requires a cloned repository.\n' +
|
|
' Clone from https://github.com/paperlinguist/autocoder and run start_ui.sh'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// "config" subcommand
|
|
if (args[0] === 'config') {
|
|
handleConfig(args.slice(1));
|
|
return;
|
|
}
|
|
|
|
// Parse flags for server start
|
|
const host = getFlagValue(args, '--host') || '127.0.0.1';
|
|
const portStr = getFlagValue(args, '--port');
|
|
const port = portStr ? Number(portStr) : null;
|
|
const noBrowser = args.includes('--no-browser');
|
|
const repair = args.includes('--repair');
|
|
|
|
if (port !== null && (!Number.isFinite(port) || port < 1 || port > 65535)) {
|
|
die('Invalid port number. Must be between 1 and 65535.');
|
|
}
|
|
|
|
// Print banner
|
|
console.log('');
|
|
log(`AutoForge v${VERSION}`);
|
|
console.log('');
|
|
|
|
startServer({ port, host, noBrowser, repair });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Argument parsing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Extract the value following a flag from the args array.
|
|
* E.g. getFlagValue(['--port', '9000', '--host', '0.0.0.0'], '--port') => '9000'
|
|
*/
|
|
function getFlagValue(args, flag) {
|
|
const idx = args.indexOf(flag);
|
|
if (idx === -1 || idx + 1 >= args.length) return null;
|
|
return args[idx + 1];
|
|
}
|