diff --git a/scripts/run.js b/scripts/run.js new file mode 100644 index 000000000..57a197647 --- /dev/null +++ b/scripts/run.js @@ -0,0 +1,331 @@ +// Unified dev/prod launcher for PictoPy: backend, sync-microservice, and frontend. +// Node.js port of run.sh with identical behavior, for native Windows (cmd/PowerShell) +// support without requiring Git Bash or WSL. +// +// On Linux, macOS, Git Bash, or WSL you can also use `bash scripts/run.sh`. +// +// Usage: +// node scripts/run.js -> dev mode (default) +// node scripts/run.js --prod -> production mode + +import { spawn, spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- Colors (matches scripts/run.sh / scripts/setup.sh conventions) --- +const RED = '\x1b[0;31m'; +const GREEN = '\x1b[0;32m'; +const YELLOW = '\x1b[0;33m'; +const NC = '\x1b[0m'; + +const MODE = process.argv[2] === '--prod' ? 'prod' : 'dev'; + +const ROOT_DIR = path.resolve(__dirname, '..'); +const BACKEND_DIR = path.join(ROOT_DIR, 'backend'); +const SYNC_DIR = path.join(ROOT_DIR, 'sync-microservice'); +const FRONTEND_DIR = path.join(ROOT_DIR, 'frontend'); + +// --- OS detection (same approach as scripts/run.sh and scripts/setup.js) --- +let OS_TYPE; +switch (process.platform) { + case 'linux': + OS_TYPE = 'linux'; + break; + case 'darwin': + OS_TYPE = 'macos'; + break; + case 'win32': + OS_TYPE = 'windows'; + break; + default: + OS_TYPE = 'unknown'; + console.log(`${YELLOW}Warning: unrecognized platform '${process.platform}'. Assuming Unix-like behavior.${NC}`); +} +const IS_WINDOWS = OS_TYPE === 'windows'; + +const SETUP_HINT = IS_WINDOWS + ? "Run 'npm run setup' from the repo root (this launches scripts/setup.ps1 on Windows)." + : "Run 'npm run setup' from the repo root to install dependencies."; + +// --- Preflight: fail fast with guidance instead of a raw "command not found" --- +function commandExists(cmd) { + const pathEnv = process.env.PATH || process.env.Path || ''; + const dirs = pathEnv.split(path.delimiter).filter(Boolean); + const exts = IS_WINDOWS ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']; + for (const dir of dirs) { + for (const ext of exts) { + const candidate = path.join(dir, cmd + ext); + try { + fs.accessSync(candidate, fs.constants.X_OK); + return candidate; + } catch { + // keep searching + } + } + } + return null; +} + +function requireCmd(cmd, guidance) { + if (!commandExists(cmd)) { + console.error(`${RED}Error: required command '${cmd}' not found.${NC}`); + console.error(`${YELLOW}${guidance}${NC}`); + process.exit(1); + } +} + +function requireDir(dir, label) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + console.error(`${RED}Error: ${label} directory not found at ${dir}${NC}`); + console.error(`${YELLOW}Make sure you're running this from a full PictoPy checkout.${NC}`); + process.exit(1); + } +} + +requireDir(BACKEND_DIR, 'backend'); +requireDir(SYNC_DIR, 'sync-microservice'); +requireDir(FRONTEND_DIR, 'frontend'); + +requireCmd('node', `Node.js not found. Install it from https://nodejs.org, or ${SETUP_HINT}`); +requireCmd('npm', `npm not found (usually bundled with Node.js). Install Node.js from https://nodejs.org, or ${SETUP_HINT}`); +requireCmd('cargo', `Rust/Cargo not found (required by 'npm run tauri dev'). Install it from https://rustup.rs, or ${SETUP_HINT}`); + +// Creates a venv at `venvDir` using whatever `python`/`python3` is on PATH. +// A venv is just an empty container (unlike a .env secrets file, there's no +// "wrong value" risk), so auto-creating a missing one on first run is safe +// and matches how poetry/uv/pipenv behave. +function createVenv(prefix, venvDir) { + const pythonCmd = commandExists('python') ? 'python' : commandExists('python3') ? 'python3' : null; + if (!pythonCmd) { + console.error(`${RED}[${prefix}] 'python' not found. Install Python 3, or ${SETUP_HINT}${NC}`); + process.exit(1); + } + console.log(`[${prefix}] Creating virtual environment at ${venvDir}...`); + const result = spawnSync(pythonCmd, ['-m', 'venv', venvDir], { stdio: 'inherit' }); + if (result.status !== 0) { + console.error(`${RED}[${prefix}] Failed to create virtual environment.${NC}`); + process.exit(1); + } +} + +// --- venv resolution helper --- +// Tries each candidate venv folder name in order, using the correct +// bin dir per OS (bin/ on Unix, Scripts/ on Windows). Instead of "sourcing" +// activate (there's no such thing for a child process spawned from Node), +// the resolved bin dir is prepended to PATH for that child. Auto-creates +// one under the first candidate name if none of them exist yet. +function resolveVenv(prefix, baseDir, candidates) { + for (const name of candidates) { + const venvDir = path.join(baseDir, name); + const binDir = path.join(venvDir, IS_WINDOWS ? 'Scripts' : 'bin'); + if (fs.existsSync(path.join(binDir, 'activate'))) { + return { venvDir, binDir }; + } + } + const venvDir = path.join(baseDir, candidates[0]); + createVenv(prefix, venvDir); + const binDir = path.join(venvDir, IS_WINDOWS ? 'Scripts' : 'bin'); + return { venvDir, binDir }; +} + +// --- process orchestration --- +const children = []; +let aliveCount = 0; +let shuttingDown = false; + +function prefixStream(stream, prefix, out) { + let buffer = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + out.write(`[${prefix}] ${line}\n`); + } + }); + stream.on('end', () => { + if (buffer.length > 0) { + out.write(`[${prefix}] ${buffer}\n`); + buffer = ''; + } + }); +} + +function spawnService(prefix, command, args, options) { + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }); + + prefixStream(child.stdout, prefix, process.stdout); + prefixStream(child.stderr, prefix, process.stdout); // merged like run.sh's 2>&1 + + child.on('error', (err) => { + process.stdout.write(`[${prefix}] Failed to start: ${err.message}\n`); + }); + + aliveCount++; + child.on('exit', () => { + aliveCount--; + if (aliveCount === 0 && !shuttingDown) { + process.exit(0); + } + }); + + children.push(child); + return child; +} + +function killChild(child) { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + if (IS_WINDOWS) { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F']); + } else { + try { + process.kill(-child.pid, 'SIGTERM'); + } catch { + try { + child.kill('SIGTERM'); + } catch { + // process already gone + } + } + } +} + +function cleanup() { + if (shuttingDown) return; + shuttingDown = true; + console.log(''); + console.log('Shutting down all services...'); + for (const child of children) { + killChild(child); + } + process.exit(0); +} +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +function venvEnv(binDir) { + return { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || process.env.Path || ''}`, + // Node pipes child stdio, so Python sees a non-tty and fully buffers + // stdout instead of line-buffering it. Force unbuffered output so + // uvicorn/fastapi logs show up live instead of only on exit. + PYTHONUNBUFFERED: '1', + // Without a real console attached, rich (used by `fastapi dev`'s banner) + // falls back to the legacy Windows console writer, which encodes with + // cp1252 and crashes on the emoji in its own startup banner. Forcing + // UTF-8 mode avoids that. + PYTHONUTF8: '1', + }; +} + +// pip/uvicorn/fastapi console-script .exe launchers generated inside a venv +// have turned out to be unreliable on this Windows setup (some fail +// instantly with no output, e.g. "Fatal error in launcher: Unable to find +// an appended archive"). Invoking everything as `python -m ` +// sidesteps those launcher stubs entirely and has proven reliable. +function venvPython(venv) { + const python = path.join(venv.binDir, IS_WINDOWS ? 'python.exe' : 'python'); + if (!fs.existsSync(python)) { + console.error(`${RED}'python' not found in venv at ${venv.venvDir}.${NC}`); + process.exit(1); + } + return python; +} + +// Installs a service's Python dependencies before starting it, matching +// `source /activate && pip install -r requirements.txt` from the +// project's README. Blocking by design: the server must not start against +// a venv with missing/outdated packages. +function pipInstall(prefix, serviceDir, venv, python) { + console.log(`[${prefix}] Installing dependencies from requirements.txt...`); + const result = spawnSync(python, ['-m', 'pip', 'install', '-r', 'requirements.txt'], { + cwd: serviceDir, + env: venvEnv(venv.binDir), + stdio: 'inherit', + }); + if (result.status !== 0) { + console.error(`${RED}[${prefix}] pip install -r requirements.txt failed.${NC}`); + process.exit(1); + } +} + +function startBackend() { + console.log(`[BACKEND] Starting... ${BACKEND_DIR}`); + const venv = resolveVenv('BACKEND', BACKEND_DIR, ['.env', 'venv']); + if (!venv) process.exit(1); + const python = venvPython(venv); + pipInstall('BACKEND', BACKEND_DIR, venv, python); + + const env = venvEnv(venv.binDir); + if (MODE === 'prod') { + console.log('[BACKEND] Starting in production mode on port 52123...'); + spawnService('BACKEND', python, ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', '52123', '--workers', process.env.WORKERS || '1'], { + cwd: BACKEND_DIR, + env, + detached: !IS_WINDOWS, + }); + } else { + // Backend pins fastapi-cli==0.0.3, which predates `fastapi.__main__` + // (no `python -m fastapi` support), unlike sync-microservice's newer + // fastapi-cli. Use uvicorn directly here instead. + console.log('[BACKEND] Starting in dev mode on port 52123...'); + spawnService('BACKEND', python, ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', '52123', '--reload'], { + cwd: BACKEND_DIR, + env, + detached: !IS_WINDOWS, + }); + } +} + +function startSync() { + const venv = resolveVenv('SYNC', SYNC_DIR, ['.sync-env', 'venv']); + if (!venv) process.exit(1); + const python = venvPython(venv); + pipInstall('SYNC', SYNC_DIR, venv, python); + console.log('[SYNC] Starting sync-microservice on port 52124...'); + + const env = venvEnv(venv.binDir); + if (MODE === 'prod') { + spawnService('SYNC', python, ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', '52124'], { + cwd: SYNC_DIR, + env, + detached: !IS_WINDOWS, + }); + } else { + spawnService('SYNC', python, ['-m', 'fastapi', 'dev', '--port', '52124'], { + cwd: SYNC_DIR, + env, + detached: !IS_WINDOWS, + }); + } +} + +function startFrontend() { + const nodeModules = path.join(FRONTEND_DIR, 'node_modules'); + if (!fs.existsSync(nodeModules)) { + console.error(`${RED}[FRONTEND] node_modules not found.${NC}`); + console.error(`${YELLOW}[FRONTEND] ${SETUP_HINT}${NC}`); + process.exit(1); + } + console.log('[FRONTEND] Starting Tauri dev...'); + spawnService('FRONTEND', 'npm', ['run', 'tauri', 'dev'], { + cwd: FRONTEND_DIR, + env: process.env, + shell: IS_WINDOWS, // npm ships as npm.cmd on Windows, which needs shell resolution + detached: !IS_WINDOWS, + }); +} + +console.log(`Starting PictoPy (mode: ${MODE}, OS: ${OS_TYPE})...`); +startBackend(); +startSync(); +startFrontend(); diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 000000000..6e30171aa --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +# Unified dev/prod launcher for PictoPy: backend, sync-microservice, and frontend +# +# Requires bash (Linux, macOS, Git Bash, or WSL on Windows). If you're on +# Windows without Git Bash/WSL, use `node scripts/run.js` instead. +# +# Usage: +# ./run.sh -> dev mode (default) +# ./run.sh --test -> dev mode (kept as an alias for backward compatibility) +# ./run.sh --prod -> production mode + +set -e + +# --- Colors (matches scripts/setup.sh conventions) --- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +MODE="dev" +if [[ "$1" == "--prod" ]]; then + MODE="prod" +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKEND_DIR="$ROOT_DIR/backend" +SYNC_DIR="$ROOT_DIR/sync-microservice" +FRONTEND_DIR="$ROOT_DIR/frontend" + +# --- OS detection (same approach as scripts/setup.sh and scripts/setup.js) --- +case "$(uname -s)" in + Linux*) OS_TYPE="linux" ;; + Darwin*) OS_TYPE="macos" ;; + MINGW*|MSYS*|CYGWIN*) OS_TYPE="windows" ;; + *) + OS_TYPE="unknown" + echo -e "${YELLOW}Warning: unrecognized OS '$(uname -s)'. Assuming Unix-like behavior.${NC}" + ;; +esac + +SETUP_HINT="Run 'npm run setup' from the repo root to install dependencies." +if [[ "$OS_TYPE" == "windows" ]]; then + SETUP_HINT="Run 'npm run setup' from the repo root (this launches scripts/setup.ps1 on Windows)." +fi + +# --- Preflight: fail fast with guidance instead of a raw "command not found" --- +require_cmd() { + local cmd="$1" + local guidance="$2" + if ! command -v "$cmd" &> /dev/null; then + echo -e "${RED}Error: required command '$cmd' not found.${NC}" + echo -e "${YELLOW}${guidance}${NC}" + exit 1 + fi +} + +require_dir() { + local dir="$1" + local label="$2" + if [[ ! -d "$dir" ]]; then + echo -e "${RED}Error: $label directory not found at $dir${NC}" + echo -e "${YELLOW}Make sure you're running this from a full PictoPy checkout.${NC}" + exit 1 + fi +} + +require_dir "$BACKEND_DIR" "backend" +require_dir "$SYNC_DIR" "sync-microservice" +require_dir "$FRONTEND_DIR" "frontend" + +require_cmd "node" "Node.js not found. Install it from https://nodejs.org, or $SETUP_HINT" +require_cmd "npm" "npm not found (usually bundled with Node.js). Install Node.js from https://nodejs.org, or $SETUP_HINT" +require_cmd "cargo" "Rust/Cargo not found (required by 'npm run tauri dev'). Install it from https://rustup.rs, or $SETUP_HINT" + +# Creates a venv at `venv_dir` using whatever `python`/`python3` is on PATH. +# A venv is just an empty container (unlike a .env secrets file, there's no +# "wrong value" risk), so auto-creating a missing one on first run is safe +# and matches how poetry/uv/pipenv behave. All output goes to stderr since +# resolve_venv's stdout is captured via command substitution by its callers. +create_venv() { + local prefix="$1" + local venv_dir="$2" + local py_cmd="python" + if ! command -v python &> /dev/null; then + if command -v python3 &> /dev/null; then + py_cmd="python3" + else + echo -e "${RED}[$prefix] 'python' not found. Install Python 3, or $SETUP_HINT${NC}" >&2 + return 1 + fi + fi + echo "[$prefix] Creating virtual environment at $venv_dir..." >&2 + if ! "$py_cmd" -m venv "$venv_dir" >&2; then + echo -e "${RED}[$prefix] Failed to create virtual environment.${NC}" >&2 + return 1 + fi + return 0 +} + +# --- venv resolution helper --- +# Tries each candidate venv folder name in order and prepends its bin dir +# (bin/ on Unix, Scripts/ on Windows) to PATH directly. Deliberately does +# NOT `source /activate`: that script bakes in an absolute path at +# creation time (see its VIRTUAL_ENV= line and pyvenv.cfg), and silently +# breaks -- falling through to system Python with no error -- if the venv +# folder is ever renamed or moved after creation, which happens easily +# since ".env"/".venv" naming is inconsistent across tooling. Resolving +# the bin dir fresh from its current location every run sidesteps that. +# Auto-creates one under the first candidate name if none of them exist yet. +resolve_venv() { + local prefix="$1" + local base_dir="$2" + shift 2 + local candidates=("$@") + local bin_subdir="bin" + [[ "$OS_TYPE" == "windows" ]] && bin_subdir="Scripts" + + for name in "${candidates[@]}"; do + local venv_dir="$base_dir/$name" + if [[ -f "$venv_dir/$bin_subdir/activate" ]]; then + echo "$venv_dir/$bin_subdir" + return 0 + fi + done + + local venv_dir="$base_dir/${candidates[0]}" + create_venv "$prefix" "$venv_dir" || return 1 + echo "$venv_dir/$bin_subdir" + return 0 +} + +# pip/uvicorn/fastapi console-script launchers generated inside a venv have +# proven unreliable on some platforms (see scripts/run.js for the Windows +# case that motivated this). Invoking everything as `python -m ` +# sidesteps those launcher stubs entirely. +export PYTHONUNBUFFERED=1 +export PYTHONUTF8=1 + +# Installs a service's Python dependencies before starting it, matching +# `source /activate && pip install -r requirements.txt` from the +# project's README. Blocking by design: the server must not start against +# a venv with missing/outdated packages. +pip_install() { + local prefix="$1" + echo "[$prefix] Installing dependencies from requirements.txt..." + if ! python -m pip install -r requirements.txt; then + echo -e "${RED}[$prefix] pip install -r requirements.txt failed.${NC}" + return 1 + fi + return 0 +} + +PIDS=() + +cleanup() { + echo "" + echo "Shutting down all services..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null + done + wait 2>/dev/null + exit 0 +} +trap cleanup INT TERM + +start_backend() { + ( + echo "[BACKEND] Starting... ${BACKEND_DIR}" + cd "$BACKEND_DIR" + bin_dir=$(resolve_venv "BACKEND" "$BACKEND_DIR" ".env" "venv") || exit 1 + export PATH="$bin_dir:$PATH" + pip_install "BACKEND" || exit 1 + if [[ "$MODE" == "prod" ]]; then + echo "[BACKEND] Starting in production mode on port 52123..." + python -m uvicorn main:app --host 0.0.0.0 --port 52123 --workers "${WORKERS:-1}" + else + # Backend pins fastapi-cli==0.0.3, which predates + # `fastapi.__main__` (no `python -m fastapi` support), unlike + # sync-microservice's newer fastapi-cli. Use uvicorn directly. + echo "[BACKEND] Starting in dev mode on port 52123..." + python -m uvicorn main:app --host 0.0.0.0 --port 52123 --reload + fi + ) 2>&1 | sed -u 's/^/[BACKEND] /' & + PIDS+=($!) +} + +start_sync() { + ( + cd "$SYNC_DIR" + bin_dir=$(resolve_venv "SYNC" "$SYNC_DIR" ".sync-env" "venv") || exit 1 + export PATH="$bin_dir:$PATH" + pip_install "SYNC" || exit 1 + echo "[SYNC] Starting sync-microservice on port 52124..." + if [[ "$MODE" == "prod" ]]; then + python -m uvicorn main:app --host 0.0.0.0 --port 52124 + else + python -m fastapi dev --port 52124 + fi + ) 2>&1 | sed -u 's/^/[SYNC] /' & + PIDS+=($!) +} + +start_frontend() { + ( + cd "$FRONTEND_DIR" + if [[ ! -d "node_modules" ]]; then + echo -e "${RED}[FRONTEND] node_modules not found.${NC}" + echo -e "${YELLOW}[FRONTEND] ${SETUP_HINT}${NC}" + exit 1 + fi + echo "[FRONTEND] Starting Tauri dev..." + npm run tauri dev + ) 2>&1 | sed -u 's/^/[FRONTEND] /' & + PIDS+=($!) +} + +echo "Starting PictoPy (mode: $MODE, OS: $OS_TYPE)..." +start_backend +start_sync +start_frontend + +wait