364 lines
10 KiB
JavaScript
364 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* One-command local startup for Fusion development.
|
|
*
|
|
* Defaults are intentionally conservative:
|
|
* - localhost only
|
|
* - first free port at/above 4050
|
|
* - dashboard/API without the AI engine unless --engine is passed
|
|
* - no bearer-token auth on localhost
|
|
*/
|
|
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import net from "node:net";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
|
|
const HELP = `
|
|
Fusion local startup
|
|
|
|
Usage:
|
|
pnpm local [options]
|
|
|
|
Options:
|
|
--engine Start the full AI engine. Default: dashboard/API only.
|
|
--paused Start with automation paused.
|
|
--port <port> Preferred port. Default: 4050. Port 4040 is reserved.
|
|
--host <host> Host to bind. Default: 127.0.0.1.
|
|
--auth Keep bearer-token auth enabled on localhost.
|
|
--no-auth Disable auth even for a non-localhost host.
|
|
--skip-install Do not auto-run pnpm install when node_modules is missing.
|
|
--skip-register Do not auto-register this repo in Fusion's project registry.
|
|
--allow-4040 Allow using reserved dashboard port 4040.
|
|
--dry-run Print what would run without starting the server.
|
|
--help Show this help.
|
|
`.trim();
|
|
|
|
function fail(message) {
|
|
console.error(`\n✗ ${message}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
function info(message) {
|
|
console.log(`▶ ${message}`);
|
|
}
|
|
|
|
function ok(message) {
|
|
console.log(`✓ ${message}`);
|
|
}
|
|
|
|
function warn(message) {
|
|
console.warn(`! ${message}`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const opts = {
|
|
engine: false,
|
|
paused: false,
|
|
port: 4050,
|
|
host: "127.0.0.1",
|
|
auth: false,
|
|
noAuth: false,
|
|
skipInstall: false,
|
|
skipRegister: false,
|
|
allow4040: false,
|
|
dryRun: false,
|
|
help: false,
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === "--") {
|
|
continue;
|
|
}
|
|
switch (arg) {
|
|
case "--engine":
|
|
opts.engine = true;
|
|
break;
|
|
case "--no-engine":
|
|
opts.engine = false;
|
|
break;
|
|
case "--paused":
|
|
opts.paused = true;
|
|
break;
|
|
case "--port": {
|
|
const value = argv[++i];
|
|
if (!value) fail("Missing value for --port");
|
|
const port = Number(value);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
fail(`Invalid port: ${value}`);
|
|
}
|
|
opts.port = port;
|
|
break;
|
|
}
|
|
case "--host": {
|
|
const value = argv[++i];
|
|
if (!value) fail("Missing value for --host");
|
|
opts.host = value;
|
|
break;
|
|
}
|
|
case "--auth":
|
|
opts.auth = true;
|
|
break;
|
|
case "--no-auth":
|
|
opts.noAuth = true;
|
|
break;
|
|
case "--skip-install":
|
|
opts.skipInstall = true;
|
|
break;
|
|
case "--skip-register":
|
|
opts.skipRegister = true;
|
|
break;
|
|
case "--allow-4040":
|
|
opts.allow4040 = true;
|
|
break;
|
|
case "--dry-run":
|
|
opts.dryRun = true;
|
|
break;
|
|
case "--help":
|
|
case "-h":
|
|
opts.help = true;
|
|
break;
|
|
default:
|
|
fail(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: repoRoot,
|
|
stdio: options.capture ? "pipe" : "inherit",
|
|
encoding: "utf8",
|
|
env: process.env,
|
|
});
|
|
|
|
if (result.error) {
|
|
fail(`${command} failed: ${result.error.message}`);
|
|
}
|
|
if (result.status !== 0) {
|
|
fail(`${command} ${args.join(" ")} exited with code ${result.status}`);
|
|
}
|
|
|
|
return result.stdout?.trim() ?? "";
|
|
}
|
|
|
|
function ensureRepoRoot() {
|
|
if (!existsSync(resolve(repoRoot, "pnpm-workspace.yaml")) || !existsSync(resolve(repoRoot, "packages/cli/src/bin.ts"))) {
|
|
fail(`Could not find the Fusion repo root at ${repoRoot}`);
|
|
}
|
|
process.chdir(repoRoot);
|
|
}
|
|
|
|
function ensureNodeVersion() {
|
|
const [majorRaw, minorRaw] = process.versions.node.split(".");
|
|
const major = Number(majorRaw);
|
|
const minor = Number(minorRaw);
|
|
if (major < 22 || (major === 22 && minor < 5)) {
|
|
fail(`Node.js >= 22.5.0 is required. Current version: ${process.versions.node}`);
|
|
}
|
|
}
|
|
|
|
function ensureDependencies(opts) {
|
|
if (existsSync(resolve(repoRoot, "node_modules"))) {
|
|
ok("Dependencies already installed");
|
|
return;
|
|
}
|
|
|
|
if (opts.skipInstall) {
|
|
fail("node_modules is missing. Run pnpm install --frozen-lockfile first.");
|
|
}
|
|
|
|
info("Installing dependencies with pnpm install --frozen-lockfile");
|
|
run(pnpm, ["install", "--frozen-lockfile"]);
|
|
}
|
|
|
|
function projectNameFromPackage() {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8"));
|
|
const raw = typeof pkg.name === "string" && pkg.name ? pkg.name : "Fusion";
|
|
return raw.replace(/^@[^/]+\//, "").replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64) || "Fusion";
|
|
} catch {
|
|
return "Fusion";
|
|
}
|
|
}
|
|
|
|
function ensureProjectInitialized() {
|
|
if (existsSync(resolve(repoRoot, ".fusion/fusion.db"))) {
|
|
ok("Project database exists");
|
|
return;
|
|
}
|
|
|
|
const name = projectNameFromPackage();
|
|
info(`Initializing Fusion project as ${name}`);
|
|
run(pnpm, ["exec", "tsx", "packages/cli/src/bin.ts", "init", "--name", name]);
|
|
}
|
|
|
|
function ensureProjectRegistered(opts) {
|
|
if (opts.skipRegister) {
|
|
warn("Skipping project registry check");
|
|
return;
|
|
}
|
|
|
|
info("Checking Fusion project registry");
|
|
const rootLiteral = JSON.stringify(repoRoot);
|
|
const nameLiteral = JSON.stringify(projectNameFromPackage());
|
|
const helper = `
|
|
import { CentralCore } from "./packages/core/src/central-core.ts";
|
|
import { ensureMemoryFileWithBackend } from "./packages/core/src/project-memory.ts";
|
|
|
|
async function main() {
|
|
const root = ${rootLiteral};
|
|
const requestedName = ${nameLiteral};
|
|
const sanitize = (value) => String(value)
|
|
.replace(/^@[^/]+\\//, "")
|
|
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
.slice(0, 64) || "Fusion";
|
|
|
|
const central = new CentralCore();
|
|
await central.init();
|
|
try {
|
|
const existingByPath = await central.getProjectByPath(root);
|
|
if (existingByPath) {
|
|
if (existingByPath.status !== "active") {
|
|
await central.updateProject(existingByPath.id, { status: "active" });
|
|
console.log("Activated registered project " + existingByPath.name);
|
|
} else {
|
|
console.log("Project already registered as " + existingByPath.name);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const projects = await central.listProjects();
|
|
const usedNames = new Set(projects.map((project) => project.name));
|
|
const baseName = sanitize(requestedName);
|
|
let name = baseName;
|
|
let suffix = 2;
|
|
while (usedNames.has(name)) {
|
|
const suffixText = "-" + suffix++;
|
|
name = baseName.slice(0, 64 - suffixText.length) + suffixText;
|
|
}
|
|
|
|
const project = await central.registerProject({
|
|
name,
|
|
path: root,
|
|
isolationMode: "in-process",
|
|
});
|
|
await central.updateProject(project.id, { status: "active" });
|
|
await ensureMemoryFileWithBackend(root).catch(() => false);
|
|
console.log("Registered project " + name);
|
|
} finally {
|
|
await central.close();
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|
|
`;
|
|
|
|
run(pnpm, ["exec", "tsx", "--eval", helper]);
|
|
}
|
|
|
|
function canListen(port, host) {
|
|
return new Promise((resolveResult) => {
|
|
const server = net.createServer();
|
|
server.once("error", () => resolveResult(false));
|
|
server.once("listening", () => {
|
|
server.close(() => resolveResult(true));
|
|
});
|
|
server.listen(port, host);
|
|
});
|
|
}
|
|
|
|
async function pickPort(preferredPort, host, allow4040) {
|
|
if (preferredPort === 4040 && !allow4040) {
|
|
warn("Port 4040 is reserved for live dashboard sessions; using 4050 instead");
|
|
preferredPort = 4050;
|
|
}
|
|
|
|
for (let port = preferredPort; port < preferredPort + 50 && port <= 65535; port++) {
|
|
if (port === 4040 && !allow4040) continue;
|
|
if (await canListen(port, host)) return port;
|
|
}
|
|
|
|
fail(`No free port found from ${preferredPort} to ${Math.min(preferredPort + 49, 65535)} on ${host}`);
|
|
}
|
|
|
|
function shouldDisableAuth(opts) {
|
|
if (opts.noAuth) return true;
|
|
if (opts.auth) return false;
|
|
return opts.host === "127.0.0.1" || opts.host === "localhost" || opts.host === "::1";
|
|
}
|
|
|
|
function printSummary(opts, port, dashboardArgs) {
|
|
const mode = opts.engine ? "dashboard + AI engine" : "dashboard/API only";
|
|
const auth = dashboardArgs.includes("--no-auth") ? "disabled" : "enabled";
|
|
console.log();
|
|
ok(`Starting ${mode}`);
|
|
console.log(` URL: http://${opts.host === "127.0.0.1" ? "localhost" : opts.host}:${port}`);
|
|
console.log(` Host: ${opts.host}`);
|
|
console.log(` Auth: ${auth}`);
|
|
console.log(` Cmd: node scripts/dev-with-memory.mjs ${dashboardArgs.join(" ")}`);
|
|
console.log();
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
if (opts.help) {
|
|
console.log(HELP);
|
|
return;
|
|
}
|
|
|
|
ensureRepoRoot();
|
|
ensureNodeVersion();
|
|
ensureDependencies(opts);
|
|
ensureProjectInitialized();
|
|
ensureProjectRegistered(opts);
|
|
|
|
const port = await pickPort(opts.port, opts.host, opts.allow4040);
|
|
if (port !== opts.port && !(opts.port === 4040 && !opts.allow4040)) {
|
|
warn(`Port ${opts.port} unavailable; using ${port}`);
|
|
}
|
|
|
|
const dashboardArgs = ["dashboard", "--host", opts.host, "--port", String(port)];
|
|
if (!opts.engine) dashboardArgs.push("--dev");
|
|
if (opts.paused) dashboardArgs.push("--paused");
|
|
if (shouldDisableAuth(opts)) dashboardArgs.push("--no-auth");
|
|
|
|
if (opts.host === "0.0.0.0" && dashboardArgs.includes("--no-auth")) {
|
|
warn("Auth is disabled while binding to 0.0.0.0. Only do this on a trusted network.");
|
|
}
|
|
|
|
printSummary(opts, port, dashboardArgs);
|
|
|
|
if (opts.dryRun) {
|
|
return;
|
|
}
|
|
|
|
const child = spawn(process.execPath, ["scripts/dev-with-memory.mjs", ...dashboardArgs], {
|
|
cwd: repoRoot,
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
if (signal) {
|
|
process.kill(process.pid, signal);
|
|
return;
|
|
}
|
|
process.exit(code ?? 1);
|
|
});
|
|
}
|
|
|
|
main().catch((error) => {
|
|
fail(error instanceof Error ? error.message : String(error));
|
|
});
|