| #!/usr/bin/env node
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
| import { randomBytes } from "node:crypto";
|
| import { createRequire } from "node:module";
|
| import { dirname, join, resolve } from "node:path";
|
| import { homedir } from "node:os";
|
| import { fileURLToPath } from "node:url";
|
|
|
| const require = createRequire(import.meta.url);
|
|
|
| const CRYPTO_SECRETS = {
|
| JWT_SECRET: () => randomBytes(64).toString("hex"),
|
| API_KEY_SECRET: () => randomBytes(32).toString("hex"),
|
|
|
|
|
|
|
|
|
| MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
|
| };
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ENCRYPTION_BOUND_KEYS = new Set([]);
|
|
|
|
|
| function resolveDataDir(env = process.env) {
|
| const configured = env.DATA_DIR?.trim();
|
| if (configured) return resolve(configured);
|
|
|
| if (process.platform === "win32") {
|
| const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
| return join(appData, "omniroute");
|
| }
|
|
|
| const xdg = env.XDG_CONFIG_HOME?.trim();
|
| if (xdg) return join(resolve(xdg), "omniroute");
|
|
|
| return join(homedir(), ".omniroute");
|
| }
|
|
|
| |
| |
| |
| |
|
|
| function hasEncryptedCredentials(dataDir) {
|
| const dbPath = join(dataDir, "storage.sqlite");
|
| if (!existsSync(dbPath)) return false;
|
|
|
| try {
|
| const Database = require("better-sqlite3");
|
| const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
| try {
|
| const row = db
|
| .prepare(
|
| `SELECT 1
|
| FROM provider_connections
|
| WHERE access_token LIKE 'enc:v1:%'
|
| OR refresh_token LIKE 'enc:v1:%'
|
| OR api_key LIKE 'enc:v1:%'
|
| OR id_token LIKE 'enc:v1:%'
|
| LIMIT 1`
|
| )
|
| .get();
|
| return !!row;
|
| } finally {
|
| db.close();
|
| }
|
| } catch {
|
|
|
|
|
| return false;
|
| }
|
| }
|
|
|
| export function parseEnvFile(filePath) {
|
| if (!existsSync(filePath)) return new Map();
|
|
|
| const content = readFileSync(filePath, "utf8");
|
| const entries = new Map();
|
|
|
| for (const line of content.split(/\r?\n/)) {
|
| const parsed = parseEnvEntry(line);
|
| if (!parsed) continue;
|
|
|
| const [key, value] = parsed;
|
| entries.set(key, value);
|
| }
|
|
|
| return entries;
|
| }
|
|
|
| function parseEnvEntry(line) {
|
| const trimmed = line.trim();
|
| if (!trimmed || trimmed.startsWith("#")) return null;
|
|
|
| const eqIndex = trimmed.indexOf("=");
|
| if (eqIndex < 1) return null;
|
|
|
| const key = trimmed.slice(0, eqIndex).trim();
|
| const value = unquoteEnvValue(trimmed.slice(eqIndex + 1).trim());
|
| return [key, value];
|
| }
|
|
|
| function unquoteEnvValue(value) {
|
| if (value.length < 2) return value;
|
| const quote = value[0];
|
| if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote) return value;
|
| return value.slice(1, -1);
|
| }
|
|
|
| function parseExampleEntries(content, scope = "full") {
|
| const entries = new Map();
|
| const lines = content.split(/\r?\n/);
|
|
|
| if (scope === "oauth") {
|
| let inOauthSection = false;
|
|
|
| for (const line of lines) {
|
| const trimmed = line.trim();
|
|
|
| if (/OAUTH PROVIDER CREDENTIALS/i.test(trimmed)) {
|
| inOauthSection = true;
|
| continue;
|
| }
|
|
|
| if (!inOauthSection) continue;
|
|
|
| if (/Provider User-Agent Overrides/i.test(trimmed)) break;
|
|
|
| const parsed = parseEnvEntry(line);
|
| if (!parsed) continue;
|
|
|
| const [key, value] = parsed;
|
| entries.set(key, value);
|
| }
|
|
|
| return entries;
|
| }
|
|
|
| for (const line of lines) {
|
| const parsed = parseEnvEntry(line);
|
| if (!parsed) continue;
|
|
|
| const [key, value] = parsed;
|
| entries.set(key, value);
|
| }
|
|
|
| return entries;
|
| }
|
|
|
| export function getEnvSyncPlan({ rootDir, scope = "full" } = {}) {
|
| const root = rootDir || dirname(dirname(fileURLToPath(import.meta.url)));
|
| const envExamplePath = join(root, ".env.example");
|
| const envPath = join(root, ".env");
|
|
|
| if (!existsSync(envExamplePath)) {
|
| return {
|
| available: false,
|
| created: false,
|
| added: 0,
|
| missingEntries: [],
|
| };
|
| }
|
|
|
| const exampleEntries = parseExampleEntries(readFileSync(envExamplePath, "utf8"), scope);
|
| const currentEntries = parseEnvFile(envPath);
|
| const missingEntries = [];
|
|
|
|
|
| let _encryptedDataExists;
|
| function encryptedDataExists() {
|
| if (_encryptedDataExists === undefined) {
|
| try {
|
| _encryptedDataExists = hasEncryptedCredentials(resolveDataDir());
|
| } catch {
|
| _encryptedDataExists = false;
|
| }
|
| }
|
| return _encryptedDataExists;
|
| }
|
|
|
| for (const [key, defaultValue] of exampleEntries) {
|
| if (currentEntries.has(key)) continue;
|
|
|
| if (CRYPTO_SECRETS[key] && !defaultValue) {
|
|
|
|
|
| if (ENCRYPTION_BOUND_KEYS.has(key) && encryptedDataExists()) {
|
| missingEntries.push({
|
| key,
|
| value: "",
|
| generated: false,
|
| blocked: true,
|
| });
|
| continue;
|
| }
|
| missingEntries.push({ key, value: CRYPTO_SECRETS[key](), generated: true });
|
| continue;
|
| }
|
|
|
| missingEntries.push({ key, value: defaultValue, generated: false });
|
| }
|
|
|
| return {
|
| available: true,
|
| created: !existsSync(envPath),
|
| added: missingEntries.length,
|
| missingEntries,
|
| };
|
| }
|
|
|
| function replaceBlankSecret(content, key, value) {
|
| const pattern = new RegExp(`^${key}=\\s*$`, "m");
|
| return pattern.test(content) ? content.replace(pattern, `${key}=${value}`) : content;
|
| }
|
|
|
| export function syncEnv({ rootDir, quiet = false, scope = "full" } = {}) {
|
| const log = quiet ? () => {} : (message) => process.stderr.write(`[sync-env] ${message}\n`);
|
| const root = rootDir || dirname(dirname(fileURLToPath(import.meta.url)));
|
| const envExamplePath = join(root, ".env.example");
|
| const envPath = join(root, ".env");
|
|
|
| if (!existsSync(envExamplePath)) {
|
| log("β οΈ .env.example not found β skipping sync");
|
| return { created: false, added: 0 };
|
| }
|
|
|
| const exampleEntries = parseExampleEntries(readFileSync(envExamplePath, "utf8"), scope);
|
|
|
| if (!existsSync(envPath)) {
|
| if (scope === "full") {
|
| copyFileSync(envExamplePath, envPath);
|
|
|
| let content = readFileSync(envPath, "utf8");
|
| let generated = 0;
|
|
|
|
|
| let dbHasEncrypted;
|
| try {
|
| dbHasEncrypted = hasEncryptedCredentials(resolveDataDir());
|
| } catch {
|
| dbHasEncrypted = false;
|
| }
|
|
|
| for (const [key, generator] of Object.entries(CRYPTO_SECRETS)) {
|
|
|
|
|
| if (ENCRYPTION_BOUND_KEYS.has(key) && dbHasEncrypted) {
|
| log(
|
| `β οΈ ${key} NOT generated β encrypted credentials exist in DB. ` +
|
| `Restore your previous key via ~/.omniroute/server.env, ~/.omniroute/.env, ` +
|
| `or the STORAGE_ENCRYPTION_KEY environment variable.`
|
| );
|
| continue;
|
| }
|
| const nextContent = replaceBlankSecret(content, key, generator());
|
| if (nextContent !== content) {
|
| content = nextContent;
|
| generated++;
|
| log(`β¨ ${key} auto-generated`);
|
| }
|
| }
|
|
|
| writeFileSync(envPath, content, "utf8");
|
| log(
|
| `β¨ Created .env from .env.example (${exampleEntries.size} keys, ${generated} secrets generated)`
|
| );
|
| return { created: true, added: exampleEntries.size };
|
| }
|
|
|
| const { missingEntries } = getEnvSyncPlan({ rootDir: root, scope });
|
| const content = [
|
| "# ββ Auto-added by sync-env (oauth defaults) ββ",
|
| ...missingEntries.map((entry) => `${entry.key}=${entry.value}`),
|
| "",
|
| ].join("\n");
|
| writeFileSync(envPath, content, "utf8");
|
| log(`β¨ Created .env with oauth defaults (${missingEntries.length} keys)`);
|
| return { created: true, added: missingEntries.length };
|
| }
|
|
|
| const { missingEntries } = getEnvSyncPlan({ rootDir: root, scope });
|
|
|
| if (missingEntries.length === 0) {
|
| log("β
.env is up to date (0 keys added)");
|
| return { created: false, added: 0 };
|
| }
|
|
|
| const appendLines = [
|
| "",
|
| `# ββ Auto-added by sync-env (${new Date().toISOString().slice(0, 10)}) ββ`,
|
| ];
|
|
|
| for (const entry of missingEntries) {
|
| if (entry.blocked) {
|
| log(
|
| `β οΈ ${entry.key} NOT generated β encrypted credentials exist in DB. ` +
|
| `Restore your previous key via ~/.omniroute/server.env, ~/.omniroute/.env, ` +
|
| `or the STORAGE_ENCRYPTION_KEY environment variable.`
|
| );
|
| continue;
|
| }
|
| appendLines.push(`${entry.key}=${entry.value}`);
|
| log(
|
| `${entry.generated ? "β¨" : "π¦"} ${entry.key}${entry.generated ? " (auto-generated)" : ""}`
|
| );
|
| }
|
|
|
| appendLines.push("");
|
|
|
| const currentContent = readFileSync(envPath, "utf8");
|
| writeFileSync(envPath, `${currentContent.trimEnd()}\n${appendLines.join("\n")}`, "utf8");
|
| log(`π¦ Synced .env β added ${missingEntries.length} missing keys`);
|
|
|
| return { created: false, added: missingEntries.length };
|
| }
|
|
|
| if (process.argv[1]?.endsWith("sync-env.mjs")) {
|
| syncEnv({ scope: process.argv.includes("--oauth-only") ? "oauth" : "full" });
|
| }
|
|
|