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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 331 additions & 0 deletions scripts/run.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +159 to +182

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Repository files matching run.js/npm/sh scripts:\n'
git ls-files | rg '(^|/)(run\.js|package\.json|run\.sh|.*\.js$)' || true

printf '\nscripts/run.js line count and relevant section:\n'
wc -l scripts/run.js
sed -n '1,240p' scripts/run.js

printf '\nSearch for spawnService/exit handlers/shutdown:\n'
rg -n "spawnService|aliveCount|children\.push|shuttingDown|process\.exit|on\\('error'|on\\(\"error\"|on\\('exit'|on\\(\"exit\"" scripts/run.js

Repository: AOSSIE-Org/PictoPy

Length of output: 9838


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe for Node's child_process spawn error/exit semantics using only core Node modules.
# This does not execute repository code; it runs a small standalone Node probe in the sandbox.
node - <<'JS'
const { spawn } = require('node:child_process');

async function runProbe(command) {
  const child = spawn(command, [], { stdio: ['ignore', 'pipe', 'pipe'] });
  const events = [];
  let aliveCount = 1;

  child.on('error', (err) => {
    events.push(`error:${err.code}`);
  });
  child.on('exit', (code, signal) => {
    events.push(`exit:${String(code)}:${String(signal)}`);
    aliveCount--;
  });

  await new Promise((resolve) => {
    child.on('close', (code, signal) => {
      events.push(`close:${String(code)}:${String(signal)}`);
      resolve(events);
    });
    setTimeout(resolve, 200, `[timeout] events=${JSON.stringify(events)} aliveCount=${aliveCount}`);
  });
}

runProbe('totally-missing-missing-command').then((events) => {
  console.log(events.join(' | '));
  // The main test harness is the presence of Node's documented behavior:
  // if error happens before launch, exit may not fire and aliveCount can stay positive.
});

runProbe('node').then((events) => {
  console.log(events.join(' | '));
});
JS

Repository: AOSSIE-Org/PictoPy

Length of output: 329


🌐 Web query:

Node.js child_process spawn error event exit event may or may not fire after error occurred documentation

💡 Result:

According to official Node.js documentation, the 'exit' event in a child_process may or may not fire after an error has occurred [1][2]. Because of this behavior, it is explicitly recommended that developers guard against accidentally invoking handler functions multiple times when listening to both the 'exit' and 'error' events [1][2]. The 'error' event is emitted for various reasons, including when a process cannot be spawned, killed, or when sending a message fails [1][2]. In contrast, the 'exit' event is emitted only after a successfully spawned process ends [1][2]. If a process fails to spawn, the 'spawn' event is not emitted, and an 'error' event is emitted instead [1][2]. Conversely, if a process is successfully spawned but later encounters an error (such as failing to execute a command within a shell), the 'spawn' event will have fired, and subsequent events like 'exit' may still occur depending on the nature of the error [1][3].

Citations:


Decrement aliveCount on failed spawns as well.

When child_process.spawn fails before the process launches, it emits error but the exit event may not fire. A misspelled/stale venv command or other failed command can therefore leave spawnService with aliveCount > 0; once the other children terminate normally, the launcher will stay alive instead of auto-exiting. Track error and exit from a shared, guarded settled handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/run.js` around lines 159 - 182, Update spawnService so child startup
failures decrement aliveCount even when no exit event is emitted. Add a shared
guarded settled handler used by both the child error and exit listeners,
ensuring aliveCount is decremented only once and the existing zero-count
shutdown behavior remains intact.


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);

Comment on lines +201 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

cleanup() exits before children actually terminate.

Unlike scripts/run.sh's cleanup(), which calls wait after kill so backgrounded services finish shutting down before the script exits, this cleanup() calls process.exit(0) immediately after issuing SIGTERM/taskkill to each child, without waiting for their exit events. Signals are delivered asynchronously, so the launcher can return control to the terminal while backend/sync/frontend are still mid-shutdown (e.g., uvicorn's reload subprocess), producing interleaved or orphaned output after the prompt returns.

♻️ Suggested fix: wait for children before exiting
 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);
+  const exits = children.map(
+    (child) => new Promise((resolve) => child.once('exit', resolve)),
+  );
+  for (const child of children) {
+    killChild(child);
+  }
+  Promise.all(exits).then(() => process.exit(0));
+  setTimeout(() => process.exit(0), 5000); // safety timeout
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 cleanup() {
if (shuttingDown) return;
shuttingDown = true;
console.log('');
console.log('Shutting down all services...');
const exits = children.map(
(child) => new Promise((resolve) => child.once('exit', resolve)),
);
for (const child of children) {
killChild(child);
}
Promise.all(exits).then(() => process.exit(0));
setTimeout(() => process.exit(0), 5000); // safety timeout
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/run.js` around lines 201 - 213, Update cleanup() to wait for all
child processes to emit their exit events after killChild(child) before
terminating the launcher. Preserve the shuttingDown guard and existing shutdown
messaging, and only call process.exit(0) once every child has finished or the
established child-wait mechanism completes.

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 <module>`
// 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 <venv>/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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading