From b15fc993f2724db159142659b6a52fcddb384f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 23 Apr 2026 07:00:15 +0000 Subject: [PATCH] feat(cli): add nerve sense schema and query commands Open each sense SQLite file read-only under data/senses. schema lists CREATE TABLE SQL from sqlite_master; query runs optional SQL or a default SELECT ordered by rowid. Human output uses aligned columns; --json for machine-readable output. Add better-sqlite3 to the CLI package and externalize it in tsup. Tests cover sense-sqlite helpers and integration against a temp database. Made-with: Cursor --- packages/cli/package.json | 1 + .../cli/src/__tests__/sense-sqlite.test.ts | 159 ++++++++++++++++++ packages/cli/src/commands/sense.ts | 123 ++++++++++++++ packages/cli/src/sense-sqlite.ts | 122 ++++++++++++++ packages/cli/tsup.config.ts | 2 +- pnpm-lock.yaml | 3 + 6 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/sense-sqlite.test.ts create mode 100644 packages/cli/src/sense-sqlite.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 51f39f5..2fea625 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@uncaged/nerve-core": "workspace:*", + "better-sqlite3": "^11.10.0", "citty": "^0.1.6" }, "devDependencies": { diff --git a/packages/cli/src/__tests__/sense-sqlite.test.ts b/packages/cli/src/__tests__/sense-sqlite.test.ts new file mode 100644 index 0000000..a9ca127 --- /dev/null +++ b/packages/cli/src/__tests__/sense-sqlite.test.ts @@ -0,0 +1,159 @@ +/** + * Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`. + */ + +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + assertSenseDbExists, + collectColumnKeys, + defaultPreviewSql, + formatRowsAsAlignedTable, + listTableSqlStatements, + parseSenseQueryArgs, + pickDefaultPreviewTable, + senseDbPath, +} from "../sense-sqlite.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = join( + tmpdir(), + `nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + mkdirSync(join(tmpDir, "data", "senses"), { recursive: true }); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("senseDbPath", () => { + it("points at data/senses/.db under the given root", () => { + expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db")); + }); +}); + +describe("assertSenseDbExists", () => { + it("throws when the file is missing", () => { + expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/); + }); + + it("returns the path when the file exists", () => { + const p = join(tmpDir, "data", "senses", "x.db"); + new Database(p).close(); + expect(assertSenseDbExists(tmpDir, "x")).toBe(p); + }); +}); + +describe("listTableSqlStatements", () => { + it("returns CREATE statements ordered by tbl_name", () => { + const p = join(tmpDir, "data", "senses", "t.db"); + const db = new Database(p); + db.exec("CREATE TABLE zebra (id INTEGER);"); + db.exec("CREATE TABLE alpha (id INTEGER);"); + const stmts = listTableSqlStatements(db); + db.close(); + expect(stmts).toHaveLength(2); + expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i); + expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i); + }); +}); + +describe("pickDefaultPreviewTable", () => { + it("prefers non-_migrations tables when both exist", () => { + const p = join(tmpDir, "data", "senses", "t.db"); + const db = new Database(p); + db.exec(`CREATE TABLE _migrations (name TEXT PRIMARY KEY); + CREATE TABLE readings (id INTEGER);`); + expect(pickDefaultPreviewTable(db)).toBe("readings"); + db.close(); + }); + + it("uses _migrations when it is the only table", () => { + const p = join(tmpDir, "data", "senses", "t.db"); + const db = new Database(p); + db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY);"); + expect(pickDefaultPreviewTable(db)).toBe("_migrations"); + db.close(); + }); +}); + +describe("defaultPreviewSql", () => { + it("quotes identifiers for SQL safety", () => { + expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`); + }); +}); + +describe("parseSenseQueryArgs", () => { + it("parses sense name only", () => { + expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined }); + }); + + it("strips --json", () => { + expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined }); + expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined }); + }); + + it("joins remaining tokens into SQL", () => { + expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" }); + }); + + it("throws when name is missing", () => { + expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/); + }); +}); + +describe("formatRowsAsAlignedTable", () => { + it("shows empty marker for no rows", () => { + expect(formatRowsAsAlignedTable([])).toContain("(0 rows)"); + }); + + it("aligns columns from row data", () => { + const out = formatRowsAsAlignedTable([ + { a: 1, b: "x" }, + { a: 22, b: "yy" }, + ]); + expect(out).toContain("a"); + expect(out).toContain("b"); + expect(out).toContain("22"); + }); +}); + +describe("collectColumnKeys", () => { + it("preserves key order from first row then appends new keys", () => { + expect( + collectColumnKeys([ + { z: 1, a: 2 }, + { a: 3, b: 4 }, + ]), + ).toEqual(["z", "a", "b"]); + }); +}); + +describe("readonly query integration", () => { + it("runs default preview SQL on a real db", () => { + const p = join(tmpDir, "data", "senses", "demo.db"); + const rw = new Database(p); + rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT);"); + rw.exec(`INSERT INTO items (v) VALUES ('a'), ('b');`); + rw.close(); + + const db = new Database(p, { readonly: true, fileMustExist: true }); + const table = pickDefaultPreviewTable(db); + expect(table).toBe("items"); + if (table === null) { + throw new Error("expected items table"); + } + const sql = defaultPreviewSql(table); + const rows = db.prepare(sql).all() as Record[]; + db.close(); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/cli/src/commands/sense.ts b/packages/cli/src/commands/sense.ts index 50e6efa..b988e2e 100644 --- a/packages/cli/src/commands/sense.ts +++ b/packages/cli/src/commands/sense.ts @@ -2,9 +2,18 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core"; +import Database from "better-sqlite3"; import { defineCommand } from "citty"; import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js"; +import { + assertSenseDbExists, + defaultPreviewSql, + formatRowsAsAlignedTable, + listTableSqlStatements, + parseSenseQueryArgs, + pickDefaultPreviewTable, +} from "../sense-sqlite.js"; import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js"; // --------------------------------------------------------------------------- @@ -139,6 +148,118 @@ const senseTriggerCommand = defineCommand({ }, }); +// --------------------------------------------------------------------------- +// nerve sense schema +// --------------------------------------------------------------------------- + +const senseSchemaCommand = defineCommand({ + meta: { + name: "schema", + description: "Print CREATE TABLE statements from a sense SQLite database", + }, + args: { + name: { + type: "positional", + description: "Sense name (data/senses/.db under the nerve workspace)", + }, + json: { + type: "boolean", + description: "Print JSON array of CREATE TABLE SQL strings", + default: false, + }, + }, + async run({ args }) { + const nerveRoot = getNerveRoot(); + let db: Database.Database | undefined; + try { + const path = assertSenseDbExists(nerveRoot, args.name); + db = new Database(path, { readonly: true, fileMustExist: true }); + const statements = listTableSqlStatements(db); + if (args.json) { + process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`); + } else if (statements.length === 0) { + process.stdout.write("(no tables)\n"); + } else { + for (const sql of statements) { + process.stdout.write(`${sql};\n\n`); + } + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`❌ ${msg}\n`); + process.exit(1); + } finally { + db?.close(); + } + }, +}); + +// --------------------------------------------------------------------------- +// nerve sense query [sql...] +// --------------------------------------------------------------------------- + +const senseQueryCommand = defineCommand({ + meta: { + name: "query", + description: + "Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.", + }, + args: { + name: { + type: "positional", + description: "Sense name (data/senses/.db under the nerve workspace)", + }, + json: { + type: "boolean", + description: "Print result rows as JSON", + default: false, + }, + }, + async run({ args, rawArgs }) { + const nerveRoot = getNerveRoot(); + let db: Database.Database | undefined; + try { + let parsed: { name: string; sql: string | undefined }; + try { + parsed = parseSenseQueryArgs(rawArgs); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`❌ ${msg}\n`); + process.exit(1); + } + + const path = assertSenseDbExists(nerveRoot, args.name); + db = new Database(path, { readonly: true, fileMustExist: true }); + + let sql = parsed.sql?.trim(); + if (!sql) { + const table = pickDefaultPreviewTable(db); + if (table === null) { + process.stderr.write("❌ No tables found in database.\n"); + process.exit(1); + } else { + sql = defaultPreviewSql(table); + } + } + + const stmt = db.prepare(sql); + const rows = stmt.all() as Record[]; + + if (args.json) { + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + } else { + process.stdout.write(formatRowsAsAlignedTable(rows)); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`❌ ${msg}\n`); + process.exit(1); + } finally { + db?.close(); + } + }, +}); + // --------------------------------------------------------------------------- // nerve sense (parent command) // --------------------------------------------------------------------------- @@ -151,5 +272,7 @@ export const senseCommand = defineCommand({ subCommands: { list: senseListCommand, trigger: senseTriggerCommand, + schema: senseSchemaCommand, + query: senseQueryCommand, }, }); diff --git a/packages/cli/src/sense-sqlite.ts b/packages/cli/src/sense-sqlite.ts new file mode 100644 index 0000000..65e268b --- /dev/null +++ b/packages/cli/src/sense-sqlite.ts @@ -0,0 +1,122 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import type Database from "better-sqlite3"; + +/** SQLite path for a sense under the nerve workspace root. */ +export function senseDbPath(nerveRoot: string, senseName: string): string { + return join(nerveRoot, "data", "senses", `${senseName}.db`); +} + +export function assertSenseDbExists(nerveRoot: string, senseName: string): string { + const path = senseDbPath(nerveRoot, senseName); + if (!existsSync(path)) { + throw new Error(`No database at ${path}`); + } + return path; +} + +/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */ +export function listTableSqlStatements(db: Database.Database): string[] { + const rows = db + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`, + ) + .all() as { sql: string }[]; + return rows.map((r) => r.sql); +} + +/** + * Table used for `nerve sense query ` with no SQL. + * Prefers real data tables over `_migrations`, then lexicographic by name. + */ +export function pickDefaultPreviewTable(db: Database.Database): string | null { + const row = db + .prepare( + `SELECT name FROM sqlite_master + WHERE type = 'table' AND sql IS NOT NULL + AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' + ORDER BY + CASE WHEN name = '_migrations' THEN 1 ELSE 0 END, + name + LIMIT 1`, + ) + .get() as { name: string } | undefined; + return row?.name ?? null; +} + +export function defaultPreviewSql(table: string): string { + return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`; +} + +/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */ +export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } { + const pos: string[] = []; + for (let i = 0; i < rawArgs.length; i++) { + const a = rawArgs[i]; + if (a === "--json" || a === "--no-json") continue; + if (a.startsWith("-")) { + const eq = a.indexOf("="); + if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) { + i += 1; + } + continue; + } + pos.push(a); + } + if (pos.length < 1) { + throw new Error("Missing sense name"); + } + const name = pos[0]; + const sql = pos.length > 1 ? pos.slice(1).join(" ") : undefined; + return { name, sql }; +} + +function stringifyCell(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("hex"); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** Collect column keys in stable order (first row keys, then any extras). */ +export function collectColumnKeys(rows: Record[]): string[] { + const keys: string[] = []; + const seen = new Set(); + for (const row of rows) { + for (const k of Object.keys(row)) { + if (!seen.has(k)) { + seen.add(k); + keys.push(k); + } + } + } + return keys; +} + +const MAX_CELL = 64; + +function truncate(s: string): string { + if (s.length <= MAX_CELL) return s; + return `${s.slice(0, MAX_CELL - 1)}…`; +} + +/** Plain aligned table for terminal output. */ +export function formatRowsAsAlignedTable(rows: Record[]): string { + if (rows.length === 0) { + return "(0 rows)\n"; + } + const cols = collectColumnKeys(rows); + const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c])))); + const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length))); + const sep = widths.map((w) => "-".repeat(w)).join("-+-"); + const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | "); + const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n"); + return `${header}\n${sep}\n${body}\n`; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 402a489..112ae6f 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -9,5 +9,5 @@ export default defineConfig({ js: "#!/usr/bin/env node", }, /** Daemon is loaded from workspace node_modules at runtime — never bundle it. */ - external: ["@uncaged/nerve-daemon"], + external: ["@uncaged/nerve-daemon", "better-sqlite3"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c181826..81addff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@uncaged/nerve-core': specifier: workspace:* version: link:../core + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 citty: specifier: ^0.1.6 version: 0.1.6 -- 2.43.0