import { initProject } from "./commands/init.js"; import { serveProject } from "./commands/serve.js"; import { runMigrations } from "./commands/migrate.js"; import { createMigration } from "./commands/migrateCreate.js"; import { migrateStatus } from "./commands/migrateStatus.js"; import { migrateRollback } from "./commands/migrateRollback.js"; import { listRoutes } from "./commands/routes.js"; import { runTests } from "./commands/test.js"; import { runLint } from "./commands/lint.js"; import { generate, GENERATORS, RESOLUTION_ENVELOPE_VERSION } from "./commands/generate.js"; import { runSeeds } from "./commands/seed.js"; import { queueCommand, QUEUE_SUBCOMMAND_NAMES } from "./commands/queue.js"; import { buildImage } from "./commands/build.js"; import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { delimiter, dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; // ── Version (cheap, side-effect-free) ─────────────────────────────── // // Walk up from this file to the nearest package.json and read its version. // From the source tree that is packages/cli/package.json; from the built // dist/bin.js (or the published `tina4nodejs` package) it is the CLI package's // own package.json — the framework version in every layout. Only touches the // filesystem (JSON reads); it never bootstraps the app or opens a DB. function readCliVersion(): string { let dir = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 6; i++) { const pkgPath = join(dir, "package.json"); if (existsSync(pkgPath)) { try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); if (typeof pkg.version === "string" && pkg.version) return pkg.version; } catch { // keep walking — a malformed package.json isn't ours } } const parent = dirname(dir); if (parent === dir) break; dir = parent; } return "0.0.0"; } // ── Port-takeover helper ──────────────────────────────────────────── // // The identity check, PID safety filter, container guard, dev gate and opt-out // all live in ONE shared module (@tina4/core portTakeover) so this CLI path and // the runtime bind-failure fallback in core/server.ts cannot diverge // (TAKEOVER-DEC-02). Loaded lazily (mirroring how serve.ts pulls in core) so a // quick `tina4nodejs --help` never pays to import it. /** * Reclaim `port` from a stale Tina4 dev server, only when it is safe. * * Signals a holder ONLY when a Tina4 dev server recorded its PID in the per-port * PID file (TAKEOVER-DEC-01). A foreign holder is left running and a clear * message is printed; takeover is also skipped in a container, outside dev mode, * and when opted out (`TINA4_NO_TAKEOVER` / `tina4 serve --no-kill`). * * Returns true only when a Tina4 holder was actually signalled. */ async function killProcessOnPort(port: number): Promise { const { takeOverPort, isDev, noTakeoverOptedOut, TAKEOVER_KILLED, TAKEOVER_REFUSALS } = await import("../../core/src/portTakeover.js"); const result = takeOverPort(port, isDev(), noTakeoverOptedOut()); if (result.status === TAKEOVER_KILLED) { console.log(` ${result.message}`); return true; } if (result.message && TAKEOVER_REFUSALS.includes(result.status)) { console.log(` ${result.message}`); } return false; } // ── Self-describing command surface ───────────────────────────────── export interface CommandManifestEntry { name: string; summary: string; args?: string[]; subcommands?: string[]; /** True when the tina4 client implements this command, not the framework. */ delegated?: boolean; } /** * A stable, machine-readable pointer to the `generate` resolution envelope * this framework speaks. Consumers (the tina4 client, an AI agent, a * downstream tool) MUST NOT hard-code an envelope shape — instead they read * `resolution_contract.envelope` from this manifest and follow its version. * `version` bumps on any breaking key rename or removal; `envelope` is the * name of the schema (currently `generate_v1`). */ export interface ResolutionContract { version: string; envelope: string; } export interface CommandManifest { framework: string; version: string; commands: CommandManifestEntry[]; resolution_contract: ResolutionContract; } /** * Build the machine-readable manifest of the CLI's command surface. * * Pure data: reads the module-level COMMANDS and DELEGATED registries plus the * framework version — no bootstrap, no database, no migrations, no app imports. * This is exactly what `commands --json` serialises and what the tina4 Rust * client consumes to discover which commands this framework supports. * * Commands handed to the `tina4` client carry `delegated: true`, so the manifest * describes the WHOLE surface the CLI accepts while still saying who implements * each one. The client needs no change: its help renderer already drops manifest * names that clash with its own natives. * * Shape (identical keys to the Python master): * { framework: "nodejs", version: "", * commands: [{ name, summary, args?, subcommands?, delegated? }, ...] } */ export function buildCommandManifest(): CommandManifest { const commands: CommandManifestEntry[] = Object.entries(COMMANDS).map(([name, spec]) => { const entry: CommandManifestEntry = { name, summary: spec.summary }; if (spec.args && spec.args.length) entry.args = [...spec.args]; if (spec.subcommands && spec.subcommands.length) entry.subcommands = [...spec.subcommands]; return entry; }); for (const [name, spec] of Object.entries(DELEGATED)) { const entry: CommandManifestEntry = { name, summary: spec.summary, delegated: true }; if (spec.args && spec.args.length) entry.args = [...spec.args]; commands.push(entry); } return { framework: "nodejs", version: readCliVersion(), commands, // Declare the resolution envelope this framework emits for // `generate --json`. Consumers read this to know which schema to // parse — never hard-code the shape. // 3.13.117: `generate_v1` at version "1" // 3.13.120: `generate_v1_1` at version "1.1" — additive superset // (adds edit_hints[] + next[]; surfaces existing test_paths[] // in the human block). ADR-0063. resolution_contract: { version: "1.1", envelope: RESOLUTION_ENVELOPE_VERSION }, }; } /** * Emit the CLI's own command surface — the self-describing manifest. * * tina4nodejs commands human-readable list * tina4nodejs commands --json machine-readable manifest (for the tina4 CLI) * * CHEAP + side-effect-free by contract: it only prints the static COMMANDS * registry plus the framework version. It MUST NOT bootstrap the framework, * open a database, run migrations, or import app modules — the Rust client * calls this on `tina4 --help`, in any directory, so it must be instant and * safe to run anywhere. */ export function runCommands(args: string[] = []): void { const manifest = buildCommandManifest(); if (args.includes("--json")) { console.log(JSON.stringify(manifest, null, 2)); return; } console.log(`\n Tina4 ${manifest.framework} — ${manifest.version}\n`); const width = Math.max(...manifest.commands.map((c) => c.name.length)); for (const c of manifest.commands) { const marker = c.delegated ? ` (${CLIENT_BINARY} client)` : ""; console.log(` ${c.name.padEnd(width)} ${c.summary}${marker}`); if (c.subcommands && c.subcommands.length) { console.log(` ${" ".repeat(width)} ${c.subcommands.join(", ")}`); } } console.log(""); } /** * Print the human-readable command reference. * * Generated from the COMMANDS, DELEGATED and GENERATORS registries — the SAME * single source of truth that drives dispatch (`main`) and the `commands --json` * manifest — so the help text can never drift from what the CLI actually does. */ function printHelp(): void { const commandRows: [string, string][] = Object.entries(COMMANDS).map( ([name, spec]) => [`${name}${spec.usage ? " " + spec.usage : ""}`, spec.summary], ); const delegatedRows: [string, string][] = Object.entries(DELEGATED).map( ([name, spec]) => [`${name}${spec.usage ? " " + spec.usage : ""}`, spec.summary], ); const generatorRows: [string, string][] = Object.entries(GENERATORS).map( ([name, spec]) => [`generate ${name}${spec.usage ? " " + spec.usage : ""}`, spec.summary], ); // Align summaries in a column; a left cell longer than the cap overflows // cleanly (2-space gap) rather than pushing every other summary out. const pad = Math.min(46, Math.max(...[...commandRows, ...delegatedRows, ...generatorRows].map(([left]) => left.length))); const row = (left: string, summary: string): string => { const gap = left.length <= pad ? pad : left.length; return ` ${left.padEnd(gap)} ${summary}`; }; const lines: string[] = [ "", " tina4nodejs — The Intelligent Native Application 4ramework", "", " Usage: tina4nodejs [options]", "", " Commands:", ...commandRows.map(([left, summary]) => row(left, summary)), "", ` Delegated to the ${CLIENT_BINARY} client (same behaviour in every framework):`, ...delegatedRows.map(([left, summary]) => row(left, summary)), ` (these run the ${CLIENT_BINARY} client — install: curl -fsSL https://tina4.com/install.sh | sh)`, "", " Generators:", ...generatorRows.map(([left, summary]) => row(left, summary)), "", " Scaffolding-first: logic-shaped generators (route without --model, service,", " queue, validator, seeder, websocket, listener) emit real wiring + an AI-FILL", " placeholder (throws until filled); CRUD-shaped ones emit working code. Writes", " are secure by default — use --public to open them.", "", " Field types: string, int, float, bool, text, datetime", " Table names: singular by default (Product → product)", "", " Options:", " --port Server port (default: 7148)", " --no-browser Don't open the browser on serve", " --no-reload Disable file watcher / live-reload on serve", " --all Install AI context for all tools (with ai command)", " --force Overwrite existing AI context files (with ai command)", " --help Show this help message", "", " https://tina4.com", "", ]; console.log(lines.join("\n")); } // ── Console REPL (heavy — imports the framework lazily on demand) ──── async function openConsole(): Promise { const repl = await import("node:repl"); const { loadEnv, Router, Log } = await import("../../core/src/index.js"); const { initDatabase, Database } = await import("../../orm/src/index.js"); loadEnv(); const dbUrl = process.env.TINA4_DATABASE_URL; let db: unknown = null; if (dbUrl) { try { db = await initDatabase({ url: dbUrl }); } catch { console.warn(" Warning: could not connect to database — db will be null"); } } console.log("\n Tina4 Node.js Console"); console.log(" Type JavaScript. Framework is loaded."); console.log(" Available: db, Router, Database, Log"); console.log(" Exit: Ctrl+D or .exit\n"); const r = repl.start({ prompt: "tina4> " }); r.context.Router = Router; r.context.Database = Database; r.context.Log = Log; r.context.db = db; await new Promise((resolve) => r.on("exit", resolve)); } async function installAiContext(args: string[]): Promise { const { showMenu, installSelected, installAll } = await import("../../core/src/ai.js"); const root = args[0] || "."; if (args.includes("--all")) { installAll(root); } else { const selection = await showMenu(root); if (selection) { installSelected(root, selection); } } } // ── Command registry — the single source of truth ─────────────────── // // One entry per command drives main() dispatch, the human help (printHelp), // AND the machine-readable manifest (commands --json). Add a command in ONE // place and it appears in dispatch, help, and discovery — there is no second // list to sync. Mirrors the Python master's COMMANDS registry // (tina4_python/cli/__init__.py). // // COMMANDS[name] = { // handler: (cmdArgs) => …, // args AFTER the command name // summary: string, // usage?: string, // arg/flag hint for printHelp (human only) // args?: string[], // positional args for the manifest ("x?" = optional) // subcommands?: string[], // sub-names for the manifest (generate) // } export interface CommandSpec { handler: (cmdArgs: string[]) => void | Promise; summary: string; usage?: string; args?: string[]; subcommands?: string[]; } export const COMMANDS: Record = { init: { handler: async (a) => { await initProject(a[0] || "."); }, usage: "[dir]", args: ["dir?"], summary: "Create a new Tina4 project (default: current directory)", }, serve: { handler: async (a) => { const portIndex = a.indexOf("--port"); const port = portIndex !== -1 ? parseInt(a[portIndex + 1], 10) : 7148; const noBrowser = a.includes("--no-browser"); const noReload = a.includes("--no-reload"); // --no-kill opts out of port takeover for the whole process, so the CLI // path here AND the runtime bind-failure fallback both honour it // (TAKEOVER-DEC-03). if (a.includes("--no-kill")) process.env.TINA4_NO_TAKEOVER = "true"; await killProcessOnPort(port); await serveProject({ port, noBrowser, noReload }); }, usage: "[--port P] [--no-browser] [--no-reload]", summary: "Start the dev server with hot-reload (default: 0.0.0.0:7148)", }, migrate: { handler: async (a) => { await runMigrations(a[0]); }, summary: "Run pending SQL migrations", }, "migrate:create": { // Pass the full argv through so `migrate:create "add users" --json --dry-run` // reaches the same envelope machinery as `generate migration ...` (ADR-0063). // The delegation itself lives in commands/migrateCreate.ts. handler: async (a) => { await createMigration(a); }, usage: "", args: ["description"], summary: "Create a new migration file (delegates to generate migration)", }, "migrate:status": { handler: async (a) => { await migrateStatus(a[0]); }, summary: "Show completed and pending migrations", }, "migrate:rollback": { handler: async (a) => { await migrateRollback(a[0]); }, summary: "Roll back the last batch of migrations", }, routes: { handler: async () => { await listRoutes(); }, summary: "List all registered routes", }, test: { handler: async (a) => { await runTests(a[0]); }, usage: "[file]", summary: "Run project tests", }, lint: { handler: (a) => { runLint(a); }, usage: "[--fix] [--no-install]", summary: "Lint the project (eslint, installed dev-only on demand; else tsc/node --check baseline)", }, queue: { handler: async (a) => { await queueCommand(a); }, usage: " [topic]", subcommands: QUEUE_SUBCOMMAND_NAMES, summary: "Run queue workers and manage jobs", }, build: { handler: (a) => { buildImage(a); }, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image", }, generate: { handler: async (a) => { await generate(a[0], a[1] || "", a.slice(2)); }, usage: " [options]", subcommands: Object.keys(GENERATORS), summary: "Generate scaffolding (see Generators below)", }, seed: { handler: async (a) => { await runSeeds(a[0]); }, usage: "[file]", summary: "Run database seed files from src/seeds/", }, console: { handler: async () => { await openConsole(); }, summary: "Open an interactive REPL with the framework loaded", }, ai: { handler: async (a) => { await installAiContext(a); }, usage: "[--all]", summary: "Install AI coding assistant context files", }, commands: { handler: (a) => { runCommands(a); }, usage: "[--json]", summary: "List available commands (add --json for the machine manifest)", }, help: { handler: () => { printHelp(); }, summary: "Show this help message", }, }; // ── Delegation to the `tina4` client ──────────────────────────────── // // `doctor`, `setup` and `deploy` are owned by the Rust `tina4` client, not by // any framework. `doctor` probes ALL FOUR runtimes plus package managers, ports // and global AI-skills currency; `setup` installs language runtimes (Homebrew / // Chocolatey, with UAC elevation on Windows) and scaffolds a project from // nothing; `deploy` writes deployment boilerplate baked into the client binary. // Cloning any of them into four languages would duplicate hundreds of lines per // language for zero new capability — and four copies would immediately drift. // // So the framework CLI DELEGATES: it resolves `tina4` on PATH, runs it with the // same argv, and exits with the client's exit code. All four frameworks reach // the SAME implementation, which is a stronger parity guarantee than four ports. // // Delegation is ALLOW-LISTED, never blind. The client forwards ITS unknown // commands to the framework CLI, so a framework that forwarded its unknowns back // would ping-pong an unknown command between two processes forever — and that is // not hypothetical here: this package publishes a `tina4` bin alias, so `tina4` // on PATH can already resolve to THIS CLI. The closed DELEGATED set contains only // commands the client dispatches natively, so no loop is possible by // construction, and a real typo still gets "Unknown command". // // There are no handlers here: main() runs `tina4 ` and exits with // its code. Keep this set closed and identical in all four frameworks. Summaries // are the client's own wording, verbatim. Mirrors the Python master's DELEGATED. export interface DelegatedSpec { summary: string; usage?: string; args?: string[]; } export const DELEGATED: Record = { doctor: { summary: "Check installed languages and tools" }, setup: { summary: "Guided, menu-driven setup: install everything + scaffold a ready-to-run project" }, deploy: { usage: " [--force]", args: ["target"], summary: "Generate deployment scaffolding (Dockerfile, systemd unit, nginx block, cPanel)", }, }; export const CLIENT_BINARY = "tina4"; // Internal process marker (same class as the client's own TINA4_SETUP_ELEVATED): // set on the child so a client that resolves back to a framework CLI is caught // instead of spawning forever. NOT user configuration — deliberately absent from // the CLI's known_vars(). export const DELEGATION_GUARD_ENV = "TINA4_CLI_DELEGATED"; // 127 is the conventional "command not found" and covers both ways the client // can be unreachable (absent from PATH, or the loop guard tripping). export const EXIT_CLIENT_UNAVAILABLE = 127; export const EXIT_UNKNOWN_COMMAND = 1; const CLIENT_INSTALL_HINT = " Install it: curl -fsSL https://tina4.com/install.sh | sh\n" + " Windows: irm https://tina4.com/install.ps1 | iex"; /** * Absolute path of the `tina4` client on PATH, or null if it isn't there. * * Scans PATH directly rather than shelling out to which/where — one less process * and it behaves the same on every platform. */ function findClient(): string | null { const windows = process.platform === "win32"; const names = windows ? [`${CLIENT_BINARY}.exe`, `${CLIENT_BINARY}.cmd`, `${CLIENT_BINARY}.bat`] : [CLIENT_BINARY]; for (const dir of (process.env.PATH ?? "").split(delimiter)) { if (!dir) continue; for (const name of names) { const candidate = join(dir, name); try { if (statSync(candidate).isFile()) return candidate; } catch { // not there — keep looking } } } return null; } /** * Run `tina4 `, returning the client's exit code. * * Returns EXIT_CLIENT_UNAVAILABLE (127) with an actionable message when the * client is not on PATH, or when the re-entry guard shows the resolved `tina4` * came back to a framework CLI (a delegation loop). */ export function delegateToClient(command: string, args: string[]): number { if (process.env[DELEGATION_GUARD_ENV] === command) { console.error( ` Refusing to delegate '${command}' again — the 'tina4' on your PATH\n` + ` resolved back to a framework CLI instead of the tina4 client.\n\n` + ` Check which 'tina4' comes first on your PATH and put the client first.`, ); return EXIT_CLIENT_UNAVAILABLE; } const client = findClient(); if (client === null) { console.error( ` '${command}' is provided by the tina4 client, which is not on your PATH.\n\n` + `${CLIENT_INSTALL_HINT}\n\n` + ` Then run: ${CLIENT_BINARY} ${command}`, ); return EXIT_CLIENT_UNAVAILABLE; } // stdio is inherited, so the client's interactive prompts (setup) and colour // output work exactly as if it had been invoked directly. const result = spawnSync(client, [command, ...args], { stdio: "inherit", env: { ...process.env, [DELEGATION_GUARD_ENV]: command }, }); if (result.error) { console.error(` Could not start the tina4 client at ${client}: ${result.error.message}`); return EXIT_CLIENT_UNAVAILABLE; } return result.status ?? EXIT_CLIENT_UNAVAILABLE; } // ── Dispatch ──────────────────────────────────────────────────────── async function main(): Promise { const args = process.argv.slice(2); let command = args[0]; const cmdArgs = args.slice(1); // No command or a bare help flag → the help command. if (command === undefined || command === "--help" || command === "-h") { command = "help"; } const spec = COMMANDS[command]; if (spec) { await spec.handler(cmdArgs); return; } if (command in DELEGATED) { process.exit(delegateToClient(command, cmdArgs)); } // A genuinely unknown command is an ERROR: exit non-zero so a typo in a script // or CI step fails loudly instead of reporting success. console.error(`Unknown command: ${command}`); printHelp(); process.exit(EXIT_UNKNOWN_COMMAND); } // Run only when invoked as the entrypoint — importing this module (e.g. in a // test to inspect COMMANDS / buildCommandManifest) must NOT dispatch a command. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((err) => { console.error(err); process.exit(1); }); }