apiforge-analyzer 1.3.6 → 1.3.8

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/dist/cjs/cli.js CHANGED
@@ -6,11 +6,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const prompts_1 = require("@clack/prompts");
8
8
  const fs_1 = require("fs");
9
+ const os_1 = require("os");
9
10
  const path_1 = require("path");
10
11
  const child_process_1 = require("child_process");
11
12
  const axios_1 = __importDefault(require("axios"));
12
13
  const arch_detect_js_1 = require("./arch-detect.js");
13
14
  const uploader_js_1 = require("./uploader.js");
15
+ const detect_js_1 = require("./detect.js");
16
+ const python_js_1 = require("./frameworks/python.js");
17
+ const python_introspect_js_1 = require("./frameworks/python-introspect.js");
14
18
  // ─── ANSI colour helpers ──────────────────────────────────────────────────────
15
19
  const r = '\x1b[0m';
16
20
  const b = '\x1b[1m';
@@ -110,6 +114,23 @@ async function main() {
110
114
  const isLocal = backendChoice === 'local';
111
115
  const backendUrl = isLocal ? BACKEND_LOCAL : BACKEND_PROD;
112
116
  const dashboardUrl = isLocal ? DASHBOARD_LOCAL : DASHBOARD_PROD;
117
+ // ── Python project: routes are read by importing the app, no injection ───
118
+ const pythonFramework = (0, detect_js_1.detectPythonFramework)(process.cwd());
119
+ if (pythonFramework) {
120
+ // Mixed repos (e.g. a Python API with a JS frontend) get to choose
121
+ const usePython = detectScripts().length === 0 || await (0, prompts_1.confirm)({
122
+ message: `${PYTHON_LABELS[pythonFramework]} project detected — document the Python API?`,
123
+ initialValue: true,
124
+ });
125
+ if ((0, prompts_1.isCancel)(usePython)) {
126
+ (0, prompts_1.cancel)('Cancelled.');
127
+ process.exit(0);
128
+ }
129
+ if (usePython) {
130
+ await pythonFlow(pythonFramework, { projectName, apiKey, baseUrl, backendUrl, dashboardUrl });
131
+ return;
132
+ }
133
+ }
113
134
  // ── Detect architecture type ─────────────────────────────────────────────
114
135
  const archResult = (0, arch_detect_js_1.detectArchitectureType)(process.cwd());
115
136
  const archLabel = {
@@ -772,6 +793,747 @@ async function microservicesFlow(config, opts) {
772
793
  }[approval.status];
773
794
  (0, prompts_1.outro)(outroMsg);
774
795
  }
796
+ // ─── Python flow ──────────────────────────────────────────────────────────────
797
+ const PYTHON_LABELS = {
798
+ fastapi: 'FastAPI',
799
+ flask: 'Flask',
800
+ django: 'Django',
801
+ };
802
+ /**
803
+ * FastAPI / Flask / Django — same shape as the Node flow: inject a snippet into
804
+ * the file that defines the app, run the user's chosen start command, collect
805
+ * the routes it reports, kill the server, upload, then offer to remove the
806
+ * snippet. If the framework isn't installed, it first offers to create .venv
807
+ * and pip install the project's dependencies (the `npm install` step). Unlike
808
+ * the Node snippet, the Python one never uploads and holds no API key: it
809
+ * writes routes to a temp status file and the CLI uploads them.
810
+ */
811
+ async function pythonFlow(framework, opts) {
812
+ const cwd = process.cwd();
813
+ tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
814
+ tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
815
+ // ── Locate the app (FastAPI/Flask) or settings module (Django) ───────────
816
+ const candidates = (0, python_js_1.findPythonApps)(framework, cwd);
817
+ let target;
818
+ if (candidates.length === 1) {
819
+ target = candidates[0];
820
+ }
821
+ else if (candidates.length > 1) {
822
+ const choice = await (0, prompts_1.select)({
823
+ message: framework === 'django' ? 'Which Django project?' : `Which ${PYTHON_LABELS[framework]} app?`,
824
+ options: candidates.map((c, i) => ({ value: i, label: c.label })),
825
+ });
826
+ if ((0, prompts_1.isCancel)(choice)) {
827
+ (0, prompts_1.cancel)('Cancelled.');
828
+ process.exit(0);
829
+ }
830
+ target = candidates[choice];
831
+ }
832
+ else {
833
+ const typed = await (0, prompts_1.text)({
834
+ message: framework === 'django'
835
+ ? 'Django settings module'
836
+ : `Where is your ${PYTHON_LABELS[framework]} app? ${dim('(module:variable)')}`,
837
+ placeholder: framework === 'django' ? 'mysite.settings' : 'main:app',
838
+ validate: v => (!v.trim() ? 'Required' : undefined),
839
+ });
840
+ if ((0, prompts_1.isCancel)(typed)) {
841
+ (0, prompts_1.cancel)('Cancelled.');
842
+ process.exit(0);
843
+ }
844
+ const value = typed.trim();
845
+ target = framework === 'django'
846
+ ? { label: value, djangoSettings: value, appDir: cwd }
847
+ : { label: value, app: value, appDir: cwd };
848
+ }
849
+ const entryFile = pythonEntryFile(framework, target) ?? await askPythonEntryFile(framework);
850
+ const entryLabel = (0, path_1.relative)(cwd, entryFile) || (0, path_1.basename)(entryFile);
851
+ // ── Python environment: offer to create/install it when the framework is missing ─
852
+ // The Node flow expects `npm install` to have been run; here we offer to do the
853
+ // equivalent (venv + pip install) rather than fail on the first ImportError.
854
+ let pythonPath = projectInterpreter(target.appDir, cwd);
855
+ if (!pythonCanImport(pythonPath, PYTHON_MODULES[framework], target.appDir)) {
856
+ pythonPath = await setupPythonEnvironment(framework, pythonPath, target.appDir, cwd);
857
+ }
858
+ // ── Which command starts the server ───────────────────────────────────────
859
+ const runChoice = await (0, prompts_1.select)({
860
+ message: 'Which command starts your server?',
861
+ options: [
862
+ ...pythonRunCommands(framework, target, entryFile, pythonPath).map(c => ({ value: c, label: c })),
863
+ { value: '__custom', label: 'Enter a command…' },
864
+ ],
865
+ });
866
+ if ((0, prompts_1.isCancel)(runChoice)) {
867
+ (0, prompts_1.cancel)('Cancelled.');
868
+ process.exit(0);
869
+ }
870
+ let runCommand = runChoice;
871
+ if (runCommand === '__custom') {
872
+ const typed = await (0, prompts_1.text)({
873
+ message: `Command that starts your server ${dim(`(run from ${(0, path_1.relative)(cwd, target.appDir) || '.'})`)}`,
874
+ placeholder: 'uvicorn main:app',
875
+ validate: v => (!v.trim() ? 'Required' : undefined),
876
+ });
877
+ if ((0, prompts_1.isCancel)(typed)) {
878
+ (0, prompts_1.cancel)('Cancelled.');
879
+ process.exit(0);
880
+ }
881
+ runCommand = typed.trim();
882
+ }
883
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
884
+ prompts_1.log.info(`${dim('Project:')} ${bold(opts.projectName)}`);
885
+ prompts_1.log.info(`${dim('Framework:')} ${cyan(PYTHON_LABELS[framework])}`);
886
+ prompts_1.log.info(`${dim(framework === 'django' ? 'Settings:' : 'App: ')} ${bold(target.label)}`);
887
+ prompts_1.log.info(`${dim('Command:')} ${bold(runCommand)}`);
888
+ prompts_1.log.info(`${dim('Backend:')} ${cyan(opts.backendUrl)}`);
889
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
890
+ // ── Patch entry file, start server, collect routes ───────────────────────
891
+ // Status + introspection script live in the OS temp dir: nothing besides the
892
+ // snippet is written into the user's project.
893
+ const statusPath = (0, path_1.join)((0, os_1.tmpdir)(), `apiforge-${process.pid}-status.json`);
894
+ const introspectPath = (0, path_1.join)((0, os_1.tmpdir)(), `apiforge-${process.pid}-introspect.py`);
895
+ const removeTempFiles = () => {
896
+ tryUnlink(statusPath);
897
+ tryUnlink(statusPath + '.tmp');
898
+ tryUnlink(introspectPath);
899
+ };
900
+ removeTempFiles();
901
+ (0, fs_1.writeFileSync)(introspectPath, python_introspect_js_1.PYTHON_INTROSPECT_SCRIPT, 'utf-8');
902
+ const injection = injectPythonSnippet(entryFile, framework, target);
903
+ prompts_1.log.info(`${green('✓')} Patched ${bold(entryLabel)} ${dim(`+${injection.linesAdded} lines`)}`);
904
+ // Like the Node flow, removal is offered at the end. Ctrl+C can't be
905
+ // prompted, so an interrupt removes the snippet straight away.
906
+ const onInterrupt = () => {
907
+ removePythonSnippet(entryFile, injection.block);
908
+ removeTempFiles();
909
+ process.exit(130);
910
+ };
911
+ process.on('SIGINT', onInterrupt);
912
+ const offerCleanup = async () => {
913
+ process.removeListener('SIGINT', onInterrupt);
914
+ await offerPythonCleanup(entryFile, entryLabel, injection.block);
915
+ };
916
+ const s = (0, prompts_1.spinner)();
917
+ const venvLabel = (0, path_1.relative)(cwd, pythonPath) || pythonPath;
918
+ // A server that dies on `No module named 'x'` gets the package installed and
919
+ // is restarted — the missing-dependency half of `npm install`, done on demand.
920
+ // Only packages the project declares (or well-known import≠pip names) are
921
+ // offered, so a missing local module never turns into a random PyPI install.
922
+ let result;
923
+ const installedForRun = new Set();
924
+ let spinning = false;
925
+ try {
926
+ for (;;) {
927
+ s.start('Starting your server and reading its routes…');
928
+ spinning = true;
929
+ tryUnlink(statusPath);
930
+ result = await runPythonServerAndCollect(runCommand, target.appDir, {
931
+ APIFORGE_INTROSPECT: introspectPath,
932
+ APIFORGE_STATUS_FILE: statusPath,
933
+ APIFORGE_FRAMEWORK: framework,
934
+ ...(target.djangoSettings ? { APIFORGE_DJANGO_SETTINGS: target.djangoSettings } : {}),
935
+ }, statusPath, 90000);
936
+ if (result.routes?.length)
937
+ break;
938
+ const missing = missingPythonPackage(result.output, pythonPath, target.appDir, cwd);
939
+ if (!missing || installedForRun.has(missing.requirement) || installedForRun.size >= 15)
940
+ break;
941
+ s.stop(yellow(`◌ Your server needs ${bold(missing.requirement)} ${dim(`(No module named '${missing.module}')`)}`));
942
+ spinning = false;
943
+ const install = await (0, prompts_1.confirm)({
944
+ message: `Install ${missing.requirement} into ${venvLabel} and retry?`,
945
+ initialValue: true,
946
+ });
947
+ if ((0, prompts_1.isCancel)(install) || !install)
948
+ break;
949
+ installedForRun.add(missing.requirement);
950
+ s.start(`Installing ${missing.requirement}…`);
951
+ const pip = await runCapture(pythonPath, ['-m', 'pip', 'install', '--disable-pip-version-check', missing.requirement], cwd, 10 * 60000);
952
+ if (pip.code !== 0) {
953
+ s.stop(red(`✗ pip install ${missing.requirement} failed`));
954
+ spinning = false;
955
+ pip.output.split('\n').slice(-15).forEach(line => prompts_1.log.info(dim(` ${line}`)));
956
+ break;
957
+ }
958
+ s.stop(green(`✓ Installed ${missing.requirement} — retrying`));
959
+ spinning = false;
960
+ }
961
+ }
962
+ finally {
963
+ removeTempFiles();
964
+ }
965
+ if (result.error || !result.routes?.length) {
966
+ const failure = red(`✗ ${result.error ?? 'No routes found'}`);
967
+ if (spinning)
968
+ s.stop(failure);
969
+ else
970
+ prompts_1.log.error(failure);
971
+ prompts_1.log.warn(yellow('Hint: run the command above yourself first — if it does not start your server, APIForge can\'t either.'));
972
+ prompts_1.log.warn(yellow(` 1. Dependencies are installed for ${pythonPath}`));
973
+ prompts_1.log.warn(yellow(' 2. Env vars your app reads at startup (DB URL, secrets) are set'));
974
+ if (result.output) {
975
+ // Python prints the actual error last — show the end of the output, not the start.
976
+ prompts_1.log.info('');
977
+ prompts_1.log.info(dim('─── Server output (last lines) ──────────────────'));
978
+ result.output.split('\n').slice(-25).forEach(line => prompts_1.log.info(dim(` ${line}`)));
979
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
980
+ }
981
+ await offerCleanup();
982
+ (0, prompts_1.outro)(red('Analysis failed.'));
983
+ process.exit(1);
984
+ }
985
+ const routes = result.routes ?? [];
986
+ s.stop(green(`✓ ${routes.length} routes found`));
987
+ // ── Upload ────────────────────────────────────────────────────────────────
988
+ const uploadSpin = (0, prompts_1.spinner)();
989
+ uploadSpin.start(`Uploading ${routes.length} routes…`);
990
+ const uploadResult = await (0, uploader_js_1.uploadRoutes)(routes, {
991
+ projectName: opts.projectName,
992
+ apiKey: opts.apiKey,
993
+ baseUrl: opts.baseUrl,
994
+ apiForgeUrl: opts.backendUrl,
995
+ architectureType: 'monolithic',
996
+ });
997
+ if (!uploadResult) {
998
+ uploadSpin.stop(red('✗ Upload failed'));
999
+ await offerCleanup();
1000
+ (0, prompts_1.outro)(red('Analysis failed.'));
1001
+ process.exit(1);
1002
+ }
1003
+ uploadSpin.stop(green(`✓ ${uploadResult.routeCount} routes submitted to APIForge`));
1004
+ // ── Poll for dashboard approval ───────────────────────────────────────────
1005
+ (0, prompts_1.note)([
1006
+ bold('Open your dashboard to review:'),
1007
+ '',
1008
+ ` ${cyan(opts.dashboardUrl)}`,
1009
+ '',
1010
+ dim('In "Import JSON" → "Pending CLI Imports"'),
1011
+ dim('Accept to start AI processing · Deny to discard.'),
1012
+ ].join('\n'), 'Action required');
1013
+ const poll = (0, prompts_1.spinner)();
1014
+ poll.start('Waiting for your decision in the dashboard… (5 min timeout)');
1015
+ const approval = await pollForApproval(uploadResult.pendingImportId, opts.apiKey, opts.backendUrl, 5 * 60000);
1016
+ const statusMsg = {
1017
+ approved: green('✓ Accepted — AI processing started!'),
1018
+ denied: red('✗ Import denied by dashboard.'),
1019
+ timeout: yellow('◌ Timed out — no decision made within 5 minutes.'),
1020
+ }[approval.status];
1021
+ poll.stop(statusMsg);
1022
+ if (approval.status === 'approved') {
1023
+ (0, prompts_1.note)([
1024
+ bold('Your API is being documented.'),
1025
+ '',
1026
+ ` ${dim('Track progress:')} ${cyan(opts.dashboardUrl)}`,
1027
+ ` ${dim('Import ID:')} ${dim(uploadResult.pendingImportId)}`,
1028
+ ].join('\n'), 'Processing');
1029
+ }
1030
+ else if (approval.status === 'denied') {
1031
+ (0, prompts_1.note)([
1032
+ ` ${bold('You denied this import from the dashboard.')}`,
1033
+ '',
1034
+ ` ${dim('No documentation was generated.')}`,
1035
+ ` ${dim('Run')} ${cyan('npx apiforge')} ${dim('again whenever you\'re ready.')}`,
1036
+ ].join('\n'), 'Denied');
1037
+ }
1038
+ else {
1039
+ (0, prompts_1.note)([
1040
+ ` ${dim('Your import is still pending:')}`,
1041
+ ` ${cyan(opts.dashboardUrl)}`,
1042
+ '',
1043
+ ` ${dim('Import ID:')} ${cyan(uploadResult.pendingImportId)}`,
1044
+ ` ${dim('You can still accept or deny it from the dashboard.')}`,
1045
+ ].join('\n'), 'Pending');
1046
+ }
1047
+ // ── Cleanup ───────────────────────────────────────────────────────────────
1048
+ await offerCleanup();
1049
+ tryUnlink((0, path_1.join)(cwd, STATUS_FILE));
1050
+ tryUnlink((0, path_1.join)(cwd, LOCK_FILE));
1051
+ const outroMsg = {
1052
+ approved: `${green('✓')} Done! View your AI docs at ${cyan(opts.dashboardUrl)}`,
1053
+ denied: `${red('✗')} Import denied. No changes were made.`,
1054
+ timeout: `${yellow('◌')} Timed out. Your import is still pending at ${cyan(opts.dashboardUrl)}`,
1055
+ }[approval.status];
1056
+ (0, prompts_1.outro)(outroMsg);
1057
+ }
1058
+ // ─── Python environment ───────────────────────────────────────────────────────
1059
+ const PYTHON_MODULES = {
1060
+ fastapi: 'fastapi',
1061
+ flask: 'flask',
1062
+ django: 'django',
1063
+ };
1064
+ const VENV_PYTHON = process.platform === 'win32'
1065
+ ? (0, path_1.join)('.venv', 'Scripts', 'python.exe')
1066
+ : (0, path_1.join)('.venv', 'bin', 'python');
1067
+ /** Interpreter for the app: a venv next to the app wins, then one at the project root. */
1068
+ function projectInterpreter(appDir, rootDir) {
1069
+ const fromApp = (0, python_js_1.findPythonInterpreter)(appDir);
1070
+ if ((0, path_1.isAbsolute)(fromApp) || appDir === rootDir)
1071
+ return fromApp;
1072
+ return (0, python_js_1.findPythonInterpreter)(rootDir);
1073
+ }
1074
+ function pythonCanImport(pythonPath, module, cwd) {
1075
+ try {
1076
+ const r = (0, child_process_1.spawnSync)(pythonPath, ['-c', `import ${module}`], { cwd, stdio: 'ignore', timeout: 30000 });
1077
+ return r.status === 0;
1078
+ }
1079
+ catch {
1080
+ return false;
1081
+ }
1082
+ }
1083
+ /** A working base interpreter to create a venv with ('python' on Windows is often the Store stub). */
1084
+ function basePython() {
1085
+ const candidates = process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python'];
1086
+ return candidates.find(c => {
1087
+ try {
1088
+ return (0, child_process_1.spawnSync)(c, ['-c', 'import venv'], { stdio: 'ignore', timeout: 15000 }).status === 0;
1089
+ }
1090
+ catch {
1091
+ return false;
1092
+ }
1093
+ }) ?? null;
1094
+ }
1095
+ /** Run a command (no shell) and keep the tail of its output for error reporting. */
1096
+ function runCapture(cmd, args, cwd, timeoutMs) {
1097
+ return new Promise(resolve => {
1098
+ let output = '';
1099
+ const keepTail = (chunk) => { output = (output + chunk.toString()).slice(-8000); };
1100
+ const child = (0, child_process_1.spawn)(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8' } });
1101
+ child.stdout?.on('data', keepTail);
1102
+ child.stderr?.on('data', keepTail);
1103
+ const timer = setTimeout(() => { if (child.pid)
1104
+ killProcessTree(child.pid); }, timeoutMs);
1105
+ child.on('error', err => { clearTimeout(timer); resolve({ code: null, output: err.message }); });
1106
+ child.on('close', code => { clearTimeout(timer); resolve({ code, output: output.trim() }); });
1107
+ });
1108
+ }
1109
+ /**
1110
+ * Where the project declares its dependencies, as pip arguments. requirements
1111
+ * files first (closest to the app), then an installable package (setup.py /
1112
+ * pyproject with a build backend), then a bare pyproject's dependency list.
1113
+ */
1114
+ function pythonInstallPlan(appDir, rootDir, pythonPath) {
1115
+ for (const dir of unique([appDir, rootDir])) {
1116
+ for (const file of ['requirements.txt', (0, path_1.join)('requirements', 'base.txt'), 'requirements-dev.txt']) {
1117
+ if ((0, fs_1.existsSync)((0, path_1.join)(dir, file))) {
1118
+ return { label: (0, path_1.relative)(rootDir, (0, path_1.join)(dir, file)) || file, args: ['-r', (0, path_1.join)(dir, file)] };
1119
+ }
1120
+ }
1121
+ }
1122
+ const pyproject = (0, path_1.join)(rootDir, 'pyproject.toml');
1123
+ const pyprojectText = (0, fs_1.existsSync)(pyproject) ? (0, fs_1.readFileSync)(pyproject, 'utf-8') : '';
1124
+ if ((0, fs_1.existsSync)((0, path_1.join)(rootDir, 'setup.py')) || (0, fs_1.existsSync)((0, path_1.join)(rootDir, 'setup.cfg')) || /^\[build-system\]/m.test(pyprojectText)) {
1125
+ return { label: `${(0, fs_1.existsSync)((0, path_1.join)(rootDir, 'setup.py')) ? 'setup.py' : 'pyproject.toml'} (pip install -e .)`, args: ['-e', rootDir] };
1126
+ }
1127
+ if (pyprojectText) {
1128
+ // An app (not a package): install [project] / [tool.poetry] dependencies directly.
1129
+ const script = [
1130
+ 'import sys, tomllib',
1131
+ 'd = tomllib.load(open(sys.argv[1], "rb"))',
1132
+ 'deps = list(d.get("project", {}).get("dependencies", []))',
1133
+ 'deps += [n for n in d.get("tool", {}).get("poetry", {}).get("dependencies", {}) if n.lower() != "python"]',
1134
+ 'print("\\n".join(deps))',
1135
+ ].join('\n');
1136
+ try {
1137
+ const r = (0, child_process_1.spawnSync)(pythonPath, ['-c', script, pyproject], { encoding: 'utf-8', timeout: 30000 });
1138
+ const deps = (r.stdout || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean);
1139
+ if (r.status === 0 && deps.length)
1140
+ return { label: 'pyproject.toml dependencies', args: deps };
1141
+ }
1142
+ catch { }
1143
+ }
1144
+ return null;
1145
+ }
1146
+ function unique(items) {
1147
+ return [...new Set(items)];
1148
+ }
1149
+ /**
1150
+ * The framework isn't importable: offer to do the `npm install` equivalent —
1151
+ * create .venv at the project root (unless a venv is already in use) and pip
1152
+ * install the project's declared dependencies into it.
1153
+ */
1154
+ async function setupPythonEnvironment(framework, currentPython, appDir, rootDir) {
1155
+ const usingVenv = (0, path_1.isAbsolute)(currentPython);
1156
+ const venvDir = (0, path_1.join)(rootDir, '.venv');
1157
+ const plan0 = pythonInstallPlan(appDir, rootDir, currentPython);
1158
+ (0, prompts_1.note)([
1159
+ `${yellow(`${PYTHON_LABELS[framework]} isn't installed for ${usingVenv ? currentPython : 'any virtualenv in this project'}.`)}`,
1160
+ '',
1161
+ usingVenv
1162
+ ? `${dim('APIForge can install the project\'s dependencies into that environment:')}`
1163
+ : `${dim('APIForge can create')} ${bold('.venv')} ${dim('here and install the project\'s dependencies into it:')}`,
1164
+ ` ${dim('·')} ${plan0 ? bold(plan0.label) : yellow('no requirements.txt / pyproject.toml / setup.py found')}`,
1165
+ '',
1166
+ dim('Same as running pip install yourself — nothing outside the environment changes.'),
1167
+ ].join('\n'), 'Python environment');
1168
+ const proceed = await (0, prompts_1.confirm)({
1169
+ message: usingVenv ? 'Install dependencies now?' : 'Create .venv and install dependencies?',
1170
+ initialValue: true,
1171
+ });
1172
+ if ((0, prompts_1.isCancel)(proceed) || !proceed) {
1173
+ (0, prompts_1.cancel)(dim(`Install your dependencies (e.g. pip install -r requirements.txt), then re-run apiforge.`));
1174
+ process.exit(0);
1175
+ }
1176
+ const s = (0, prompts_1.spinner)();
1177
+ let pythonPath = currentPython;
1178
+ if (!usingVenv) {
1179
+ s.start('Creating .venv…');
1180
+ const base = basePython();
1181
+ if (!base) {
1182
+ s.stop(red('✗ No Python found to create a virtualenv with — install Python 3, then re-run.'));
1183
+ process.exit(1);
1184
+ }
1185
+ const made = await runCapture(base, ['-m', 'venv', venvDir], rootDir, 120000);
1186
+ pythonPath = (0, path_1.join)(rootDir, VENV_PYTHON);
1187
+ if (made.code !== 0 || !(0, fs_1.existsSync)(pythonPath)) {
1188
+ s.stop(red('✗ Could not create .venv'));
1189
+ if (made.output)
1190
+ prompts_1.log.info(dim(made.output.split('\n').slice(-10).join('\n')));
1191
+ process.exit(1);
1192
+ }
1193
+ s.stop(green(`✓ Created .venv ${dim(`(${base})`)}`));
1194
+ }
1195
+ const plan = pythonInstallPlan(appDir, rootDir, pythonPath);
1196
+ const installs = plan ? [plan] : [{ label: PYTHON_MODULES[framework], args: [PYTHON_MODULES[framework]] }];
1197
+ for (const step of installs) {
1198
+ s.start(`Installing ${step.label}… ${dim('(this can take a few minutes)')}`);
1199
+ const r = await runCapture(pythonPath, ['-m', 'pip', 'install', '--disable-pip-version-check', ...step.args], rootDir, 15 * 60000);
1200
+ if (r.code !== 0) {
1201
+ s.stop(red(`✗ pip install failed (${step.label})`));
1202
+ prompts_1.log.info(dim('─── pip output (last lines) ─────────────────────'));
1203
+ r.output.split('\n').slice(-20).forEach(line => prompts_1.log.info(dim(` ${line}`)));
1204
+ prompts_1.log.info(dim('─────────────────────────────────────────────────'));
1205
+ prompts_1.log.warn(yellow(`Fix the error above, then re-run apiforge — it will reuse ${(0, path_1.relative)(rootDir, pythonPath) || pythonPath}.`));
1206
+ process.exit(1);
1207
+ }
1208
+ s.stop(green(`✓ Installed ${step.label}`));
1209
+ }
1210
+ // The FastAPI dev server isn't always a declared dependency.
1211
+ if (framework === 'fastapi' && !pythonCanImport(pythonPath, 'uvicorn', appDir)) {
1212
+ s.start('Installing uvicorn…');
1213
+ const r = await runCapture(pythonPath, ['-m', 'pip', 'install', '--disable-pip-version-check', 'uvicorn'], rootDir, 5 * 60000);
1214
+ s.stop(r.code === 0 ? green('✓ Installed uvicorn') : yellow('◌ Could not install uvicorn — pick another start command'));
1215
+ }
1216
+ if (!pythonCanImport(pythonPath, PYTHON_MODULES[framework], appDir)) {
1217
+ prompts_1.log.error(red(`${PYTHON_LABELS[framework]} still can't be imported with ${pythonPath}.`));
1218
+ prompts_1.log.warn(yellow('Install it into that environment, then re-run apiforge.'));
1219
+ process.exit(1);
1220
+ }
1221
+ return pythonPath;
1222
+ }
1223
+ /** Import name → pip name, for the common cases where they differ. */
1224
+ const PIP_ALIASES = {
1225
+ jose: 'python-jose[cryptography]', jwt: 'PyJWT', dotenv: 'python-dotenv', yaml: 'PyYAML',
1226
+ PIL: 'Pillow', cv2: 'opencv-python', sklearn: 'scikit-learn', bs4: 'beautifulsoup4',
1227
+ dateutil: 'python-dateutil', multipart: 'python-multipart', magic: 'python-magic',
1228
+ environ: 'django-environ', decouple: 'python-decouple', rest_framework: 'djangorestframework',
1229
+ rest_framework_simplejwt: 'djangorestframework-simplejwt', corsheaders: 'django-cors-headers',
1230
+ debug_toolbar: 'django-debug-toolbar', django_filters: 'django-filter', crispy_forms: 'django-crispy-forms',
1231
+ allauth: 'django-allauth', storages: 'django-storages', sorl: 'sorl-thumbnail', haystack: 'django-haystack',
1232
+ psycopg2: 'psycopg2-binary', MySQLdb: 'mysqlclient', Crypto: 'pycryptodome', attr: 'attrs',
1233
+ slugify: 'python-slugify', socketio: 'python-socketio', docx: 'python-docx', OpenSSL: 'pyOpenSSL',
1234
+ };
1235
+ const normalizePkg = (name) => name.toLowerCase().replace(/[-_.]+/g, '-');
1236
+ /** Every requirement the project declares: requirements*.txt plus pyproject deps, extras and groups. */
1237
+ function declaredPythonRequirements(pythonPath, appDir, rootDir) {
1238
+ const reqs = [];
1239
+ const reqDir = (0, path_1.join)(rootDir, 'requirements');
1240
+ for (const dir of unique([appDir, rootDir, reqDir])) {
1241
+ let files = [];
1242
+ // requirements*.txt next to the app / at the root; any .txt inside requirements/
1243
+ try {
1244
+ files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.txt') && (dir === reqDir || f.startsWith('requirements')));
1245
+ }
1246
+ catch { }
1247
+ for (const file of files) {
1248
+ try {
1249
+ for (const line of (0, detect_js_1.readTextFile)((0, path_1.join)(dir, file)).split(/\r?\n/)) {
1250
+ const req = line.split(/\s+#|;/)[0].trim();
1251
+ if (req && !req.startsWith('#') && !req.startsWith('-'))
1252
+ reqs.push(req);
1253
+ }
1254
+ }
1255
+ catch { }
1256
+ }
1257
+ }
1258
+ const pyproject = (0, path_1.join)(rootDir, 'pyproject.toml');
1259
+ if ((0, fs_1.existsSync)(pyproject)) {
1260
+ const script = [
1261
+ 'import sys, tomllib',
1262
+ 'd = tomllib.load(open(sys.argv[1], "rb"))',
1263
+ 'p, poetry = d.get("project", {}), d.get("tool", {}).get("poetry", {})',
1264
+ 'out = list(p.get("dependencies", []))',
1265
+ 'for extra in p.get("optional-dependencies", {}).values(): out += extra',
1266
+ 'for group in d.get("dependency-groups", {}).values(): out += [g for g in group if isinstance(g, str)]',
1267
+ 'out += [n for n in poetry.get("dependencies", {}) if n.lower() != "python"]',
1268
+ 'for group in poetry.get("group", {}).values(): out += list(group.get("dependencies", {}))',
1269
+ 'print("\\n".join(out))',
1270
+ ].join('\n');
1271
+ try {
1272
+ const r = (0, child_process_1.spawnSync)(pythonPath, ['-c', script, pyproject], { encoding: 'utf-8', timeout: 30000 });
1273
+ if (r.status === 0)
1274
+ reqs.push(...(r.stdout || '').split(/\r?\n/).map(s => s.split(';')[0].trim()).filter(Boolean));
1275
+ }
1276
+ catch { }
1277
+ }
1278
+ return reqs;
1279
+ }
1280
+ /**
1281
+ * The pip requirement for a `No module named 'x'` crash, or null when it isn't
1282
+ * safe to guess. Prefers the project's own declaration (keeps its version pins,
1283
+ * and maps e.g. `environ` → `django-environ>=0.4`), then PIP_ALIASES.
1284
+ */
1285
+ function missingPythonPackage(output, pythonPath, appDir, rootDir) {
1286
+ // FastAPI raises RuntimeError, not ModuleNotFoundError, for form/file params.
1287
+ if (/python-multipart/.test(output) && /Form data requires|File uploads require/.test(output)) {
1288
+ return { module: 'multipart', requirement: 'python-multipart' };
1289
+ }
1290
+ const matches = [...output.matchAll(/ModuleNotFoundError: No module named '([\w.]+)'/g)];
1291
+ if (!matches.length)
1292
+ return null;
1293
+ const module = matches[matches.length - 1][1].split('.')[0];
1294
+ const wanted = normalizePkg(module);
1295
+ const declared = declaredPythonRequirements(pythonPath, appDir, rootDir)
1296
+ .map(req => ({ req, name: normalizePkg(req.match(/^[A-Za-z0-9][A-Za-z0-9._-]*/)?.[0] ?? '') }))
1297
+ .filter(d => d.name);
1298
+ const hit = declared.find(d => d.name === wanted)
1299
+ ?? declared.find(d => d.name.endsWith(`-${wanted}`) || d.name.startsWith(`${wanted}-`));
1300
+ if (hit)
1301
+ return { module, requirement: hit.req };
1302
+ if (PIP_ALIASES[module])
1303
+ return { module, requirement: PIP_ALIASES[module] };
1304
+ return null;
1305
+ }
1306
+ /** Node-flow parity: ask before removing the snippet (it's inert if kept). */
1307
+ async function offerPythonCleanup(entryFile, entryLabel, block) {
1308
+ const shouldClean = await (0, prompts_1.confirm)({
1309
+ message: `Remove APIForge code changes from ${bold(entryLabel)}?`,
1310
+ initialValue: true,
1311
+ });
1312
+ if ((0, prompts_1.isCancel)(shouldClean) || !shouldClean) {
1313
+ prompts_1.log.info(dim(`Left in ${entryLabel} — it does nothing unless the APIForge CLI runs it, and the next run replaces it.`));
1314
+ return;
1315
+ }
1316
+ if (removePythonSnippet(entryFile, block)) {
1317
+ prompts_1.log.success(green(`✓ Removed code changes from ${entryLabel}`));
1318
+ }
1319
+ else {
1320
+ prompts_1.log.warn(yellow(`Could not auto-remove — please delete the APIForge block from ${entryLabel} manually.`));
1321
+ }
1322
+ }
1323
+ // stripPythonSnippet() matches on the "# APIForge — temporary" prefix — keep it stable.
1324
+ const PY_SNIPPET_START = '# APIForge — temporary analysis snippet, safe to delete';
1325
+ const PY_SNIPPET_END = '# /APIForge';
1326
+ const PY_MAIN_GUARD = /^if\s+__name__\s*==\s*['"]__main__['"]\s*:/m;
1327
+ /** The file the snippet goes into: the module defining the app, or Django's manage.py. */
1328
+ function pythonEntryFile(framework, target) {
1329
+ if (target.file && (0, fs_1.existsSync)(target.file))
1330
+ return target.file;
1331
+ if (framework === 'django') {
1332
+ const manage = (0, path_1.join)(target.appDir, 'manage.py');
1333
+ return (0, fs_1.existsSync)(manage) ? manage : null;
1334
+ }
1335
+ // Typed by hand: derive it from "module:app".
1336
+ const dotted = target.app?.split(':')[0];
1337
+ if (!dotted)
1338
+ return null;
1339
+ const base = (0, path_1.join)(target.appDir, ...dotted.split('.'));
1340
+ return [`${base}.py`, (0, path_1.join)(base, '__init__.py')].find(f => (0, fs_1.existsSync)(f)) ?? null;
1341
+ }
1342
+ async function askPythonEntryFile(framework) {
1343
+ const resolve = (p) => ((0, path_1.isAbsolute)(p) ? p : (0, path_1.join)(process.cwd(), p));
1344
+ const typed = await (0, prompts_1.text)({
1345
+ message: framework === 'django' ? 'Path to your manage.py' : 'Path to the file that creates your app',
1346
+ placeholder: framework === 'django' ? 'manage.py' : 'main.py',
1347
+ validate: v => {
1348
+ if (!v.trim())
1349
+ return 'Required';
1350
+ if (!(0, fs_1.existsSync)(resolve(v.trim())))
1351
+ return `Not found: ${v.trim()}`;
1352
+ },
1353
+ });
1354
+ if ((0, prompts_1.isCancel)(typed)) {
1355
+ (0, prompts_1.cancel)('Cancelled.');
1356
+ process.exit(0);
1357
+ }
1358
+ return resolve(typed.trim());
1359
+ }
1360
+ /**
1361
+ * Every usual way to start each framework's dev server, using the interpreter
1362
+ * that has the project's dependencies — the Python counterpart of listing
1363
+ * package.json scripts. Commands run from target.appDir.
1364
+ */
1365
+ function pythonRunCommands(framework, target, entryFile, pythonPath) {
1366
+ const py = `"${pythonPath}"`;
1367
+ const relFile = (0, path_1.relative)(target.appDir, entryFile);
1368
+ let source = '';
1369
+ try {
1370
+ source = (0, fs_1.readFileSync)(entryFile, 'utf-8');
1371
+ }
1372
+ catch { }
1373
+ // The file starts its own server: `if __name__ == "__main__": uvicorn.run(app)` / app.run()
1374
+ const runsItself = PY_MAIN_GUARD.test(source);
1375
+ if (framework === 'django') {
1376
+ return [
1377
+ `${py} manage.py runserver --noreload`,
1378
+ `${py} manage.py runserver`,
1379
+ ];
1380
+ }
1381
+ if (framework === 'fastapi') {
1382
+ const app = target.app ?? 'main:app';
1383
+ const factory = target.factory ? ' --factory' : '';
1384
+ const commands = [];
1385
+ // `fastapi dev` is FastAPI's own recommended command (and the only one that
1386
+ // sets FASTAPI_ENV=development) — list it first when fastapi-cli is installed.
1387
+ if (!target.factory && pythonCanImport(pythonPath, 'fastapi_cli', target.appDir)) {
1388
+ commands.push(`${py} -m fastapi dev ${relFile}`, `${py} -m fastapi run ${relFile}`);
1389
+ }
1390
+ commands.push(`${py} -m uvicorn ${app}${factory}`);
1391
+ if (runsItself)
1392
+ commands.push(`${py} ${relFile}`);
1393
+ return commands;
1394
+ }
1395
+ const commands = [`${py} -m flask --app ${target.app ?? 'app'} run`];
1396
+ if (runsItself)
1397
+ commands.push(`${py} ${relFile}`);
1398
+ return commands;
1399
+ }
1400
+ /**
1401
+ * The snippet is inert unless APIFORGE_INTROSPECT is set — only the CLI sets
1402
+ * it — so a copy left behind by a hard-killed run can't do anything.
1403
+ * Factories (Flask create_app, uvicorn --factory) are wrapped so the routes are
1404
+ * read from the app the factory returns.
1405
+ */
1406
+ function buildPythonSnippet(appAttr, factory) {
1407
+ const run = (appExpr, indent) => [
1408
+ `${indent}with open(_apiforge_os.environ["APIFORGE_INTROSPECT"], encoding="utf-8") as _apiforge_fh:`,
1409
+ `${indent} exec(_apiforge_fh.read(), {"__name__": "apiforge_introspect", "APIFORGE_APP": ${appExpr}})`,
1410
+ ];
1411
+ const body = factory && appAttr
1412
+ ? [
1413
+ ` _apiforge_factory = ${appAttr}`,
1414
+ ` def ${appAttr}(*args, **kwargs):`,
1415
+ ` _apiforge_app = _apiforge_factory(*args, **kwargs)`,
1416
+ ...run('_apiforge_app', ' '),
1417
+ ` return _apiforge_app`,
1418
+ ]
1419
+ : run(appAttr ?? 'None', ' ');
1420
+ return [
1421
+ PY_SNIPPET_START,
1422
+ `# https://apiforge.brainfogagency.com — does nothing unless run by the APIForge CLI`,
1423
+ `import os as _apiforge_os`,
1424
+ `if _apiforge_os.environ.get("APIFORGE_INTROSPECT"):`,
1425
+ ...body,
1426
+ PY_SNIPPET_END,
1427
+ ];
1428
+ }
1429
+ /**
1430
+ * Insert the snippet after the app is built: before an `if __name__ == "__main__":`
1431
+ * block (which would otherwise start the server first), else at the end of the
1432
+ * file. Django's manage.py gets it before the command runs; the snippet then sets
1433
+ * Django up itself. Returns the exact inserted text so removal restores the file
1434
+ * byte-for-byte.
1435
+ */
1436
+ function injectPythonSnippet(filePath, framework, target) {
1437
+ // A previous run that was hard-killed before cleanup: drop its block first.
1438
+ const content = stripPythonSnippet((0, fs_1.readFileSync)(filePath, 'utf-8'));
1439
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
1440
+ const appAttr = framework === 'django' ? null : (target.app?.split(':')[1] || 'app');
1441
+ const factory = !!target.factory || (!!appAttr && new RegExp(`^def\\s+${appAttr}\\s*\\(`, 'm').test(content));
1442
+ const lines = buildPythonSnippet(appAttr, factory);
1443
+ const snippet = lines.join(eol) + eol;
1444
+ // manage.py without a __main__ guard runs the command at top level; go before that call.
1445
+ const guard = PY_MAIN_GUARD.exec(content)
1446
+ ?? (framework === 'django' ? /^execute_from_command_line\s*\(/m.exec(content) : null);
1447
+ let block;
1448
+ let next;
1449
+ if (guard) {
1450
+ block = snippet + eol;
1451
+ next = content.slice(0, guard.index) + block + content.slice(guard.index);
1452
+ }
1453
+ else {
1454
+ block = (content === '' || content.endsWith('\n') ? '' : eol) + eol + snippet;
1455
+ next = content + block;
1456
+ }
1457
+ (0, fs_1.writeFileSync)(filePath, next, 'utf-8');
1458
+ return { block, linesAdded: lines.length };
1459
+ }
1460
+ function removePythonSnippet(filePath, block) {
1461
+ try {
1462
+ const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
1463
+ const cleaned = block && content.includes(block)
1464
+ ? content.replace(block, () => '')
1465
+ : stripPythonSnippet(content);
1466
+ if (cleaned !== content)
1467
+ (0, fs_1.writeFileSync)(filePath, cleaned, 'utf-8');
1468
+ return !cleaned.includes(PY_SNIPPET_START);
1469
+ }
1470
+ catch {
1471
+ return false;
1472
+ }
1473
+ }
1474
+ function stripPythonSnippet(content) {
1475
+ return content.replace(/(?:\r?\n)?# APIForge — temporary[^\n]*\r?\n[\s\S]*?# \/APIForge\r?\n?/g, '');
1476
+ }
1477
+ /**
1478
+ * Run the user's start command and wait for the injected snippet to write the
1479
+ * status file. The server is killed as soon as routes arrive — before it would
1480
+ * normally even bind its port, since every supported server loads the app first.
1481
+ */
1482
+ function runPythonServerAndCollect(command, cwd, env, statusPath, timeoutMs) {
1483
+ return new Promise(resolve => {
1484
+ const child = (0, child_process_1.spawn)(command, {
1485
+ cwd,
1486
+ stdio: ['ignore', 'pipe', 'pipe'],
1487
+ shell: true,
1488
+ env: { ...process.env, ...env, PYTHONDONTWRITEBYTECODE: '1', PYTHONIOENCODING: 'utf-8' },
1489
+ });
1490
+ // Tail-capped, unlike the Node flow: a Python traceback ends with the real error.
1491
+ let output = '';
1492
+ const capture = (chunk) => { output = (output + chunk.toString()).slice(-12000); };
1493
+ child.stdout?.on('data', capture);
1494
+ child.stderr?.on('data', capture);
1495
+ let done = false;
1496
+ const finish = (r) => {
1497
+ if (done)
1498
+ return;
1499
+ done = true;
1500
+ clearInterval(poller);
1501
+ clearTimeout(timer);
1502
+ if (child.pid)
1503
+ killProcessTree(child.pid);
1504
+ resolve({ ...r, output: output.trim() });
1505
+ };
1506
+ const readStatus = () => {
1507
+ try {
1508
+ if (!(0, fs_1.existsSync)(statusPath))
1509
+ return false;
1510
+ const data = JSON.parse((0, fs_1.readFileSync)(statusPath, 'utf-8'));
1511
+ if (data.status === 'collected' && Array.isArray(data.routes)) {
1512
+ finish({ routes: data.routes });
1513
+ return true;
1514
+ }
1515
+ if (data.status === 'error') {
1516
+ finish({ error: data.error || 'Route collection failed' });
1517
+ return true;
1518
+ }
1519
+ }
1520
+ catch { }
1521
+ return false;
1522
+ };
1523
+ const poller = setInterval(readStatus, 500);
1524
+ const timer = setTimeout(() => finish({
1525
+ error: `Timed out after ${Math.round(timeoutMs / 1000)}s — the server never loaded the app`,
1526
+ }), timeoutMs);
1527
+ child.on('error', err => finish({ error: err.message }));
1528
+ child.on('exit', code => {
1529
+ // The status file can land just before the server dies (e.g. port already in use).
1530
+ setTimeout(() => {
1531
+ if (!readStatus())
1532
+ finish({ error: `Server exited (code ${code}) before routes were collected` });
1533
+ }, 600);
1534
+ });
1535
+ });
1536
+ }
775
1537
  // ─── Server subprocess ────────────────────────────────────────────────────────
776
1538
  /**
777
1539
  * Kill the whole process tree rooted at `pid`, not just that one process.