diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000000..98511140a9 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,41 @@ +name: Publish to npm + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: npm ci + + - name: Build standalone app + run: npm run build:cli + + - name: Sync version from release tag + run: | + VERSION="${GITHUB_REF_NAME}" + # Remove 'v' prefix if present (v0.1.0 -> 0.1.0) + VERSION="${VERSION#v}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + echo "Publishing version: $VERSION" + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index f294286292..ae22ac6339 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # production /build +/app cloud/* # misc diff --git a/.npmignore b/.npmignore index e3aecb3513..d3aba1017a 100644 --- a/.npmignore +++ b/.npmignore @@ -3,24 +3,42 @@ data/ **/data/ **/db.json -# Development +# Source code (pre-built app/ is published instead) src/ -docs/ -test/ -tests/ open-sse/ +docs/ +tests/ +cloud/ +images/ +logs/ +scripts/ -# Config files +# Config/dev files *.md !README.md .gitignore +.git/ +.github/ +.husky/ +.vscode/ .env* -jsconfig.json eslint.config.mjs +prettier.config.mjs postcss.config.mjs next.config.mjs tsconfig.json +playwright.config.ts +next-env.d.ts -# Build artifacts that shouldn't be published -.next/cache/ -.next/standalone/data/ +# Docker +docker-compose*.yml +Dockerfile +.dockerignore + +# Misc +restart.sh +AGENTS.md + +# Build artifacts (pre-built goes inside app/) +.next/ +node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..b5c9f58d06 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 diegosouzapw + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs new file mode 100755 index 0000000000..a655e248d1 --- /dev/null +++ b/bin/omniroute.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +/** + * OmniRoute CLI — Smart AI Router with Auto Fallback + * + * Usage: + * omniroute Start the server (default port 20128) + * omniroute --port 3000 Start on custom port + * omniroute --no-open Start without opening browser + * omniroute --help Show help + * omniroute --version Show version + */ + +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); +const APP_DIR = join(ROOT, "app"); + +// ── Parse args ───────────────────────────────────────────── +const args = process.argv.slice(2); + +if (args.includes("--help") || args.includes("-h")) { + console.log(` + \x1b[1m\x1b[36m⚡ OmniRoute\x1b[0m — Smart AI Router with Auto Fallback + + \x1b[1mUsage:\x1b[0m + omniroute Start the server + omniroute --port Use custom port (default: 20128) + omniroute --no-open Don't open browser automatically + omniroute --help Show this help + omniroute --version Show version + + \x1b[1mAfter starting:\x1b[0m + Dashboard: http://localhost: + API: http://localhost:/v1 + + \x1b[1mConnect your tools:\x1b[0m + Set your CLI tool (Cursor, Cline, Codex, etc.) to use: + \x1b[33mhttp://localhost:20128/v1\x1b[0m + `); + process.exit(0); +} + +if (args.includes("--version") || args.includes("-v")) { + try { + const pkg = await import(join(ROOT, "package.json"), { + with: { type: "json" }, + }); + console.log(pkg.default.version); + } catch { + console.log("unknown"); + } + process.exit(0); +} + +// Parse --port +let port = 20128; +const portIdx = args.indexOf("--port"); +if (portIdx !== -1 && args[portIdx + 1]) { + port = parseInt(args[portIdx + 1], 10); + if (isNaN(port)) { + console.error("\x1b[31m✖ Invalid port number\x1b[0m"); + process.exit(1); + } +} + +const noOpen = args.includes("--no-open"); + +// ── Banner ───────────────────────────────────────────────── +console.log(` +\x1b[36m ____ _ ____ _ + / __ \\ (_) __ \\ | | + | | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___ + | | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\ + | |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/ + \\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___| +\x1b[0m`); + +// ── Resolve server entry ─────────────────────────────────── +const serverJs = join(APP_DIR, "server.js"); + +if (!existsSync(serverJs)) { + console.error( + "\x1b[31m✖ Server not found at:\x1b[0m", + serverJs, + ); + console.error( + " This usually means the package was not built correctly.", + ); + console.error(" Try reinstalling: npm install -g omniroute"); + process.exit(1); +} + +// ── Start server ─────────────────────────────────────────── +console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`); + +const env = { + ...process.env, + PORT: String(port), + HOSTNAME: "0.0.0.0", + NODE_ENV: "production", +}; + +const server = spawn("node", [serverJs], { + cwd: APP_DIR, + env, + stdio: "pipe", +}); + +let started = false; + +server.stdout.on("data", (data) => { + const text = data.toString(); + process.stdout.write(text); + + // Detect server ready + if (!started && (text.includes("Ready") || text.includes("started") || text.includes("listening"))) { + started = true; + onReady(); + } +}); + +server.stderr.on("data", (data) => { + process.stderr.write(data); +}); + +server.on("error", (err) => { + console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message); + process.exit(1); +}); + +server.on("exit", (code) => { + if (code !== 0 && code !== null) { + console.error(`\x1b[31m✖ Server exited with code ${code}\x1b[0m`); + } + process.exit(code ?? 0); +}); + +// ── Graceful shutdown ────────────────────────────────────── +function shutdown() { + console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m"); + server.kill("SIGTERM"); + setTimeout(() => { + server.kill("SIGKILL"); + process.exit(0); + }, 5000); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + +// ── On ready ─────────────────────────────────────────────── +async function onReady() { + const url = `http://localhost:${port}`; + + console.log(` + \x1b[32m✔ OmniRoute is running!\x1b[0m + + \x1b[1m Dashboard:\x1b[0m ${url} + \x1b[1m API Base:\x1b[0m ${url}/v1 + + \x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m + \x1b[33m ${url}/v1\x1b[0m + + \x1b[2m Press Ctrl+C to stop\x1b[0m + `); + + if (!noOpen) { + try { + const open = await import("open"); + await open.default(url); + } catch { + // open is optional — if not available, just skip + } + } +} + +// Fallback: if no "Ready" message detected in 15s, assume server is up +setTimeout(() => { + if (!started) { + started = true; + onReady(); + } +}, 15000); diff --git a/next.config.mjs b/next.config.mjs index df06768035..1823b8b1e0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,13 +1,12 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: "standalone", transpilePackages: ["@omniroute/open-sse"], allowedDevOrigins: ["192.168.*"], images: { unoptimized: true, }, - env: { - NEXT_PUBLIC_CLOUD_URL: "https://omniroute.dev", - }, + // NEXT_PUBLIC_CLOUD_URL is set in .env — do NOT hardcode here (it overrides .env) webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle if (!isServer) { diff --git a/package-lock.json b/package-lock.json index 80c1f37376..a52eaf5929 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "omniroute-app", - "version": "0.0.1", + "name": "omniroute", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "omniroute-app", - "version": "0.0.1", + "name": "omniroute", + "version": "0.1.0", + "license": "MIT", "workspaces": [ "open-sse" ], @@ -38,6 +39,9 @@ "zod": "^4.3.6", "zustand": "^5.0.10" }, + "bin": { + "omniroute": "bin/omniroute.mjs" + }, "devDependencies": { "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", @@ -52,6 +56,9 @@ "prettier": "^3.8.1", "tailwindcss": "^4", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -4800,6 +4807,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 04c27a7a41..5e94eacdca 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,49 @@ { - "name": "omniroute-app", - "version": "0.0.1", - "description": "OmniRoute web dashboard", - "private": true, + "name": "omniroute", + "version": "0.1.0", + "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", + "type": "module", + "bin": { + "omniroute": "bin/omniroute.mjs" + }, + "files": [ + "bin/", + "app/", + "README.md", + "LICENSE" + ], "workspaces": [ "open-sse" ], + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "ai", + "router", + "proxy", + "openai", + "claude", + "anthropic", + "gemini", + "fallback", + "cursor", + "cline", + "codex", + "llm", + "auto-fallback" + ], + "license": "MIT", + "author": "diegosouzapw", + "repository": { + "type": "git", + "url": "https://github.com/diegosouzapw/OmniRoute" + }, + "homepage": "https://github.com/diegosouzapw/OmniRoute", "scripts": { "dev": "next dev --webpack --port 20128", "build": "next build --webpack", + "build:cli": "node scripts/prepublish.mjs", "start": "next start --port 20128", "lint": "eslint .", "test:plan3": "node --test tests/unit/plan3-p0.test.mjs", @@ -16,6 +51,7 @@ "test": "npm run build", "test:e2e": "npx playwright test", "check": "npm run lint && npm run test", + "prepublishOnly": "npm run build:cli", "prepare": "husky" }, "dependencies": { diff --git a/scripts/prepublish.mjs b/scripts/prepublish.mjs new file mode 100644 index 0000000000..55b5ec1075 --- /dev/null +++ b/scripts/prepublish.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * OmniRoute — Prepublish Build Script + * + * Builds the Next.js app in standalone mode and copies output + * into the `app/` directory that gets published to npm. + * + * Run with: node scripts/prepublish.mjs + */ + +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, cpSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const ROOT = join(__dirname, ".."); + +const APP_DIR = join(ROOT, "app"); + +console.log("🔨 OmniRoute — Building for npm publish...\n"); + +// ── Step 1: Clean previous app/ directory ────────────────── +if (existsSync(APP_DIR)) { + console.log(" 🧹 Cleaning previous app/ directory..."); + rmSync(APP_DIR, { recursive: true, force: true }); +} + +// ── Step 2: Install dependencies ─────────────────────────── +console.log(" 📦 Installing dependencies..."); +execSync("npm install", { cwd: ROOT, stdio: "inherit" }); + +// ── Step 3: Build Next.js ────────────────────────────────── +console.log(" 🏗️ Building Next.js (standalone)..."); +execSync("npx next build --webpack", { cwd: ROOT, stdio: "inherit" }); + +// ── Step 4: Verify standalone output ─────────────────────── +const standaloneDir = join(ROOT, ".next", "standalone"); +const serverJs = join(standaloneDir, "server.js"); + +if (!existsSync(serverJs)) { + console.error("\n ❌ Standalone build not found at:", standaloneDir); + console.error(" Make sure next.config.mjs has: output: 'standalone'"); + process.exit(1); +} + +// ── Step 5: Copy standalone output to app/ ───────────────── +console.log(" 📋 Copying standalone build to app/..."); +mkdirSync(APP_DIR, { recursive: true }); +cpSync(standaloneDir, APP_DIR, { recursive: true }); + +// ── Step 6: Copy static assets ───────────────────────────── +const staticSrc = join(ROOT, ".next", "static"); +const staticDest = join(APP_DIR, ".next", "static"); +if (existsSync(staticSrc)) { + console.log(" 📋 Copying static assets..."); + mkdirSync(staticDest, { recursive: true }); + cpSync(staticSrc, staticDest, { recursive: true }); +} + +// ── Step 7: Copy public/ assets ──────────────────────────── +const publicSrc = join(ROOT, "public"); +const publicDest = join(APP_DIR, "public"); +if (existsSync(publicSrc)) { + console.log(" 📋 Copying public/ assets..."); + mkdirSync(publicDest, { recursive: true }); + cpSync(publicSrc, publicDest, { recursive: true }); +} + +// ── Step 8: Copy MITM cert utilities (if needed) ─────────── +const mitmSrc = join(ROOT, "src", "mitm"); +const mitmDest = join(APP_DIR, "src", "mitm"); +if (existsSync(mitmSrc)) { + console.log(" 📋 Copying MITM utilities..."); + mkdirSync(mitmDest, { recursive: true }); + cpSync(mitmSrc, mitmDest, { recursive: true }); +} + +// ── Step 9: Copy shared utilities needed at runtime ──────── +const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js"); +const sharedApiKeyDest = join(APP_DIR, "src", "shared", "utils"); +if (existsSync(sharedApiKey)) { + console.log(" 📋 Copying shared utilities..."); + mkdirSync(sharedApiKeyDest, { recursive: true }); + cpSync(sharedApiKey, join(sharedApiKeyDest, "apiKey.js")); +} + +// ── Step 10: Ensure data/ directory exists ────────────────── +mkdirSync(join(APP_DIR, "data"), { recursive: true }); + +// ── Done ─────────────────────────────────────────────────── +const appPkg = join(APP_DIR, "package.json"); +if (existsSync(appPkg)) { + const pkg = JSON.parse(readFileSync(appPkg, "utf8")); + console.log(`\n ✅ Build complete!`); + console.log(` App directory: app/`); + console.log(` Server entry: app/server.js`); +} else { + console.log(`\n ✅ Build complete! (app/ ready for publish)`); +} + +console.log("");