feat(npm): add npm package publishing with CLI entry point (#15)

- Add bin/omniroute.mjs CLI with banner, auto-open browser, graceful shutdown
- Add scripts/prepublish.mjs to build Next.js standalone into app/
- Add .github/workflows/npm-publish.yml for automated publish on release
- Update package.json: name=omniroute, bin, files, engines, keywords, prepublishOnly
- Add output: 'standalone' to next.config.mjs
- Add MIT LICENSE
- Update .npmignore and .gitignore for app/ build artifact

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-02-14 04:14:14 -03:00
committed by GitHub
parent 4f0155eb4a
commit 92b4c59fec
9 changed files with 437 additions and 20 deletions

41
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -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 }}

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@
# production
/build
/app
cloud/*
# misc

View File

@@ -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/

21
LICENSE Normal file
View File

@@ -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.

189
bin/omniroute.mjs Executable file
View File

@@ -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 <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:<port>
API: http://localhost:<port>/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);

View File

@@ -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) {

16
package-lock.json generated
View File

@@ -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,

View File

@@ -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": {

104
scripts/prepublish.mjs Normal file
View File

@@ -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("");