autoforge-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
package/lib/cli.js
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoForge CLI
|
|
3
|
+
* =============
|
|
4
|
+
*
|
|
5
|
+
* Main CLI module for the AutoForge npm global package.
|
|
6
|
+
* Handles Python detection, virtual environment management,
|
|
7
|
+
* config loading, and uvicorn server lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Uses only Node.js built-in modules -- no external dependencies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync, spawn, execSync } from 'node:child_process';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync, copyFileSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { createServer } from 'node:net';
|
|
17
|
+
import { homedir, platform } from 'node:os';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Path constants
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Root of the autoforge npm package (one level up from lib/) */
|
|
26
|
+
const PKG_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
27
|
+
|
|
28
|
+
/** User config home: ~/.autoforge/ */
|
|
29
|
+
const CONFIG_HOME = join(homedir(), '.autoforge');
|
|
30
|
+
|
|
31
|
+
/** Virtual-environment directory managed by the CLI */
|
|
32
|
+
const VENV_DIR = join(CONFIG_HOME, 'venv');
|
|
33
|
+
|
|
34
|
+
/** Composite marker written after a successful pip install */
|
|
35
|
+
const DEPS_MARKER = join(VENV_DIR, '.deps-installed');
|
|
36
|
+
|
|
37
|
+
/** PID file for the running server */
|
|
38
|
+
const PID_FILE = join(CONFIG_HOME, 'server.pid');
|
|
39
|
+
|
|
40
|
+
/** Path to the production requirements file inside the package */
|
|
41
|
+
const REQUIREMENTS_FILE = join(PKG_DIR, 'requirements-prod.txt');
|
|
42
|
+
|
|
43
|
+
/** Path to the .env example shipped with the package */
|
|
44
|
+
const ENV_EXAMPLE = join(PKG_DIR, '.env.example');
|
|
45
|
+
|
|
46
|
+
/** User .env config file */
|
|
47
|
+
const ENV_FILE = join(CONFIG_HOME, '.env');
|
|
48
|
+
|
|
49
|
+
const IS_WIN = platform() === 'win32';
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Package version (read lazily via createRequire)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const require = createRequire(import.meta.url);
|
|
56
|
+
const { version: VERSION } = require(join(PKG_DIR, 'package.json'));
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** Indented console output matching the spec format. */
|
|
63
|
+
function log(msg = '') {
|
|
64
|
+
console.log(` ${msg}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Print a fatal error and exit. */
|
|
68
|
+
function die(msg) {
|
|
69
|
+
console.error(`\n Error: ${msg}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a Python version string like "Python 3.13.6" and return
|
|
75
|
+
* { major, minor, patch, raw } or null on failure.
|
|
76
|
+
*/
|
|
77
|
+
function parsePythonVersion(raw) {
|
|
78
|
+
const m = raw.match(/Python\s+(\d+)\.(\d+)\.(\d+)/);
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
return {
|
|
81
|
+
major: Number(m[1]),
|
|
82
|
+
minor: Number(m[2]),
|
|
83
|
+
patch: Number(m[3]),
|
|
84
|
+
raw: `${m[1]}.${m[2]}.${m[3]}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Try a single Python candidate. Returns { exe, version } or null.
|
|
90
|
+
* `candidate` is either a bare name or an array of args (e.g. ['py', '-3']).
|
|
91
|
+
*/
|
|
92
|
+
function tryPythonCandidate(candidate) {
|
|
93
|
+
const args = Array.isArray(candidate) ? candidate : [candidate];
|
|
94
|
+
const exe = args[0];
|
|
95
|
+
const extraArgs = args.slice(1);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const out = execFileSync(exe, [...extraArgs, '--version'], {
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
timeout: 10_000,
|
|
101
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const ver = parsePythonVersion(out);
|
|
105
|
+
if (!ver) return null;
|
|
106
|
+
|
|
107
|
+
// Require 3.11+
|
|
108
|
+
if (ver.major < 3 || (ver.major === 3 && ver.minor < 11)) {
|
|
109
|
+
return { exe: args.join(' '), version: ver, tooOld: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { exe: args.join(' '), version: ver, tooOld: false };
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Python detection
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find a suitable Python >= 3.11 interpreter.
|
|
124
|
+
*
|
|
125
|
+
* Search order is platform-dependent:
|
|
126
|
+
* Windows: python -> py -3 -> python3
|
|
127
|
+
* macOS/Linux: python3 -> python
|
|
128
|
+
*
|
|
129
|
+
* The AUTOFORGE_PYTHON env var overrides automatic detection.
|
|
130
|
+
*
|
|
131
|
+
* After finding a candidate we also verify that the venv module is
|
|
132
|
+
* available (Debian/Ubuntu strip it out of the base package).
|
|
133
|
+
*/
|
|
134
|
+
function findPython() {
|
|
135
|
+
// Allow explicit override via environment variable
|
|
136
|
+
const override = process.env.AUTOFORGE_PYTHON;
|
|
137
|
+
if (override) {
|
|
138
|
+
const result = tryPythonCandidate(override);
|
|
139
|
+
if (!result) {
|
|
140
|
+
die(`AUTOFORGE_PYTHON is set to "${override}" but it could not be executed.`);
|
|
141
|
+
}
|
|
142
|
+
if (result.tooOld) {
|
|
143
|
+
die(
|
|
144
|
+
`Python ${result.version.raw} found (via AUTOFORGE_PYTHON), but 3.11+ required.\n` +
|
|
145
|
+
' Install Python 3.11+ from https://python.org'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Platform-specific candidate order
|
|
152
|
+
const candidates = IS_WIN
|
|
153
|
+
? ['python', ['py', '-3'], 'python3']
|
|
154
|
+
: ['python3', 'python'];
|
|
155
|
+
|
|
156
|
+
let bestTooOld = null;
|
|
157
|
+
|
|
158
|
+
for (const candidate of candidates) {
|
|
159
|
+
const result = tryPythonCandidate(candidate);
|
|
160
|
+
if (!result) continue;
|
|
161
|
+
|
|
162
|
+
if (result.tooOld) {
|
|
163
|
+
// Remember the first "too old" result for a better error message
|
|
164
|
+
if (!bestTooOld) bestTooOld = result;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Verify venv module is available (Debian/Ubuntu may need python3-venv)
|
|
169
|
+
try {
|
|
170
|
+
const exeParts = result.exe.split(' ');
|
|
171
|
+
execFileSync(exeParts[0], [...exeParts.slice(1), '-c', 'import ensurepip'], {
|
|
172
|
+
encoding: 'utf8',
|
|
173
|
+
timeout: 10_000,
|
|
174
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
die(
|
|
178
|
+
`Python venv module not available.\n` +
|
|
179
|
+
` Run: sudo apt install python3.${result.version.minor}-venv`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Provide the most helpful error message we can
|
|
187
|
+
if (bestTooOld) {
|
|
188
|
+
die(
|
|
189
|
+
`Python ${bestTooOld.version.raw} found, but 3.11+ required.\n` +
|
|
190
|
+
' Install Python 3.11+ from https://python.org'
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
die(
|
|
194
|
+
'Python 3.11+ required but not found.\n' +
|
|
195
|
+
' Install from https://python.org'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Venv management
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
/** Return the path to the Python executable inside the venv. */
|
|
204
|
+
function venvPython() {
|
|
205
|
+
return IS_WIN
|
|
206
|
+
? join(VENV_DIR, 'Scripts', 'python.exe')
|
|
207
|
+
: join(VENV_DIR, 'bin', 'python');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** SHA-256 hash of the requirements-prod.txt file contents. */
|
|
211
|
+
function requirementsHash() {
|
|
212
|
+
const content = readFileSync(REQUIREMENTS_FILE, 'utf8');
|
|
213
|
+
return createHash('sha256').update(content).digest('hex');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read the composite deps marker. Returns the parsed JSON object
|
|
218
|
+
* or null if the file is missing / corrupt.
|
|
219
|
+
*/
|
|
220
|
+
function readMarker() {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(readFileSync(DEPS_MARKER, 'utf8'));
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Ensure the virtual environment exists and dependencies are installed.
|
|
230
|
+
* Returns true if all setup steps were already satisfied (fast path).
|
|
231
|
+
*
|
|
232
|
+
* @param {object} python - The result of findPython()
|
|
233
|
+
* @param {boolean} forceRecreate - If true, delete and recreate the venv
|
|
234
|
+
*/
|
|
235
|
+
function ensureVenv(python, forceRecreate) {
|
|
236
|
+
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
237
|
+
|
|
238
|
+
const marker = readMarker();
|
|
239
|
+
const reqHash = requirementsHash();
|
|
240
|
+
const pyExe = venvPython();
|
|
241
|
+
|
|
242
|
+
// Determine if the venv itself needs to be (re)created
|
|
243
|
+
let needsCreate = forceRecreate || !existsSync(pyExe);
|
|
244
|
+
|
|
245
|
+
if (!needsCreate && marker) {
|
|
246
|
+
// Recreate if Python major.minor changed
|
|
247
|
+
const markerMinor = marker.python_version;
|
|
248
|
+
const currentMinor = `${python.version.major}.${python.version.minor}`;
|
|
249
|
+
if (markerMinor && markerMinor !== currentMinor) {
|
|
250
|
+
needsCreate = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Recreate if the recorded python path no longer exists
|
|
254
|
+
if (marker.python_path && !existsSync(marker.python_path)) {
|
|
255
|
+
needsCreate = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let depsUpToDate = false;
|
|
260
|
+
if (!needsCreate && marker && marker.requirements_hash === reqHash) {
|
|
261
|
+
depsUpToDate = true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Fast path: nothing to do
|
|
265
|
+
if (!needsCreate && depsUpToDate) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Slow path: show setup progress ---
|
|
270
|
+
|
|
271
|
+
log('[2/3] Setting up environment...');
|
|
272
|
+
|
|
273
|
+
if (needsCreate) {
|
|
274
|
+
if (existsSync(VENV_DIR)) {
|
|
275
|
+
log(' Removing old virtual environment...');
|
|
276
|
+
rmSync(VENV_DIR, { recursive: true, force: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
log(` Creating virtual environment at ~/.autoforge/venv/`);
|
|
280
|
+
const exeParts = python.exe.split(' ');
|
|
281
|
+
try {
|
|
282
|
+
execFileSync(exeParts[0], [...exeParts.slice(1), '-m', 'venv', VENV_DIR], {
|
|
283
|
+
encoding: 'utf8',
|
|
284
|
+
timeout: 120_000,
|
|
285
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
286
|
+
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
die(`Failed to create virtual environment: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Install / update dependencies
|
|
293
|
+
log(' Installing dependencies...');
|
|
294
|
+
try {
|
|
295
|
+
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '--upgrade', 'pip'], {
|
|
296
|
+
encoding: 'utf8',
|
|
297
|
+
timeout: 300_000,
|
|
298
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
execFileSync(pyExe, ['-m', 'pip', 'install', '-q', '-r', REQUIREMENTS_FILE], {
|
|
302
|
+
encoding: 'utf8',
|
|
303
|
+
timeout: 600_000,
|
|
304
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
die(`Failed to install dependencies: ${err.message}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Write marker only after pip succeeds to prevent partial state
|
|
311
|
+
const markerData = {
|
|
312
|
+
requirements_hash: reqHash,
|
|
313
|
+
python_version: `${python.version.major}.${python.version.minor}`,
|
|
314
|
+
python_path: pyExe,
|
|
315
|
+
created_at: new Date().toISOString(),
|
|
316
|
+
};
|
|
317
|
+
writeFileSync(DEPS_MARKER, JSON.stringify(markerData, null, 2), 'utf8');
|
|
318
|
+
|
|
319
|
+
log(' Done');
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Config (.env) management
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Parse a .env file into a plain object.
|
|
329
|
+
* Handles comments, blank lines, and quoted values.
|
|
330
|
+
*/
|
|
331
|
+
function parseEnvFile(filePath) {
|
|
332
|
+
const env = {};
|
|
333
|
+
if (!existsSync(filePath)) return env;
|
|
334
|
+
|
|
335
|
+
const lines = readFileSync(filePath, 'utf8').split('\n');
|
|
336
|
+
for (const line of lines) {
|
|
337
|
+
const trimmed = line.trim();
|
|
338
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
339
|
+
|
|
340
|
+
const eqIdx = trimmed.indexOf('=');
|
|
341
|
+
if (eqIdx === -1) continue;
|
|
342
|
+
|
|
343
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
344
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
345
|
+
|
|
346
|
+
// Strip matching quotes (single or double)
|
|
347
|
+
if (
|
|
348
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
349
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
350
|
+
) {
|
|
351
|
+
value = value.slice(1, -1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (key) {
|
|
355
|
+
env[key] = value;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return env;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Ensure ~/.autoforge/.env exists. On first run, copy .env.example
|
|
363
|
+
* from the package directory and print a notice.
|
|
364
|
+
*
|
|
365
|
+
* Returns true if the file was newly created.
|
|
366
|
+
*/
|
|
367
|
+
function ensureEnvFile() {
|
|
368
|
+
if (existsSync(ENV_FILE)) return false;
|
|
369
|
+
|
|
370
|
+
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
371
|
+
|
|
372
|
+
if (existsSync(ENV_EXAMPLE)) {
|
|
373
|
+
copyFileSync(ENV_EXAMPLE, ENV_FILE);
|
|
374
|
+
} else {
|
|
375
|
+
// Fallback: create a minimal placeholder
|
|
376
|
+
writeFileSync(ENV_FILE, '# AutoForge configuration\n# See documentation for available options.\n', 'utf8');
|
|
377
|
+
}
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Port detection
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Find an available TCP port starting from `start`.
|
|
387
|
+
* Tries by actually binding a socket (most reliable cross-platform approach).
|
|
388
|
+
*/
|
|
389
|
+
function findAvailablePort(start = 8888, maxAttempts = 20) {
|
|
390
|
+
for (let port = start; port < start + maxAttempts; port++) {
|
|
391
|
+
try {
|
|
392
|
+
const server = createServer();
|
|
393
|
+
// Use a synchronous-like approach: try to listen, then close immediately
|
|
394
|
+
const result = new Promise((resolve, reject) => {
|
|
395
|
+
server.once('error', reject);
|
|
396
|
+
server.listen(port, '127.0.0.1', () => {
|
|
397
|
+
server.close(() => resolve(port));
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
// We cannot await here (sync context), so use the blocking approach:
|
|
401
|
+
// Try to bind synchronously using a different technique.
|
|
402
|
+
server.close();
|
|
403
|
+
} catch {
|
|
404
|
+
// fall through
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Synchronous fallback: try to connect; if connection refused, port is free.
|
|
408
|
+
for (let port = start; port < start + maxAttempts; port++) {
|
|
409
|
+
try {
|
|
410
|
+
execFileSync(process.execPath, [
|
|
411
|
+
'-e',
|
|
412
|
+
`const s=require("net").createServer();` +
|
|
413
|
+
`s.listen(${port},"127.0.0.1",()=>{s.close();process.exit(0)});` +
|
|
414
|
+
`s.on("error",()=>process.exit(1))`,
|
|
415
|
+
], { timeout: 3000, stdio: 'pipe' });
|
|
416
|
+
return port;
|
|
417
|
+
} catch {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
die(`No available ports found in range ${start}-${start + maxAttempts - 1}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// PID file management
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/** Read PID from the PID file. Returns the PID number or null. */
|
|
429
|
+
function readPid() {
|
|
430
|
+
try {
|
|
431
|
+
const content = readFileSync(PID_FILE, 'utf8').trim();
|
|
432
|
+
const pid = Number(content);
|
|
433
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Check whether a process with the given PID is still running. */
|
|
440
|
+
function isProcessAlive(pid) {
|
|
441
|
+
try {
|
|
442
|
+
process.kill(pid, 0); // signal 0 = existence check
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Write the PID file. */
|
|
450
|
+
function writePid(pid) {
|
|
451
|
+
mkdirSync(CONFIG_HOME, { recursive: true });
|
|
452
|
+
writeFileSync(PID_FILE, String(pid), 'utf8');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Remove the PID file. */
|
|
456
|
+
function removePid() {
|
|
457
|
+
try {
|
|
458
|
+
unlinkSync(PID_FILE);
|
|
459
|
+
} catch {
|
|
460
|
+
// Ignore -- file may already be gone
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
// Browser opening
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
/** Open a URL in the user's default browser (best-effort). */
|
|
469
|
+
function openBrowser(url) {
|
|
470
|
+
try {
|
|
471
|
+
if (IS_WIN) {
|
|
472
|
+
// "start" is a cmd built-in; the empty title string avoids
|
|
473
|
+
// issues when the URL contains special characters.
|
|
474
|
+
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
475
|
+
} else if (platform() === 'darwin') {
|
|
476
|
+
execFileSync('open', [url], { stdio: 'ignore' });
|
|
477
|
+
} else {
|
|
478
|
+
// Linux: only attempt if a display server is available and
|
|
479
|
+
// we are not in an SSH session.
|
|
480
|
+
const hasDisplay = process.env.DISPLAY || process.env.WAYLAND_DISPLAY;
|
|
481
|
+
const isSSH = !!process.env.SSH_TTY;
|
|
482
|
+
if (hasDisplay && !isSSH) {
|
|
483
|
+
execFileSync('xdg-open', [url], { stdio: 'ignore' });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Non-fatal: user can open the URL manually
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Detect headless / CI environments where opening a browser is pointless. */
|
|
492
|
+
function isHeadless() {
|
|
493
|
+
if (process.env.CI) return true;
|
|
494
|
+
if (process.env.CODESPACES) return true;
|
|
495
|
+
if (process.env.SSH_TTY) return true;
|
|
496
|
+
// Linux without a display server
|
|
497
|
+
if (!IS_WIN && platform() !== 'darwin' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
// Process cleanup
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
/** Kill a process tree. On Windows uses taskkill; elsewhere sends SIGTERM. */
|
|
508
|
+
function killProcess(pid) {
|
|
509
|
+
try {
|
|
510
|
+
if (IS_WIN) {
|
|
511
|
+
execSync(`taskkill /pid ${pid} /t /f`, { stdio: 'ignore' });
|
|
512
|
+
} else {
|
|
513
|
+
process.kill(pid, 'SIGTERM');
|
|
514
|
+
}
|
|
515
|
+
} catch {
|
|
516
|
+
// Process may already be gone
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// CLI commands
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
function printVersion() {
|
|
525
|
+
console.log(`autoforge v${VERSION}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function printHelp() {
|
|
529
|
+
console.log(`
|
|
530
|
+
AutoForge v${VERSION}
|
|
531
|
+
Autonomous coding agent with web UI
|
|
532
|
+
|
|
533
|
+
Usage:
|
|
534
|
+
autoforge Start the server (default)
|
|
535
|
+
autoforge config Open ~/.autoforge/.env in $EDITOR
|
|
536
|
+
autoforge config --path Print config file path
|
|
537
|
+
autoforge config --show Show effective configuration
|
|
538
|
+
|
|
539
|
+
Options:
|
|
540
|
+
--port PORT Custom port (default: auto from 8888)
|
|
541
|
+
--host HOST Custom host (default: 127.0.0.1)
|
|
542
|
+
--no-browser Don't auto-open browser
|
|
543
|
+
--repair Delete and recreate virtual environment
|
|
544
|
+
--dev Development mode (requires cloned repo)
|
|
545
|
+
--version Print version
|
|
546
|
+
--help Show this help
|
|
547
|
+
`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function handleConfig(args) {
|
|
551
|
+
ensureEnvFile();
|
|
552
|
+
|
|
553
|
+
if (args.includes('--path')) {
|
|
554
|
+
console.log(ENV_FILE);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (args.includes('--show')) {
|
|
559
|
+
if (!existsSync(ENV_FILE)) {
|
|
560
|
+
log('No configuration file found.');
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const lines = readFileSync(ENV_FILE, 'utf8').split('\n');
|
|
564
|
+
const active = lines.filter(l => {
|
|
565
|
+
const t = l.trim();
|
|
566
|
+
return t && !t.startsWith('#');
|
|
567
|
+
});
|
|
568
|
+
if (active.length === 0) {
|
|
569
|
+
log('No active configuration. All lines are commented out.');
|
|
570
|
+
log(`Edit: ${ENV_FILE}`);
|
|
571
|
+
} else {
|
|
572
|
+
for (const line of active) {
|
|
573
|
+
console.log(line);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Open in editor
|
|
580
|
+
const editor = process.env.EDITOR || process.env.VISUAL || (IS_WIN ? 'notepad' : 'vi');
|
|
581
|
+
try {
|
|
582
|
+
execFileSync(editor, [ENV_FILE], { stdio: 'inherit' });
|
|
583
|
+
} catch {
|
|
584
|
+
log(`Could not open editor "${editor}".`);
|
|
585
|
+
log(`Edit the file manually: ${ENV_FILE}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
// Main server start
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
function startServer(opts) {
|
|
594
|
+
const { port: requestedPort, host, noBrowser, repair } = opts;
|
|
595
|
+
|
|
596
|
+
// Step 1: Find Python
|
|
597
|
+
const fastPath = !repair && existsSync(venvPython()) && readMarker()?.requirements_hash === requirementsHash();
|
|
598
|
+
|
|
599
|
+
let python;
|
|
600
|
+
if (fastPath) {
|
|
601
|
+
// Skip the Python search header on fast path -- we already have a working venv
|
|
602
|
+
python = null;
|
|
603
|
+
} else {
|
|
604
|
+
log(`[1/3] Checking Python...`);
|
|
605
|
+
python = findPython();
|
|
606
|
+
log(` Found Python ${python.version.raw} at ${python.exe}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Step 2: Ensure venv and deps
|
|
610
|
+
if (!python) {
|
|
611
|
+
// Fast path still needs a python reference for potential repair
|
|
612
|
+
python = findPython();
|
|
613
|
+
}
|
|
614
|
+
const wasAlreadyReady = ensureVenv(python, repair);
|
|
615
|
+
|
|
616
|
+
// Step 3: Config file
|
|
617
|
+
const configCreated = ensureEnvFile();
|
|
618
|
+
|
|
619
|
+
// Load .env into process.env for the spawned server
|
|
620
|
+
const dotenvVars = parseEnvFile(ENV_FILE);
|
|
621
|
+
|
|
622
|
+
// Determine port
|
|
623
|
+
const port = requestedPort || findAvailablePort();
|
|
624
|
+
|
|
625
|
+
// Check for already-running instance
|
|
626
|
+
const existingPid = readPid();
|
|
627
|
+
if (existingPid && isProcessAlive(existingPid)) {
|
|
628
|
+
log(`AutoForge is already running at http://${host}:${port}`);
|
|
629
|
+
log('Opening browser...');
|
|
630
|
+
if (!noBrowser && !isHeadless()) {
|
|
631
|
+
openBrowser(`http://${host}:${port}`);
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Clean up stale PID file
|
|
637
|
+
if (existingPid) {
|
|
638
|
+
removePid();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Show server startup step only on slow path
|
|
642
|
+
if (!wasAlreadyReady) {
|
|
643
|
+
log('[3/3] Starting server...');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (configCreated) {
|
|
647
|
+
log(` Created config file: ~/.autoforge/.env`);
|
|
648
|
+
log(' Edit this file to configure API providers (Ollama, Vertex AI, z.ai)');
|
|
649
|
+
log('');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Security warning for non-localhost host
|
|
653
|
+
if (host !== '127.0.0.1') {
|
|
654
|
+
console.log('');
|
|
655
|
+
console.log(' !! SECURITY WARNING !!');
|
|
656
|
+
console.log(` Remote access enabled on host: ${host}`);
|
|
657
|
+
console.log(' The AutoForge UI will be accessible from other machines.');
|
|
658
|
+
console.log(' Ensure you understand the security implications.');
|
|
659
|
+
console.log('');
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Build environment for uvicorn
|
|
663
|
+
const serverEnv = { ...process.env, ...dotenvVars, PYTHONPATH: PKG_DIR };
|
|
664
|
+
|
|
665
|
+
// Enable remote access flag for the FastAPI server
|
|
666
|
+
if (host !== '127.0.0.1') {
|
|
667
|
+
serverEnv.AUTOFORGE_ALLOW_REMOTE = '1';
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Spawn uvicorn
|
|
671
|
+
const pyExe = venvPython();
|
|
672
|
+
const child = spawn(
|
|
673
|
+
pyExe,
|
|
674
|
+
[
|
|
675
|
+
'-m', 'uvicorn',
|
|
676
|
+
'server.main:app',
|
|
677
|
+
'--host', host,
|
|
678
|
+
'--port', String(port),
|
|
679
|
+
],
|
|
680
|
+
{
|
|
681
|
+
cwd: PKG_DIR,
|
|
682
|
+
env: serverEnv,
|
|
683
|
+
stdio: 'inherit',
|
|
684
|
+
}
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
writePid(child.pid);
|
|
688
|
+
|
|
689
|
+
// Open browser after a short delay to let the server start
|
|
690
|
+
if (!noBrowser && !isHeadless()) {
|
|
691
|
+
setTimeout(() => openBrowser(`http://${host}:${port}`), 2000);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const url = `http://${host}:${port}`;
|
|
695
|
+
console.log('');
|
|
696
|
+
log(`Server running at ${url}`);
|
|
697
|
+
log('Press Ctrl+C to stop');
|
|
698
|
+
|
|
699
|
+
// Graceful shutdown handlers
|
|
700
|
+
const cleanup = () => {
|
|
701
|
+
killProcess(child.pid);
|
|
702
|
+
removePid();
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
process.on('SIGINT', () => {
|
|
706
|
+
console.log('');
|
|
707
|
+
cleanup();
|
|
708
|
+
process.exit(0);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
process.on('SIGTERM', () => {
|
|
712
|
+
cleanup();
|
|
713
|
+
process.exit(0);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// If the child exits on its own, clean up and propagate the exit code
|
|
717
|
+
child.on('exit', (code) => {
|
|
718
|
+
removePid();
|
|
719
|
+
process.exit(code ?? 1);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// Entry point
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Main CLI entry point.
|
|
729
|
+
*
|
|
730
|
+
* @param {string[]} args - Command-line arguments (process.argv.slice(2))
|
|
731
|
+
*/
|
|
732
|
+
export function run(args) {
|
|
733
|
+
// --version / -v
|
|
734
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
735
|
+
printVersion();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// --help / -h
|
|
740
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
741
|
+
printHelp();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// --dev guard: this only works from a cloned repository
|
|
746
|
+
if (args.includes('--dev')) {
|
|
747
|
+
die(
|
|
748
|
+
'Dev mode requires a cloned repository.\n' +
|
|
749
|
+
' Clone from https://github.com/paperlinguist/autocoder and run start_ui.sh'
|
|
750
|
+
);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// "config" subcommand
|
|
755
|
+
if (args[0] === 'config') {
|
|
756
|
+
handleConfig(args.slice(1));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Parse flags for server start
|
|
761
|
+
const host = getFlagValue(args, '--host') || '127.0.0.1';
|
|
762
|
+
const portStr = getFlagValue(args, '--port');
|
|
763
|
+
const port = portStr ? Number(portStr) : null;
|
|
764
|
+
const noBrowser = args.includes('--no-browser');
|
|
765
|
+
const repair = args.includes('--repair');
|
|
766
|
+
|
|
767
|
+
if (port !== null && (!Number.isFinite(port) || port < 1 || port > 65535)) {
|
|
768
|
+
die('Invalid port number. Must be between 1 and 65535.');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Print banner
|
|
772
|
+
console.log('');
|
|
773
|
+
log(`AutoForge v${VERSION}`);
|
|
774
|
+
console.log('');
|
|
775
|
+
|
|
776
|
+
startServer({ port, host, noBrowser, repair });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// Argument parsing helpers
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Extract the value following a flag from the args array.
|
|
785
|
+
* E.g. getFlagValue(['--port', '9000', '--host', '0.0.0.0'], '--port') => '9000'
|
|
786
|
+
*/
|
|
787
|
+
function getFlagValue(args, flag) {
|
|
788
|
+
const idx = args.indexOf(flag);
|
|
789
|
+
if (idx === -1 || idx + 1 >= args.length) return null;
|
|
790
|
+
return args[idx + 1];
|
|
791
|
+
}
|