From 7054511d93262c97075cb2a9048acf6b63029123 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Thu, 2 Apr 2026 16:52:21 +0200 Subject: [PATCH 1/7] feat(cli): scaffold @neoboard/cli package with commander.js - Create cli/ package with commander, ora, chalk, dotenv - Add stub commands: init, start, stop, dev, setup, status, doctor, demo, env - Add db subcommand group: migrate, reset, seed, dump - Add config system: neoboard.config.json (shared) + .neoboard.local (personal) - Add bin/neoboard entry point shim - Wire up root package.json scripts Closes #302 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 +- bin/neoboard | 2 + cli/package-lock.json | 320 ++++++++++++++++++++++++++++++++++++++++++ cli/package.json | 23 +++ cli/src/index.ts | 115 +++++++++++++++ cli/src/lib/config.ts | 124 ++++++++++++++++ cli/src/lib/output.ts | 34 +++++ cli/tsconfig.json | 13 ++ neoboard.config.json | 21 +++ package.json | 2 + 10 files changed, 659 insertions(+), 1 deletion(-) create mode 100755 bin/neoboard create mode 100644 cli/package-lock.json create mode 100644 cli/package.json create mode 100644 cli/src/index.ts create mode 100644 cli/src/lib/config.ts create mode 100644 cli/src/lib/output.ts create mode 100644 cli/tsconfig.json create mode 100644 neoboard.config.json diff --git a/.gitignore b/.gitignore index 382e6637..78c51559 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,8 @@ scripts/claude-setup.sh # Fumadocs generated docs/.source -docs/.next \ No newline at end of file +docs/.next + +# CLI build output +cli/dist/ +.neoboard.local \ No newline at end of file diff --git a/bin/neoboard b/bin/neoboard new file mode 100755 index 00000000..d093e923 --- /dev/null +++ b/bin/neoboard @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import("../cli/dist/index.js"); diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 00000000..72240350 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,320 @@ +{ + "name": "@neoboard/cli", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@neoboard/cli", + "version": "0.0.1", + "dependencies": { + "chalk": "^5.0.0", + "commander": "^13.0.0", + "dotenv": "^17.0.0", + "ora": "^8.0.0" + }, + "bin": { + "neoboard": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "~5.9.3" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 00000000..2ba372b3 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,23 @@ +{ + "name": "@neoboard/cli", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "neoboard": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "chalk": "^5.0.0", + "commander": "^13.0.0", + "dotenv": "^17.0.0", + "ora": "^8.0.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "~5.9.3" + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 00000000..b8cda7c1 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { Command } from "commander"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const pkg = JSON.parse( + readFileSync(join(__dirname, "..", "package.json"), "utf-8"), +); + +const program = new Command(); + +program + .name("neoboard") + .description("NeoBoard CLI — local development and database management") + .version(pkg.version); + +// Top-level stub commands + +program + .command("init") + .description("Initialize a new NeoBoard project") + .action(() => { + console.log("Not yet implemented — see issue #305"); + }); + +program + .command("start") + .description("Start NeoBoard services") + .action(() => { + console.log("Not yet implemented — see issue #305"); + }); + +program + .command("stop") + .description("Stop NeoBoard services") + .action(() => { + console.log("Not yet implemented — see issue #305"); + }); + +program + .command("dev") + .description("Start NeoBoard in development mode") + .action(() => { + console.log("Not yet implemented — see issue #307"); + }); + +program + .command("setup") + .description("Set up local development environment") + .action(() => { + console.log("Not yet implemented — see issue #305"); + }); + +program + .command("status") + .description("Show status of NeoBoard services") + .action(() => { + console.log("Not yet implemented — see issue #308"); + }); + +program + .command("doctor") + .description("Check system prerequisites and configuration") + .action(() => { + console.log("Not yet implemented — see issue #303"); + }); + +program + .command("demo") + .description("Load demo data and dashboards") + .action(() => { + console.log("Not yet implemented — see issue #309"); + }); + +program + .command("env") + .description("Manage environment variables") + .action(() => { + console.log("Not yet implemented — see issue #304"); + }); + +// db subcommand group + +const db = program.command("db").description("Database management commands"); + +db.command("migrate") + .description("Run database migrations") + .action(() => { + console.log("Not yet implemented — see issue #306"); + }); + +db.command("reset") + .description("Reset database to clean state") + .action(() => { + console.log("Not yet implemented — see issue #310"); + }); + +db.command("seed") + .description("Seed database with sample data") + .action(() => { + console.log("Not yet implemented — see issue #309"); + }); + +db.command("dump") + .description("Dump database contents") + .action(() => { + console.log("Not yet implemented — see issue #311"); + }); + +program.parse(); diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts new file mode 100644 index 00000000..4ba9e306 --- /dev/null +++ b/cli/src/lib/config.ts @@ -0,0 +1,124 @@ +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; + +// Types +export interface ProjectConfig { + ports: { + app: number; + postgres: number; + neo4j_http: number; + neo4j_bolt: number; + }; + postgres: { user: string; password: string; database: string }; + neo4j: { user: string; password: string }; + seed: { script: string; neo4j_cypher: string }; +} + +export interface LocalConfig { + mode: "docker" | "local"; +} + +// Project root detection +export function findProjectRoot(startDir?: string): string { + let dir = startDir ?? dirname(fileURLToPath(import.meta.url)); + while (dir !== "/") { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.name === "neoboard") return dir; + } catch { + /* skip */ + } + } + dir = dirname(dir); + } + throw new Error( + "Could not find NeoBoard project root (package.json with name 'neoboard')", + ); +} + +// Path constants (lazy-initialized) +let _root: string | null = null; +function root(): string { + if (!_root) _root = findProjectRoot(); + return _root; +} + +export const paths = { + get root() { + return root(); + }, + get appDir() { + return join(root(), "app"); + }, + get dockerDir() { + return join(root(), "docker"); + }, + get migrationsDir() { + return join(root(), "app", "drizzle", "migrations"); + }, + get journalPath() { + return join( + root(), + "app", + "drizzle", + "migrations", + "meta", + "_journal.json", + ); + }, + get envFile() { + return join(root(), "app", ".env.local"); + }, + get envExample() { + return join(root(), ".env.example"); + }, + get projectConfig() { + return join(root(), "neoboard.config.json"); + }, + get localConfig() { + return join(root(), ".neoboard.local"); + }, +}; + +// Config defaults +const DEFAULT_PROJECT_CONFIG: ProjectConfig = { + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, +}; + +const DEFAULT_LOCAL_CONFIG: LocalConfig = { mode: "docker" }; + +// Read/write functions +export function readProjectConfig(): ProjectConfig { + if (!existsSync(paths.projectConfig)) return DEFAULT_PROJECT_CONFIG; + try { + return JSON.parse(readFileSync(paths.projectConfig, "utf-8")); + } catch { + return DEFAULT_PROJECT_CONFIG; + } +} + +export function readLocalConfig(): LocalConfig { + if (!existsSync(paths.localConfig)) return DEFAULT_LOCAL_CONFIG; + try { + return JSON.parse(readFileSync(paths.localConfig, "utf-8")); + } catch { + return DEFAULT_LOCAL_CONFIG; + } +} + +export function writeLocalConfig(config: LocalConfig): void { + writeFileSync(paths.localConfig, JSON.stringify(config, null, 2) + "\n"); +} + +export function getMode(): "docker" | "local" { + return readLocalConfig().mode; +} diff --git a/cli/src/lib/output.ts b/cli/src/lib/output.ts new file mode 100644 index 00000000..e0718dfe --- /dev/null +++ b/cli/src/lib/output.ts @@ -0,0 +1,34 @@ +import ora from "ora"; +import chalk from "chalk"; + +export function createSpinner(text: string) { + return ora(text); +} + +export function info(msg: string): void { + console.log(chalk.blue(msg)); +} + +export function warn(msg: string): void { + console.log(chalk.yellow(`WARN: ${msg}`)); +} + +export function error(msg: string): void { + console.log(chalk.red(`ERROR: ${msg}`)); +} + +export function success(msg: string): void { + console.log(chalk.green(`\u2714 ${msg}`)); +} + +export function banner(lines: string[]): void { + const maxLen = Math.max(...lines.map((l) => l.length)); + const top = "\u2554" + "\u2550".repeat(maxLen + 2) + "\u2557"; + const bottom = "\u255A" + "\u2550".repeat(maxLen + 2) + "\u255D"; + + console.log(top); + for (const line of lines) { + console.log("\u2551 " + line.padEnd(maxLen) + " \u2551"); + } + console.log(bottom); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 00000000..df0a20b3 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "declaration": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/neoboard.config.json b/neoboard.config.json new file mode 100644 index 00000000..a06057a2 --- /dev/null +++ b/neoboard.config.json @@ -0,0 +1,21 @@ +{ + "ports": { + "app": 3000, + "postgres": 5432, + "neo4j_http": 7474, + "neo4j_bolt": 7687 + }, + "postgres": { + "user": "neoboard", + "password": "neoboard", + "database": "neoboard" + }, + "neo4j": { + "user": "neo4j", + "password": "neoboard123" + }, + "seed": { + "script": "scripts/seed-demo.mjs", + "neo4j_cypher": "docker/neo4j/init.cypher" + } +} diff --git a/package.json b/package.json index c803bac4..4dddd3c6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "db:generate": "npm run db:generate --prefix app", "docs:dev": "npm run dev --prefix docs", "docs:build": "npm run build --prefix docs", + "neoboard": "node cli/dist/index.js", + "cli:build": "npm run build --prefix cli", "prepare": "husky" }, "lint-staged": { From f7696dc642fad40da48cf0cddc5bb6b83a7df4c0 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Thu, 2 Apr 2026 23:46:22 +0200 Subject: [PATCH 2/7] feat(cli): implement all CLI commands with Vitest test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement all 9 remaining CLI issues (#303–#311) on top of the existing scaffold (#302). The CLI provides a Supabase-inspired zero-friction developer experience: `neoboard init → start → demo`. Commands implemented: - doctor: prerequisite checks (Docker, ports, Node, deps, env) - env: generate/validate app/.env.local with secure defaults - init/start/stop/setup: full lifecycle management - dev: local-mode Next.js dev server - status: service health dashboard - demo: one-command demo environment with seed data - db migrate: version-aware Drizzle migrations (--status, --dry-run) - db seed: idempotent Neo4j + PostgreSQL seeding - db dump: pg_dump backup (--data-only, --output) - db reset: safe database reset with confirmation Shared utilities: exec (child_process wrapper), docker (compose ops), health (polling), ports (availability), prompt (confirmation). 142 Vitest tests across 20 test files, all passing. Tests mock at one layer up (commands mock exec.ts, not child_process directly). Closes #303, Closes #304, Closes #305, Closes #306, Closes #307, Closes #308, Closes #309, Closes #310, Closes #311 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/package-lock.json | 1626 ++++++++++++++++- cli/package.json | 9 +- cli/src/__tests__/commands/db/dump.test.ts | 90 + cli/src/__tests__/commands/db/migrate.test.ts | 133 ++ cli/src/__tests__/commands/db/reset.test.ts | 140 ++ cli/src/__tests__/commands/db/seed.test.ts | 103 ++ cli/src/__tests__/commands/demo.test.ts | 50 + cli/src/__tests__/commands/dev.test.ts | 55 + cli/src/__tests__/commands/doctor.test.ts | 169 ++ cli/src/__tests__/commands/env.test.ts | 142 ++ cli/src/__tests__/commands/init.test.ts | 103 ++ cli/src/__tests__/commands/setup.test.ts | 36 + cli/src/__tests__/commands/start.test.ts | 86 + cli/src/__tests__/commands/status.test.ts | 108 ++ cli/src/__tests__/commands/stop.test.ts | 30 + cli/src/__tests__/lib/config.test.ts | 125 ++ cli/src/__tests__/lib/docker.test.ts | 176 ++ cli/src/__tests__/lib/exec.test.ts | 105 ++ cli/src/__tests__/lib/health.test.ts | 68 + cli/src/__tests__/lib/output.test.ts | 60 + cli/src/__tests__/lib/ports.test.ts | 49 + cli/src/__tests__/program.test.ts | 105 ++ cli/src/commands/db/dump.ts | 44 + cli/src/commands/db/migrate.ts | 88 + cli/src/commands/db/reset.ts | 89 + cli/src/commands/db/seed.ts | 60 + cli/src/commands/demo.ts | 19 + cli/src/commands/dev.ts | 26 + cli/src/commands/doctor.ts | 115 ++ cli/src/commands/env.ts | 101 + cli/src/commands/init.ts | 46 + cli/src/commands/setup.ts | 11 + cli/src/commands/start.ts | 42 + cli/src/commands/status.ts | 66 + cli/src/commands/stop.ts | 7 + cli/src/index.ts | 101 +- cli/src/lib/config.ts | 5 + cli/src/lib/docker.ts | 80 + cli/src/lib/exec.ts | 51 + cli/src/lib/health.ts | 26 + cli/src/lib/ports.ts | 12 + cli/src/lib/prompt.ts | 14 + cli/tsconfig.json | 3 +- cli/vitest.config.ts | 15 + package.json | 3 +- 45 files changed, 4486 insertions(+), 106 deletions(-) create mode 100644 cli/src/__tests__/commands/db/dump.test.ts create mode 100644 cli/src/__tests__/commands/db/migrate.test.ts create mode 100644 cli/src/__tests__/commands/db/reset.test.ts create mode 100644 cli/src/__tests__/commands/db/seed.test.ts create mode 100644 cli/src/__tests__/commands/demo.test.ts create mode 100644 cli/src/__tests__/commands/dev.test.ts create mode 100644 cli/src/__tests__/commands/doctor.test.ts create mode 100644 cli/src/__tests__/commands/env.test.ts create mode 100644 cli/src/__tests__/commands/init.test.ts create mode 100644 cli/src/__tests__/commands/setup.test.ts create mode 100644 cli/src/__tests__/commands/start.test.ts create mode 100644 cli/src/__tests__/commands/status.test.ts create mode 100644 cli/src/__tests__/commands/stop.test.ts create mode 100644 cli/src/__tests__/lib/config.test.ts create mode 100644 cli/src/__tests__/lib/docker.test.ts create mode 100644 cli/src/__tests__/lib/exec.test.ts create mode 100644 cli/src/__tests__/lib/health.test.ts create mode 100644 cli/src/__tests__/lib/output.test.ts create mode 100644 cli/src/__tests__/lib/ports.test.ts create mode 100644 cli/src/__tests__/program.test.ts create mode 100644 cli/src/commands/db/dump.ts create mode 100644 cli/src/commands/db/migrate.ts create mode 100644 cli/src/commands/db/reset.ts create mode 100644 cli/src/commands/db/seed.ts create mode 100644 cli/src/commands/demo.ts create mode 100644 cli/src/commands/dev.ts create mode 100644 cli/src/commands/doctor.ts create mode 100644 cli/src/commands/env.ts create mode 100644 cli/src/commands/init.ts create mode 100644 cli/src/commands/setup.ts create mode 100644 cli/src/commands/start.ts create mode 100644 cli/src/commands/status.ts create mode 100644 cli/src/commands/stop.ts create mode 100644 cli/src/lib/docker.ts create mode 100644 cli/src/lib/exec.ts create mode 100644 cli/src/lib/health.ts create mode 100644 cli/src/lib/ports.ts create mode 100644 cli/src/lib/prompt.ts create mode 100644 cli/vitest.config.ts diff --git a/cli/package-lock.json b/cli/package-lock.json index 72240350..b23a17d1 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -18,131 +18,1169 @@ }, "devDependencies": { "@types/node": "^25.5.0", - "typescript": "~5.9.3" + "@vitest/coverage-v8": "^4.1.2", + "typescript": "~5.9.3", + "vitest": "^4.1.2" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, - "license": "MIT", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", + "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.2", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.2", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "undici-types": "~7.18.0" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "license": "MIT", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/dotenv": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", - "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", - "license": "BSD-2-Clause", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://dotenvx.com" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/log-symbols": { @@ -173,6 +1211,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -185,6 +1261,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -223,6 +1329,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -239,6 +1401,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -251,6 +1467,30 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -295,6 +1535,71 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -315,6 +1620,183 @@ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/cli/package.json b/cli/package.json index 2ba372b3..2515b810 100644 --- a/cli/package.json +++ b/cli/package.json @@ -8,7 +8,10 @@ }, "scripts": { "build": "tsc", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "chalk": "^5.0.0", @@ -18,6 +21,8 @@ }, "devDependencies": { "@types/node": "^25.5.0", - "typescript": "~5.9.3" + "@vitest/coverage-v8": "^4.1.2", + "typescript": "~5.9.3", + "vitest": "^4.1.2" } } diff --git a/cli/src/__tests__/commands/db/dump.test.ts b/cli/src/__tests__/commands/db/dump.test.ts new file mode 100644 index 00000000..88d5e20d --- /dev/null +++ b/cli/src/__tests__/commands/db/dump.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(() => "-- SQL dump"), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { root: "/project" }, + readProjectConfig: vi.fn(() => ({ + ports: { postgres: 5432 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../../lib/output.js", () => ({ + success: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + statSync: vi.fn(() => ({ size: 2048 })), +})); + +import { run } from "../../../lib/exec.js"; +import { getMode } from "../../../lib/config.js"; +import { writeFileSync } from "node:fs"; +import { runDbDump } from "../../../commands/db/dump.js"; + +const mockRun = vi.mocked(run); +const mockGetMode = vi.mocked(getMode); +const mockWriteFileSync = vi.mocked(writeFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); +}); + +describe("runDbDump", () => { + it("dumps via docker exec in docker mode", async () => { + await runDbDump({}); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("docker exec neoboard-postgres pg_dump"), + ); + }); + + it("dumps via local pg_dump in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runDbDump({}); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("pg_dump -h localhost"), + ); + }); + + it("uses custom output path", async () => { + await runDbDump({ output: "/tmp/backup.sql" }); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/tmp/backup.sql", + "-- SQL dump", + ); + }); + + it("generates timestamped default filename", async () => { + await runDbDump({}); + const path = mockWriteFileSync.mock.calls[0][0] as string; + expect(path).toMatch( + /neoboard-dump-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.sql$/, + ); + }); + + it("passes --data-only flag", async () => { + await runDbDump({ dataOnly: true }); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("--data-only"), + ); + }); + + it("writes sql output to file", async () => { + await runDbDump({}); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.any(String), + "-- SQL dump", + ); + }); +}); diff --git a/cli/src/__tests__/commands/db/migrate.test.ts b/cli/src/__tests__/commands/db/migrate.test.ts new file mode 100644 index 00000000..0943843b --- /dev/null +++ b/cli/src/__tests__/commands/db/migrate.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { + journalPath: "/project/app/drizzle/migrations/meta/_journal.json", + appDir: "/project/app", + }, + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), +})); + +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { getMode } from "../../../lib/config.js"; +import { info, warn } from "../../../lib/output.js"; +import { existsSync, readFileSync } from "node:fs"; +import { + showMigrationStatus, + showDryRun, + runDbMigrate, +} from "../../../commands/db/migrate.js"; + +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); +const mockGetMode = vi.mocked(getMode); +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); + +const SAMPLE_JOURNAL = JSON.stringify({ + version: "7", + entries: [ + { idx: 0, tag: "0000_wooden_zeigeist", when: 1700000000000 }, + { idx: 1, tag: "0001_rapid_iron_monger", when: 1700100000000 }, + ], +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("showMigrationStatus", () => { + it("displays migration entries", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + showMigrationStatus(); + expect(info).toHaveBeenCalledWith("Migrations: 2 available"); + }); + + it("warns when no journal found", () => { + mockExistsSync.mockReturnValue(false); + showMigrationStatus(); + expect(warn).toHaveBeenCalledWith("No migration journal found."); + }); +}); + +describe("showDryRun", () => { + it("shows pending migrations without applying", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + showDryRun(); + expect(info).toHaveBeenCalledWith("Would apply 2 migration(s):"); + expect(mockRun).not.toHaveBeenCalled(); + }); +}); + +describe("runDbMigrate", () => { + it("shows status when --status flag set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + await runDbMigrate({ status: true }); + expect(info).toHaveBeenCalledWith("Migrations: 2 available"); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("shows dry run when --dry-run flag set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(SAMPLE_JOURNAL); + await runDbMigrate({ dryRun: true }); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("runs migrations in local mode", async () => { + await runDbMigrate({}); + expect(mockRun).toHaveBeenCalledWith("npx drizzle-kit migrate", { + cwd: "/project/app", + }); + }); + + it("runs migrations via docker exec in docker mode", async () => { + mockGetMode.mockReturnValue("docker"); + await runDbMigrate({}); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-app", + "npx drizzle-kit migrate", + ); + }); + + it("prints backup warning", async () => { + await runDbMigrate({}); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("neoboard db dump"), + ); + }); + + it("warns about --to flag limitation", async () => { + await runDbMigrate({ to: "1.0.0" }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("--to 1.0.0")); + }); +}); diff --git a/cli/src/__tests__/commands/db/reset.test.ts b/cli/src/__tests__/commands/db/reset.test.ts new file mode 100644 index 00000000..79463134 --- /dev/null +++ b/cli/src/__tests__/commands/db/reset.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn( + () => + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard\n", + ), +})); + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { envFile: "/project/app/.env.local" }, + readProjectConfig: vi.fn(() => ({ + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +vi.mock("../../../lib/prompt.js", () => ({ + confirm: vi.fn(async () => true), +})); + +vi.mock("../../../commands/db/migrate.js", () => ({ + runDbMigrate: vi.fn(), +})); + +vi.mock("../../../commands/db/seed.js", () => ({ + runDbSeed: vi.fn(), +})); + +import { readFileSync } from "node:fs"; +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { getMode } from "../../../lib/config.js"; +import { error as logError } from "../../../lib/output.js"; +import { confirm } from "../../../lib/prompt.js"; +import { runDbMigrate } from "../../../commands/db/migrate.js"; +import { runDbSeed } from "../../../commands/db/seed.js"; +import { runDbReset } from "../../../commands/db/reset.js"; + +const mockReadFileSync = vi.mocked(readFileSync); +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); +const mockGetMode = vi.mocked(getMode); +const mockConfirm = vi.mocked(confirm); +const mockRunDbMigrate = vi.mocked(runDbMigrate); +const mockRunDbSeed = vi.mocked(runDbSeed); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); + mockConfirm.mockResolvedValue(true); + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard\n", + ); + process.exitCode = undefined; +}); + +describe("runDbReset", () => { + it("refuses on non-localhost DATABASE_URL", async () => { + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgresql://neoboard:neoboard@prod-db.example.com:5432/neoboard\n", + ); + await runDbReset(); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("prod-db.example.com"), + ); + expect(mockDockerExec).not.toHaveBeenCalled(); + }); + + it("prompts for confirmation", async () => { + await runDbReset(); + expect(mockConfirm).toHaveBeenCalledWith(expect.stringContaining("DROP")); + }); + + it("aborts when user declines", async () => { + mockConfirm.mockResolvedValue(false); + await runDbReset(); + expect(mockDockerExec).not.toHaveBeenCalled(); + }); + + it("skips confirmation with --force", async () => { + await runDbReset({ force: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockDockerExec).toHaveBeenCalled(); + }); + + it("drops and creates database in docker mode", async () => { + await runDbReset({ force: true }); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-postgres", + expect.stringContaining("DROP DATABASE"), + ); + expect(mockDockerExec).toHaveBeenCalledWith( + "neoboard-postgres", + expect.stringContaining("CREATE DATABASE"), + ); + }); + + it("uses local psql in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runDbReset({ force: true }); + expect(mockRun).toHaveBeenCalledWith( + expect.stringContaining("psql -h localhost"), + ); + }); + + it("replays migrations after reset", async () => { + await runDbReset({ force: true }); + expect(mockRunDbMigrate).toHaveBeenCalledWith({}); + }); + + it("seeds after migration by default", async () => { + await runDbReset({ force: true }); + expect(mockRunDbSeed).toHaveBeenCalled(); + }); + + it("skips seed with --no-seed", async () => { + await runDbReset({ force: true, noSeed: true }); + expect(mockRunDbSeed).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/db/seed.test.ts b/cli/src/__tests__/commands/db/seed.test.ts new file mode 100644 index 00000000..24855627 --- /dev/null +++ b/cli/src/__tests__/commands/db/seed.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../../lib/docker.js", () => ({ + dockerExec: vi.fn(), +})); + +vi.mock("../../../lib/config.js", () => ({ + paths: { root: "/project" }, + readProjectConfig: vi.fn(() => ({ + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +vi.mock("../../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +import { run } from "../../../lib/exec.js"; +import { dockerExec } from "../../../lib/docker.js"; +import { + seedNeo4j, + seedPostgres, + runDbSeed, +} from "../../../commands/db/seed.js"; + +const mockRun = vi.mocked(run); +const mockDockerExec = vi.mocked(dockerExec); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("seedNeo4j", () => { + it("seeds when database is empty", async () => { + // First call: count query returns 0 + mockDockerExec.mockReturnValueOnce("c\n0"); + // Second call: cypher-shell seed + mockDockerExec.mockReturnValueOnce("ok"); + + await seedNeo4j(); + expect(mockDockerExec).toHaveBeenCalledTimes(2); + expect(mockDockerExec).toHaveBeenLastCalledWith( + "neoboard-neo4j", + expect.stringContaining("-f /var/lib/neo4j/import/init.cypher"), + ); + }); + + it("skips when database has nodes", async () => { + mockDockerExec.mockReturnValue("c\n42"); + await seedNeo4j(); + // Only the count query, no seed + expect(mockDockerExec).toHaveBeenCalledTimes(1); + }); +}); + +describe("seedPostgres", () => { + it("runs seed script", async () => { + await seedPostgres(); + expect(mockRun).toHaveBeenCalledWith( + "node /project/scripts/seed-demo.mjs", + { cwd: "/project" }, + ); + }); +}); + +describe("runDbSeed", () => { + it("seeds both by default", async () => { + mockDockerExec.mockReturnValue("c\n0"); + await runDbSeed(); + // Neo4j count + Neo4j seed + PG seed + expect(mockDockerExec).toHaveBeenCalled(); + expect(mockRun).toHaveBeenCalled(); + }); + + it("seeds only neo4j with --neo4j flag", async () => { + mockDockerExec.mockReturnValue("c\n0"); + await runDbSeed({ neo4j: true }); + expect(mockDockerExec).toHaveBeenCalled(); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it("seeds only postgres with --demo flag", async () => { + await runDbSeed({ demo: true }); + expect(mockRun).toHaveBeenCalled(); + // No Neo4j exec calls + expect(mockDockerExec).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/demo.test.ts b/cli/src/__tests__/commands/demo.test.ts new file mode 100644 index 00000000..c1f5c8ed --- /dev/null +++ b/cli/src/__tests__/commands/demo.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../commands/setup.js", () => ({ + runSetup: vi.fn(), +})); + +vi.mock("../../commands/db/seed.js", () => ({ + runDbSeed: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), + banner: vi.fn(), +})); + +import { runSetup } from "../../commands/setup.js"; +import { runDbSeed } from "../../commands/db/seed.js"; +import { banner } from "../../lib/output.js"; +import { runDemo } from "../../commands/demo.js"; + +const mockRunSetup = vi.mocked(runSetup); +const mockRunDbSeed = vi.mocked(runDbSeed); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runDemo", () => { + it("calls setup then seed", async () => { + await runDemo(); + expect(mockRunSetup).toHaveBeenCalledBefore(mockRunDbSeed); + }); + + it("passes mode to setup", async () => { + await runDemo({ mode: "local" }); + expect(mockRunSetup).toHaveBeenCalledWith({ mode: "local" }); + }); + + it("seeds both neo4j and demo data", async () => { + await runDemo(); + expect(mockRunDbSeed).toHaveBeenCalledWith({ neo4j: true, demo: true }); + }); + + it("shows login credentials", async () => { + await runDemo(); + expect(banner).toHaveBeenCalledWith( + expect.arrayContaining([expect.stringContaining("admin@neoboard.local")]), + ); + }); +}); diff --git a/cli/src/__tests__/commands/dev.test.ts b/cli/src/__tests__/commands/dev.test.ts new file mode 100644 index 00000000..9281837b --- /dev/null +++ b/cli/src/__tests__/commands/dev.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + spawn: vi.fn(() => ({ + kill: vi.fn(), + on: vi.fn(), + })), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { appDir: "/project/app" }, + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), +})); + +import { spawn } from "../../lib/exec.js"; +import { getMode } from "../../lib/config.js"; +import { info } from "../../lib/output.js"; +import { runDev } from "../../commands/dev.js"; + +const mockSpawn = vi.mocked(spawn); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("runDev", () => { + it("prints info message in docker mode without spawning", async () => { + mockGetMode.mockReturnValue("docker"); + await runDev(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("Docker mode")); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it("spawns npm run dev in local mode", async () => { + const mockChild = { + kill: vi.fn(), + on: vi.fn((_event: string, cb: () => void) => { + // Immediately close to resolve the promise + if (_event === "close") cb(); + }), + }; + mockSpawn.mockReturnValue(mockChild as ReturnType); + + await runDev(); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["run", "dev"], { + cwd: "/project/app", + }); + }); +}); diff --git a/cli/src/__tests__/commands/doctor.test.ts b/cli/src/__tests__/commands/doctor.test.ts new file mode 100644 index 00000000..53314373 --- /dev/null +++ b/cli/src/__tests__/commands/doctor.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + runOrNull: vi.fn(), +})); + +vi.mock("../../lib/ports.js", () => ({ + isPortAvailable: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { appDir: "/project/app", envFile: "/project/app/.env.local" }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), +})); + +import { runOrNull } from "../../lib/exec.js"; +import { isPortAvailable } from "../../lib/ports.js"; +import { existsSync } from "node:fs"; +import { + checkDockerRunning, + checkDockerComposeV2, + checkNodeVersion, + checkPortAvailable, + checkNodeModulesExist, + checkEnvFileExists, + runDoctor, + printResults, +} from "../../commands/doctor.js"; +import { success, warn, error as logError } from "../../lib/output.js"; + +const mockRunOrNull = vi.mocked(runOrNull); +const mockIsPortAvailable = vi.mocked(isPortAvailable); +const mockExistsSync = vi.mocked(existsSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("checkDockerRunning", () => { + it("returns ok when docker info succeeds", () => { + mockRunOrNull.mockReturnValue("ok"); + const result = checkDockerRunning(); + expect(result.status).toBe("ok"); + }); + + it("returns fail when docker info fails", () => { + mockRunOrNull.mockReturnValue(null); + const result = checkDockerRunning(); + expect(result.status).toBe("fail"); + }); +}); + +describe("checkDockerComposeV2", () => { + it("returns ok for v2", () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + expect(checkDockerComposeV2().status).toBe("ok"); + }); + + it("returns fail when not available", () => { + mockRunOrNull.mockReturnValue(null); + expect(checkDockerComposeV2().status).toBe("fail"); + }); +}); + +describe("checkNodeVersion", () => { + it("returns ok for current node (>= 20)", () => { + const result = checkNodeVersion(); + const major = parseInt(process.version.slice(1), 10); + expect(result.status).toBe(major >= 20 ? "ok" : "fail"); + }); +}); + +describe("checkPortAvailable", () => { + it("returns ok when port is free", async () => { + mockIsPortAvailable.mockResolvedValue(true); + const result = await checkPortAvailable(3000, "App"); + expect(result.status).toBe("ok"); + expect(result.name).toBe("Port 3000 (App)"); + }); + + it("returns warn when port is in use", async () => { + mockIsPortAvailable.mockResolvedValue(false); + const result = await checkPortAvailable(5432, "PostgreSQL"); + expect(result.status).toBe("warn"); + }); +}); + +describe("checkNodeModulesExist", () => { + it("returns ok when node_modules exists", () => { + mockExistsSync.mockReturnValue(true); + expect(checkNodeModulesExist().status).toBe("ok"); + }); + + it("returns warn when missing", () => { + mockExistsSync.mockReturnValue(false); + expect(checkNodeModulesExist().status).toBe("warn"); + }); +}); + +describe("checkEnvFileExists", () => { + it("returns ok when .env.local exists", () => { + mockExistsSync.mockReturnValue(true); + expect(checkEnvFileExists().status).toBe("ok"); + }); + + it("returns warn when missing", () => { + mockExistsSync.mockReturnValue(false); + expect(checkEnvFileExists().status).toBe("warn"); + }); +}); + +describe("runDoctor", () => { + it("returns all check results", async () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + mockIsPortAvailable.mockResolvedValue(true); + mockExistsSync.mockReturnValue(true); + + const results = await runDoctor(); + // 3 sync checks + 4 port checks + 2 file checks = 9 + expect(results.length).toBe(9); + expect(results.every((r) => r.status === "ok")).toBe(true); + }); +}); + +describe("printResults", () => { + it("calls success for ok results", () => { + printResults([{ name: "test", status: "ok", message: "all good" }]); + expect(success).toHaveBeenCalledWith("all good"); + }); + + it("calls warn for warn results", () => { + printResults([{ name: "test", status: "warn", message: "careful" }]); + expect(warn).toHaveBeenCalledWith("careful"); + }); + + it("calls error for fail results and returns true", () => { + const hasFailure = printResults([ + { name: "test", status: "fail", message: "broken" }, + ]); + expect(logError).toHaveBeenCalledWith("broken"); + expect(hasFailure).toBe(true); + }); + + it("returns false when no failures", () => { + const hasFailure = printResults([ + { name: "a", status: "ok", message: "ok" }, + { name: "b", status: "warn", message: "warn" }, + ]); + expect(hasFailure).toBe(false); + }); +}); diff --git a/cli/src/__tests__/commands/env.test.ts b/cli/src/__tests__/commands/env.test.ts new file mode 100644 index 00000000..0c4a71e7 --- /dev/null +++ b/cli/src/__tests__/commands/env.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("node:crypto", () => ({ + randomBytes: vi.fn(() => ({ + toString: () => "a".repeat(64), + })), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + envFile: "/project/app/.env.local", + envExample: "/project/.env.example", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + })), + getMode: vi.fn(() => "local"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + banner: vi.fn(), +})); + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { getMode } from "../../lib/config.js"; +import { info, error as logError } from "../../lib/output.js"; +import { validateEnv, generateEnvFile, runEnv } from "../../commands/env.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("local"); +}); + +describe("validateEnv", () => { + it("reports missing when file does not exist", () => { + mockExistsSync.mockReturnValue(false); + const result = validateEnv(); + expect(result.ok).toBe(false); + expect(result.missing).toContain("(file does not exist)"); + }); + + it("passes when all required vars present", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + "DATABASE_URL=postgres://...\nENCRYPTION_KEY=abc\nNEXTAUTH_SECRET=def\nNEXTAUTH_URL=http://localhost:3000\n", + ); + const result = validateEnv(); + expect(result.ok).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("reports specific missing vars", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("DATABASE_URL=postgres://...\n"); + const result = validateEnv(); + expect(result.ok).toBe(false); + expect(result.missing).toContain("ENCRYPTION_KEY"); + expect(result.missing).toContain("NEXTAUTH_SECRET"); + }); + + it("ignores comments and blank lines", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + "# comment\n\nDATABASE_URL=x\nENCRYPTION_KEY=x\nNEXTAUTH_SECRET=x\nNEXTAUTH_URL=x\n", + ); + expect(validateEnv().ok).toBe(true); + }); +}); + +describe("generateEnvFile", () => { + it("generates file when none exists", () => { + mockExistsSync.mockReturnValue(false); + generateEnvFile(); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const content = mockWriteFileSync.mock.calls[0][1] as string; + expect(content).toContain("DATABASE_URL="); + expect(content).toContain("ENCRYPTION_KEY="); + expect(content).toContain("NEXTAUTH_SECRET="); + expect(content).toContain("ADMIN_BOOTSTRAP_TOKEN="); + }); + + it("skips when file exists and no regenerate flag", () => { + mockExistsSync.mockReturnValue(true); + generateEnvFile(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("overwrites when regenerate is true", () => { + mockExistsSync.mockReturnValue(true); + generateEnvFile({ regenerate: true }); + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + }); + + it("builds DATABASE_URL from config", () => { + mockExistsSync.mockReturnValue(false); + generateEnvFile(); + const content = mockWriteFileSync.mock.calls[0][1] as string; + expect(content).toContain( + "DATABASE_URL=postgresql://neoboard:neoboard@localhost:5432/neoboard", + ); + }); +}); + +describe("runEnv", () => { + it("exits early in docker mode", async () => { + mockGetMode.mockReturnValue("docker"); + await runEnv({}); + expect(info).toHaveBeenCalledWith(expect.stringContaining("Docker mode")); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("validates when --validate flag is set", async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("DATABASE_URL=x\n"); + await runEnv({ validate: true }); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("Missing variables"), + ); + }); + + it("generates env file by default", async () => { + mockExistsSync.mockReturnValue(false); + await runEnv({}); + expect(mockWriteFileSync).toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/init.test.ts b/cli/src/__tests__/commands/init.test.ts new file mode 100644 index 00000000..4ceecb5d --- /dev/null +++ b/cli/src/__tests__/commands/init.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock("../../lib/exec.js", () => ({ + run: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + root: "/project", + appDir: "/project/app", + projectConfig: "/project/neoboard.config.json", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), + writeLocalConfig: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + })), +})); + +vi.mock("../../commands/env.js", () => ({ + generateEnvFile: vi.fn(), +})); + +import { existsSync, writeFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { writeLocalConfig } from "../../lib/config.js"; +import { generateEnvFile } from "../../commands/env.js"; +import { runInit } from "../../commands/init.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockRun = vi.mocked(run); +const mockWriteLocalConfig = vi.mocked(writeLocalConfig); +const mockGenerateEnvFile = vi.mocked(generateEnvFile); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runInit", () => { + it("creates config files with docker mode by default", async () => { + mockExistsSync.mockReturnValue(false); + await runInit(); + expect(mockWriteFileSync).toHaveBeenCalledWith( + "/project/neoboard.config.json", + expect.stringContaining('"ports"'), + ); + expect(mockWriteLocalConfig).toHaveBeenCalledWith({ mode: "docker" }); + }); + + it("skips config creation when already exists", async () => { + mockExistsSync.mockReturnValue(true); + await runInit(); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it("sets local mode when specified", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockWriteLocalConfig).toHaveBeenCalledWith({ mode: "local" }); + }); + + it("installs deps in local mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockRun).toHaveBeenCalledWith("npm install", { cwd: "/project" }); + expect(mockRun).toHaveBeenCalledWith("npm install", { + cwd: "/project/app", + }); + }); + + it("generates env file in local mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "local" }); + expect(mockGenerateEnvFile).toHaveBeenCalled(); + }); + + it("does not install deps or generate env in docker mode", async () => { + mockExistsSync.mockReturnValue(false); + await runInit({ mode: "docker" }); + expect(mockRun).not.toHaveBeenCalled(); + expect(mockGenerateEnvFile).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/__tests__/commands/setup.test.ts b/cli/src/__tests__/commands/setup.test.ts new file mode 100644 index 00000000..b77b350e --- /dev/null +++ b/cli/src/__tests__/commands/setup.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../commands/init.js", () => ({ + runInit: vi.fn(), +})); + +vi.mock("../../commands/start.js", () => ({ + runStart: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), +})); + +import { runInit } from "../../commands/init.js"; +import { runStart } from "../../commands/start.js"; +import { runSetup } from "../../commands/setup.js"; + +const mockRunInit = vi.mocked(runInit); +const mockRunStart = vi.mocked(runStart); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runSetup", () => { + it("calls init then start", async () => { + await runSetup(); + expect(mockRunInit).toHaveBeenCalledBefore(mockRunStart); + }); + + it("passes mode to init", async () => { + await runSetup({ mode: "local" }); + expect(mockRunInit).toHaveBeenCalledWith({ mode: "local" }); + }); +}); diff --git a/cli/src/__tests__/commands/start.test.ts b/cli/src/__tests__/commands/start.test.ts new file mode 100644 index 00000000..727b2dbb --- /dev/null +++ b/cli/src/__tests__/commands/start.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composeUp: vi.fn(), + isPgReady: vi.fn(() => true), + isNeo4jReady: vi.fn(() => true), +})); + +vi.mock("../../lib/health.js", () => ({ + waitForHealth: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + })), + getMode: vi.fn(() => "docker"), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), + success: vi.fn(), + banner: vi.fn(), +})); + +vi.mock("../../commands/doctor.js", () => ({ + runDoctor: vi.fn(async () => []), + printResults: vi.fn(() => false), +})); + +vi.mock("../../commands/db/migrate.js", () => ({ + runDbMigrate: vi.fn(), +})); + +import { composeUp } from "../../lib/docker.js"; +import { waitForHealth } from "../../lib/health.js"; +import { getMode } from "../../lib/config.js"; +import { printResults } from "../../commands/doctor.js"; +import { runDbMigrate } from "../../commands/db/migrate.js"; +import { runStart } from "../../commands/start.js"; + +const mockComposeUp = vi.mocked(composeUp); +const mockWaitForHealth = vi.mocked(waitForHealth); +const mockPrintResults = vi.mocked(printResults); +const mockRunDbMigrate = vi.mocked(runDbMigrate); +const mockGetMode = vi.mocked(getMode); + +beforeEach(() => { + vi.clearAllMocks(); + mockGetMode.mockReturnValue("docker"); + mockPrintResults.mockReturnValue(false); +}); + +describe("runStart", () => { + it("runs doctor checks first", async () => { + await runStart(); + expect(printResults).toHaveBeenCalled(); + }); + + it("aborts if doctor finds failures", async () => { + mockPrintResults.mockReturnValue(true); + await runStart(); + expect(mockComposeUp).not.toHaveBeenCalled(); + }); + + it("starts containers with full stack in docker mode", async () => { + await runStart(); + expect(mockComposeUp).toHaveBeenCalledWith({ full: true }); + }); + + it("starts only DB containers in local mode", async () => { + mockGetMode.mockReturnValue("local"); + await runStart(); + expect(mockComposeUp).toHaveBeenCalledWith({ full: false }); + }); + + it("waits for health checks", async () => { + await runStart(); + expect(mockWaitForHealth).toHaveBeenCalledTimes(2); + }); + + it("runs migrations after health checks pass", async () => { + await runStart(); + expect(mockRunDbMigrate).toHaveBeenCalledWith({}); + }); +}); diff --git a/cli/src/__tests__/commands/status.test.ts b/cli/src/__tests__/commands/status.test.ts new file mode 100644 index 00000000..089d6320 --- /dev/null +++ b/cli/src/__tests__/commands/status.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composePs: vi.fn(() => [ + { name: "neoboard-postgres", state: "running", status: "Up" }, + { name: "neoboard-neo4j", state: "running", status: "Up" }, + ]), + isPgReady: vi.fn(() => true), + isNeo4jReady: vi.fn(() => true), +})); + +vi.mock("../../lib/exec.js", () => ({ + runOrNull: vi.fn(() => "200"), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + journalPath: "/project/app/drizzle/migrations/meta/_journal.json", + root: "/project", + }, + getMode: vi.fn(() => "docker"), + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + })), +})); + +vi.mock("../../lib/output.js", () => ({ + info: vi.fn(), +})); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(() => true), + readFileSync: vi.fn((p: string) => { + if (p.includes("_journal.json")) { + return JSON.stringify({ + entries: [{ idx: 0, tag: "0000_wooden_zeigeist" }], + }); + } + if (p.includes("package.json")) { + return JSON.stringify({ version: "0.0.1" }); + } + return "{}"; + }), +})); + +import { composePs, isPgReady, isNeo4jReady } from "../../lib/docker.js"; +import { info } from "../../lib/output.js"; +import { runStatus } from "../../commands/status.js"; + +const mockComposePs = vi.mocked(composePs); +const mockIsPgReady = vi.mocked(isPgReady); +const mockIsNeo4jReady = vi.mocked(isNeo4jReady); + +beforeEach(() => { + vi.clearAllMocks(); + mockComposePs.mockReturnValue([ + { name: "neoboard-postgres", state: "running", status: "Up" }, + { name: "neoboard-neo4j", state: "running", status: "Up" }, + ]); + mockIsPgReady.mockReturnValue(true); + mockIsNeo4jReady.mockReturnValue(true); +}); + +describe("runStatus", () => { + it("displays mode and version", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("docker")); + expect(info).toHaveBeenCalledWith(expect.stringContaining("0.0.1")); + }); + + it("shows container count", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("2 containers")); + }); + + it("shows healthy services", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("PostgreSQL healthy"), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Neo4j healthy"), + ); + }); + + it("shows stopped services", async () => { + mockIsPgReady.mockReturnValue(false); + mockIsNeo4jReady.mockReturnValue(false); + await runStatus(); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("PostgreSQL stopped"), + ); + expect(info).toHaveBeenCalledWith( + expect.stringContaining("Neo4j stopped"), + ); + }); + + it("shows migration status", async () => { + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("1 applied")); + }); + + it("shows no containers when none running", async () => { + mockComposePs.mockReturnValue([]); + await runStatus(); + expect(info).toHaveBeenCalledWith(expect.stringContaining("no containers")); + }); +}); diff --git a/cli/src/__tests__/commands/stop.test.ts b/cli/src/__tests__/commands/stop.test.ts new file mode 100644 index 00000000..08a7ac6b --- /dev/null +++ b/cli/src/__tests__/commands/stop.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/docker.js", () => ({ + composeDown: vi.fn(), +})); + +vi.mock("../../lib/output.js", () => ({ + success: vi.fn(), +})); + +import { composeDown } from "../../lib/docker.js"; +import { runStop } from "../../commands/stop.js"; + +const mockComposeDown = vi.mocked(composeDown); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runStop", () => { + it("calls composeDown", async () => { + await runStop(); + expect(mockComposeDown).toHaveBeenCalledWith({ volumes: undefined }); + }); + + it("passes volumes flag through", async () => { + await runStop({ volumes: true }); + expect(mockComposeDown).toHaveBeenCalledWith({ volumes: true }); + }); +}); diff --git a/cli/src/__tests__/lib/config.test.ts b/cli/src/__tests__/lib/config.test.ts new file mode 100644 index 00000000..4f49e086 --- /dev/null +++ b/cli/src/__tests__/lib/config.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { + findProjectRoot, + readProjectConfig, + readLocalConfig, + writeLocalConfig, + _setRootForTesting, +} from "../../lib/config.js"; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockWriteFileSync = vi.mocked(writeFileSync); + +beforeEach(() => { + vi.clearAllMocks(); + _setRootForTesting(null); +}); + +describe("findProjectRoot", () => { + it("finds root when package.json has name neoboard", () => { + mockExistsSync.mockImplementation((p) => { + return p === "/projects/neoboard/package.json"; + }); + mockReadFileSync.mockReturnValue(JSON.stringify({ name: "neoboard" })); + expect(findProjectRoot("/projects/neoboard/cli/src")).toBe( + "/projects/neoboard", + ); + }); + + it("walks up directories until found", () => { + mockExistsSync.mockImplementation((p) => { + return ( + p === "/a/b/c/package.json" || + p === "/a/b/package.json" || + p === "/a/package.json" + ); + }); + mockReadFileSync.mockImplementation((p) => { + if (p === "/a/package.json") return JSON.stringify({ name: "neoboard" }); + return JSON.stringify({ name: "other" }); + }); + expect(findProjectRoot("/a/b/c")).toBe("/a"); + }); + + it("throws when no project root found", () => { + mockExistsSync.mockReturnValue(false); + expect(() => findProjectRoot("/nowhere")).toThrow( + "Could not find NeoBoard project root", + ); + }); +}); + +describe("readProjectConfig", () => { + beforeEach(() => { + _setRootForTesting("/project"); + }); + + it("returns default config when file missing", () => { + mockExistsSync.mockReturnValue(false); + const config = readProjectConfig(); + expect(config.ports.app).toBe(3000); + expect(config.postgres.user).toBe("neoboard"); + expect(config.neo4j.user).toBe("neo4j"); + }); + + it("parses config from file", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + ports: { app: 4000, postgres: 5433, neo4j_http: 7475, neo4j_bolt: 7688 }, + postgres: { user: "custom", password: "pass", database: "mydb" }, + neo4j: { user: "admin", password: "secret" }, + seed: { script: "seed.mjs", neo4j_cypher: "init.cypher" }, + }), + ); + const config = readProjectConfig(); + expect(config.ports.app).toBe(4000); + expect(config.postgres.user).toBe("custom"); + }); + + it("returns default on invalid json", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue("not json"); + const config = readProjectConfig(); + expect(config.ports.app).toBe(3000); + }); +}); + +describe("readLocalConfig", () => { + beforeEach(() => { + _setRootForTesting("/project"); + }); + + it("returns default config when file missing", () => { + mockExistsSync.mockReturnValue(false); + const config = readLocalConfig(); + expect(config.mode).toBe("docker"); + }); + + it("parses local config from file", () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify({ mode: "local" })); + const config = readLocalConfig(); + expect(config.mode).toBe("local"); + }); +}); + +describe("writeLocalConfig", () => { + it("writes config as formatted json", () => { + _setRootForTesting("/project"); + writeLocalConfig({ mode: "local" }); + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining(".neoboard.local"), + JSON.stringify({ mode: "local" }, null, 2) + "\n", + ); + }); +}); diff --git a/cli/src/__tests__/lib/docker.test.ts b/cli/src/__tests__/lib/docker.test.ts new file mode 100644 index 00000000..2599cce4 --- /dev/null +++ b/cli/src/__tests__/lib/docker.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../lib/exec.js", () => ({ + run: vi.fn(), + runOrNull: vi.fn(), +})); + +vi.mock("../../lib/config.js", () => ({ + paths: { + root: "/project", + dockerDir: "/project/docker", + }, + readProjectConfig: vi.fn(() => ({ + ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, + seed: { + script: "scripts/seed-demo.mjs", + neo4j_cypher: "docker/neo4j/init.cypher", + }, + })), +})); + +import { run, runOrNull } from "../../lib/exec.js"; +import { + isDockerRunning, + isComposeV2, + composeFile, + composeUp, + composeDown, + composePs, + dockerExec, + isPgReady, + isNeo4jReady, +} from "../../lib/docker.js"; + +const mockRun = vi.mocked(run); +const mockRunOrNull = vi.mocked(runOrNull); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isDockerRunning", () => { + it("returns true when docker info succeeds", () => { + mockRunOrNull.mockReturnValue("some output"); + expect(isDockerRunning()).toBe(true); + }); + + it("returns false when docker info fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isDockerRunning()).toBe(false); + }); +}); + +describe("isComposeV2", () => { + it("returns true for v2 output", () => { + mockRunOrNull.mockReturnValue("Docker Compose version v2.24.0"); + expect(isComposeV2()).toBe(true); + }); + + it("returns false when compose not available", () => { + mockRunOrNull.mockReturnValue(null); + expect(isComposeV2()).toBe(false); + }); + + it("returns false for v1 output", () => { + mockRunOrNull.mockReturnValue("docker-compose version 1.29.0"); + expect(isComposeV2()).toBe(false); + }); +}); + +describe("composeFile", () => { + it("returns dev compose file by default", () => { + expect(composeFile()).toBe("/project/docker/docker-compose.yml"); + }); + + it("returns full compose file when full=true", () => { + expect(composeFile(true)).toBe("/project/docker/docker-compose.full.yml"); + }); +}); + +describe("composeUp", () => { + it("runs docker compose up with dev file", () => { + composeUp(); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml up -d --build", + { cwd: "/project" }, + ); + }); + + it("uses full compose file when full=true", () => { + composeUp({ full: true }); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.full.yml up -d --build", + { cwd: "/project" }, + ); + }); +}); + +describe("composeDown", () => { + it("runs docker compose down", () => { + composeDown(); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml down", + { cwd: "/project" }, + ); + }); + + it("adds -v flag when volumes=true", () => { + composeDown({ volumes: true }); + expect(mockRun).toHaveBeenCalledWith( + "docker compose -f /project/docker/docker-compose.yml down -v", + { cwd: "/project" }, + ); + }); +}); + +describe("composePs", () => { + it("parses json output into container info", () => { + mockRunOrNull.mockReturnValue( + '{"Name":"neoboard-postgres","State":"running","Status":"Up 5 minutes"}\n' + + '{"Name":"neoboard-neo4j","State":"running","Status":"Up 5 minutes"}', + ); + const result = composePs(); + expect(result).toEqual([ + { name: "neoboard-postgres", state: "running", status: "Up 5 minutes" }, + { name: "neoboard-neo4j", state: "running", status: "Up 5 minutes" }, + ]); + }); + + it("returns empty array when command fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(composePs()).toEqual([]); + }); + + it("returns empty array on invalid json", () => { + mockRunOrNull.mockReturnValue("not json"); + expect(composePs()).toEqual([]); + }); +}); + +describe("dockerExec", () => { + it("runs command in container", () => { + mockRun.mockReturnValue("output"); + const result = dockerExec("neoboard-postgres", "pg_isready"); + expect(result).toBe("output"); + expect(mockRun).toHaveBeenCalledWith( + "docker exec neoboard-postgres pg_isready", + ); + }); +}); + +describe("isPgReady", () => { + it("returns true when pg_isready succeeds", () => { + mockRunOrNull.mockReturnValue("accepting connections"); + expect(isPgReady()).toBe(true); + }); + + it("returns false when pg_isready fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isPgReady()).toBe(false); + }); +}); + +describe("isNeo4jReady", () => { + it("returns true when cypher-shell succeeds", () => { + mockRunOrNull.mockReturnValue("1"); + expect(isNeo4jReady()).toBe(true); + }); + + it("returns false when cypher-shell fails", () => { + mockRunOrNull.mockReturnValue(null); + expect(isNeo4jReady()).toBe(false); + }); +}); diff --git a/cli/src/__tests__/lib/exec.test.ts b/cli/src/__tests__/lib/exec.test.ts new file mode 100644 index 00000000..993a5903 --- /dev/null +++ b/cli/src/__tests__/lib/exec.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { run, runOrNull, spawn, ExecError } from "../../lib/exec.js"; + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), + spawn: vi.fn(), +})); + +import { execSync, spawn as nodeSpawn } from "node:child_process"; + +const mockExecSync = vi.mocked(execSync); +const mockSpawn = vi.mocked(nodeSpawn); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("run", () => { + it("returns trimmed stdout on success", () => { + mockExecSync.mockReturnValue(" hello world \n"); + expect(run("echo hello")).toBe("hello world"); + }); + + it("passes cwd and env options", () => { + mockExecSync.mockReturnValue("ok"); + const env = { ...process.env, FOO: "bar" }; + run("test-cmd", { cwd: "/tmp", env }); + expect(mockExecSync).toHaveBeenCalledWith( + "test-cmd", + expect.objectContaining({ + cwd: "/tmp", + env, + }), + ); + }); + + it("throws ExecError on failure", () => { + const err = Object.assign(new Error("fail"), { + status: 42, + stderr: "bad stuff", + }); + mockExecSync.mockImplementation(() => { + throw err; + }); + try { + run("bad-cmd"); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(ExecError); + const execErr = e as ExecError; + expect(execErr.cmd).toBe("bad-cmd"); + expect(execErr.exitCode).toBe(42); + expect(execErr.stderr).toBe("bad stuff"); + } + }); + + it("defaults exitCode to 1 when status is undefined", () => { + const err = Object.assign(new Error("fail"), { stderr: "" }); + mockExecSync.mockImplementation(() => { + throw err; + }); + try { + run("fail-cmd"); + expect.unreachable("should have thrown"); + } catch (e) { + expect((e as ExecError).exitCode).toBe(1); + } + }); +}); + +describe("runOrNull", () => { + it("returns stdout on success", () => { + mockExecSync.mockReturnValue("result"); + expect(runOrNull("echo ok")).toBe("result"); + }); + + it("returns null on failure", () => { + mockExecSync.mockImplementation(() => { + throw new Error("fail"); + }); + expect(runOrNull("bad-cmd")).toBeNull(); + }); +}); + +describe("spawn", () => { + it("calls child_process.spawn with inherited stdio by default", () => { + const fakeChild = {} as ReturnType; + mockSpawn.mockReturnValue(fakeChild); + const result = spawn("npm", ["run", "dev"]); + expect(result).toBe(fakeChild); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["run", "dev"], { + stdio: "inherit", + }); + }); + + it("allows overriding spawn options", () => { + const fakeChild = {} as ReturnType; + mockSpawn.mockReturnValue(fakeChild); + spawn("npm", ["test"], { cwd: "/app" }); + expect(mockSpawn).toHaveBeenCalledWith("npm", ["test"], { + stdio: "inherit", + cwd: "/app", + }); + }); +}); diff --git a/cli/src/__tests__/lib/health.test.ts b/cli/src/__tests__/lib/health.test.ts new file mode 100644 index 00000000..dbd130ef --- /dev/null +++ b/cli/src/__tests__/lib/health.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../lib/output.js", () => ({ + createSpinner: vi.fn(() => ({ + start: vi.fn(), + succeed: vi.fn(), + fail: vi.fn(), + })), +})); + +import { waitForHealth } from "../../lib/health.js"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("waitForHealth", () => { + it("resolves immediately when check passes on first try", async () => { + const check = vi.fn(() => true); + await waitForHealth({ check, label: "test-service" }); + expect(check).toHaveBeenCalledTimes(1); + }); + + it("polls until check passes", async () => { + let callCount = 0; + const check = vi.fn(() => { + callCount++; + return callCount >= 3; + }); + + const promise = waitForHealth({ + check, + label: "test-service", + interval: 100, + }); + + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(100); + + await promise; + expect(check).toHaveBeenCalledTimes(3); + }); + + it("throws on timeout", async () => { + const check = vi.fn(() => false); + const promise = waitForHealth({ + check, + label: "test-service", + interval: 100, + timeout: 250, + }); + + // Attach the rejection handler BEFORE advancing timers + const rejection = expect(promise).rejects.toThrow( + "Timeout waiting for test-service", + ); + + // Now advance past the timeout + await vi.advanceTimersByTimeAsync(300); + + await rejection; + }); +}); diff --git a/cli/src/__tests__/lib/output.test.ts b/cli/src/__tests__/lib/output.test.ts new file mode 100644 index 00000000..69b63acc --- /dev/null +++ b/cli/src/__tests__/lib/output.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + +beforeEach(() => { + logSpy.mockClear(); +}); + +// Import after spy is set up +import { info, warn, error, success, banner } from "../../lib/output.js"; + +describe("info", () => { + it("logs a message", () => { + info("test message"); + expect(logSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe("warn", () => { + it("logs a warning with prefix", () => { + warn("test warning"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("WARN"); + }); +}); + +describe("error", () => { + it("logs an error with prefix", () => { + error("test error"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("ERROR"); + }); +}); + +describe("success", () => { + it("logs a success message with checkmark", () => { + success("done"); + expect(logSpy).toHaveBeenCalledTimes(1); + const output = logSpy.mock.calls[0][0] as string; + expect(output).toContain("\u2714"); + }); +}); + +describe("banner", () => { + it("prints boxed output", () => { + banner(["Line 1", "Line 2"]); + // Top border + 2 lines + bottom border = 4 calls + expect(logSpy).toHaveBeenCalledTimes(4); + }); + + it("pads lines to equal width", () => { + banner(["Short", "Much longer line"]); + const line1 = logSpy.mock.calls[1][0] as string; + const line2 = logSpy.mock.calls[2][0] as string; + // Both content lines should have the same length + expect(line1.length).toBe(line2.length); + }); +}); diff --git a/cli/src/__tests__/lib/ports.test.ts b/cli/src/__tests__/lib/ports.test.ts new file mode 100644 index 00000000..d943fda4 --- /dev/null +++ b/cli/src/__tests__/lib/ports.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockServer = { + once: vi.fn(), + listen: vi.fn(), + close: vi.fn(), +}; + +vi.mock("node:net", () => ({ + createServer: vi.fn(() => mockServer), +})); + +import { isPortAvailable } from "../../lib/ports.js"; + +beforeEach(() => { + vi.clearAllMocks(); + mockServer.once.mockReset(); + mockServer.listen.mockReset(); + mockServer.close.mockReset(); +}); + +describe("isPortAvailable", () => { + it("returns true when port is free", async () => { + mockServer.once.mockImplementation((event: string, cb: () => void) => { + if (event === "listening") { + // Simulate successful listen + setTimeout(() => cb(), 0); + } + return mockServer; + }); + mockServer.close.mockImplementation((cb: () => void) => cb()); + + const result = await isPortAvailable(3000); + expect(result).toBe(true); + expect(mockServer.listen).toHaveBeenCalledWith(3000, "127.0.0.1"); + }); + + it("returns false when port is in use", async () => { + mockServer.once.mockImplementation((event: string, cb: () => void) => { + if (event === "error") { + setTimeout(() => cb(), 0); + } + return mockServer; + }); + + const result = await isPortAvailable(3000); + expect(result).toBe(false); + }); +}); diff --git a/cli/src/__tests__/program.test.ts b/cli/src/__tests__/program.test.ts new file mode 100644 index 00000000..63d72fad --- /dev/null +++ b/cli/src/__tests__/program.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +// We test the program structure without executing real commands. +// Import the program export to inspect its configuration. + +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(() => JSON.stringify({ version: "0.0.1" })), +})); + +// Prevent actual command execution +vi.mock("../commands/doctor.js", () => ({ + runDoctor: vi.fn(async () => []), + printResults: vi.fn(() => false), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CLI program", () => { + let program: Command; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import("../index.js"); + program = mod.program; + }); + + it("has correct name", () => { + expect(program.name()).toBe("neoboard"); + }); + + it("has a version set", () => { + expect(program.version()).toBe("0.0.1"); + }); + + it("registers top-level commands", () => { + const commandNames = program.commands.map((c) => c.name()); + expect(commandNames).toContain("init"); + expect(commandNames).toContain("start"); + expect(commandNames).toContain("stop"); + expect(commandNames).toContain("dev"); + expect(commandNames).toContain("setup"); + expect(commandNames).toContain("status"); + expect(commandNames).toContain("doctor"); + expect(commandNames).toContain("demo"); + expect(commandNames).toContain("env"); + expect(commandNames).toContain("db"); + }); + + it("registers db subcommands", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + expect(dbCmd).toBeDefined(); + const subNames = dbCmd!.commands.map((c) => c.name()); + expect(subNames).toContain("migrate"); + expect(subNames).toContain("reset"); + expect(subNames).toContain("seed"); + expect(subNames).toContain("dump"); + }); + + it("init has --mode option", () => { + const initCmd = program.commands.find((c) => c.name() === "init"); + const opts = initCmd!.options.map((o) => o.long); + expect(opts).toContain("--mode"); + }); + + it("env has --regenerate and --validate options", () => { + const envCmd = program.commands.find((c) => c.name() === "env"); + const opts = envCmd!.options.map((o) => o.long); + expect(opts).toContain("--regenerate"); + expect(opts).toContain("--validate"); + }); + + it("db migrate has --status, --to, --dry-run options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const migrateCmd = dbCmd!.commands.find((c) => c.name() === "migrate"); + const opts = migrateCmd!.options.map((o) => o.long); + expect(opts).toContain("--status"); + expect(opts).toContain("--to"); + expect(opts).toContain("--dry-run"); + }); + + it("db dump has --output and --data-only options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const dumpCmd = dbCmd!.commands.find((c) => c.name() === "dump"); + const opts = dumpCmd!.options.map((o) => o.long); + expect(opts).toContain("--output"); + expect(opts).toContain("--data-only"); + }); + + it("db reset has --no-seed and --force options", () => { + const dbCmd = program.commands.find((c) => c.name() === "db"); + const resetCmd = dbCmd!.commands.find((c) => c.name() === "reset"); + const opts = resetCmd!.options.map((o) => o.long); + expect(opts).toContain("--no-seed"); + expect(opts).toContain("--force"); + }); + + it("stop has --volumes option", () => { + const stopCmd = program.commands.find((c) => c.name() === "stop"); + const opts = stopCmd!.options.map((o) => o.long); + expect(opts).toContain("--volumes"); + }); +}); diff --git a/cli/src/commands/db/dump.ts b/cli/src/commands/db/dump.ts new file mode 100644 index 00000000..fcbd2d15 --- /dev/null +++ b/cli/src/commands/db/dump.ts @@ -0,0 +1,44 @@ +import { writeFileSync, statSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { paths, readProjectConfig, getMode } from "../../lib/config.js"; +import { success, createSpinner } from "../../lib/output.js"; + +function defaultFilename(): string { + const now = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + return `neoboard-dump-${now}.sql`; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export async function runDbDump(opts: { + output?: string; + dataOnly?: boolean; +}): Promise { + const config = readProjectConfig(); + const outPath = opts.output ?? `${paths.root}/${defaultFilename()}`; + const dataFlag = opts.dataOnly ? " --data-only" : ""; + + const spinner = createSpinner("Dumping database..."); + spinner.start(); + + const mode = getMode(); + let sql: string; + if (mode === "docker") { + sql = run( + `docker exec neoboard-postgres pg_dump -U ${config.postgres.user} ${config.postgres.database}${dataFlag}`, + ); + } else { + sql = run( + `pg_dump -h localhost -p ${config.ports.postgres} -U ${config.postgres.user} ${config.postgres.database}${dataFlag}`, + ); + } + + writeFileSync(outPath, sql); + const size = statSync(outPath).size; + spinner.succeed(`Backup saved to ${outPath} (${formatSize(size)})`); + success("Database dump complete"); +} diff --git a/cli/src/commands/db/migrate.ts b/cli/src/commands/db/migrate.ts new file mode 100644 index 00000000..70de2d94 --- /dev/null +++ b/cli/src/commands/db/migrate.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, getMode } from "../../lib/config.js"; +import { info, success, warn, createSpinner } from "../../lib/output.js"; + +interface JournalEntry { + idx: number; + tag: string; + when: number; +} + +interface Journal { + version: string; + entries: JournalEntry[]; +} + +function readJournal(): Journal | null { + if (!existsSync(paths.journalPath)) return null; + try { + return JSON.parse(readFileSync(paths.journalPath, "utf-8")); + } catch { + return null; + } +} + +export function showMigrationStatus(): void { + const journal = readJournal(); + if (!journal) { + warn("No migration journal found."); + return; + } + + info(`Migrations: ${journal.entries.length} available`); + for (const entry of journal.entries) { + const date = new Date(entry.when).toISOString().slice(0, 10); + info(` ${entry.idx}: ${entry.tag} (${date})`); + } +} + +export function showDryRun(): void { + const journal = readJournal(); + if (!journal) { + warn("No migration journal found."); + return; + } + info(`Would apply ${journal.entries.length} migration(s):`); + for (const entry of journal.entries) { + info(` - ${entry.tag}`); + } +} + +export async function runDbMigrate(opts: { + status?: boolean; + to?: string; + dryRun?: boolean; +}): Promise { + if (opts.status) { + showMigrationStatus(); + return; + } + + if (opts.dryRun) { + showDryRun(); + return; + } + + info("Tip: Run 'neoboard db dump' to backup before migrating"); + + if (opts.to) { + warn( + `--to ${opts.to}: Drizzle Kit applies all pending migrations. Version validation is not yet supported.`, + ); + } + + const spinner = createSpinner("Running migrations..."); + spinner.start(); + + const mode = getMode(); + if (mode === "docker") { + dockerExec("neoboard-app", "npx drizzle-kit migrate"); + } else { + run("npx drizzle-kit migrate", { cwd: paths.appDir }); + } + + spinner.succeed("Migrations applied"); + success("Database is up to date"); +} diff --git a/cli/src/commands/db/reset.ts b/cli/src/commands/db/reset.ts new file mode 100644 index 00000000..2cdffa00 --- /dev/null +++ b/cli/src/commands/db/reset.ts @@ -0,0 +1,89 @@ +import { readFileSync } from "node:fs"; +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, readProjectConfig, getMode } from "../../lib/config.js"; +import { + info, + success, + error as logError, + createSpinner, +} from "../../lib/output.js"; +import { confirm } from "../../lib/prompt.js"; +import { runDbMigrate } from "./migrate.js"; +import { runDbSeed } from "./seed.js"; + +function getDatabaseHost(): string { + try { + const content = readFileSync(paths.envFile, "utf-8"); + const match = content.match(/DATABASE_URL=.*@([^:/]+)/); + return match?.[1] ?? "localhost"; + } catch { + return "localhost"; + } +} + +function isLocalhost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1"; +} + +export async function runDbReset(opts?: { + noSeed?: boolean; + force?: boolean; +}): Promise { + const host = getDatabaseHost(); + if (!isLocalhost(host)) { + logError( + `Refusing to reset: DATABASE_URL points to '${host}' (not localhost). This command only works on local databases.`, + ); + process.exitCode = 1; + return; + } + + if (!opts?.force) { + const confirmed = await confirm( + "This will DROP the neoboard database and recreate it. Continue?", + ); + if (!confirmed) { + info("Aborted."); + return; + } + } + + const config = readProjectConfig(); + const mode = getMode(); + const { user, database } = config.postgres; + + const spinner = createSpinner("Resetting database..."); + spinner.start(); + + if (mode === "docker") { + // Connect to 'postgres' db to drop/create target db + dockerExec( + "neoboard-postgres", + `psql -U ${user} -d postgres -c "DROP DATABASE IF EXISTS ${database}"`, + ); + dockerExec( + "neoboard-postgres", + `psql -U ${user} -d postgres -c "CREATE DATABASE ${database}"`, + ); + } else { + run( + `psql -h localhost -U ${user} -d postgres -c "DROP DATABASE IF EXISTS ${database}"`, + ); + run( + `psql -h localhost -U ${user} -d postgres -c "CREATE DATABASE ${database}"`, + ); + } + + spinner.succeed("Database dropped and recreated"); + + // Replay migrations + await runDbMigrate({}); + + // Re-seed unless --no-seed + if (!opts?.noSeed) { + await runDbSeed(); + } + + success("Database reset complete"); +} diff --git a/cli/src/commands/db/seed.ts b/cli/src/commands/db/seed.ts new file mode 100644 index 00000000..b27b3c81 --- /dev/null +++ b/cli/src/commands/db/seed.ts @@ -0,0 +1,60 @@ +import { run } from "../../lib/exec.js"; +import { dockerExec } from "../../lib/docker.js"; +import { paths, readProjectConfig } from "../../lib/config.js"; +import { info, success, warn, createSpinner } from "../../lib/output.js"; + +function getNeo4jNodeCount(): number { + const config = readProjectConfig(); + const out = dockerExec( + "neoboard-neo4j", + `cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} "MATCH (n) RETURN count(n) AS c"`, + ); + const match = out.match(/(\d+)/); + return match ? parseInt(match[1], 10) : 0; +} + +export async function seedNeo4j(): Promise { + const spinner = createSpinner("Seeding Neo4j..."); + spinner.start(); + + const count = getNeo4jNodeCount(); + if (count > 0) { + spinner.succeed(`Neo4j already has ${count} nodes — skipping seed`); + return; + } + + const config = readProjectConfig(); + dockerExec( + "neoboard-neo4j", + `cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} -f /var/lib/neo4j/import/init.cypher`, + ); + spinner.succeed("Neo4j seeded with demo data"); +} + +export async function seedPostgres(): Promise { + const config = readProjectConfig(); + const spinner = createSpinner("Seeding PostgreSQL demo data..."); + spinner.start(); + + run(`node ${paths.root}/${config.seed.script}`, { cwd: paths.root }); + spinner.succeed("PostgreSQL seeded with demo data"); +} + +export async function runDbSeed(opts?: { + neo4j?: boolean; + demo?: boolean; +}): Promise { + const seedNeo4jOnly = opts?.neo4j && !opts?.demo; + const seedDemoOnly = opts?.demo && !opts?.neo4j; + const seedBoth = (!opts?.neo4j && !opts?.demo) || (opts?.neo4j && opts?.demo); + + if (seedBoth || seedNeo4jOnly) { + await seedNeo4j(); + } + + if (seedBoth || seedDemoOnly) { + await seedPostgres(); + } + + success("Seeding complete"); +} diff --git a/cli/src/commands/demo.ts b/cli/src/commands/demo.ts new file mode 100644 index 00000000..8d3a5642 --- /dev/null +++ b/cli/src/commands/demo.ts @@ -0,0 +1,19 @@ +import { runSetup } from "./setup.js"; +import { runDbSeed } from "./db/seed.js"; +import { success, banner } from "../lib/output.js"; + +export async function runDemo(opts?: { + mode?: "docker" | "local"; +}): Promise { + await runSetup(opts); + await runDbSeed({ neo4j: true, demo: true }); + + banner([ + "Demo environment ready!", + "", + "Login credentials:", + " Email: admin@neoboard.local", + " Password: admin123", + ]); + success("Open http://localhost:3000 to get started"); +} diff --git a/cli/src/commands/dev.ts b/cli/src/commands/dev.ts new file mode 100644 index 00000000..8b6b8a0c --- /dev/null +++ b/cli/src/commands/dev.ts @@ -0,0 +1,26 @@ +import { spawn } from "../lib/exec.js"; +import { paths, getMode } from "../lib/config.js"; +import { info } from "../lib/output.js"; + +export async function runDev(): Promise { + const mode = getMode(); + + if (mode === "docker") { + info( + "In Docker mode, the app runs inside the container. Use 'neoboard start' and visit http://localhost:3000.", + ); + return; + } + + info("Starting Next.js dev server..."); + const child = spawn("npm", ["run", "dev"], { cwd: paths.appDir }); + + // Forward signals for clean shutdown + const cleanup = () => child.kill(); + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + + await new Promise((resolve) => { + child.on("close", () => resolve()); + }); +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts new file mode 100644 index 00000000..1cc3ebdd --- /dev/null +++ b/cli/src/commands/doctor.ts @@ -0,0 +1,115 @@ +import { existsSync } from "node:fs"; +import { runOrNull } from "../lib/exec.js"; +import { isPortAvailable } from "../lib/ports.js"; +import { paths, readProjectConfig } from "../lib/config.js"; +import { success, warn, error as logError } from "../lib/output.js"; + +export interface CheckResult { + name: string; + status: "ok" | "warn" | "fail"; + message: string; +} + +export function checkDockerRunning(): CheckResult { + const ok = runOrNull("docker info") !== null; + return { + name: "Docker daemon", + status: ok ? "ok" : "fail", + message: ok ? "Docker daemon running" : "Docker daemon not running", + }; +} + +export function checkDockerComposeV2(): CheckResult { + const out = runOrNull("docker compose version"); + const ok = out !== null && out.includes("v2"); + return { + name: "Docker Compose v2", + status: ok ? "ok" : "fail", + message: ok ? "Docker Compose v2 available" : "Docker Compose v2 not found", + }; +} + +export function checkNodeVersion(): CheckResult { + const major = parseInt(process.version.slice(1), 10); + const ok = major >= 20; + return { + name: "Node.js", + status: ok ? "ok" : "fail", + message: ok + ? `Node.js ${process.version}` + : `Node.js >= 20 required (found: ${process.version})`, + }; +} + +export async function checkPortAvailable( + port: number, + label: string, +): Promise { + const available = await isPortAvailable(port); + return { + name: `Port ${port} (${label})`, + status: available ? "ok" : "warn", + message: available + ? `Port ${port} available` + : `Port ${port} in use — another process may be running`, + }; +} + +export function checkNodeModulesExist(): CheckResult { + const exists = existsSync(`${paths.appDir}/node_modules`); + return { + name: "Dependencies", + status: exists ? "ok" : "warn", + message: exists + ? "app/node_modules exists" + : "app/node_modules missing — run 'neoboard init'", + }; +} + +export function checkEnvFileExists(): CheckResult { + const exists = existsSync(paths.envFile); + return { + name: ".env.local", + status: exists ? "ok" : "warn", + message: exists + ? "app/.env.local exists" + : "app/.env.local missing — run 'neoboard env'", + }; +} + +export async function runDoctor(): Promise { + const config = readProjectConfig(); + const results: CheckResult[] = [ + checkDockerRunning(), + checkDockerComposeV2(), + checkNodeVersion(), + ]; + + const portChecks = await Promise.all([ + checkPortAvailable(config.ports.postgres, "PostgreSQL"), + checkPortAvailable(config.ports.neo4j_http, "Neo4j HTTP"), + checkPortAvailable(config.ports.neo4j_bolt, "Neo4j Bolt"), + checkPortAvailable(config.ports.app, "App"), + ]); + results.push(...portChecks); + + results.push(checkNodeModulesExist()); + results.push(checkEnvFileExists()); + + return results; +} + +export function printResults(results: CheckResult[]): boolean { + let hasFailure = false; + for (const r of results) { + if (r.status === "ok") { + success(r.message); + } else if (r.status === "warn") { + warn(r.message); + } else { + logError(r.message); + hasFailure = true; + } + } + return hasFailure; +} diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts new file mode 100644 index 00000000..aeda090a --- /dev/null +++ b/cli/src/commands/env.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { paths, readProjectConfig, getMode } from "../lib/config.js"; +import { + info, + success, + warn, + error as logError, + banner, +} from "../lib/output.js"; + +const REQUIRED_VARS = [ + "DATABASE_URL", + "ENCRYPTION_KEY", + "NEXTAUTH_SECRET", + "NEXTAUTH_URL", +]; + +function generateSecret(): string { + return randomBytes(32).toString("hex"); +} + +function parseEnvFile(content: string): Record { + const vars: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + vars[key] = value; + } + return vars; +} + +export function validateEnv(): { ok: boolean; missing: string[] } { + if (!existsSync(paths.envFile)) { + return { ok: false, missing: ["(file does not exist)"] }; + } + const content = readFileSync(paths.envFile, "utf-8"); + const vars = parseEnvFile(content); + const missing = REQUIRED_VARS.filter((k) => !vars[k]); + return { ok: missing.length === 0, missing }; +} + +export function generateEnvFile(opts?: { regenerate?: boolean }): void { + if (existsSync(paths.envFile) && !opts?.regenerate) { + info("app/.env.local already exists. Use --regenerate to overwrite."); + return; + } + + const config = readProjectConfig(); + const dbUrl = `postgresql://${config.postgres.user}:${config.postgres.password}@localhost:${config.ports.postgres}/${config.postgres.database}`; + const encryptionKey = generateSecret(); + const nextauthSecret = generateSecret(); + const bootstrapToken = generateSecret(); + + const lines = [ + `DATABASE_URL=${dbUrl}`, + `ENCRYPTION_KEY=${encryptionKey}`, + `NEXTAUTH_SECRET=${nextauthSecret}`, + `NEXTAUTH_URL=http://localhost:${config.ports.app}`, + `ADMIN_BOOTSTRAP_TOKEN=${bootstrapToken}`, + "", + ]; + + writeFileSync(paths.envFile, lines.join("\n")); + success("Generated app/.env.local"); + + banner([ + "Save this token — you'll need it for first-time signup:", + "", + `ADMIN_BOOTSTRAP_TOKEN=${bootstrapToken}`, + ]); +} + +export async function runEnv(opts: { + regenerate?: boolean; + validate?: boolean; +}): Promise { + if (getMode() === "docker") { + info( + "In Docker mode, environment is managed by docker-compose. Not needed.", + ); + return; + } + + if (opts.validate) { + const result = validateEnv(); + if (result.ok) { + success("All required environment variables are set."); + } else { + logError(`Missing variables: ${result.missing.join(", ")}`); + process.exitCode = 1; + } + return; + } + + generateEnvFile({ regenerate: opts.regenerate }); +} diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts new file mode 100644 index 00000000..94d4603c --- /dev/null +++ b/cli/src/commands/init.ts @@ -0,0 +1,46 @@ +import { existsSync, writeFileSync } from "node:fs"; +import { run } from "../lib/exec.js"; +import { + paths, + readProjectConfig, + writeLocalConfig, + type ProjectConfig, +} from "../lib/config.js"; +import { info, success, createSpinner } from "../lib/output.js"; +import { generateEnvFile } from "./env.js"; + +function writeProjectConfig(config: ProjectConfig): void { + writeFileSync(paths.projectConfig, JSON.stringify(config, null, 2) + "\n"); +} + +export async function runInit(opts?: { + mode?: "docker" | "local"; +}): Promise { + const mode = opts?.mode ?? "docker"; + + if (existsSync(paths.projectConfig)) { + info("neoboard.config.json already exists — skipping config generation."); + } else { + const config = readProjectConfig(); // returns defaults + writeProjectConfig(config); + success("Created neoboard.config.json"); + } + + writeLocalConfig({ mode }); + success(`Mode set to '${mode}' in .neoboard.local`); + + if (mode === "local") { + const spinner = createSpinner("Installing dependencies..."); + spinner.start(); + const dirs = [paths.root, paths.appDir]; + for (const dir of dirs) { + run("npm install", { cwd: dir }); + } + spinner.succeed("Dependencies installed"); + + generateEnvFile(); + } + + info(""); + info("Next step: run 'neoboard start' to launch services."); +} diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts new file mode 100644 index 00000000..620b1693 --- /dev/null +++ b/cli/src/commands/setup.ts @@ -0,0 +1,11 @@ +import { runInit } from "./init.js"; +import { runStart } from "./start.js"; +import { success } from "../lib/output.js"; + +export async function runSetup(opts?: { + mode?: "docker" | "local"; +}): Promise { + await runInit(opts); + await runStart(); + success("Setup complete!"); +} diff --git a/cli/src/commands/start.ts b/cli/src/commands/start.ts new file mode 100644 index 00000000..0f7cd20b --- /dev/null +++ b/cli/src/commands/start.ts @@ -0,0 +1,42 @@ +import { composeUp } from "../lib/docker.js"; +import { waitForHealth } from "../lib/health.js"; +import { isPgReady, isNeo4jReady } from "../lib/docker.js"; +import { readProjectConfig, getMode } from "../lib/config.js"; +import { info, success, banner } from "../lib/output.js"; +import { runDoctor, printResults } from "./doctor.js"; +import { runDbMigrate } from "./db/migrate.js"; + +export async function runStart(): Promise { + // 1. Prerequisite checks + const results = await runDoctor(); + const hasFailure = printResults(results); + if (hasFailure) { + process.exitCode = 1; + return; + } + + // 2. Start containers + const mode = getMode(); + const full = mode === "docker"; + info(full ? "Starting full stack..." : "Starting database containers..."); + composeUp({ full }); + + // 3. Wait for health + const config = readProjectConfig(); + await waitForHealth({ check: isPgReady, label: "PostgreSQL" }); + await waitForHealth({ check: isNeo4jReady, label: "Neo4j" }); + + // 4. Run migrations + await runDbMigrate({}); + + // 5. Done + const url = `http://localhost:${config.ports.app}`; + banner([ + "NeoBoard is running!", + "", + `App: ${url}`, + `Neo4j: http://localhost:${config.ports.neo4j_http}`, + `PostgreSQL: localhost:${config.ports.postgres}`, + ]); + success(`Open ${url} in your browser`); +} diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts new file mode 100644 index 00000000..737bf777 --- /dev/null +++ b/cli/src/commands/status.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync } from "node:fs"; +import { composePs, isPgReady, isNeo4jReady } from "../lib/docker.js"; +import { paths, getMode, readProjectConfig } from "../lib/config.js"; +import { info } from "../lib/output.js"; +import { runOrNull } from "../lib/exec.js"; + +function getAppHealth(port: number): string { + const out = runOrNull( + `curl -s -o /dev/null -w "%{http_code}" http://localhost:${port}`, + ); + if (out === "200") return "healthy"; + if (out) return `unhealthy (HTTP ${out})`; + return "not running"; +} + +function getMigrationStatus(): string { + if (!existsSync(paths.journalPath)) return "no journal found"; + try { + const journal = JSON.parse(readFileSync(paths.journalPath, "utf-8")); + const count = journal.entries?.length ?? 0; + const latest = journal.entries?.[count - 1]?.tag ?? "none"; + return `${count} applied (latest: ${latest})`; + } catch { + return "error reading journal"; + } +} + +function getVersion(): string { + try { + const pkg = JSON.parse( + readFileSync(`${paths.root}/cli/package.json`, "utf-8"), + ); + return pkg.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +export async function runStatus(): Promise { + const mode = getMode(); + const config = readProjectConfig(); + const containers = composePs(); + + info(`Mode: ${mode}`); + info(`Version: ${getVersion()}`); + info( + `Docker: ${containers.length > 0 ? `running (${containers.length} containers)` : "no containers"}`, + ); + info(""); + + const pgHealthy = isPgReady(); + const neo4jHealthy = isNeo4jReady(); + const appHealth = getAppHealth(config.ports.app); + + info("Service Status"); + info("\u2500".repeat(30)); + info( + `PostgreSQL ${pgHealthy ? "healthy" : "stopped"} (localhost:${config.ports.postgres})`, + ); + info( + `Neo4j ${neo4jHealthy ? "healthy" : "stopped"} (localhost:${config.ports.neo4j_bolt})`, + ); + info(`App ${appHealth} (http://localhost:${config.ports.app})`); + info(""); + info(`Migrations: ${getMigrationStatus()}`); +} diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts new file mode 100644 index 00000000..76c1f502 --- /dev/null +++ b/cli/src/commands/stop.ts @@ -0,0 +1,7 @@ +import { composeDown } from "../lib/docker.js"; +import { success } from "../lib/output.js"; + +export async function runStop(opts?: { volumes?: boolean }): Promise { + composeDown({ volumes: opts?.volumes }); + success("NeoBoard services stopped"); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index b8cda7c1..d7e35a3c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -12,76 +12,93 @@ const pkg = JSON.parse( readFileSync(join(__dirname, "..", "package.json"), "utf-8"), ); -const program = new Command(); +export const program = new Command(); program .name("neoboard") .description("NeoBoard CLI — local development and database management") .version(pkg.version); -// Top-level stub commands +// Top-level commands program .command("init") .description("Initialize a new NeoBoard project") - .action(() => { - console.log("Not yet implemented — see issue #305"); + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runInit } = await import("./commands/init.js"); + await runInit({ mode: opts.mode }); }); program .command("start") .description("Start NeoBoard services") - .action(() => { - console.log("Not yet implemented — see issue #305"); + .action(async () => { + const { runStart } = await import("./commands/start.js"); + await runStart(); }); program .command("stop") .description("Stop NeoBoard services") - .action(() => { - console.log("Not yet implemented — see issue #305"); + .option("--volumes", "Also remove volumes") + .action(async (opts) => { + const { runStop } = await import("./commands/stop.js"); + await runStop({ volumes: opts.volumes }); }); program .command("dev") .description("Start NeoBoard in development mode") - .action(() => { - console.log("Not yet implemented — see issue #307"); + .action(async () => { + const { runDev } = await import("./commands/dev.js"); + await runDev(); }); program .command("setup") - .description("Set up local development environment") - .action(() => { - console.log("Not yet implemented — see issue #305"); + .description("Set up local development environment (init + start)") + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runSetup } = await import("./commands/setup.js"); + await runSetup({ mode: opts.mode }); }); program .command("status") .description("Show status of NeoBoard services") - .action(() => { - console.log("Not yet implemented — see issue #308"); + .action(async () => { + const { runStatus } = await import("./commands/status.js"); + await runStatus(); }); program .command("doctor") .description("Check system prerequisites and configuration") - .action(() => { - console.log("Not yet implemented — see issue #303"); + .action(async () => { + const { runDoctor, printResults } = await import("./commands/doctor.js"); + const results = await runDoctor(); + const hasFailure = printResults(results); + if (hasFailure) process.exitCode = 1; }); program .command("demo") .description("Load demo data and dashboards") - .action(() => { - console.log("Not yet implemented — see issue #309"); + .option("--mode ", "Set mode: docker or local", "docker") + .action(async (opts) => { + const { runDemo } = await import("./commands/demo.js"); + await runDemo({ mode: opts.mode }); }); program .command("env") .description("Manage environment variables") - .action(() => { - console.log("Not yet implemented — see issue #304"); + .option("--regenerate", "Force regenerate all secrets") + .option("--validate", "Check all required vars are set") + .action(async (opts) => { + const { runEnv } = await import("./commands/env.js"); + await runEnv({ regenerate: opts.regenerate, validate: opts.validate }); }); // db subcommand group @@ -90,26 +107,50 @@ const db = program.command("db").description("Database management commands"); db.command("migrate") .description("Run database migrations") - .action(() => { - console.log("Not yet implemented — see issue #306"); + .option("--status", "Show migration status") + .option("--to ", "Target version") + .option("--dry-run", "Preview without applying") + .action(async (opts) => { + const { runDbMigrate } = await import("./commands/db/migrate.js"); + await runDbMigrate({ + status: opts.status, + to: opts.to, + dryRun: opts.dryRun, + }); }); db.command("reset") .description("Reset database to clean state") - .action(() => { - console.log("Not yet implemented — see issue #310"); + .option("--no-seed", "Skip seeding after reset") + .option("--force", "Skip confirmation prompt") + .action(async (opts) => { + const { runDbReset } = await import("./commands/db/reset.js"); + await runDbReset({ noSeed: !opts.seed, force: opts.force }); }); db.command("seed") .description("Seed database with sample data") - .action(() => { - console.log("Not yet implemented — see issue #309"); + .option("--neo4j", "Seed Neo4j graph data only") + .option("--demo", "Seed demo user/dashboards only") + .action(async (opts) => { + const { runDbSeed } = await import("./commands/db/seed.js"); + await runDbSeed({ neo4j: opts.neo4j, demo: opts.demo }); }); db.command("dump") .description("Dump database contents") - .action(() => { - console.log("Not yet implemented — see issue #311"); + .option("--output ", "Output file path") + .option("--data-only", "Dump data only, no schema") + .action(async (opts) => { + const { runDbDump } = await import("./commands/db/dump.js"); + await runDbDump({ output: opts.output, dataOnly: opts.dataOnly }); }); -program.parse(); +// Only parse when run directly (not when imported in tests) +const isDirectRun = + process.argv[1]?.endsWith("index.js") || + process.argv[1]?.endsWith("neoboard"); + +if (isDirectRun) { + program.parse(); +} diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 4ba9e306..70a8f44d 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -46,6 +46,11 @@ function root(): string { return _root; } +/** @internal — test-only helper to override cached root */ +export function _setRootForTesting(dir: string | null): void { + _root = dir; +} + export const paths = { get root() { return root(); diff --git a/cli/src/lib/docker.ts b/cli/src/lib/docker.ts new file mode 100644 index 00000000..860a9661 --- /dev/null +++ b/cli/src/lib/docker.ts @@ -0,0 +1,80 @@ +import { run, runOrNull } from "./exec.js"; +import { paths, readProjectConfig } from "./config.js"; +import { join } from "node:path"; + +export function isDockerRunning(): boolean { + return runOrNull("docker info") !== null; +} + +export function isComposeV2(): boolean { + const out = runOrNull("docker compose version"); + return out !== null && out.includes("v2"); +} + +export function composeFile(full = false): string { + const name = full ? "docker-compose.full.yml" : "docker-compose.yml"; + return join(paths.dockerDir, name); +} + +export function composeUp(opts?: { full?: boolean }): void { + const file = composeFile(opts?.full); + run(`docker compose -f ${file} up -d --build`, { cwd: paths.root }); +} + +export function composeDown(opts?: { volumes?: boolean }): void { + const file = composeFile(); + const flags = opts?.volumes ? " -v" : ""; + run(`docker compose -f ${file} down${flags}`, { cwd: paths.root }); +} + +export interface ContainerInfo { + name: string; + state: string; + status: string; +} + +export function composePs(): ContainerInfo[] { + const file = composeFile(); + const out = runOrNull(`docker compose -f ${file} ps --format json`, { + cwd: paths.root, + }); + if (!out) return []; + try { + // docker compose ps --format json outputs one JSON object per line + return out + .split("\n") + .filter(Boolean) + .map((line) => { + const obj = JSON.parse(line); + return { + name: obj.Name ?? obj.name ?? "", + state: obj.State ?? obj.state ?? "", + status: obj.Status ?? obj.status ?? "", + }; + }); + } catch { + return []; + } +} + +export function dockerExec(container: string, cmd: string): string { + return run(`docker exec ${container} ${cmd}`); +} + +export function isPgReady(): boolean { + const config = readProjectConfig(); + return ( + runOrNull( + `docker exec neoboard-postgres pg_isready -U ${config.postgres.user}`, + ) !== null + ); +} + +export function isNeo4jReady(): boolean { + const config = readProjectConfig(); + return ( + runOrNull( + `docker exec neoboard-neo4j cypher-shell -u ${config.neo4j.user} -p ${config.neo4j.password} "RETURN 1"`, + ) !== null + ); +} diff --git a/cli/src/lib/exec.ts b/cli/src/lib/exec.ts new file mode 100644 index 00000000..92c3a7e5 --- /dev/null +++ b/cli/src/lib/exec.ts @@ -0,0 +1,51 @@ +import { execSync, spawn as nodeSpawn } from "node:child_process"; +import type { SpawnOptions, ChildProcess } from "node:child_process"; + +export class ExecError extends Error { + constructor( + public readonly cmd: string, + public readonly exitCode: number, + public readonly stderr: string, + ) { + super(`Command failed (exit ${exitCode}): ${cmd}\n${stderr}`); + this.name = "ExecError"; + } +} + +export interface RunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeout?: number; +} + +export function run(cmd: string, opts?: RunOptions): string { + try { + const result = execSync(cmd, { + cwd: opts?.cwd, + env: opts?.env ?? process.env, + timeout: opts?.timeout, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + return result.trim(); + } catch (err: unknown) { + const e = err as { status?: number; stderr?: string | Buffer }; + throw new ExecError(cmd, e.status ?? 1, String(e.stderr ?? "").trim()); + } +} + +export function runOrNull(cmd: string, opts?: RunOptions): string | null { + try { + return run(cmd, opts); + } catch { + return null; + } +} + +export function spawn( + cmd: string, + args: string[], + opts?: SpawnOptions, +): ChildProcess { + return nodeSpawn(cmd, args, { stdio: "inherit", ...opts }); +} diff --git a/cli/src/lib/health.ts b/cli/src/lib/health.ts new file mode 100644 index 00000000..c393865d --- /dev/null +++ b/cli/src/lib/health.ts @@ -0,0 +1,26 @@ +import { createSpinner } from "./output.js"; + +export interface HealthCheckOptions { + check: () => boolean; + label: string; + interval?: number; + timeout?: number; +} + +export async function waitForHealth(opts: HealthCheckOptions): Promise { + const { check, label, interval = 1000, timeout = 60_000 } = opts; + const spinner = createSpinner(`Waiting for ${label}...`); + spinner.start(); + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (check()) { + spinner.succeed(`${label} is ready`); + return; + } + await new Promise((r) => setTimeout(r, interval)); + } + + spinner.fail(`${label} did not become ready within ${timeout / 1000}s`); + throw new Error(`Timeout waiting for ${label}`); +} diff --git a/cli/src/lib/ports.ts b/cli/src/lib/ports.ts new file mode 100644 index 00000000..332e1534 --- /dev/null +++ b/cli/src/lib/ports.ts @@ -0,0 +1,12 @@ +import { createServer } from "node:net"; + +export function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} diff --git a/cli/src/lib/prompt.ts b/cli/src/lib/prompt.ts new file mode 100644 index 00000000..cda81f35 --- /dev/null +++ b/cli/src/lib/prompt.ts @@ -0,0 +1,14 @@ +import { createInterface } from "node:readline"; + +export function confirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y"); + }); + }); +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json index df0a20b3..514307e1 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -9,5 +9,6 @@ "declaration": true, "skipLibCheck": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/__tests__/**"] } diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 00000000..2a52742d --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/__tests__/**/*.test.ts"], + coverage: { + provider: "v8", + reportsDirectory: "./coverage", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/**/*.d.ts"], + }, + }, +}); diff --git a/package.json b/package.json index 4dddd3c6..3d925aae 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "scripts": { "dev": "npm run dev --prefix app", "build": "npm run build --prefix app", - "test": "npm run test --prefix app && npm run test --prefix component", + "test": "npm run test --prefix app && npm run test --prefix component && npm run test --prefix cli", "test:app": "npm run test --prefix app", "test:components": "npm run test --prefix component", + "test:cli": "npm run test --prefix cli", "test:e2e": "npm run test:e2e --prefix app", "storybook": "npm run storybook --prefix component", "lint": "eslint .", From 26632066ce04812d7e7dc093059b42359770d24f Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Thu, 2 Apr 2026 23:48:04 +0200 Subject: [PATCH 3/7] chore(cli): add CLI package to CI pipeline and SonarCloud config - Add cli/ to CI path triggers, install, test, and coverage upload - Add cli/src to sonar.sources and sonar.tests - Add cli/coverage/lcov.info to sonar coverage report paths Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 10 ++++++++++ sonar-project.properties | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0649e8a8..4485dabb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - 'app/**' - 'component/**' - 'connection/**' + - 'cli/**' - 'sonar-project.properties' - 'Dockerfile' - '.github/workflows/ci.yml' @@ -16,6 +17,7 @@ on: - 'app/**' - 'component/**' - 'connection/**' + - 'cli/**' - 'sonar-project.properties' - 'Dockerfile' - '.github/workflows/ci.yml' @@ -120,6 +122,7 @@ jobs: app/package-lock.json component/package-lock.json connection/package-lock.json + cli/package-lock.json - name: Install dependencies run: | @@ -129,11 +132,14 @@ jobs: COMP_CI_PID=$! npm ci --prefix connection & CONN_CI_PID=$! + npm ci --prefix cli & + CLI_CI_PID=$! FAIL=0 wait $APP_CI_PID || FAIL=1 wait $COMP_CI_PID || FAIL=1 wait $CONN_CI_PID || FAIL=1 + wait $CLI_CI_PID || FAIL=1 exit $FAIL - name: Run all tests in parallel @@ -144,11 +150,14 @@ jobs: COMP_PID=$! cd connection && npm run test:coverage & CONN_PID=$! + cd cli && npm run test:coverage & + CLI_PID=$! FAIL=0 wait $APP_PID || FAIL=1 wait $COMP_PID || FAIL=1 wait $CONN_PID || FAIL=1 + wait $CLI_PID || FAIL=1 exit $FAIL env: POSTGRES_HOST: localhost @@ -170,6 +179,7 @@ jobs: app/coverage/lcov.info component/coverage/lcov.info connection/coverage/lcov.info + cli/coverage/lcov.info # ── Job 2: E2E tests — 5 shards with isolated containers ────────────────── # Each shard gets its own runner + Testcontainers (no shared state). diff --git a/sonar-project.properties b/sonar-project.properties index 0419b504..5978b0eb 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,10 +3,10 @@ sonar.organization=alfredo1996 sonar.projectName=NeoBoard # Sources -sonar.sources=app/src,component/src,connection/src +sonar.sources=app/src,component/src,connection/src,cli/src # Tests -sonar.tests=app/src,component/src,connection/__tests__ +sonar.tests=app/src,component/src,connection/__tests__,cli/src # Only scan these three packages — everything else (scripts/, stress/, docker/, etc.) is excluded sonar.exclusions=\ @@ -64,7 +64,8 @@ sonar.javascript.lcov.reportPaths=\ app/coverage/lcov.info,\ app/coverage-e2e/lcov.info,\ component/coverage/lcov.info,\ - connection/coverage/lcov.info + connection/coverage/lcov.info,\ + cli/coverage/lcov.info # TypeScript configs sonar.typescript.tsconfigPath=app/tsconfig.json From d63cbe9c324f73268d0d79dc9eb5b6fe9dd32fec Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 3 Apr 2026 00:06:06 +0200 Subject: [PATCH 4/7] fix(cli): remove unused imports flagged by eslint Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/db/seed.ts | 2 +- cli/src/commands/env.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/commands/db/seed.ts b/cli/src/commands/db/seed.ts index b27b3c81..fdb63cff 100644 --- a/cli/src/commands/db/seed.ts +++ b/cli/src/commands/db/seed.ts @@ -1,7 +1,7 @@ import { run } from "../../lib/exec.js"; import { dockerExec } from "../../lib/docker.js"; import { paths, readProjectConfig } from "../../lib/config.js"; -import { info, success, warn, createSpinner } from "../../lib/output.js"; +import { success, createSpinner } from "../../lib/output.js"; function getNeo4jNodeCount(): number { const config = readProjectConfig(); diff --git a/cli/src/commands/env.ts b/cli/src/commands/env.ts index aeda090a..1e6b3e72 100644 --- a/cli/src/commands/env.ts +++ b/cli/src/commands/env.ts @@ -4,7 +4,6 @@ import { paths, readProjectConfig, getMode } from "../lib/config.js"; import { info, success, - warn, error as logError, banner, } from "../lib/output.js"; From 52e0e00e11911a4443703329b0a11eae249096e3 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 3 Apr 2026 13:36:44 +0200 Subject: [PATCH 5/7] fix(cli): suppress SonarCloud false positive for dev-only default passwords These are local development defaults matching docker-compose, not production credentials. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/lib/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 70a8f44d..5085915e 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -91,8 +91,8 @@ export const paths = { // Config defaults const DEFAULT_PROJECT_CONFIG: ProjectConfig = { ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, - postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, - neo4j: { user: "neo4j", password: "neoboard123" }, + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, // NOSONAR — local dev defaults matching docker-compose + neo4j: { user: "neo4j", password: "neoboard123" }, // NOSONAR — local dev defaults matching docker-compose seed: { script: "scripts/seed-demo.mjs", neo4j_cypher: "docker/neo4j/init.cypher", From 2d125b668ee76d8421591bec6391450c0888d5b7 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 3 Apr 2026 13:37:18 +0200 Subject: [PATCH 6/7] fix(cli): suppress SonarCloud execSync security hotspot CLI commands are internally constructed, not user-supplied input. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/lib/exec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/lib/exec.ts b/cli/src/lib/exec.ts index 92c3a7e5..a494708b 100644 --- a/cli/src/lib/exec.ts +++ b/cli/src/lib/exec.ts @@ -21,6 +21,7 @@ export interface RunOptions { export function run(cmd: string, opts?: RunOptions): string { try { const result = execSync(cmd, { + // NOSONAR — CLI commands are not user-supplied cwd: opts?.cwd, env: opts?.env ?? process.env, timeout: opts?.timeout, From 2f5d01534a8787c138e2216df9868794b79d5657 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Fri, 3 Apr 2026 21:14:51 +0200 Subject: [PATCH 7/7] revert: remove NOSONAR comments from CLI lib files Will resolve security hotspots via SonarCloud UI instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/lib/config.ts | 4 ++-- cli/src/lib/exec.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index 5085915e..70a8f44d 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -91,8 +91,8 @@ export const paths = { // Config defaults const DEFAULT_PROJECT_CONFIG: ProjectConfig = { ports: { app: 3000, postgres: 5432, neo4j_http: 7474, neo4j_bolt: 7687 }, - postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, // NOSONAR — local dev defaults matching docker-compose - neo4j: { user: "neo4j", password: "neoboard123" }, // NOSONAR — local dev defaults matching docker-compose + postgres: { user: "neoboard", password: "neoboard", database: "neoboard" }, + neo4j: { user: "neo4j", password: "neoboard123" }, seed: { script: "scripts/seed-demo.mjs", neo4j_cypher: "docker/neo4j/init.cypher", diff --git a/cli/src/lib/exec.ts b/cli/src/lib/exec.ts index a494708b..92c3a7e5 100644 --- a/cli/src/lib/exec.ts +++ b/cli/src/lib/exec.ts @@ -21,7 +21,6 @@ export interface RunOptions { export function run(cmd: string, opts?: RunOptions): string { try { const result = execSync(cmd, { - // NOSONAR — CLI commands are not user-supplied cwd: opts?.cwd, env: opts?.env ?? process.env, timeout: opts?.timeout,