From 8019a57b73be8e38b1e5bddf34949608c792d879 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 14:20:50 +0300 Subject: [PATCH 1/8] fix(security): remove unauthenticated server-mounted /mcp split-brain + route init --force reset through Store - server: drop mountMcpHttpRoutes from the cloud server. The /mcp route sat outside the /v1/* auth middleware (unauthenticated) and its tools resolved a server-side LocalConfigStore -> ephemeral on-container SQLite instead of RDS. MCP is a client transport: run locally with HASNA_INSTRUCTIONS_API_URL/KEY it routes through the authenticated /v1 API. Matches economy/mementos/knowledge. - mcp/http: delete now-dead mountMcpHttpRoutes. - store: add ConfigStore.reset(); LocalConfigStore wipes the on-disk db via resetLocalDatabase(), CloudConfigStore refuses (cannot wipe shared cloud). - cli: init --force now calls store.reset() instead of touching sqlite directly. - db: add resetLocalDatabase() (close handle + rm db/-wal/-shm, honors env path). --- README.md | 23 +- package.json | 10 +- src/cli/index.tsx | 192 ++++++------ src/cli/storage.test.ts | 52 ---- src/cli/storage.ts | 73 ----- src/data/config-store.test.ts | 2 +- src/data/config-store.ts | 290 +++++++++++++++--- src/db/database.ts | 33 +- src/db/remote-storage.ts | 83 ----- src/db/storage-sync.test.ts | 171 ----------- src/db/storage-sync.ts | 249 --------------- src/index.ts | 51 +--- src/lib/apply-batch.test.ts | 7 +- src/lib/apply.test.ts | 31 +- src/lib/apply.ts | 16 +- src/lib/export-import.test.ts | 13 +- src/lib/export.ts | 11 +- src/lib/import.ts | 17 +- src/lib/platform-profiles.ts | 26 +- src/lib/project-dashboard-standard.test.ts | 15 +- src/lib/project-dashboard-standard.ts | 11 +- src/lib/sync-dir.test.ts | 15 +- src/lib/sync-dir.ts | 21 +- src/lib/sync-known.test.ts | 85 +++--- src/lib/sync.test.ts | 27 +- src/lib/sync.ts | 53 ++-- src/mcp/http.ts | 11 - src/mcp/server.ts | 90 +++--- src/server/index.ts | 339 +-------------------- src/server/server.test.ts | 175 ----------- src/server/v1.ts | 95 +++++- src/status.test.ts | 5 +- src/status.ts | 49 +-- src/storage.ts | 30 -- src/storage/cloud-store.ts | 193 ++++++++++++ src/storage/schema.ts | 8 + 36 files changed, 959 insertions(+), 1613 deletions(-) delete mode 100644 src/cli/storage.test.ts delete mode 100644 src/cli/storage.ts delete mode 100644 src/db/remote-storage.ts delete mode 100644 src/db/storage-sync.test.ts delete mode 100644 src/db/storage-sync.ts delete mode 100644 src/server/server.test.ts delete mode 100644 src/storage.ts diff --git a/README.md b/README.md index 7166973..5c4b863 100644 --- a/README.md +++ b/README.md @@ -134,21 +134,20 @@ and `API_KEY_SIGNING_SECRET` are also accepted). Client apps use `InstructionsV1Client` is generated from the serve OpenAPI document (`bun run generate:sdk`). -## Storage Sync (local CLI) +## Storage Modes -The local CLI supports optional remote storage sync through a package-local -Postgres connection: +Every CLI command, MCP tool, and SDK method routes through a single `ConfigStore` +abstraction with two transports: -```bash -export HASNA_INSTRUCTIONS_DATABASE_URL=postgres://... -instructions storage status -instructions storage push -instructions storage pull -instructions storage sync -``` +- **local** — on-box SQLite (`LocalConfigStore`), fully first-class. Used when no + API env vars are set. +- **api** (self_hosted / cloud) — HTTP `/v1` + bearer key (`CloudConfigStore`). + Activated by setting **both** `HASNA_INSTRUCTIONS_API_URL` and + `HASNA_INSTRUCTIONS_API_KEY`. Identical client code; only the URL/key differ, + and the self_hosted/cloud distinction is enforced server-side by tenancy. -The MCP server also exposes `storage_status`, `storage_push`, `storage_pull`, and -`storage_sync`. +Clients never hold a database DSN. The raw Postgres connection is a server-only +concern (`instructions-serve`). ## Data Directory diff --git a/package.json b/package.json index b5df639..4ee1e22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/instructions", - "version": "0.3.2", + "version": "0.4.0", "description": "AI coding agent instruction & configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", @@ -15,10 +15,6 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./storage": { - "types": "./dist/storage.d.ts", - "import": "./dist/storage.js" } }, "files": [ @@ -29,8 +25,8 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "bun run clean && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external pg --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external pg --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun --external pg && bun build src/index.ts src/storage.ts --outdir dist --target bun --external pg && tsc --emitDeclarationOnly --outDir dist", - "build:server": "bun run clean && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external pg --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external pg --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun --external pg && bun build src/index.ts src/storage.ts --outdir dist --target bun --external pg", + "build": "bun run clean && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external pg --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external pg --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun --external pg && bun build src/index.ts --outdir dist --target bun --external pg && tsc --emitDeclarationOnly --outDir dist", + "build:server": "bun run clean && bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external pg --external @modelcontextprotocol/sdk && bun build src/mcp/index.ts --outdir dist/mcp --target bun --external pg --external @modelcontextprotocol/sdk && bun build src/server/index.ts --outdir dist/server --target bun --external pg && bun build src/index.ts --outdir dist --target bun --external pg", "build:dashboard": "cd dashboard && bun run build", "migrate": "bun run src/server/index.ts migrate", "generate:sdk": "bun run scripts/generate-sdk.ts", diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 92b0c1b..330e0fb 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -5,10 +5,6 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; -import { createConfig, deleteConfig, getConfig, getConfigStats, listConfigs, updateConfig } from "../db/configs.js"; -import { createProfile, deleteProfile, getProfile, getProfileConfigs, listProfiles, updateProfile, addConfigToProfile, removeConfigFromProfile, resolveProfileForMachine } from "../db/profiles.js"; -import { listSnapshots, getSnapshot } from "../db/snapshots.js"; -import { getDatabase, resetDatabase } from "../db/database.js"; import { applyConfig, applyConfigs, expandPath } from "../lib/apply.js"; import { diffConfig, syncKnown, syncToDisk, syncProject, detectCategory, detectAgent, detectFormat, KNOWN_CONFIGS } from "../lib/sync.js"; import { syncFromDir } from "../lib/sync-dir.js"; @@ -22,15 +18,14 @@ import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFile import { ensurePlatformProfiles } from "../lib/platform-profiles.js"; import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js"; import { getConfigsStatus } from "../status.js"; -import { resolveConfigStore, isCloudMode } from "../data/config-store.js"; -import { registerStorageCommands } from "./storage.js"; +import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; import { DEFAULT_LIST_LIMIT, paginate, parseLimit, truncateMiddle, truncateText } from "../lib/compact-output.js"; -import type { ConfigAgent, ConfigCategory, ConfigFormat, ConfigKind, Profile, ProfileSelector, ProfileVariables } from "../types/index.js"; +import type { Config, ConfigAgent, ConfigCategory, ConfigFormat, ConfigKind, Profile, ProfileSelector, ProfileVariables } from "../types/index.js"; import { createRequire } from "node:module"; const pkg = createRequire(import.meta.url)("../../package.json") as { version: string }; -function fmtConfig(c: ReturnType, format: string) { +function fmtConfig(c: Config, format: string) { if (format === "json") return JSON.stringify(c, null, 2); if (format === "compact") return `${c.slug} [${c.category}/${c.agent}] ${c.kind === "reference" ? "(ref)" : truncateMiddle(c.target_path ?? "(no path)", 72)}`; // table @@ -53,7 +48,7 @@ function pageFooter(command: string, page: { items: unknown[]; total: number; li console.log(chalk.dim(detailsHint)); } -function printConfigRows(configs: ReturnType): void { +function printConfigRows(configs: Config[]): void { console.log(`${pad("slug", 32)} ${pad("type", 15)} ${pad("fmt", 8)} ${pad("path", 44)} out v`); for (const c of configs) { const type = `${c.category}/${c.agent}`; @@ -121,7 +116,7 @@ function parseLayeredReference(value: string): { layer?: SessionInstructionLayer return { id: trimmed }; } -function collectSessionSources( +async function collectSessionSources( opts: { source?: string[]; config?: string[]; @@ -129,13 +124,14 @@ function collectSessionSources( replaceSource?: string[]; }, tool: SessionRenderTool, -): SessionInstructionSource[] { + store: ConfigStore, +): Promise { const replaceIds = new Set(opts.replaceSource ?? []); const sources = (opts.source ?? []).map((value, index) => parseSessionSource(value, index, replaceIds)); for (const value of opts.config ?? []) { const { layer, id } = parseLayeredReference(value); - sources.push(sourceFromConfig(getConfig(id), sources.length, layer)); + sources.push(sourceFromConfig(await store.getConfig(id), sources.length, layer)); } for (const value of opts.identityExport ?? []) { @@ -198,9 +194,12 @@ function formatProfileVariables(profile: Pick): string { .join(", "); } -function getMachineProfileContext(opts: { hostname?: string; os?: string; arch?: string }) { +async function getMachineProfileContext( + opts: { hostname?: string; os?: string; arch?: string }, + store: ConfigStore, +) { const machine = detectMachineContext({ hostname: opts.hostname, os: opts.os, arch: opts.arch }); - const profile = resolveProfileForMachine(machine); + const profile = await store.resolveProfileForMachine(machine); return { machine, profile, vars: resolveProfileVariables(profile, machine) }; } @@ -336,8 +335,9 @@ program .option("--force", "overwrite even if unchanged") .action(async (id, opts) => { try { - const config = getConfig(id); - const result = await applyConfig(config, { dryRun: opts.dryRun }); + const store = resolveConfigStore(); + const config = await store.getConfig(id); + const result = await applyConfig(config, { dryRun: opts.dryRun, store }); const status = opts.dryRun ? chalk.yellow("[dry-run]") : (result.changed ? chalk.green("✓") : chalk.dim("=")); const change = result.changed ? "changed" : "unchanged"; console.log(`${status} ${result.path} ${chalk.dim(`(${change})`)}`); @@ -359,17 +359,18 @@ program .option("--all", "diff every known config against disk") .action(async (id, opts) => { try { + const store = resolveConfigStore(); if (id) { - const config = getConfig(id); - console.log(diffConfig(config)); + const config = await store.getConfig(id); + console.log(await diffConfig(config, { store })); return; } // --all or no id: diff all known file-type configs - const configs = listConfigs({ kind: "file" }); + const configs = await store.listConfigs({ kind: "file" }); let drifted = 0; for (const c of configs) { if (!c.target_path) continue; - const diff = diffConfig(c); + const diff = await diffConfig(c, { store }); if (diff.includes("no diff") || diff.includes("not found")) continue; drifted++; console.log(chalk.bold(c.slug) + chalk.dim(` (${c.target_path})`)); @@ -397,6 +398,7 @@ program .option("--limit ", `with --list, max rows (default ${DEFAULT_LIST_LIMIT})`) .option("--cursor ", "with --list, zero-based pagination cursor") .action(async (opts) => { + const store = resolveConfigStore(); if (opts.list) { const targets = KNOWN_CONFIGS.filter((k) => { if (opts.agent && k.agent !== opts.agent) return false; @@ -427,7 +429,7 @@ program // Only sync dirs that have CLAUDE.md, .mcp.json, or .claude/ const hasClaude = existsSync(join(projDir, "CLAUDE.md")) || existsSync(join(projDir, ".mcp.json")) || existsSync(join(projDir, ".claude")); if (!hasClaude) continue; - const result = await syncProject({ projectDir: projDir, dryRun: opts.dryRun }); + const result = await syncProject({ projectDir: projDir, dryRun: opts.dryRun, store }); if (result.added + result.updated > 0) { console.log(` ${chalk.green("✓")} ${entry.name}: +${result.added} updated:${result.updated}`); } @@ -437,15 +439,15 @@ program return; } - const result = await syncProject({ projectDir: dir, dryRun: opts.dryRun }); + const result = await syncProject({ projectDir: dir, dryRun: opts.dryRun, store }); console.log(chalk.green("✓") + ` Project sync: +${result.added} updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); return; } if (opts.toDisk) { - const result = await syncToDisk({ dryRun: opts.dryRun, agent: opts.agent, category: opts.category }); + const result = await syncToDisk({ dryRun: opts.dryRun, agent: opts.agent, category: opts.category, store }); console.log(chalk.green("✓") + ` Written to disk: updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); } else { - const result = await syncKnown({ dryRun: opts.dryRun, agent: opts.agent, category: opts.category }); + const result = await syncKnown({ dryRun: opts.dryRun, agent: opts.agent, category: opts.category, store }); console.log(chalk.green("✓") + ` Synced: +${result.added} updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); if (result.skipped.length > 0) { console.log(chalk.dim(" skipped (not found): " + result.skipped.join(", "))); @@ -462,6 +464,7 @@ program .action(async (opts) => { const result = await exportConfigs(opts.output, { filter: opts.category ? { category: opts.category as ConfigCategory } : undefined, + store: resolveConfigStore(), }); console.log(chalk.green("✓") + ` Exported ${result.count} configs to ${result.path}`); }); @@ -474,6 +477,7 @@ program .action(async (file, opts) => { const result = await importConfigs(file, { conflict: opts.overwrite ? "overwrite" : "skip", + store: resolveConfigStore(), }); console.log(chalk.green("✓") + ` Import complete: +${result.created} updated:${result.updated} skipped:${result.skipped}`); if (result.errors.length > 0) { @@ -586,16 +590,18 @@ profileCmd.command("show ").description("Show profile and its configs") profileCmd.command("add ").description("Add a config to a profile").action(async (profile, config) => { try { - const c = getConfig(config); - addConfigToProfile(profile, c.id); + const store = resolveConfigStore(); + const c = await store.getConfig(config); + await store.addConfigToProfile(profile, c.id); console.log(chalk.green("✓") + ` Added ${c.slug} to profile ${profile}`); } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); } }); profileCmd.command("remove ").description("Remove a config from a profile").action(async (profile, config) => { try { - const c = getConfig(config); - removeConfigFromProfile(profile, c.id); + const store = resolveConfigStore(); + const c = await store.getConfig(config); + await store.removeConfigFromProfile(profile, c.id); console.log(chalk.green("✓") + ` Removed ${c.slug} from profile ${profile}`); } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); } }); @@ -608,15 +614,16 @@ profileCmd.command("apply [id]").description("Apply all configs in a profile to .option("--arch ", "override detected arch for auto resolution") .action(async (id, opts) => { try { - const { machine, profile } = getMachineProfileContext(opts); - const selected = opts.auto ? profile : (id ? getProfile(id) : null); + const store = resolveConfigStore(); + const { machine, profile } = await getMachineProfileContext(opts, store); + const selected = opts.auto ? profile : (id ? await store.getProfile(id) : null); if (!selected) { console.error(chalk.red(opts.auto ? "No matching machine-aware profile found." : "Provide a profile id or use --auto.")); process.exit(1); } - const configs = getProfileConfigs(selected.id); + const configs = await store.getProfileConfigs(selected.id); const vars = resolveProfileVariables(selected, machine); - const results = await applyConfigs(configs, { dryRun: opts.dryRun, vars }); + const results = await applyConfigs(configs, { dryRun: opts.dryRun, vars, store }); let changed = 0; for (const r of results) { const status = opts.dryRun ? chalk.yellow("[dry-run]") : (r.changed ? chalk.green("✓") : chalk.dim("=")); @@ -632,7 +639,8 @@ profileCmd.command("resolve").description("Resolve the matching machine-aware pr .option("--os ", "override detected OS") .option("--arch ", "override detected arch") .action(async (opts) => { - const { machine, profile, vars } = getMachineProfileContext(opts); + const store = resolveConfigStore(); + const { machine, profile, vars } = await getMachineProfileContext(opts, store); if (!profile) { console.log(chalk.yellow(`No matching profile for ${machine.hostname} ${machine.os_family}/${machine.arch}`)); process.exit(1); @@ -679,7 +687,7 @@ sessionCmd.command("plan") console.error(chalk.red(`Unsupported tool: ${opts.tool}`)); process.exit(1); } - const sources = collectSessionSources(opts, tool); + const sources = await collectSessionSources(opts, tool, resolveConfigStore()); const plan = planSessionRender({ tool, profile: opts.profile, @@ -737,7 +745,7 @@ sessionCmd.command("apply") console.error(chalk.red(`Unsupported tool: ${opts.tool}`)); process.exit(1); } - const sources = collectSessionSources(opts, tool); + const sources = await collectSessionSources(opts, tool, resolveConfigStore()); const plan = planSessionRender({ tool, profile: opts.profile, @@ -787,8 +795,9 @@ snapshotCmd.command("list ").description("List snapshots for a config") .option("--cursor ", "zero-based pagination cursor") .action(async (configId, opts) => { try { - const c = getConfig(configId); - const snaps = listSnapshots(c.id); + const store = resolveConfigStore(); + const c = await store.getConfig(configId); + const snaps = await store.listSnapshots(c.id); if (snaps.length === 0) { console.log(chalk.dim("No snapshots.")); return; } const page = paginate(snaps, { limit: opts.limit, cursor: opts.cursor }); for (const s of page.items) { @@ -799,16 +808,17 @@ snapshotCmd.command("list ").description("List snapshots for a config") }); snapshotCmd.command("show ").description("Show a snapshot's content").action(async (id) => { - const snap = getSnapshot(id); + const snap = await resolveConfigStore().getSnapshot(id); if (!snap) { console.error(chalk.red("Snapshot not found: " + id)); process.exit(1); } console.log(snap.content); }); snapshotCmd.command("restore ").description("Restore a config to a snapshot version").action(async (configId, snapId) => { try { - const snap = getSnapshot(snapId); + const store = resolveConfigStore(); + const snap = await store.getSnapshot(snapId); if (!snap) { console.error(chalk.red("Snapshot not found: " + snapId)); process.exit(1); } - updateConfig(configId, { content: snap.content }); + await store.updateConfig(configId, { content: snap.content }); console.log(chalk.green("✓") + ` Restored ${configId} to snapshot v${snap.version}`); } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); } }); @@ -818,7 +828,7 @@ const templateCmd = program.command("template").description("Work with template templateCmd.command("vars ").description("Show template variables").action(async (id) => { try { - const c = getConfig(id); + const c = await resolveConfigStore().getConfig(id); const vars = extractTemplateVars(c.content); if (vars.length === 0) { console.log(chalk.dim("No template variables found.")); return; } for (const v of vars) { @@ -836,7 +846,7 @@ templateCmd.command("render ") .action(async (id, opts) => { try { const { renderTemplate } = await import("../lib/template.js"); - const c = getConfig(id); + const c = await resolveConfigStore().getConfig(id); const vars: Record = {}; // Collect vars from --var KEY=VALUE @@ -890,12 +900,13 @@ program .option("-c, --category ", "scan only a specific category") .option("--limit ", `max findings to print (default ${DEFAULT_LIST_LIMIT})`) .action(async (id, opts) => { + const store = resolveConfigStore(); let configs; if (id) { - configs = [getConfig(id)]; + configs = [await store.getConfig(id)]; } else if (opts.all) { // Scan full DB in batches to avoid OOM - configs = listConfigs(opts.category ? { kind: "file", category: opts.category as ConfigCategory } : { kind: "file" }); + configs = await store.listConfigs(opts.category ? { kind: "file", category: opts.category as ConfigCategory } : { kind: "file" }); } else { // Default: fetch only the ~30 known configs individually by slug (fast, no full table scan) const { KNOWN_CONFIGS } = await import("../lib/sync.js"); @@ -905,10 +916,10 @@ program ]; const fetched = []; for (const slug of slugs) { - try { fetched.push(getConfig(slug)); } catch { /* not in DB yet */ } + try { fetched.push(await store.getConfig(slug)); } catch { /* not in DB yet */ } } // Also grab rules by category+agent (small set) - const rules = listConfigs({ category: "rules", agent: "claude" }); + const rules = await store.listConfigs({ category: "rules", agent: "claude" }); for (const r of rules) if (!fetched.find((c) => c.id === r.id)) fetched.push(r); configs = fetched; } @@ -935,7 +946,7 @@ program } if (opts.fix) { const { content, isTemplate } = redactContent(c.content, fmt); - updateConfig(c.id, { content, is_template: isTemplate }); + await store.updateConfig(c.id, { content, is_template: isTemplate }); if (visible.length > 0) console.log(chalk.green(" ✓ Redacted.")); } } @@ -1009,7 +1020,7 @@ mcpCmd.command("install") } for (const target of targets) { try { - const { vars } = getMachineProfileContext({}); + const { vars } = await getMachineProfileContext({}, resolveConfigStore()); const mcpBinary = `${vars["BUN_BIN_DIR"]}/configs-mcp`; if (target === "claude") { const cmd = opts.profile && opts.profile !== "full" @@ -1071,17 +1082,17 @@ program .description("First-time setup: sync all known configs, create default profile") .option("--force", "delete existing DB and start fresh") .action(async (opts) => { - const dbPath = join(homedir(), ".hasna", "configs", "configs.db"); - if (opts.force && existsSync(dbPath)) { - const { rmSync } = await import("node:fs"); - rmSync(dbPath); - console.log(chalk.dim("Deleted existing DB.")); - resetDatabase(); + const store = resolveConfigStore(); + if (opts.force) { + // Routes through the Store: LocalConfigStore wipes the on-disk SQLite db; + // CloudConfigStore refuses (you can't force-wipe the shared cloud store). + await store.reset(); + console.log(chalk.dim("Reset local store.")); } console.log(chalk.bold("@hasna/configs — initializing\n")); // Sync known configs - const result = await syncKnown({}); + const result = await syncKnown({ store }); console.log(chalk.green("✓") + ` Synced: +${result.added} updated:${result.updated} unchanged:${result.unchanged}`); if (result.skipped.length > 0) { console.log(chalk.dim(" skipped: " + result.skipped.join(", "))); @@ -1093,30 +1104,33 @@ program { slug: "secrets-schema", name: "Secrets Schema", category: "secrets_schema" as const, content: "# .secrets Schema\n\nLocation: ~/.secrets (sourced by ~/.zshrc)\nFormat: export KEY_NAME=\"value\"\n\nKeys: ANTHROPIC_API_KEY, OPENAI_API_KEY, EXA_API_KEY, NPM_TOKEN, GITHUB_TOKEN", desc: "Shape of ~/.secrets (no values)" }, ]; for (const ref of refs) { - try { getConfig(ref.slug); } catch { - createConfig({ name: ref.name, category: ref.category, agent: "global", format: "markdown", content: ref.content, kind: "reference", description: ref.desc }); + try { await store.getConfig(ref.slug); } catch { + await store.createConfig({ name: ref.name, category: ref.category, agent: "global", format: "markdown", content: ref.content, kind: "reference", description: ref.desc }); } } - ensureProjectDashboardStandardConfig(); + await ensureProjectDashboardStandardConfig(store); // Create default profile - try { getProfile("my-setup"); } catch { - const p = createProfile({ name: "my-setup", description: "Default profile with all known configs" }); - const allConfigs = listConfigs(); - for (const c of allConfigs) addConfigToProfile(p.id, c.id); + try { await store.getProfile("my-setup"); } catch { + const p = await store.createProfile({ name: "my-setup", description: "Default profile with all known configs" }); + const allConfigs = await store.listConfigs(); + for (const c of allConfigs) await store.addConfigToProfile(p.id, c.id); console.log(chalk.green("✓") + ` Created profile "my-setup" with ${allConfigs.length} configs`); } - const machineProfiles = ensurePlatformProfiles(); + const machineProfiles = await ensurePlatformProfiles(store); console.log(chalk.green("✓") + ` Ensured ${machineProfiles.length} machine-aware profile(s)`); // Show summary - const stats = getConfigStats(); + const stats = await store.getConfigStats(); console.log(chalk.bold("\nDB stats:")); for (const [key, count] of Object.entries(stats)) { if (count > 0) console.log(` ${key.padEnd(18)} ${count}`); } - console.log(chalk.dim(`\nDB: ${dbPath}`)); + const location = isCloudMode() + ? `${process.env["HASNA_INSTRUCTIONS_API_URL"]}/v1 (self_hosted)` + : process.env["CONFIGS_DB_PATH"] || join(homedir(), ".hasna", "configs", "configs.db"); + console.log(chalk.dim(`\n${isCloudMode() ? "API" : "DB"}: ${location}`)); }); // ── status ──────────────────────────────────────────────────────────────────── @@ -1125,7 +1139,7 @@ program .description("Health check: total configs, drift from disk, unredacted secrets") .option("--json", "output metadata-only JSON") .action(async (opts: { json?: boolean }) => { - const status = getConfigsStatus(); + const status = await getConfigsStatus(resolveConfigStore()); if (opts.json) { console.log(JSON.stringify(status, null, 2)); @@ -1154,7 +1168,7 @@ program mk(backupDir, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").slice(0, 19); const outPath = join(backupDir, `configs-${ts}.tar.gz`); - const result = await exportConfigs(outPath); + const result = await exportConfigs(outPath, { store: resolveConfigStore() }); const { statSync: st } = await import("node:fs"); const size = st(outPath).size; console.log(chalk.green("✓") + ` Backup: ${result.count} configs → ${outPath} (${(size / 1024).toFixed(1)}KB)`); @@ -1165,7 +1179,7 @@ program .description("Restore configs from a backup file") .option("--overwrite", "overwrite existing configs (default: skip)") .action(async (file, opts) => { - const result = await importConfigs(file, { conflict: opts.overwrite ? "overwrite" : "skip" }); + const result = await importConfigs(file, { conflict: opts.overwrite ? "overwrite" : "skip", store: resolveConfigStore() }); console.log(chalk.green("✓") + ` Restored: +${result.created} updated:${result.updated} skipped:${result.skipped}`); if (result.errors.length > 0) { for (const e of result.errors) console.log(chalk.red(" " + e)); @@ -1177,6 +1191,7 @@ program .command("doctor") .description("Validate configs: syntax, permissions, missing files, secrets") .action(async () => { + const store = resolveConfigStore(); let issues = 0; const pass = (msg: string) => console.log(chalk.green(" ✓ ") + msg); const fail = (msg: string) => { issues++; console.log(chalk.red(" ✗ ") + msg); }; @@ -1195,7 +1210,7 @@ program } // Check DB configs - const allConfigs = listConfigs(); + const allConfigs = await store.listConfigs(); console.log(chalk.cyan(`\nStored configs (${allConfigs.length}):`)); // Validate JSON/TOML syntax @@ -1271,8 +1286,9 @@ program .description("Diff two stored configs against each other") .action(async (a, b) => { try { - const configA = getConfig(a); - const configB = getConfig(b); + const store = resolveConfigStore(); + const configA = await store.getConfig(a); + const configB = await store.getConfig(b); console.log(chalk.bold(`${configA.slug}`) + chalk.dim(` (${configA.category}/${configA.agent})`)); console.log(chalk.bold(`${configB.slug}`) + chalk.dim(` (${configB.category}/${configB.agent})`)); console.log(); @@ -1310,6 +1326,7 @@ program .description("Watch known config files for changes and auto-sync to DB") .option("-i, --interval ", "poll interval in milliseconds", "3000") .action(async (opts) => { + const store = resolveConfigStore(); const interval = Number(opts.interval); const { statSync: st } = await import("node:fs"); const { expandPath } = await import("../lib/apply.js"); @@ -1368,7 +1385,7 @@ program } } if (changed > 0) { - const result = await syncKnown({}); + const result = await syncKnown({ store }); const ts = new Date().toLocaleTimeString(); console.log(`${chalk.dim(ts)} ${chalk.green("✓")} ${changed} file(s) changed/new → synced +${result.added} updated:${result.updated}`); } @@ -1386,12 +1403,13 @@ program .option("--json", "output as JSON") .option("--markdown", "output as markdown") .action(async () => { - const stats = getConfigStats(); - const allConfigs = listConfigs(); + const store = resolveConfigStore(); + const stats = await store.getConfigStats(); + const allConfigs = await store.listConfigs(); const fileConfigs = allConfigs.filter((c) => c.kind === "file"); const refConfigs = allConfigs.filter((c) => c.kind === "reference"); const templates = allConfigs.filter((c) => c.is_template); - const profiles = listProfiles(); + const profiles = await store.listProfiles(); // Drift check let drifted = 0, missing = 0; @@ -1440,7 +1458,8 @@ program .option("--dry-run", "show what would be removed") .option("--limit ", `max orphan rows to print (default ${DEFAULT_LIST_LIMIT})`) .action(async (opts) => { - const configs = listConfigs({ kind: "file" }); + const store = resolveConfigStore(); + const configs = await store.listConfigs({ kind: "file" }); let removed = 0; let printed = 0; const maxPrinted = parseLimit(opts.limit, DEFAULT_LIST_LIMIT); @@ -1456,7 +1475,7 @@ program } printed++; } - if (!opts.dryRun) deleteConfig(c.id); + if (!opts.dryRun) await store.deleteConfig(c.id); removed++; } } @@ -1475,6 +1494,7 @@ program .option("--dry-run", "show what would be installed without doing it") .option("--skip-mcp", "skip MCP server registration") .action(async (opts) => { + const store = resolveConfigStore(); const packages = [ { name: "@hasna/todos", bin: "todos", mcp: "todos-mcp" }, { name: "@hasna/mementos", bin: "mementos", mcp: "mementos-mcp" }, @@ -1521,7 +1541,7 @@ program // 3. Run configs init console.log(chalk.cyan("\nInitializing configs:")); if (!opts.dryRun) { - const result = await syncKnown({}); + const result = await syncKnown({ store }); console.log(chalk.green(" ✓ ") + `Synced ${result.added + result.updated + result.unchanged} known configs`); } else { console.log(chalk.dim(" would run: configs init")); @@ -1537,7 +1557,7 @@ program .option("-a, --agent ", "only sync this agent") .option("--dry-run", "preview without writing") .action(async (opts) => { - const result = await syncKnown({ dryRun: opts.dryRun, agent: opts.agent }); + const result = await syncKnown({ dryRun: opts.dryRun, agent: opts.agent, store: resolveConfigStore() }); console.log(chalk.green("✓") + ` Pulled: +${result.added} updated:${result.updated} unchanged:${result.unchanged}`); }); @@ -1547,7 +1567,7 @@ program .option("-a, --agent ", "only push this agent") .option("--dry-run", "preview without writing") .action(async (opts) => { - const result = await syncToDisk({ dryRun: opts.dryRun, agent: opts.agent }); + const result = await syncToDisk({ dryRun: opts.dryRun, agent: opts.agent, store: resolveConfigStore() }); console.log(chalk.green("✓") + ` Pushed: updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); }); @@ -1584,15 +1604,15 @@ program .option("-e, --email ", "Contact email") .option("-c, --category ", "Category: bug, feature, general", "general") .action(async (message, opts) => { - const db = getDatabase(); - db.run( - "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", - [message, opts.email || null, opts.category || "general", pkg.version] - ); + await resolveConfigStore().sendFeedback({ + message, + email: opts.email || null, + category: opts.category || "general", + version: pkg.version, + }); console.log(chalk.green("✓") + " Feedback saved. Thank you!"); }); program.version(pkg.version).name("instructions"); -registerStorageCommands(program); registerEventsCommands(program, { source: "configs" }); program.parse(process.argv); diff --git a/src/cli/storage.test.ts b/src/cli/storage.test.ts deleted file mode 100644 index 8b7446e..0000000 --- a/src/cli/storage.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); - -function runCli(args: string[], env: Record = {}) { - return spawnSync("bun", ["src/cli/index.tsx", ...args], { - cwd: repoRoot, - encoding: "utf8", - env: { - ...process.env, - ...env, - }, - }); -} - -describe("configs storage CLI", () => { - test("help advertises storage sync without legacy cloud command", () => { - const result = runCli(["--help"]); - - expect(result.status).toBe(0); - expect(result.stdout).toContain("storage"); - expect(result.stdout).not.toContain("cloud"); - }); - - test("storage status reports local mode as JSON", () => { - const home = mkdtempSync(join(tmpdir(), "open-configs-storage-cli-")); - try { - const result = runCli(["storage", "status", "--json"], { - HOME: home, - HASNA_CONFIGS_DB_PATH: "", - CONFIGS_DB_PATH: join(home, "configs.db"), - HASNA_CONFIGS_DATABASE_URL: "", - CONFIGS_DATABASE_URL: "", - HASNA_CONFIGS_STORAGE_MODE: "", - CONFIGS_STORAGE_MODE: "", - }); - - expect(result.status).toBe(0); - const status = JSON.parse(result.stdout) as { configured: boolean; mode: string; tables: string[] }; - expect(status.configured).toBe(false); - expect(status.mode).toBe("local"); - expect(status.tables).toContain("configs"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }); -}); diff --git a/src/cli/storage.ts b/src/cli/storage.ts deleted file mode 100644 index df485db..0000000 --- a/src/cli/storage.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Command } from "commander"; -import { - getStorageStatus, - storagePull, - storagePush, - storageSync, - type SyncResult, -} from "../db/storage-sync.js"; - -function parseTables(value?: string): string[] | undefined { - if (!value) return undefined; - return value.split(",").map((table) => table.trim()).filter(Boolean); -} - -function printJson(value: unknown): void { - console.log(JSON.stringify(value, null, 2)); -} - -function printResults(results: SyncResult[], label: string): void { - const total = results.reduce((sum, result) => sum + result.rowsWritten, 0); - for (const result of results) { - const errors = result.errors.length > 0 ? ` (${result.errors.join("; ")})` : ""; - console.log(` ${result.table}: ${result.rowsWritten}/${result.rowsRead} rows ${label}${errors}`); - } - console.log(`Done. ${total} rows ${label}.`); -} - -export function registerStorageCommands(program: Command): void { - const storageCmd = program.command("storage").description("Storage sync commands"); - - storageCmd.command("status").description("Show storage config and local sync state").option("--json", "Output as JSON").action((opts: { json?: boolean }) => { - const info = getStorageStatus(); - if (opts.json) { printJson(info); return; } - console.log(`Storage configured: ${info.configured ? "yes" : "no"}`); - console.log(`Tables: ${info.tables.join(", ")}`); - if (info.sync.length === 0) console.log("Sync: no local sync history"); - for (const entry of info.sync) console.log(` ${entry.table_name} ${entry.direction}: ${entry.last_synced_at ?? "never"}`); - }); - - storageCmd.command("push").description("Push local configs data to storage PostgreSQL").option("--tables ", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => { - try { - const results = await storagePush({ tables: parseTables(opts.tables) }); - if (opts.json) { printJson(results); return; } - printResults(results, "pushed"); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - }); - - storageCmd.command("pull").description("Pull configs data from storage PostgreSQL to local SQLite").option("--tables ", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => { - try { - const results = await storagePull({ tables: parseTables(opts.tables) }); - if (opts.json) { printJson(results); return; } - printResults(results, "pulled"); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - }); - - storageCmd.command("sync").description("Bidirectional sync: pull then push").option("--tables ", "Comma-separated table names").option("--json", "Output as JSON").action(async (opts: { tables?: string; json?: boolean }) => { - try { - const result = await storageSync({ tables: parseTables(opts.tables) }); - if (opts.json) { printJson(result); return; } - printResults(result.pull, "pulled"); - printResults(result.push, "pushed"); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } - }); -} diff --git a/src/data/config-store.test.ts b/src/data/config-store.test.ts index f95fed8..6a0847e 100644 --- a/src/data/config-store.test.ts +++ b/src/data/config-store.test.ts @@ -98,7 +98,7 @@ describe("resolveConfigStore", () => { HASNA_INSTRUCTIONS_API_KEY: "k", }); expect(store).toBeInstanceOf(CloudConfigStore); - expect(store.mode).toBe("cloud"); + expect(store.mode).toBe("api"); }); }); diff --git a/src/data/config-store.ts b/src/data/config-store.ts index 2dc8d90..9aca601 100644 --- a/src/data/config-store.ts +++ b/src/data/config-store.ts @@ -1,33 +1,52 @@ -// Async config/profile data store with a local (SQLite) and a cloud (HTTP /v1) -// implementation, selected at runtime. +// Single Store abstraction for the instructions app. // // LOCKED architecture (client -> AWS API only): when HASNA_INSTRUCTIONS_API_URL -// and HASNA_INSTRUCTIONS_API_KEY are both set (self_hosted mode), ALL config and -// profile reads/writes route to https:///v1 with a bearer key — no local -// SQLite, no DSN on the client. With the env unset, the local SQLite store is -// used and the local database is never touched. Setting only one var throws -// (no silent local drift). +// and HASNA_INSTRUCTIONS_API_KEY are both set the app runs in `api` transport +// (self_hosted OR cloud — the client code is identical; only URL/key differ and +// the self_hosted/cloud distinction is enforced server-side by tenancy). In api +// mode ALL config/profile/snapshot/machine reads and writes route to +// `https:///v1` with a bearer key — no local SQLite, no DSN on the client. +// With the env unset the app uses the local SQLite store (LocalConfigStore), +// which stays fully first-class. Setting exactly one var throws (no silent +// local drift). +// +// EVERY CLI command, MCP tool, and SDK method routes through this interface. +// No consumer may import `../db/*` or call `fetch` directly. import { randomUUID } from "node:crypto"; +import type { Database } from "bun:sqlite"; import { createConfig as dbCreateConfig, deleteConfig as dbDeleteConfig, getConfig as dbGetConfig, + getConfigById as dbGetConfigById, getConfigStats as dbGetConfigStats, listConfigs as dbListConfigs, updateConfig as dbUpdateConfig, } from "../db/configs.js"; import { + addConfigToProfile as dbAddConfigToProfile, createProfile as dbCreateProfile, deleteProfile as dbDeleteProfile, getProfile as dbGetProfile, getProfileConfigs as dbGetProfileConfigs, listProfiles as dbListProfiles, + removeConfigFromProfile as dbRemoveConfigFromProfile, + resolveProfileForMachine as dbResolveProfileForMachine, updateProfile as dbUpdateProfile, } from "../db/profiles.js"; import { createSnapshot as dbCreateSnapshot, + getSnapshot as dbGetSnapshot, + getSnapshotByVersion as dbGetSnapshotByVersion, listSnapshots as dbListSnapshots, + pruneSnapshots as dbPruneSnapshots, } from "../db/snapshots.js"; +import { + listMachines as dbListMachines, + registerMachine as dbRegisterMachine, + updateMachineApplied as dbUpdateMachineApplied, +} from "../db/machines.js"; +import { insertFeedback as dbInsertFeedback, resetLocalDatabase as dbResetLocalDatabase, type FeedbackInput } from "../db/database.js"; import { ConfigNotFoundError, ProfileNotFoundError } from "../types/index.js"; import type { Config, @@ -35,6 +54,8 @@ import type { ConfigSnapshot, CreateConfigInput, CreateProfileInput, + Machine, + MachineContext, Profile, UpdateConfigInput, UpdateProfileInput, @@ -58,7 +79,7 @@ const API_KEY_ENV = "HASNA_INSTRUCTIONS_API_KEY"; /** * Resolve cloud config from the environment. - * - both vars set -> config (self_hosted / cloud-http) + * - both vars set -> config (api transport: self_hosted / cloud) * - neither set -> null (local SQLite) * - exactly one set -> throws (no silent local drift) */ @@ -68,7 +89,7 @@ export function resolveCloudConfig(env: NodeJS.ProcessEnv = process.env): CloudC if (!apiUrl && !apiKey) return null; if (!apiUrl || !apiKey) { throw new Error( - `Cloud (self_hosted) mode requires BOTH ${API_URL_ENV} and ${API_KEY_ENV}; only ` + + `API mode requires BOTH ${API_URL_ENV} and ${API_KEY_ENV}; only ` + `${apiUrl ? API_URL_ENV : API_KEY_ENV} is set. Set both to use the cloud API, ` + `or unset both to use the local store.`, ); @@ -76,80 +97,154 @@ export function resolveCloudConfig(env: NodeJS.ProcessEnv = process.env): CloudC return { apiUrl, apiKey }; } -/** True when self_hosted cloud mode is active. */ +/** True when api (self_hosted/cloud) mode is active. */ export function isCloudMode(env: NodeJS.ProcessEnv = process.env): boolean { return resolveCloudConfig(env) !== null; } +/** + * The single Store contract. Two transports implement it: LocalConfigStore + * (on-box SQLite) and CloudConfigStore (HTTP /v1 + bearer key). + */ export interface ConfigStore { - readonly mode: "local" | "cloud"; + readonly mode: "local" | "api"; + // Configs listConfigs(filter?: ConfigFilter): Promise; getConfig(idOrSlug: string): Promise; + getConfigById(id: string): Promise; createConfig(input: CreateConfigInput): Promise; updateConfig(idOrSlug: string, input: UpdateConfigInput): Promise; deleteConfig(idOrSlug: string): Promise; getConfigStats(): Promise>; + // Snapshots + listSnapshots(configId: string): Promise; + getSnapshot(id: string): Promise; + getSnapshotByVersion(configId: string, version: number): Promise; + createSnapshot(configId: string, content: string, version: number): Promise; + pruneSnapshots(configId: string, keep?: number): Promise; + // Profiles listProfiles(): Promise; getProfile(idOrSlug: string): Promise; getProfileConfigs(idOrSlug: string): Promise; createProfile(input: CreateProfileInput): Promise; updateProfile(idOrSlug: string, input: UpdateProfileInput): Promise; deleteProfile(idOrSlug: string): Promise; - listSnapshots(idOrSlug: string): Promise; - createSnapshot(idOrSlug: string): Promise; + addConfigToProfile(profileIdOrSlug: string, configId: string): Promise; + removeConfigFromProfile(profileIdOrSlug: string, configId: string): Promise; + resolveProfileForMachine(machine?: MachineContext): Promise; + // Machines + registerMachine(hostname?: string, os?: string, arch?: string): Promise; + updateMachineApplied(hostname?: string): Promise; + listMachines(): Promise; + // Feedback + sendFeedback(input: FeedbackInput): Promise; + // Lifecycle + /** + * Destroy all data in this store (used by `init --force`). Local: wipes the + * on-disk SQLite database. Api: forbidden — you cannot wipe the shared cloud + * store from a client, so the CloudConfigStore throws. + */ + reset(): Promise; } -/** Local SQLite-backed store (wraps the synchronous db layer). */ +/** + * Local SQLite-backed store (wraps the synchronous db layer). Accepts an + * explicit `Database` handle for isolated use (tests); otherwise uses the + * process-wide singleton via the db layer's default. + */ export class LocalConfigStore implements ConfigStore { readonly mode = "local" as const; + constructor(private readonly db?: Database) {} + + // Configs async listConfigs(filter?: ConfigFilter): Promise { - return dbListConfigs(filter); + return dbListConfigs(filter, this.db); } async getConfig(idOrSlug: string): Promise { - return dbGetConfig(idOrSlug); + return dbGetConfig(idOrSlug, this.db); + } + async getConfigById(id: string): Promise { + return dbGetConfigById(id, this.db); } async createConfig(input: CreateConfigInput): Promise { - return dbCreateConfig(input); + return dbCreateConfig(input, this.db); } async updateConfig(idOrSlug: string, input: UpdateConfigInput): Promise { - return dbUpdateConfig(idOrSlug, input); + return dbUpdateConfig(idOrSlug, input, this.db); } async deleteConfig(idOrSlug: string): Promise { - dbDeleteConfig(idOrSlug); + dbDeleteConfig(idOrSlug, this.db); } async getConfigStats(): Promise> { - return dbGetConfigStats(); + return dbGetConfigStats(this.db); + } + // Snapshots + async listSnapshots(configId: string): Promise { + return dbListSnapshots(configId, this.db); + } + async getSnapshot(id: string): Promise { + return dbGetSnapshot(id, this.db); } + async getSnapshotByVersion(configId: string, version: number): Promise { + return dbGetSnapshotByVersion(configId, version, this.db); + } + async createSnapshot(configId: string, content: string, version: number): Promise { + return dbCreateSnapshot(configId, content, version, this.db); + } + async pruneSnapshots(configId: string, keep = 10): Promise { + return dbPruneSnapshots(configId, keep, this.db); + } + // Profiles async listProfiles(): Promise { - return dbListProfiles(); + return dbListProfiles(this.db); } async getProfile(idOrSlug: string): Promise { - return dbGetProfile(idOrSlug); + return dbGetProfile(idOrSlug, this.db); } async getProfileConfigs(idOrSlug: string): Promise { - return dbGetProfileConfigs(idOrSlug); + return dbGetProfileConfigs(idOrSlug, this.db); } async createProfile(input: CreateProfileInput): Promise { - return dbCreateProfile(input); + return dbCreateProfile(input, this.db); } async updateProfile(idOrSlug: string, input: UpdateProfileInput): Promise { - return dbUpdateProfile(idOrSlug, input); + return dbUpdateProfile(idOrSlug, input, this.db); } async deleteProfile(idOrSlug: string): Promise { - dbDeleteProfile(idOrSlug); + dbDeleteProfile(idOrSlug, this.db); + } + async addConfigToProfile(profileIdOrSlug: string, configId: string): Promise { + dbAddConfigToProfile(profileIdOrSlug, configId, this.db); + } + async removeConfigFromProfile(profileIdOrSlug: string, configId: string): Promise { + dbRemoveConfigFromProfile(profileIdOrSlug, configId, this.db); } - async listSnapshots(idOrSlug: string): Promise { - return dbListSnapshots(idOrSlug); + async resolveProfileForMachine(machine?: MachineContext): Promise { + return machine + ? dbResolveProfileForMachine(machine, this.db) + : dbResolveProfileForMachine(undefined, this.db); } - async createSnapshot(idOrSlug: string): Promise { - const config = dbGetConfig(idOrSlug); - return dbCreateSnapshot(config.id, config.content, config.version); + // Machines + async registerMachine(hostname?: string, os?: string, arch?: string): Promise { + return dbRegisterMachine(hostname, os, arch, this.db); + } + async updateMachineApplied(hostname?: string): Promise { + dbUpdateMachineApplied(hostname, this.db); + } + async listMachines(): Promise { + return dbListMachines(this.db); + } + async sendFeedback(input: FeedbackInput): Promise { + dbInsertFeedback(input, this.db); + } + async reset(): Promise { + dbResetLocalDatabase(); } } -/** Cloud store: routes every operation to the `/v1` HTTP API. */ +/** Cloud store: routes every operation to the `/v1` HTTP API with a bearer key. */ export class CloudConfigStore implements ConfigStore { - readonly mode = "cloud" as const; + readonly mode = "api" as const; private readonly base: string; private readonly apiKey: string; private readonly timeoutMs: number; @@ -204,6 +299,7 @@ export class CloudConfigStore implements ConfigStore { } } + // Configs async listConfigs(filter: ConfigFilter = {}): Promise { const params = new URLSearchParams(); if (filter.category) params.set("category", filter.category); @@ -216,7 +312,6 @@ export class CloudConfigStore implements ConfigStore { `/configs${qs ? `?${qs}` : ""}`, ); let configs = data?.configs ?? []; - // Filters not supported by the API query are applied client-side. if (filter.tags && filter.tags.length > 0) { configs = configs.filter((c) => filter.tags!.every((t) => c.tags.includes(t))); } @@ -237,6 +332,10 @@ export class CloudConfigStore implements ConfigStore { return data.config; } + async getConfigById(id: string): Promise { + return this.getConfig(id); + } + async createConfig(input: CreateConfigInput): Promise { const { data } = await this.request<{ config: Config }>("POST", "/configs", input, { idempotent: true, @@ -268,6 +367,57 @@ export class CloudConfigStore implements ConfigStore { return data ?? { total: 0 }; } + // Snapshots + async listSnapshots(configId: string): Promise { + const { data } = await this.request<{ snapshots: ConfigSnapshot[] }>( + "GET", + `/configs/${encodeURIComponent(configId)}/snapshots`, + ); + return data?.snapshots ?? []; + } + + async getSnapshot(id: string): Promise { + const { status, data } = await this.request<{ snapshot: ConfigSnapshot }>( + "GET", + `/snapshots/${encodeURIComponent(id)}`, + undefined, + { allow404: true }, + ); + if (status === 404 || !data?.snapshot) return null; + return data.snapshot; + } + + async getSnapshotByVersion(configId: string, version: number): Promise { + const { status, data } = await this.request<{ snapshot: ConfigSnapshot }>( + "GET", + `/configs/${encodeURIComponent(configId)}/snapshots/${version}`, + undefined, + { allow404: true }, + ); + if (status === 404 || !data?.snapshot) return null; + return data.snapshot; + } + + async createSnapshot(configId: string, content: string, version: number): Promise { + const { data } = await this.request<{ snapshot: ConfigSnapshot }>( + "POST", + `/configs/${encodeURIComponent(configId)}/snapshots`, + { content, version }, + { idempotent: true }, + ); + return (data as { snapshot: ConfigSnapshot }).snapshot; + } + + async pruneSnapshots(configId: string, keep = 10): Promise { + const { data } = await this.request<{ pruned: number }>( + "POST", + `/configs/${encodeURIComponent(configId)}/snapshots/prune`, + { keep }, + ); + return data?.pruned ?? 0; + } + + // Profiles async listProfiles(): Promise { const { data } = await this.request<{ profiles: Profile[] }>("GET", "/profiles"); return data?.profiles ?? []; @@ -322,26 +472,78 @@ export class CloudConfigStore implements ConfigStore { if (status === 404) throw new ProfileNotFoundError(idOrSlug); } - async listSnapshots(idOrSlug: string): Promise { - const { data } = await this.request<{ snapshots: ConfigSnapshot[] }>( + async addConfigToProfile(profileIdOrSlug: string, configId: string): Promise { + await this.request<{ added: boolean }>( + "POST", + `/profiles/${encodeURIComponent(profileIdOrSlug)}/configs`, + { config_id: configId }, + { idempotent: true }, + ); + } + + async removeConfigFromProfile(profileIdOrSlug: string, configId: string): Promise { + await this.request<{ removed: boolean }>( + "DELETE", + `/profiles/${encodeURIComponent(profileIdOrSlug)}/configs/${encodeURIComponent(configId)}`, + undefined, + { allow404: true }, + ); + } + + async resolveProfileForMachine(machine?: MachineContext): Promise { + const params = new URLSearchParams(); + if (machine?.hostname) params.set("hostname", machine.hostname); + if (machine?.os) params.set("os", machine.os); + if (machine?.arch) params.set("arch", machine.arch); + const qs = params.toString(); + const { status, data } = await this.request<{ profile: Profile | null }>( "GET", - `/configs/${encodeURIComponent(idOrSlug)}/snapshots`, + `/profiles/resolve${qs ? `?${qs}` : ""}`, + undefined, + { allow404: true }, ); - return data?.snapshots ?? []; + if (status === 404 || !data?.profile) return null; + return data.profile; } - async createSnapshot(idOrSlug: string): Promise { - const { data } = await this.request<{ snapshot: ConfigSnapshot }>( + // Machines + async registerMachine(hostname?: string, os?: string, arch?: string): Promise { + const { data } = await this.request<{ machine: Machine }>( "POST", - `/configs/${encodeURIComponent(idOrSlug)}/snapshots`, - {}, + "/machines", + { hostname, os, arch }, { idempotent: true }, ); - return (data as { snapshot: ConfigSnapshot }).snapshot; + return (data as { machine: Machine }).machine; + } + + async updateMachineApplied(hostname?: string): Promise { + await this.request<{ updated: boolean }>("POST", "/machines/applied", { hostname }); + } + + async listMachines(): Promise { + const { data } = await this.request<{ machines: Machine[] }>("GET", "/machines"); + return data?.machines ?? []; + } + + async sendFeedback(input: FeedbackInput): Promise { + await this.request<{ ok: boolean }>("POST", "/feedback", { + message: input.message, + email: input.email ?? undefined, + category: input.category ?? undefined, + version: input.version ?? undefined, + }); + } + + async reset(): Promise { + throw new Error( + "`init --force` cannot wipe the shared cloud store from a client. " + + "Unset HASNA_INSTRUCTIONS_API_URL / HASNA_INSTRUCTIONS_API_KEY to reset the local store instead.", + ); } } -/** Resolve the active store: cloud when self_hosted env is set, else local. */ +/** Resolve the active store: api transport when the env is set, else local. */ export function resolveConfigStore(env: NodeJS.ProcessEnv = process.env): ConfigStore { const cloud = resolveCloudConfig(env); return cloud ? new CloudConfigStore(cloud) : new LocalConfigStore(); diff --git a/src/db/database.ts b/src/db/database.ts index 5eb66c1..e7e2c23 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,5 +1,5 @@ import { Database } from "bun:sqlite"; -import { cpSync, existsSync, mkdirSync, statSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -133,6 +133,22 @@ export function resetDatabase(): void { _db = null; } +/** + * Destroy the on-disk local database: close the handle and delete the db file + * plus its WAL/SHM sidecars. Used by `init --force`. Resolves the path from the + * db module (honoring HASNA_CONFIGS_DB_PATH / CONFIGS_DB_PATH); a no-op for the + * in-memory (`:memory:`) database. Local-only — the CloudConfigStore never calls + * this (destroying the shared cloud store from a client is forbidden). + */ +export function resetLocalDatabase(): void { + resetDatabase(); + const dbPath = getDbPath(); + if (dbPath === ":memory:") return; + for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + if (existsSync(p)) rmSync(p); + } +} + function applyMigrations(db: Database): void { let currentVersion = 0; try { @@ -165,6 +181,21 @@ function ensureFeedbackTable(db: Database): void { `); } +export interface FeedbackInput { + message: string; + email?: string | null; + category?: string | null; + version?: string | null; +} + +export function insertFeedback(input: FeedbackInput, db?: Database): void { + const d = db || getDatabase(); + d.run( + "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", + [input.message, input.email ?? null, input.category ?? "general", input.version ?? null], + ); +} + function migrateDotfile(): void { const home = process.env["HOME"] || process.env["USERPROFILE"] || "~"; const oldDirs = [join(home, ".open-configs"), join(home, ".configs")]; diff --git a/src/db/remote-storage.ts b/src/db/remote-storage.ts deleted file mode 100644 index 6bbd69a..0000000 --- a/src/db/remote-storage.ts +++ /dev/null @@ -1,83 +0,0 @@ -import pg from "pg"; -import type { Pool, PoolConfig } from "pg"; - -const DISABLED_SSL_MODE = "disable"; - -function translatePlaceholders(sql: string): string { - let index = 0; - return sql.replace(/\?/g, () => `$${++index}`); -} - -function normalizeParams(params: unknown[]): unknown[] { - const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params; - return flat.map((value) => value === undefined ? null : value); -} - -function normalizeHost(hostname: string): string { - const stripped = hostname.replace(/^\[/, "").replace(/\]$/, ""); - try { - return decodeURIComponent(stripped).toLowerCase(); - } catch { - return stripped.toLowerCase(); - } -} - -export function isLocalPostgresHost(hostname: string): boolean { - const host = normalizeHost(hostname); - return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "" || host.startsWith("/"); -} - -function effectivePgHost(url: URL): string { - const hosts = url.searchParams.getAll("host"); - const finalHost = hosts.length > 0 ? hosts[hosts.length - 1] : null; - return finalHost?.trim() ? finalHost : url.hostname; -} - -export function buildPgPoolConfig(connectionString: string): PoolConfig { - let url: URL; - try { - url = new URL(connectionString); - } catch { - throw new Error("Invalid PostgreSQL connection string"); - } - - const sslMode = url.searchParams.get("sslmode")?.trim().toLowerCase(); - const sslValue = url.searchParams.get("ssl")?.trim().toLowerCase(); - const isLocal = isLocalPostgresHost(effectivePgHost(url)); - const hasDisabledSsl = sslMode === DISABLED_SSL_MODE || sslValue === "false"; - - if (!isLocal && hasDisabledSsl) { - throw new Error("Refusing remote PostgreSQL connection with TLS disabled"); - } - - const shouldUseSsl = !isLocal || sslMode === "require" || sslMode === "verify-ca" || sslMode === "verify-full" || sslValue === "true"; - url.searchParams.delete("sslmode"); - url.searchParams.delete("ssl"); - - return { - connectionString: url.toString(), - ssl: shouldUseSsl ? { rejectUnauthorized: true } : undefined, - }; -} - -export class PgAdapterAsync { - private readonly pool: Pool; - - constructor(connectionString: string) { - this.pool = new pg.Pool(buildPgPoolConfig(connectionString)); - } - - async run(sql: string, ...params: unknown[]): Promise<{ changes: number }> { - const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params)); - return { changes: result.rowCount ?? 0 }; - } - - async all(sql: string, ...params: unknown[]): Promise { - const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params)); - return result.rows; - } - - async close(): Promise { - await this.pool.end(); - } -} diff --git a/src/db/storage-sync.test.ts b/src/db/storage-sync.test.ts deleted file mode 100644 index 9232e26..0000000 --- a/src/db/storage-sync.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import pg from "pg"; -import { - STORAGE_DATABASE_ENV, - STORAGE_MODE_ENV, - STORAGE_TABLES, - getStorageDatabaseEnvName, - getStorageDatabaseUrl, - getStorageMode, - getStorageStatus, - resolveTables, -} from "./storage-sync"; -import { buildPgPoolConfig, isLocalPostgresHost } from "./remote-storage"; - -const envKeys = [ - ...STORAGE_DATABASE_ENV, - ...STORAGE_MODE_ENV, -] as const; - -const savedEnv = new Map(); - -function inspectClientParameters(connectionString: string): { host?: string; ssl?: unknown } { - const client = new pg.Client(buildPgPoolConfig(connectionString)); - const params = (client as unknown as { connectionParameters: { host?: string; ssl?: unknown } }).connectionParameters; - return { - host: params.host, - ssl: params.ssl, - }; -} - -beforeEach(() => { - savedEnv.clear(); - for (const key of envKeys) { - savedEnv.set(key, process.env[key]); - delete process.env[key]; - } -}); - -afterEach(() => { - for (const [key, value] of savedEnv) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } -}); - -describe("configs storage sync config", () => { - test("canonical storage database env wins over fallback", () => { - process.env.HASNA_CONFIGS_DATABASE_URL = "postgres://new.example/configs"; - process.env.CONFIGS_DATABASE_URL = "postgres://fallback.example/configs"; - - expect(getStorageDatabaseUrl()).toBe("postgres://new.example/configs"); - expect(getStorageDatabaseEnvName()).toBe("HASNA_CONFIGS_DATABASE_URL"); - expect(getStorageMode()).toBe("hybrid"); - }); - - test("fallback storage database env is used when canonical env is absent", () => { - process.env.CONFIGS_DATABASE_URL = "postgres://fallback.example/configs"; - - expect(getStorageDatabaseUrl()).toBe("postgres://fallback.example/configs"); - expect(getStorageDatabaseEnvName()).toBe("CONFIGS_DATABASE_URL"); - expect(getStorageMode()).toBe("hybrid"); - }); - - test("canonical storage mode wins", () => { - process.env.HASNA_CONFIGS_STORAGE_MODE = "remote"; - - expect(getStorageMode()).toBe("remote"); - }); - - test("reports storage status for CLI and MCP surfaces", () => { - process.env.HASNA_CONFIGS_DATABASE_URL = "postgres://new.example/configs"; - - expect(getStorageStatus()).toMatchObject({ - configured: true, - mode: "hybrid", - service: "configs", - }); - }); - - test("resolves storage tables", () => { - expect(resolveTables()).toEqual([...STORAGE_TABLES]); - expect(resolveTables(["feedback"])).toEqual(["feedback"]); - expect(() => resolveTables(["missing"])).toThrow("Unknown configs sync table"); - }); - - test("verifies TLS for remote PostgreSQL by default", () => { - expect(inspectClientParameters("postgres://user:pass@db.example.com/configs")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - expect(buildPgPoolConfig("postgres://user:pass@db.example.com/configs")).toMatchObject({ - connectionString: "postgres://user:pass@db.example.com/configs", - ssl: { rejectUnauthorized: true }, - }); - }); - - test("verifies TLS for the exact remote SSL request forms", () => { - expect(buildPgPoolConfig("postgres://user:pass@db.example.com/configs?sslmode=require")).toMatchObject({ - connectionString: "postgres://user:pass@db.example.com/configs", - ssl: { rejectUnauthorized: true }, - }); - expect(buildPgPoolConfig("postgres://user:pass@db.example.com/configs?ssl=true")).toMatchObject({ - connectionString: "postgres://user:pass@db.example.com/configs", - ssl: { rejectUnauthorized: true }, - }); - expect(inspectClientParameters("postgres://user:pass@db.example.com/configs?sslmode=require")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - expect(inspectClientParameters("postgres://user:pass@db.example.com/configs?ssl=true")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - }); - - test("allows local PostgreSQL without TLS", () => { - expect(isLocalPostgresHost("localhost")).toBe(true); - expect(isLocalPostgresHost("%2Fvar%2Frun%2Fpostgresql")).toBe(true); - expect(buildPgPoolConfig("postgres://user:pass@localhost/configs")).toMatchObject({ - connectionString: "postgres://user:pass@localhost/configs", - ssl: undefined, - }); - }); - - test("allows local PostgreSQL to request verified TLS", () => { - expect(inspectClientParameters("postgres://user:pass@localhost/configs?sslmode=require")).toMatchObject({ - host: "localhost", - ssl: { rejectUnauthorized: true }, - }); - }); - - test("rejects remote PostgreSQL when TLS is explicitly disabled", () => { - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?ssl=false")).toThrow("TLS disabled"); - }); - - test("enforces TLS for remote query host overrides", () => { - expect(inspectClientParameters("postgres://user:pass@localhost/configs?host=db.example.com")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - expect(inspectClientParameters("postgres://user:pass@localhost/configs?host=localhost&host=db.example.com")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - expect(() => buildPgPoolConfig("postgres://user:pass@localhost/configs?host=db.example.com&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@localhost/configs?host=db.example.com&ssl=false")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?host=&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?hostaddr=&ssl=false")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?hostaddr=127.0.0.1&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?hostaddr=127.0.0.1&ssl=false")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?hostaddr=::1&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@localhost/configs?host=localhost&host=db.example.com&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@db.example.com/configs?host=&host=db.example.com&sslmode=disable")).toThrow("TLS disabled"); - expect(() => buildPgPoolConfig("postgres://user:pass@localhost/configs?host=127.0.0.1&host=db.example.com&ssl=false")).toThrow("TLS disabled"); - }); - - test("treats remote no-verify mode as verified TLS", () => { - expect(inspectClientParameters("postgres://user:pass@db.example.com/configs?sslmode=no-verify")).toMatchObject({ - host: "db.example.com", - ssl: { rejectUnauthorized: true }, - }); - }); - - test("preserves non-mode SSL parameters while enforcing verification", () => { - expect(buildPgPoolConfig("postgres://user:pass@db.example.com/configs?sslrootcert=/tmp/ca.pem")).toMatchObject({ - connectionString: "postgres://user:pass@db.example.com/configs?sslrootcert=%2Ftmp%2Fca.pem", - ssl: { rejectUnauthorized: true }, - }); - }); -}); diff --git a/src/db/storage-sync.ts b/src/db/storage-sync.ts deleted file mode 100644 index 43c2ed2..0000000 --- a/src/db/storage-sync.ts +++ /dev/null @@ -1,249 +0,0 @@ -import type { Database } from "bun:sqlite"; -import { getDatabase } from "./database.js"; -import { PG_MIGRATIONS } from "./pg-migrations.js"; -import { PgAdapterAsync } from "./remote-storage.js"; - -export const STORAGE_TABLES = ["configs", "config_snapshots", "profiles", "profile_configs", "machines", "feedback"] as const; -export const CONFIGS_STORAGE_TABLES = STORAGE_TABLES; - -type StorageTable = (typeof STORAGE_TABLES)[number]; -type Row = Record; -export type StorageMode = "local" | "hybrid" | "remote"; - -const PRIMARY_KEYS: Record = { - configs: ["id"], - config_snapshots: ["id"], - profiles: ["id"], - profile_configs: ["profile_id", "config_id"], - machines: ["id"], - feedback: ["id"], -}; - -export interface SyncResult { table: string; rowsRead: number; rowsWritten: number; errors: string[]; } -export interface SyncMeta { table_name: string; last_synced_at: string | null; direction: "push" | "pull"; } -export interface StorageStatus { - configured: boolean; - mode: StorageMode; - env: typeof STORAGE_DATABASE_ENV; - service: "configs"; - tables: typeof STORAGE_TABLES; - sync: SyncMeta[]; -} - -export const CONFIGS_STORAGE_ENV = "HASNA_CONFIGS_DATABASE_URL"; -export const CONFIGS_STORAGE_FALLBACK_ENV = "CONFIGS_DATABASE_URL"; -export const CONFIGS_STORAGE_MODE_ENV = "HASNA_CONFIGS_STORAGE_MODE"; -export const CONFIGS_STORAGE_MODE_FALLBACK_ENV = "CONFIGS_STORAGE_MODE"; -export const STORAGE_DATABASE_ENV = [CONFIGS_STORAGE_ENV, CONFIGS_STORAGE_FALLBACK_ENV] as const; -export const STORAGE_MODE_ENV = [CONFIGS_STORAGE_MODE_ENV, CONFIGS_STORAGE_MODE_FALLBACK_ENV] as const; - -function firstEnv(names: readonly string[]): string | null { - for (const name of names) { - const value = process.env[name]; - if (value) return value; - } - return null; -} - -function normalizeStorageMode(value: string | null): StorageMode | undefined { - const normalized = value?.trim().toLowerCase(); - if (normalized === "local" || normalized === "hybrid" || normalized === "remote") return normalized; - return undefined; -} - -export function getStorageDatabaseUrl(): string | null { - return firstEnv(STORAGE_DATABASE_ENV); -} - -export function getStorageDatabaseEnvName(): (typeof STORAGE_DATABASE_ENV)[number] | null { - for (const name of STORAGE_DATABASE_ENV) { - if (process.env[name]) return name; - } - return null; -} - -export function getStorageMode(): StorageMode { - const mode = normalizeStorageMode(firstEnv(STORAGE_MODE_ENV)); - if (mode) return mode; - return getStorageDatabaseUrl() ? "hybrid" : "local"; -} - -export async function getStoragePg(): Promise { - const url = getStorageDatabaseUrl(); - if (!url) throw new Error("Missing HASNA_CONFIGS_DATABASE_URL or CONFIGS_DATABASE_URL"); - return new PgAdapterAsync(url); -} - -export async function runStorageMigrations(remote: PgAdapterAsync): Promise { - await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto"); - for (const sql of PG_MIGRATIONS) await remote.run(sql); -} - -export async function storagePush(options?: { tables?: string[] }): Promise { - const remote = await getStoragePg(); - const db = getDatabase(); - try { - await runStorageMigrations(remote); - const results: SyncResult[] = []; - for (const table of resolveTables(options?.tables)) results.push(await pushTable(db, remote, table)); - recordSyncMeta(db, "push", results); - return results; - } finally { - await remote.close(); - } -} - -export async function storagePull(options?: { tables?: string[] }): Promise { - const remote = await getStoragePg(); - const db = getDatabase(); - try { - await runStorageMigrations(remote); - const results: SyncResult[] = []; - for (const table of resolveTables(options?.tables)) results.push(await pullTable(remote, db, table)); - recordSyncMeta(db, "pull", results); - return results; - } finally { - await remote.close(); - } -} - -export async function storageSync(options?: { tables?: string[] }): Promise<{ pull: SyncResult[]; push: SyncResult[] }> { - const pull = await storagePull(options); - const push = await storagePush(options); - return { pull, push }; -} - -export function getStorageSyncMetaAll(): SyncMeta[] { - const db = getDatabase(); - ensureSyncMetaTable(db); - return db.query("SELECT table_name, last_synced_at, direction FROM _configs_sync_meta ORDER BY table_name, direction").all(); -} - -export function getSyncMetaAll(): SyncMeta[] { - return getStorageSyncMetaAll(); -} - -export function getStorageStatus(): StorageStatus { - return { - configured: Boolean(getStorageDatabaseUrl()), - mode: getStorageMode(), - env: STORAGE_DATABASE_ENV, - service: "configs", - tables: STORAGE_TABLES, - sync: getStorageSyncMetaAll(), - }; -} - -export function resolveTables(tables?: string[]): StorageTable[] { - if (!tables || tables.length === 0) return [...STORAGE_TABLES]; - const allowed = new Set(STORAGE_TABLES); - const requested = tables.map((table) => table.trim()).filter(Boolean); - const invalid = requested.filter((table) => !allowed.has(table)); - if (invalid.length > 0) throw new Error(`Unknown configs sync table(s): ${invalid.join(", ")}`); - return requested as StorageTable[]; -} - -async function pushTable(db: Database, remote: PgAdapterAsync, table: StorageTable): Promise { - const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; - try { - const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all(); - result.rowsRead = rows.length; - if (rows.length === 0) return result; - const columns = await filterRemoteColumns(remote, table, Object.keys(rows[0]!)); - result.rowsWritten = await upsertPg(remote, table, columns, rows); - } catch (error) { - result.errors.push(error instanceof Error ? error.message : String(error)); - } - return result; -} - -async function pullTable(remote: PgAdapterAsync, db: Database, table: StorageTable): Promise { - const result: SyncResult = { table, rowsRead: 0, rowsWritten: 0, errors: [] }; - try { - const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`) as Row[]; - result.rowsRead = rows.length; - if (rows.length === 0) return result; - const columns = filterLocalColumns(db, table, Object.keys(rows[0]!)); - result.rowsWritten = upsertSqlite(db, table, columns, rows); - } catch (error) { - result.errors.push(error instanceof Error ? error.message : String(error)); - } - return result; -} - -async function filterRemoteColumns(remote: PgAdapterAsync, table: string, columns: string[]): Promise { - const rows = await remote.all("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table) as Array<{ column_name: string }>; - if (rows.length === 0) return columns; - const allowed = new Set(rows.map((row) => row.column_name)); - return columns.filter((column) => allowed.has(column)); -} - -function filterLocalColumns(db: Database, table: string, columns: string[]): string[] { - const rows = db.query<{ name: string }, []>(`PRAGMA table_info(${quoteIdent(table)})`).all(); - const allowed = new Set(rows.map((row) => row.name)); - return columns.filter((column) => allowed.has(column)); -} - -async function upsertPg(remote: PgAdapterAsync, table: StorageTable, columns: string[], rows: Row[]): Promise { - if (columns.length === 0) return 0; - const primaryKeys = PRIMARY_KEYS[table]; - const columnList = columns.map(quoteIdent).join(", "); - const placeholders = columns.map(() => "?").join(", "); - const keyList = primaryKeys.map(quoteIdent).join(", "); - const updateColumns = columns.filter((column) => !primaryKeys.includes(column)); - const fallbackKey = primaryKeys[0]!; - const setClause = updateColumns.length > 0 - ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") - : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`; - for (const row of rows) { - await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => row[column] ?? null)); - } - return rows.length; -} - -function upsertSqlite(db: Database, table: StorageTable, columns: string[], rows: Row[]): number { - if (columns.length === 0) return 0; - const primaryKeys = PRIMARY_KEYS[table]; - const columnList = columns.map(quoteIdent).join(", "); - const placeholders = columns.map(() => "?").join(", "); - const keyList = primaryKeys.map(quoteIdent).join(", "); - const updateColumns = columns.filter((column) => !primaryKeys.includes(column)); - const fallbackKey = primaryKeys[0]!; - const setClause = updateColumns.length > 0 - ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") - : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`; - const statement = db.prepare(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders}) ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`); - db.transaction((batch: Row[]) => { - for (const row of batch) statement.run(...columns.map((column) => coerceForSqlite(row[column]))); - })(rows); - return rows.length; -} - -function recordSyncMeta(db: Database, direction: "push" | "pull", results: SyncResult[]): void { - ensureSyncMetaTable(db); - const now = new Date().toISOString(); - const statement = db.prepare( - "INSERT INTO _configs_sync_meta (table_name, last_synced_at, direction) VALUES (?, ?, ?) ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at", - ); - for (const result of results) { - if (result.errors.length > 0) continue; - statement.run(result.table, now, direction); - } -} - -function ensureSyncMetaTable(db: Database): void { - db.exec("CREATE TABLE IF NOT EXISTS _configs_sync_meta (table_name TEXT NOT NULL, last_synced_at TEXT, direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')), PRIMARY KEY (table_name, direction))"); -} - -function quoteIdent(identifier: string): string { - return `"${identifier.replace(/"/g, '""')}"`; -} - -function coerceForSqlite(value: unknown): string | number | bigint | boolean | null | Uint8Array { - if (value === undefined || value === null) return null; - if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return value; - if (value instanceof Date) return value.toISOString(); - if (Buffer.isBuffer(value) || value instanceof Uint8Array) return value; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} diff --git a/src/index.ts b/src/index.ts index b1be494..77e5442 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,44 +1,21 @@ // Types export * from "./types/index.js"; -// DB — configs -export { createConfig, getConfig, getConfigById, listConfigs, updateConfig, deleteConfig, getConfigStats } from "./db/configs.js"; - -// DB — snapshots -export { createSnapshot, listSnapshots, getSnapshot, getSnapshotByVersion, pruneSnapshots } from "./db/snapshots.js"; - -// DB — profiles -export { createProfile, getProfile, listProfiles, updateProfile, deleteProfile, addConfigToProfile, removeConfigFromProfile, getProfileConfigs, profileHasSelectors, profileMatchesMachine, resolveProfileForMachine } from "./db/profiles.js"; - -// DB — machines -export { registerMachine, updateMachineApplied, listMachines, currentHostname, currentOs, currentArch } from "./db/machines.js"; - -// DB — database utilities -export { getDatabase, resetDatabase, uuid, now, slugify } from "./db/database.js"; +// Store — the single data abstraction (LocalStore + ApiStore). Every SDK data +// operation routes through this interface; no raw sqlite/fetch is exposed. export { - CONFIGS_STORAGE_ENV, - CONFIGS_STORAGE_FALLBACK_ENV, - CONFIGS_STORAGE_MODE_ENV, - CONFIGS_STORAGE_MODE_FALLBACK_ENV, - CONFIGS_STORAGE_TABLES, - STORAGE_DATABASE_ENV, - STORAGE_MODE_ENV, - STORAGE_TABLES, - getStorageDatabaseEnvName, - getStorageDatabaseUrl, - getStorageMode, - getStoragePg, - getStorageStatus, - getStorageSyncMetaAll, - getSyncMetaAll, - resolveTables, - runStorageMigrations, - storagePull, - storagePush, - storageSync, -} from "./db/storage-sync.js"; -export type { StorageMode, StorageStatus, SyncMeta, SyncResult } from "./db/storage-sync.js"; -export * from "./db/remote-storage.js"; + CloudConfigStore, + CloudHttpError, + LocalConfigStore, + isCloudMode, + resolveCloudConfig, + resolveConfigStore, +} from "./data/config-store.js"; +export type { CloudConfig, ConfigStore } from "./data/config-store.js"; + +// Machine + slug helpers (pure) +export { currentHostname, currentOs, currentArch } from "./db/machines.js"; +export { uuid, now, slugify } from "./db/database.js"; // Status contract export { getConfigsStatus } from "./status.js"; diff --git a/src/lib/apply-batch.test.ts b/src/lib/apply-batch.test.ts index d6776a0..39145f6 100644 --- a/src/lib/apply-batch.test.ts +++ b/src/lib/apply-batch.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -25,7 +26,7 @@ describe("applyConfigs (batch)", () => { const db = getDatabase(); const c1 = createConfig({ name: "A", category: "tools", content: "aaa", target_path: join(tmpDir, "a.txt") }, db); const c2 = createConfig({ name: "B", category: "tools", content: "bbb", target_path: join(tmpDir, "b.txt") }, db); - const results = await applyConfigs([c1, c2], { db }); + const results = await applyConfigs([c1, c2], { store: new LocalConfigStore(db) }); expect(results.length).toBe(2); expect(readFileSync(join(tmpDir, "a.txt"), "utf-8")).toBe("aaa"); expect(readFileSync(join(tmpDir, "b.txt"), "utf-8")).toBe("bbb"); @@ -35,7 +36,7 @@ describe("applyConfigs (batch)", () => { const db = getDatabase(); const file = createConfig({ name: "File", category: "tools", content: "data", target_path: join(tmpDir, "f.txt") }, db); const ref = createConfig({ name: "Ref", category: "workspace", content: "doc", kind: "reference" }, db); - const results = await applyConfigs([file, ref], { db }); + const results = await applyConfigs([file, ref], { store: new LocalConfigStore(db) }); expect(results.length).toBe(1); // only file applied expect(results[0]!.config_id).toBe(file.id); }); @@ -43,7 +44,7 @@ describe("applyConfigs (batch)", () => { test("dry-run returns results without writing", async () => { const db = getDatabase(); const c = createConfig({ name: "Dry", category: "tools", content: "test", target_path: join(tmpDir, "dry.txt") }, db); - const results = await applyConfigs([c], { dryRun: true, db }); + const results = await applyConfigs([c], { dryRun: true, store: new LocalConfigStore(db) }); expect(results.length).toBe(1); expect(results[0]!.dry_run).toBe(true); expect(existsSync(join(tmpDir, "dry.txt"))).toBe(false); diff --git a/src/lib/apply.test.ts b/src/lib/apply.test.ts index ad426f1..3d902f0 100644 --- a/src/lib/apply.test.ts +++ b/src/lib/apply.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync, symlinkSync } from "node:fs"; import { join } from "node:path"; @@ -26,7 +27,7 @@ describe("applyConfig", () => { const db = getDatabase(); const target = join(tmpDir, "test.md"); const c = createConfig({ name: "T", category: "rules", content: "hello", target_path: target }, db); - await applyConfig(c, { db }); + await applyConfig(c, { store: new LocalConfigStore(db) }); expect(readFileSync(target, "utf-8")).toBe("hello"); }); @@ -34,7 +35,7 @@ describe("applyConfig", () => { const db = getDatabase(); const target = join(tmpDir, "dry.md"); const c = createConfig({ name: "T", category: "rules", content: "hello", target_path: target }, db); - const result = await applyConfig(c, { dryRun: true, db }); + const result = await applyConfig(c, { dryRun: true, store: new LocalConfigStore(db) }); expect(existsSync(target)).toBe(false); expect(result.dry_run).toBe(true); }); @@ -43,7 +44,7 @@ describe("applyConfig", () => { const db = getDatabase(); const target = join(tmpDir, "deep", "nested", "file.txt"); const c = createConfig({ name: "T", category: "tools", content: "data", target_path: target }, db); - await applyConfig(c, { db }); + await applyConfig(c, { store: new LocalConfigStore(db) }); expect(existsSync(target)).toBe(true); }); @@ -52,7 +53,7 @@ describe("applyConfig", () => { const target = join(tmpDir, "same.txt"); writeFileSync(target, "same"); const c = createConfig({ name: "T", category: "tools", content: "same", target_path: target }, db); - const result = await applyConfig(c, { db }); + const result = await applyConfig(c, { store: new LocalConfigStore(db) }); expect(result.changed).toBe(false); }); @@ -61,7 +62,7 @@ describe("applyConfig", () => { const target = join(tmpDir, "existing.txt"); writeFileSync(target, "old content"); const c = createConfig({ name: "T", category: "tools", content: "new content", target_path: target }, db); - const result = await applyConfig(c, { db }); + const result = await applyConfig(c, { store: new LocalConfigStore(db) }); expect(result.previous_content).toBe("old content"); expect(result.new_content).toBe("new content"); }); @@ -69,7 +70,7 @@ describe("applyConfig", () => { test("throws for reference kind", async () => { const db = getDatabase(); const c = createConfig({ name: "Ref", category: "workspace", content: "doc", kind: "reference" }, db); - expect(applyConfig(c, { db })).rejects.toThrow("reference"); + expect(applyConfig(c, { store: new LocalConfigStore(db) })).rejects.toThrow("reference"); }); test("renders machine-aware variables in content and target path", async () => { @@ -93,7 +94,7 @@ describe("applyConfig", () => { target_path: join(tmpDir, "{{HOSTNAME}}.txt"), is_template: true, }, db); - const result = await applyConfig(c, { db, vars }); + const result = await applyConfig(c, { store: new LocalConfigStore(db), vars }); expect(result.path).toBe(join(tmpDir, "macos-node-a.txt")); expect(readFileSync(result.path, "utf-8")).toBe(`workspace=${tmpDir}/Workspace`); }); @@ -139,7 +140,7 @@ describe("applyConfig", () => { ], }, db); - const result = await applyConfig(c, { db }); + const result = await applyConfig(c, { store: new LocalConfigStore(db) }); expect(result.outputs?.length).toBe(5); expect(readFileSync(claudeTarget, "utf-8")).toContain("Claude-specific local detail"); @@ -177,8 +178,8 @@ describe("applyConfig", () => { format: "markdown", }, db); - await applyConfig(canonical, { db }); - await expect(applyConfig(stale, { db })).rejects.toThrow("generated output"); + await applyConfig(canonical, { store: new LocalConfigStore(db) }); + await expect(applyConfig(stale, { store: new LocalConfigStore(db) })).rejects.toThrow("generated output"); expect(readFileSync(codexTarget, "utf-8")).toContain("Generated"); expect(readFileSync(codexTarget, "utf-8")).not.toContain("# stale"); @@ -207,8 +208,8 @@ describe("applyConfig", () => { format: "markdown", }, db); - await applyConfig(canonical, { db }); - await expect(applyConfig(stale, { db })).rejects.toThrow("generated output"); + await applyConfig(canonical, { store: new LocalConfigStore(db) }); + await expect(applyConfig(stale, { store: new LocalConfigStore(db) })).rejects.toThrow("generated output"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Generated"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).not.toContain("absolute stale"); @@ -239,8 +240,8 @@ describe("applyConfig", () => { format: "markdown", }, db); - await applyConfig(canonical, { db }); - await expect(applyConfig(stale, { db })).rejects.toThrow("generated output"); + await applyConfig(canonical, { store: new LocalConfigStore(db) }); + await expect(applyConfig(stale, { store: new LocalConfigStore(db) })).rejects.toThrow("generated output"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Generated"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).not.toContain("symlink stale"); @@ -271,7 +272,7 @@ describe("applyConfig", () => { format: "markdown", }, db); - await expect(applyConfig(stale, { db })).rejects.toThrow("generated output"); + await expect(applyConfig(stale, { store: new LocalConfigStore(db) })).rejects.toThrow("generated output"); expect(existsSync(join(tmpDir, ".codex", "AGENTS.md"))).toBe(false); }); }); diff --git a/src/lib/apply.ts b/src/lib/apply.ts index 03f7738..cc621cf 100644 --- a/src/lib/apply.ts +++ b/src/lib/apply.ts @@ -3,9 +3,7 @@ import { basename, dirname, resolve } from "node:path"; import { homedir } from "node:os"; import type { ApplyResult, Config, ConfigOutput } from "../types/index.js"; import { ConfigApplyError } from "../types/index.js"; -import { getDatabase, now } from "../db/database.js"; -import { listConfigs, updateConfig } from "../db/configs.js"; -import { createSnapshot } from "../db/snapshots.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import type { ProfileVariables } from "../types/index.js"; import { renderMachineAwareContent } from "./machine.js"; import { applyTransform } from "./transforms.js"; @@ -48,7 +46,7 @@ export function normalizeTargetPath(p: string): string { export interface ApplyOptions { dryRun?: boolean; force?: boolean; - db?: ReturnType; + store?: ConfigStore; vars?: ProfileVariables; outputAgent?: Config["agent"]; } @@ -79,8 +77,8 @@ async function writeConfigResult( } if (previousContent !== null && changed) { - const db = opts.db || getDatabase(); - createSnapshot(config.id, previousContent, config.version, db); + const store = opts.store ?? resolveConfigStore(); + await store.createSnapshot(config.id, previousContent, config.version); } writeFileSync(path, renderedContent, "utf-8"); @@ -121,8 +119,8 @@ export async function applyConfig( ); } - const db = opts.db || getDatabase(); - const contextConfigs = selectedOutputs.length > 0 || config.target_path ? listConfigs(undefined, db) : [config]; + const store = opts.store ?? resolveConfigStore(); + const contextConfigs = selectedOutputs.length > 0 || config.target_path ? await store.listConfigs() : [config]; if (isGeneratedOutputTarget(config, contextConfigs)) { throw new ConfigApplyError( `Config "${config.name}" targets a generated output path. Apply the canonical source config instead.` @@ -149,7 +147,7 @@ export async function applyConfig( } if (!opts.dryRun) { - updateConfig(config.id, { synced_at: now() }, db); + await store.updateConfig(config.id, { synced_at: new Date().toISOString() }); } return result; diff --git a/src/lib/export-import.test.ts b/src/lib/export-import.test.ts index b4eeceb..1f57f8b 100644 --- a/src/lib/export-import.test.ts +++ b/src/lib/export-import.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { existsSync, rmSync, mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -28,7 +29,7 @@ describe("export + import roundtrip", () => { createConfig({ name: "test-tools", category: "tools", content: "data", agent: "npm" }, db); const outPath = join(tmpDir, "test-export.tar.gz"); - const exportResult = await exportConfigs(outPath, { db }); + const exportResult = await exportConfigs(outPath, { store: new LocalConfigStore(db) }); expect(exportResult.count).toBe(2); expect(existsSync(outPath)).toBe(true); @@ -36,7 +37,7 @@ describe("export + import roundtrip", () => { resetDatabase(); process.env["CONFIGS_DB_PATH"] = ":memory:"; const db2 = getDatabase(); - const importResult = await importConfigs(outPath, { db: db2 }); + const importResult = await importConfigs(outPath, { store: new LocalConfigStore(db2) }); expect(importResult.created).toBe(2); expect(importResult.errors.length).toBe(0); @@ -49,9 +50,9 @@ describe("export + import roundtrip", () => { createConfig({ name: "existing", category: "rules", content: "original" }, db); const outPath = join(tmpDir, "conflict-test.tar.gz"); - await exportConfigs(outPath, { db }); + await exportConfigs(outPath, { store: new LocalConfigStore(db) }); - const importResult = await importConfigs(outPath, { db, conflict: "skip" }); + const importResult = await importConfigs(outPath, { store: new LocalConfigStore(db), conflict: "skip" }); expect(importResult.skipped).toBe(1); expect(importResult.created).toBe(0); }); @@ -61,14 +62,14 @@ describe("export + import roundtrip", () => { const c = createConfig({ name: "overwrite-me", category: "rules", content: "v1" }, db); const outPath = join(tmpDir, "overwrite-test.tar.gz"); - await exportConfigs(outPath, { db }); + await exportConfigs(outPath, { store: new LocalConfigStore(db) }); // Modify the config const { updateConfig } = await import("../db/configs"); updateConfig(c.id, { content: "v2-modified" }, db); // Import with overwrite — should restore to v1 - const importResult = await importConfigs(outPath, { db, conflict: "overwrite" }); + const importResult = await importConfigs(outPath, { store: new LocalConfigStore(db), conflict: "overwrite" }); expect(importResult.updated).toBe(1); }); }); diff --git a/src/lib/export.ts b/src/lib/export.ts index 2ff4c62..1540d12 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -2,21 +2,20 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import type { ConfigFilter, ExportManifest } from "../types/index.js"; -import { getDatabase, now } from "../db/database.js"; -import { listConfigs, getConfig } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; export interface ExportOptions { filter?: ConfigFilter; profileId?: string; - db?: ReturnType; + store?: ConfigStore; } export async function exportConfigs( outputPath: string, opts: ExportOptions = {} ): Promise<{ path: string; count: number }> { - const d = opts.db || getDatabase(); - const configs = listConfigs(opts.filter, d); + const store = opts.store ?? resolveConfigStore(); + const configs = await store.listConfigs(opts.filter); const absOutput = resolve(outputPath); const tmpDir = join(tmpdir(), `configs-export-${Date.now()}`); @@ -28,7 +27,7 @@ export async function exportConfigs( // Write manifest (metadata only, no content) const manifest: ExportManifest = { version: "1.0.0", - exported_at: now(), + exported_at: new Date().toISOString(), configs: configs.map(({ content: _content, ...meta }) => meta), }; writeFileSync(join(tmpDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8"); diff --git a/src/lib/import.ts b/src/lib/import.ts index 8394b66..7131b8d 100644 --- a/src/lib/import.ts +++ b/src/lib/import.ts @@ -2,14 +2,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import type { ExportManifest } from "../types/index.js"; -import { getDatabase } from "../db/database.js"; -import { createConfig, getConfig, updateConfig } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; export type ImportConflict = "skip" | "overwrite" | "version"; export interface ImportOptions { conflict?: ImportConflict; - db?: ReturnType; + store?: ConfigStore; } export interface ImportResult { @@ -23,7 +22,7 @@ export async function importConfigs( bundlePath: string, opts: ImportOptions = {} ): Promise { - const d = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const conflict = opts.conflict ?? "skip"; const absPath = resolve(bundlePath); const tmpDir = join(tmpdir(), `configs-import-${Date.now()}`); @@ -56,18 +55,18 @@ export async function importConfigs( const content = existsSync(contentFile) ? readFileSync(contentFile, "utf-8") : ""; // Check if exists by slug - let existing: Awaited> | null = null; - try { existing = getConfig(meta.slug, d); } catch { /* not found */ } + let existing: Awaited> | null = null; + try { existing = await store.getConfig(meta.slug); } catch { /* not found */ } if (existing) { if (conflict === "skip") { result.skipped++; } else if (conflict === "overwrite" || conflict === "version") { - updateConfig(existing.id, { content, description: meta.description ?? undefined, tags: meta.tags, outputs: meta.outputs }, d); + await store.updateConfig(existing.id, { content, description: meta.description ?? undefined, tags: meta.tags, outputs: meta.outputs }); result.updated++; } } else { - createConfig({ + await store.createConfig({ name: meta.name, kind: meta.kind, category: meta.category, @@ -79,7 +78,7 @@ export async function importConfigs( description: meta.description ?? undefined, tags: meta.tags, is_template: meta.is_template, - }, d); + }); result.created++; } } catch (err) { diff --git a/src/lib/platform-profiles.ts b/src/lib/platform-profiles.ts index c574472..1201d6d 100644 --- a/src/lib/platform-profiles.ts +++ b/src/lib/platform-profiles.ts @@ -1,9 +1,15 @@ -import type { Database } from "bun:sqlite"; import type { CreateProfileInput, Profile } from "../types/index.js"; -import { addConfigToProfile, createProfile, getProfile, profileHasSelectors, updateProfile } from "../db/profiles.js"; -import { listConfigs } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import { PROJECT_DASHBOARD_PROFILE_VARIABLES } from "./project-dashboard-standard.js"; +/** Pure selector check (mirrors db profileHasSelectors without touching sqlite). */ +function profileHasSelectors(profile: Pick): boolean { + const selectors = profile.selectors ?? {}; + return (selectors.os?.length ?? 0) > 0 + || (selectors.arch?.length ?? 0) > 0 + || (selectors.hostnames?.length ?? 0) > 0; +} + export const PLATFORM_PROFILE_PRESETS: CreateProfileInput[] = [ { name: "linux-arm64", @@ -31,27 +37,27 @@ export const PLATFORM_PROFILE_PRESETS: CreateProfileInput[] = [ }, ]; -export function ensurePlatformProfiles(db?: Database): Profile[] { - const configs = listConfigs(undefined, db); +export async function ensurePlatformProfiles(store: ConfigStore = resolveConfigStore()): Promise { + const configs = await store.listConfigs(); const ensured: Profile[] = []; for (const preset of PLATFORM_PROFILE_PRESETS) { let profile: Profile; try { - profile = getProfile(preset.name, db); + profile = await store.getProfile(preset.name); if (!profileHasSelectors(profile) || Object.keys(profile.variables).length === 0) { - profile = updateProfile(profile.id, { + profile = await store.updateProfile(profile.id, { description: profile.description ?? preset.description, selectors: profileHasSelectors(profile) ? profile.selectors : preset.selectors, variables: Object.keys(profile.variables).length > 0 ? profile.variables : preset.variables, - }, db); + }); } } catch { - profile = createProfile(preset, db); + profile = await store.createProfile(preset); } for (const config of configs) { - addConfigToProfile(profile.id, config.id, db); + await store.addConfigToProfile(profile.id, config.id); } ensured.push(profile); } diff --git a/src/lib/project-dashboard-standard.test.ts b/src/lib/project-dashboard-standard.test.ts index 8f4935d..0c4b930 100644 --- a/src/lib/project-dashboard-standard.test.ts +++ b/src/lib/project-dashboard-standard.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { beforeEach, describe, expect, test } from "bun:test"; import type { Database } from "bun:sqlite"; import { createConfig, getConfig } from "../db/configs"; @@ -20,8 +21,8 @@ beforeEach(() => { }); describe("project dashboard standard", () => { - test("seeds the agent-managed project dashboard reference", () => { - const config = ensureProjectDashboardStandardConfig(db); + test("seeds the agent-managed project dashboard reference", async () => { + const config = await ensureProjectDashboardStandardConfig(new LocalConfigStore(db)); expect(config.slug).toBe(PROJECT_DASHBOARD_STANDARD_SLUG); expect(config.kind).toBe("reference"); @@ -34,7 +35,7 @@ describe("project dashboard standard", () => { expect(config.content).not.toContain(`sk-${"proj"}-`); }); - test("updates stale seeded content instead of creating a duplicate", () => { + test("updates stale seeded content instead of creating a duplicate", async () => { createConfig({ name: "Agent Managed Project Dashboard Standard", category: "workspace", @@ -44,7 +45,7 @@ describe("project dashboard standard", () => { content: "old content", }, db); - const config = ensureProjectDashboardStandardConfig(db); + const config = await ensureProjectDashboardStandardConfig(new LocalConfigStore(db)); const stored = getConfig(PROJECT_DASHBOARD_STANDARD_SLUG, db); expect(config.id).toBe(stored.id); @@ -52,9 +53,9 @@ describe("project dashboard standard", () => { expect(stored.version).toBe(2); }); - test("platform profiles include dashboard variables and link the standard config", () => { - const standard = ensureProjectDashboardStandardConfig(db); - const profiles = ensurePlatformProfiles(db); + test("platform profiles include dashboard variables and link the standard config", async () => { + const standard = await ensureProjectDashboardStandardConfig(new LocalConfigStore(db)); + const profiles = await ensurePlatformProfiles(new LocalConfigStore(db)); for (const preset of PLATFORM_PROFILE_PRESETS) { expect(preset.variables).toMatchObject(PROJECT_DASHBOARD_PROFILE_VARIABLES); diff --git a/src/lib/project-dashboard-standard.ts b/src/lib/project-dashboard-standard.ts index 7cccfcf..9315135 100644 --- a/src/lib/project-dashboard-standard.ts +++ b/src/lib/project-dashboard-standard.ts @@ -1,6 +1,5 @@ -import type { Database } from "bun:sqlite"; import type { Config, ProfileVariables } from "../types/index.js"; -import { createConfig, getConfig, updateConfig } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; export const PROJECT_DASHBOARD_STANDARD_SLUG = "agent-managed-project-dashboard-standard"; @@ -82,7 +81,7 @@ ids, passport numbers, credentials, and contract clauses unless an explicit approved storage policy exists. `; -export function ensureProjectDashboardStandardConfig(db?: Database): Config { +export async function ensureProjectDashboardStandardConfig(store: ConfigStore = resolveConfigStore()): Promise { const input = { name: "Agent Managed Project Dashboard Standard", category: "workspace" as const, @@ -95,7 +94,7 @@ export function ensureProjectDashboardStandardConfig(db?: Database): Config { }; try { - const existing = getConfig(PROJECT_DASHBOARD_STANDARD_SLUG, db); + const existing = await store.getConfig(PROJECT_DASHBOARD_STANDARD_SLUG); if ( existing.content !== input.content || existing.description !== input.description @@ -104,10 +103,10 @@ export function ensureProjectDashboardStandardConfig(db?: Database): Config { || existing.format !== input.format || existing.kind !== input.kind ) { - return updateConfig(existing.id, input, db); + return await store.updateConfig(existing.id, input); } return existing; } catch { - return createConfig(input, db); + return await store.createConfig(input); } } diff --git a/src/lib/sync-dir.test.ts b/src/lib/sync-dir.test.ts index 6b29e14..e8d33ae 100644 --- a/src/lib/sync-dir.test.ts +++ b/src/lib/sync-dir.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { writeFileSync, mkdirSync, existsSync, rmSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -25,7 +26,7 @@ describe("syncToDir", () => { const db = getDatabase(); const target = join(tmpDir, "output.txt"); createConfig({ name: "Out", category: "tools", content: "hello", target_path: target }, db); - const result = await syncToDir(tmpDir, { db }); + const result = await syncToDir(tmpDir, { store: new LocalConfigStore(db) }); expect(result.updated + result.unchanged).toBeGreaterThanOrEqual(0); }); @@ -33,14 +34,14 @@ describe("syncToDir", () => { const db = getDatabase(); const target = join(tmpDir, "dryout.txt"); createConfig({ name: "Dry", category: "tools", content: "data", target_path: target }, db); - await syncToDir(tmpDir, { db, dryRun: true }); + await syncToDir(tmpDir, { store: new LocalConfigStore(db), dryRun: true }); expect(existsSync(target)).toBe(false); }); test("skips reference kind configs", async () => { const db = getDatabase(); createConfig({ name: "Ref", category: "workspace", content: "doc", kind: "reference" }, db); - const result = await syncToDir(tmpDir, { db }); + const result = await syncToDir(tmpDir, { store: new LocalConfigStore(db) }); expect(result.updated).toBe(0); expect(result.unchanged).toBe(0); }); @@ -53,7 +54,7 @@ describe("syncFromDir recursive", () => { mkdirSync(nested, { recursive: true }); writeFileSync(join(tmpDir, "root.txt"), "root"); writeFileSync(join(nested, "deep.txt"), "deep"); - const result = await syncFromDir(tmpDir, { db, recursive: true }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: true }); expect(result.added).toBe(2); expect(listConfigs(undefined, db).length).toBe(2); }); @@ -65,7 +66,7 @@ describe("syncFromDir recursive", () => { writeFileSync(join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main"); writeFileSync(join(tmpDir, "node_modules", "pkg.json"), "{}"); writeFileSync(join(tmpDir, "real.txt"), "real"); - const result = await syncFromDir(tmpDir, { db }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db) }); expect(result.added).toBe(1); // only real.txt }); @@ -73,7 +74,7 @@ describe("syncFromDir recursive", () => { const db = getDatabase(); writeFileSync(join(tmpDir, "data.db"), "binary"); writeFileSync(join(tmpDir, "config.txt"), "config"); - const result = await syncFromDir(tmpDir, { db }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db) }); expect(result.added).toBe(1); // only config.txt }); @@ -82,7 +83,7 @@ describe("syncFromDir recursive", () => { mkdirSync(join(tmpDir, "sub"), { recursive: true }); writeFileSync(join(tmpDir, "top.txt"), "top"); writeFileSync(join(tmpDir, "sub", "nested.txt"), "nested"); - const result = await syncFromDir(tmpDir, { db, recursive: false }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); expect(result.added).toBe(1); // only top.txt }); }); diff --git a/src/lib/sync-dir.ts b/src/lib/sync-dir.ts index fac8513..41e03cc 100644 --- a/src/lib/sync-dir.ts +++ b/src/lib/sync-dir.ts @@ -4,8 +4,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { homedir } from "node:os"; import type { SyncResult } from "../types/index.js"; -import { getDatabase } from "../db/database.js"; -import { createConfig, listConfigs, updateConfig } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import { applyConfig, expandPath } from "./apply.js"; import { detectAgent, detectCategory, detectFormat } from "./sync.js"; @@ -13,13 +12,13 @@ const SKIP = [".db", ".db-shm", ".db-wal", ".log", ".lock", ".DS_Store", "node_m function shouldSkip(p: string) { return SKIP.some((s) => p.includes(s)); } export interface SyncFromDirOptions { - db?: ReturnType; + store?: ConfigStore; dryRun?: boolean; recursive?: boolean; } export async function syncFromDir(dir: string, opts: SyncFromDirOptions = {}): Promise { - const d = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const absDir = expandPath(dir); if (!existsSync(absDir)) return { added: 0, updated: 0, unchanged: 0, skipped: [`Not found: ${absDir}`] }; @@ -29,7 +28,7 @@ export async function syncFromDir(dir: string, opts: SyncFromDirOptions = {}): P const result: SyncResult = { added: 0, updated: 0, unchanged: 0, skipped: [] }; const home = homedir(); - const allConfigs = listConfigs(undefined, d); + const allConfigs = await store.listConfigs(); for (const file of files) { if (shouldSkip(file)) { result.skipped.push(file); continue; } @@ -39,10 +38,10 @@ export async function syncFromDir(dir: string, opts: SyncFromDirOptions = {}): P const targetPath = file.replace(home, "~"); const existing = allConfigs.find((c) => c.target_path === targetPath); if (!existing) { - if (!opts.dryRun) createConfig({ name: relative(absDir, file), category: detectCategory(file), agent: detectAgent(file), target_path: targetPath, format: detectFormat(file), content }, d); + if (!opts.dryRun) await store.createConfig({ name: relative(absDir, file), category: detectCategory(file), agent: detectAgent(file), target_path: targetPath, format: detectFormat(file), content }); result.added++; } else if (existing.content !== content) { - if (!opts.dryRun) updateConfig(existing.id, { content }, d); + if (!opts.dryRun) await store.updateConfig(existing.id, { content }); result.updated++; } else { result.unchanged++; } } catch { result.skipped.push(file); } @@ -50,17 +49,17 @@ export async function syncFromDir(dir: string, opts: SyncFromDirOptions = {}): P return result; } -export async function syncToDir(dir: string, opts: { db?: ReturnType; dryRun?: boolean } = {}): Promise { - const d = opts.db || getDatabase(); +export async function syncToDir(dir: string, opts: { store?: ConfigStore; dryRun?: boolean } = {}): Promise { + const store = opts.store ?? resolveConfigStore(); const home = homedir(); const absDir = expandPath(dir); const normalized = dir.startsWith("~/") ? dir : absDir.replace(home, "~"); - const configs = listConfigs(undefined, d).filter((c) => c.target_path && (c.target_path.startsWith(normalized) || c.target_path.startsWith(absDir))); + const configs = (await store.listConfigs()).filter((c) => c.target_path && (c.target_path.startsWith(normalized) || c.target_path.startsWith(absDir))); const result: SyncResult = { added: 0, updated: 0, unchanged: 0, skipped: [] }; for (const config of configs) { if (config.kind === "reference") continue; try { - const r = await applyConfig(config, { dryRun: opts.dryRun, db: d }); + const r = await applyConfig(config, { dryRun: opts.dryRun, store }); r.changed ? result.updated++ : result.unchanged++; } catch { result.skipped.push(config.target_path || config.id); } } diff --git a/src/lib/sync-known.test.ts b/src/lib/sync-known.test.ts index cc7393e..fc2a434 100644 --- a/src/lib/sync-known.test.ts +++ b/src/lib/sync-known.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { writeFileSync, mkdirSync, existsSync, rmSync, readFileSync, symlinkSync } from "node:fs"; import { join } from "node:path"; @@ -76,7 +77,7 @@ describe("KNOWN_CONFIGS", () => { describe("syncKnown", () => { test("dry-run does not write to DB", async () => { const db = getDatabase(); - const result = await syncKnown({ db, dryRun: true }); + const result = await syncKnown({ store: new LocalConfigStore(db), dryRun: true }); // Should report found files but not write them expect(listConfigs(undefined, db).length).toBe(0); expect(result.added + result.unchanged + result.skipped.length).toBeGreaterThan(0); @@ -84,7 +85,7 @@ describe("syncKnown", () => { test("filters by agent", async () => { const db = getDatabase(); - const result = await syncKnown({ db, agent: "git", dryRun: true }); + const result = await syncKnown({ store: new LocalConfigStore(db), agent: "git", dryRun: true }); // Should only report git configs expect(result.skipped.every((s) => !s.includes(".claude/"))).toBe(true); }); @@ -97,7 +98,7 @@ describe("syncKnown", () => { mkdirSync(join(tmpDir, ".cursor", "rules"), { recursive: true }); writeFileSync(join(tmpDir, ".cursor", "rules", "security.mdc"), "---\nalwaysApply: true\n---\n# Security"); - const result = await syncKnown({ db, agent: "cursor" }); + const result = await syncKnown({ store: new LocalConfigStore(db), agent: "cursor" }); const configs = listConfigs({ agent: "cursor" }, db); expect(result.added).toBe(1); @@ -120,7 +121,7 @@ describe("syncKnown", () => { writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nShared."); writeFileSync(join(tmpDir, ".claude", "rules", "security.md"), "# Security"); - const result = await syncKnown({ db, agent: "claude" }); + const result = await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); const config = getConfig("claude-claude-md", db); expect(result.added).toBe(2); @@ -153,7 +154,7 @@ describe("syncKnown", () => { outputs: [], }, db); - const result = await syncKnown({ db, agent: "claude" }); + const result = await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); const config = getConfig("claude-claude-md", db); expect(result.updated).toBe(1); @@ -167,7 +168,7 @@ describe("syncProject", () => { const projDir = join(tmpDir, "test-project"); mkdirSync(projDir, { recursive: true }); writeFileSync(join(projDir, "CLAUDE.md"), "# Test Project\n\nHello."); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.added).toBe(1); const configs = listConfigs(undefined, db); expect(configs.length).toBe(1); @@ -179,7 +180,7 @@ describe("syncProject", () => { const projDir = join(tmpDir, "mcp-project"); mkdirSync(projDir, { recursive: true }); writeFileSync(join(projDir, ".mcp.json"), '{"mcpServers":{}}'); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.added).toBe(1); }); @@ -188,7 +189,7 @@ describe("syncProject", () => { const projDir = join(tmpDir, "rules-project"); mkdirSync(join(projDir, ".claude", "rules"), { recursive: true }); writeFileSync(join(projDir, ".claude", "rules", "test.md"), "# Test Rule"); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.added).toBe(1); }); @@ -197,14 +198,14 @@ describe("syncProject", () => { const projDir = join(tmpDir, "dry-project"); mkdirSync(projDir, { recursive: true }); writeFileSync(join(projDir, "CLAUDE.md"), "# Dry"); - await syncProject({ db, projectDir: projDir, dryRun: true }); + await syncProject({ store: new LocalConfigStore(db), projectDir: projDir, dryRun: true }); // dry-run should not persist anything to DB expect(listConfigs(undefined, db).length).toBe(0); }); test("skips empty project dir", async () => { const db = getDatabase(); - const result = await syncProject({ db, projectDir: join(tmpDir, "empty") }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: join(tmpDir, "empty") }); expect(result.added).toBe(0); }); @@ -217,7 +218,7 @@ describe("syncProject", () => { join(projDir, "AGENTS.md"), `workspace=${machine.workspace_root}\ncommand=${machine.bun_bin_dir}/configs-mcp\nbun=${machine.bun_path}` ); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.added).toBe(1); const configs = listConfigs(undefined, db); expect(configs[0]!.content).toContain("{{WORKSPACE_ROOT}}"); @@ -243,7 +244,7 @@ describe("syncToDisk", () => { const target = join(tmpDir, "sync-to-disk.txt"); createConfig({ name: "ToDisk", category: "tools", content: "written by syncToDisk", target_path: target }, db); const { syncToDisk } = await import("./sync"); - const result = await syncToDisk({ db }); + const result = await syncToDisk({ store: new LocalConfigStore(db) }); expect(existsSync(target)).toBe(true); expect(readFileSync(target, "utf-8")).toBe("written by syncToDisk"); }); @@ -255,7 +256,7 @@ describe("syncToDisk", () => { createConfig({ name: "Claude", category: "agent", agent: "claude", content: "claude", target_path: target1 }, db); createConfig({ name: "Codex", category: "agent", agent: "codex", content: "codex", target_path: target2 }, db); const { syncToDisk } = await import("./sync"); - const result = await syncToDisk({ db, agent: "claude" }); + const result = await syncToDisk({ store: new LocalConfigStore(db), agent: "claude" }); expect(existsSync(target1)).toBe(true); expect(existsSync(target2)).toBe(false); }); @@ -265,7 +266,7 @@ describe("syncToDisk", () => { const target = join(tmpDir, "no-write.txt"); createConfig({ name: "NoWrite", category: "tools", content: "nope", target_path: target }, db); const { syncToDisk } = await import("./sync"); - await syncToDisk({ db, dryRun: true }); + await syncToDisk({ store: new LocalConfigStore(db), dryRun: true }); expect(existsSync(target)).toBe(false); }); @@ -273,7 +274,7 @@ describe("syncToDisk", () => { const db = getDatabase(); createConfig({ name: "NoPath", category: "workspace", content: "ref", kind: "reference" }, db); const { syncToDisk } = await import("./sync"); - const result = await syncToDisk({ db }); + const result = await syncToDisk({ store: new LocalConfigStore(db) }); expect(result.skipped.length).toBe(0); expect(result.updated).toBe(0); }); @@ -286,10 +287,10 @@ describe("syncToDisk", () => { writeFileSync(join(tmpDir, ".claude", "rules", "security.md"), "# Security\n\nRule"); const { syncToDisk } = await import("./sync"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); - await syncKnown({ db }); + await syncKnown({ store: new LocalConfigStore(db) }); expect(listConfigs({ agent: "codex" }, db).some((config) => config.target_path === "~/.codex/AGENTS.md")).toBe(false); createConfig({ @@ -310,8 +311,8 @@ describe("syncToDisk", () => { }, db); writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 2"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Version 2"); expect(readFileSync(join(tmpDir, ".codewith", "CODEWITH.md"), "utf-8")).toContain("Version 2"); @@ -324,13 +325,13 @@ describe("syncToDisk", () => { writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 1"); const { syncToDisk } = await import("./sync"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db, agent: "codex" }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db), agent: "codex" }); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Version 1"); writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 2"); - await syncKnown({ db, agent: "claude" }); - const result = await syncToDisk({ db, agent: "codex" }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + const result = await syncToDisk({ store: new LocalConfigStore(db), agent: "codex" }); expect(result.updated).toBe(1); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Version 2"); @@ -344,8 +345,8 @@ describe("syncToDisk", () => { writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 1"); const { syncToDisk } = await import("./sync"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); createConfig({ name: "zz-stale-codex-generated-absolute", @@ -357,8 +358,8 @@ describe("syncToDisk", () => { }, db); writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 2"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Version 2"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).not.toContain("absolute stale"); @@ -373,8 +374,8 @@ describe("syncToDisk", () => { writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 1"); const { syncToDisk } = await import("./sync"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); createConfig({ name: "zz-stale-codex-generated-symlink", @@ -386,8 +387,8 @@ describe("syncToDisk", () => { }, db); writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nVersion 2"); - await syncKnown({ db, agent: "claude" }); - await syncToDisk({ db }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); + await syncToDisk({ store: new LocalConfigStore(db) }); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Version 2"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).not.toContain("symlink stale"); @@ -400,7 +401,7 @@ describe("syncToDisk", () => { symlinkSync(tmpDir, linkHome, "dir"); mkdirSync(join(tmpDir, ".claude", "rules"), { recursive: true }); writeFileSync(join(tmpDir, ".claude", "CLAUDE.md"), "# Claude\n\nGenerated"); - await syncKnown({ db, agent: "claude" }); + await syncKnown({ store: new LocalConfigStore(db), agent: "claude" }); createConfig({ name: "zz-stale-codex-generated-symlink-before-dir", @@ -412,7 +413,7 @@ describe("syncToDisk", () => { }, db); const { syncToDisk } = await import("./sync"); - await syncToDisk({ db }); + await syncToDisk({ store: new LocalConfigStore(db) }); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).toContain("Generated"); expect(readFileSync(join(tmpDir, ".codex", "AGENTS.md"), "utf-8")).not.toContain("symlink stale"); @@ -425,10 +426,10 @@ describe("syncProject — update + unchanged paths", () => { const projDir = join(tmpDir, "update-proj"); mkdirSync(projDir, { recursive: true }); writeFileSync(join(projDir, "CLAUDE.md"), "# Version 1"); - await syncProject({ db, projectDir: projDir }); + await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); // Change content writeFileSync(join(projDir, "CLAUDE.md"), "# Version 2 — updated"); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.updated).toBe(1); }); @@ -437,8 +438,8 @@ describe("syncProject — update + unchanged paths", () => { const projDir = join(tmpDir, "unchanged-proj"); mkdirSync(projDir, { recursive: true }); writeFileSync(join(projDir, "CLAUDE.md"), "# Same content"); - await syncProject({ db, projectDir: projDir }); - const result = await syncProject({ db, projectDir: projDir }); + await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.unchanged).toBeGreaterThanOrEqual(1); expect(result.added).toBe(0); expect(result.updated).toBe(0); @@ -449,9 +450,9 @@ describe("syncProject — update + unchanged paths", () => { const projDir = join(tmpDir, "rules-update-proj"); mkdirSync(join(projDir, ".claude", "rules"), { recursive: true }); writeFileSync(join(projDir, ".claude", "rules", "test.md"), "# Rule v1"); - await syncProject({ db, projectDir: projDir }); + await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); writeFileSync(join(projDir, ".claude", "rules", "test.md"), "# Rule v2 — changed"); - const result = await syncProject({ db, projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.updated).toBe(1); }); @@ -460,8 +461,8 @@ describe("syncProject — update + unchanged paths", () => { const projDir = join(tmpDir, "rules-same-proj"); mkdirSync(join(projDir, ".claude", "rules"), { recursive: true }); writeFileSync(join(projDir, ".claude", "rules", "same.md"), "# Same rule"); - await syncProject({ db, projectDir: projDir }); - const result = await syncProject({ db, projectDir: projDir }); + await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); expect(result.unchanged).toBeGreaterThanOrEqual(1); }); }); diff --git a/src/lib/sync.test.ts b/src/lib/sync.test.ts index 6f8a480..f67c50e 100644 --- a/src/lib/sync.test.ts +++ b/src/lib/sync.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import { writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; import { join } from "node:path"; @@ -49,7 +50,7 @@ describe("syncFromDir", () => { test("adds new files from disk", async () => { writeFileSync(join(tmpDir, "test.md"), "# Hello"); const db = getDatabase(); - const result = await syncFromDir(tmpDir, { db, recursive: false }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); expect(result.added).toBe(1); expect(listConfigs(undefined, db).length).toBe(1); }); @@ -57,8 +58,8 @@ describe("syncFromDir", () => { test("unchanged files are not updated", async () => { const db = getDatabase(); writeFileSync(join(tmpDir, "same.txt"), "same"); - await syncFromDir(tmpDir, { db, recursive: false }); - const result2 = await syncFromDir(tmpDir, { db, recursive: false }); + await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); + const result2 = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); expect(result2.unchanged).toBe(1); expect(result2.updated).toBe(0); }); @@ -67,23 +68,23 @@ describe("syncFromDir", () => { const db = getDatabase(); const file = join(tmpDir, "change.txt"); writeFileSync(file, "v1"); - await syncFromDir(tmpDir, { db, recursive: false }); + await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); writeFileSync(file, "v2"); - const result = await syncFromDir(tmpDir, { db, recursive: false }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), recursive: false }); expect(result.updated).toBe(1); }); test("dry-run does not write to DB", async () => { const db = getDatabase(); writeFileSync(join(tmpDir, "new.md"), "content"); - const result = await syncFromDir(tmpDir, { db, dryRun: true, recursive: false }); + const result = await syncFromDir(tmpDir, { store: new LocalConfigStore(db), dryRun: true, recursive: false }); expect(result.added).toBe(1); expect(listConfigs(undefined, db).length).toBe(0); }); test("returns skipped for missing dir", async () => { const db = getDatabase(); - const result = await syncFromDir("/nonexistent/path", { db }); + const result = await syncFromDir("/nonexistent/path", { store: new LocalConfigStore(db) }); expect(result.skipped.length).toBeGreaterThan(0); }); }); @@ -93,25 +94,25 @@ describe("diffConfig", () => { writeFileSync(join(tmpDir, "same.txt"), "content"); const db = getDatabase(); const c = createConfig({ name: "same", category: "tools", content: "content", target_path: join(tmpDir, "same.txt") }, db); - expect(diffConfig(c)).toBe("(no diff — identical)"); + expect(await diffConfig(c)).toBe("(no diff — identical)"); }); test("returns diff for different content", async () => { writeFileSync(join(tmpDir, "diff.txt"), "disk content"); const db = getDatabase(); const c = createConfig({ name: "diff", category: "tools", content: "stored content", target_path: join(tmpDir, "diff.txt") }, db); - const diff = diffConfig(c); + const diff = await diffConfig(c); expect(diff).toContain("-stored content"); expect(diff).toContain("+disk content"); }); - test("returns file not found for missing path", () => { + test("returns file not found for missing path", async () => { const db = getDatabase(); const c = createConfig({ name: "missing", category: "tools", content: "x", target_path: join(tmpDir, "nope.txt") }, db); - expect(diffConfig(c)).toContain("not found on disk"); + expect(await diffConfig(c)).toContain("not found on disk"); }); - test("diff includes transformed output paths", () => { + test("diff includes transformed output paths", async () => { const db = getDatabase(); const primary = join(tmpDir, "CLAUDE.md"); const output = join(tmpDir, "AGENTS.md"); @@ -128,7 +129,7 @@ describe("diffConfig", () => { ], }, db); - const diff = diffConfig(c, { db }); + const diff = await diffConfig(c, { store: new LocalConfigStore(db) }); expect(diff).toContain(`+++ disk (${output})`); expect(diff).toContain("-# Claude"); diff --git a/src/lib/sync.ts b/src/lib/sync.ts index ba3eeb7..d320593 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -1,8 +1,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; import type { Config, ConfigAgent, ConfigCategory, ConfigFormat, ConfigOutput, SyncResult } from "../types/index.js"; -import { getDatabase } from "../db/database.js"; -import { createConfig, listConfigs, updateConfig } from "../db/configs.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import { applyConfig, expandPath, getConfigHome, normalizeTargetPath } from "./apply.js"; import { redactContent } from "./redact.js"; import { detectMachineContext, templateizeMachineContent } from "./machine.js"; @@ -156,17 +155,17 @@ export const PROJECT_CONFIG_FILES = [ ]; export interface SyncProjectOptions { - db?: ReturnType; + store?: ConfigStore; dryRun?: boolean; projectDir: string; } export async function syncProject(opts: SyncProjectOptions): Promise { - const d = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const absDir = expandPath(opts.projectDir); const projectName = absDir.split("/").pop() || "project"; const result: SyncResult = { added: 0, updated: 0, unchanged: 0, skipped: [] }; - const allConfigs = listConfigs(undefined, d); + const allConfigs = await store.listConfigs(); const machine = detectMachineContext(); // Sync project config files @@ -186,10 +185,10 @@ export async function syncProject(opts: SyncProjectOptions): Promise const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug); if (!existing) { - if (!opts.dryRun) createConfig({ name, category: pf.category, agent: pf.agent, format: pf.format, content, target_path: targetPath, is_template: isTemplate }, d); + if (!opts.dryRun) await store.createConfig({ name, category: pf.category, agent: pf.agent, format: pf.format, content, target_path: targetPath, is_template: isTemplate }); result.added++; } else if (existing.content !== content) { - if (!opts.dryRun) updateConfig(existing.id, { content, is_template: isTemplate }, d); + if (!opts.dryRun) await store.updateConfig(existing.id, { content, is_template: isTemplate }); result.updated++; } else { result.unchanged++; @@ -213,10 +212,10 @@ export async function syncProject(opts: SyncProjectOptions): Promise const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug); if (!existing) { - if (!opts.dryRun) createConfig({ name, category: "rules", agent: "claude", format: "markdown", content, target_path: targetPath, is_template: isTemplate }, d); + if (!opts.dryRun) await store.createConfig({ name, category: "rules", agent: "claude", format: "markdown", content, target_path: targetPath, is_template: isTemplate }); result.added++; } else if (existing.content !== content) { - if (!opts.dryRun) updateConfig(existing.id, { content, is_template: isTemplate }, d); + if (!opts.dryRun) await store.updateConfig(existing.id, { content, is_template: isTemplate }); result.updated++; } else { result.unchanged++; } } @@ -226,14 +225,14 @@ export async function syncProject(opts: SyncProjectOptions): Promise } export interface SyncKnownOptions { - db?: ReturnType; + store?: ConfigStore; dryRun?: boolean; agent?: ConfigAgent; category?: ConfigCategory; } export async function syncKnown(opts: SyncKnownOptions = {}): Promise { - const d = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const result: SyncResult = { added: 0, updated: 0, unchanged: 0, skipped: [] }; const home = getConfigHome(); const machine = detectMachineContext(); @@ -242,7 +241,7 @@ export async function syncKnown(opts: SyncKnownOptions = {}): Promise k.agent === opts.agent); if (opts.category) targets = targets.filter((k) => k.category === opts.category); - const allConfigs = listConfigs(undefined, d); + const allConfigs = await store.listConfigs(); const existingOutputOwners = outputOwnerIdsByTarget(allConfigs); for (const known of targets) { @@ -269,13 +268,13 @@ export async function syncKnown(opts: SyncKnownOptions = {}): Promise c.target_path === targetPath || c.slug === slug); const outputs = known.agent === "claude" ? claudeRuleOutputs(f) : known.outputs; if (!existing) { - if (!opts.dryRun) createConfig({ name, category: known.category, agent: known.agent, format: "markdown", content, target_path: targetPath, is_template: isTemplate, outputs }, d); + if (!opts.dryRun) await store.createConfig({ name, category: known.category, agent: known.agent, format: "markdown", content, target_path: targetPath, is_template: isTemplate, outputs }); result.added++; } else if (existing.content !== content) { - if (!opts.dryRun) updateConfig(existing.id, { content, is_template: isTemplate, outputs }, d); + if (!opts.dryRun) await store.updateConfig(existing.id, { content, is_template: isTemplate, outputs }); result.updated++; } else if (!outputsEqual(existing.outputs, outputs)) { - if (!opts.dryRun) updateConfig(existing.id, { outputs }, d); + if (!opts.dryRun) await store.updateConfig(existing.id, { outputs }); result.updated++; } else { result.unchanged++; @@ -305,7 +304,7 @@ export async function syncKnown(opts: SyncKnownOptions = {}): Promise; + store?: ConfigStore; dryRun?: boolean; agent?: ConfigAgent; category?: ConfigCategory; } export async function syncToDisk(opts: SyncToDiskOptions = {}): Promise { - const d = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const result: SyncResult = { added: 0, updated: 0, unchanged: 0, skipped: [] }; - const allFileConfigs = listConfigs({ kind: "file", ...opts.category ? { category: opts.category } : {} }, d); + const allFileConfigs = await store.listConfigs({ kind: "file", ...opts.category ? { category: opts.category } : {} }); const outputOwners = outputOwnerIdsByTarget(allFileConfigs); let configs = allFileConfigs.filter((config) => { return !isGeneratedOutputTarget(config, outputOwners); @@ -359,7 +358,7 @@ export async function syncToDisk(opts: SyncToDiskOptions = {}): Promise; + store?: ConfigStore; } function buildDiff(expectedContent: string, targetPath: string): string { @@ -395,13 +394,13 @@ function buildDiff(expectedContent: string, targetPath: string): string { return lines.join("\n"); } -export function diffConfig(config: Config, opts: DiffConfigOptions = {}): string { +export async function diffConfig(config: Config, opts: DiffConfigOptions = {}): Promise { if (!config.target_path && config.outputs.length === 0) return "(reference — no target path)"; const diffs: string[] = []; - const db = opts.db || getDatabase(); + const store = opts.store ?? resolveConfigStore(); const contextConfigs = config.outputs.length > 0 || config.target_path - ? listConfigs(undefined, db) + ? await store.listConfigs() : [config]; if (isGeneratedOutputTarget(config, outputOwnerIdsByTarget(contextConfigs))) { diff --git a/src/mcp/http.ts b/src/mcp/http.ts index 244c17c..061772f 100644 --- a/src/mcp/http.ts +++ b/src/mcp/http.ts @@ -70,14 +70,3 @@ export async function startMcpHttpServer( }); return { port: httpServer.port!, stop: () => httpServer.stop() }; } - -export function mountMcpHttpRoutes(app: { - get: (path: string, handler: (c: { json: (body: unknown) => Response; req: { raw: Request } }) => Response | Promise) => void; - all: (path: string, handler: (c: { req: { raw: Request } }) => Response | Promise) => void; -}): void { - app.get("/health", (c) => c.json(healthPayload())); - app.all("/mcp", async (c) => { - const response = await handleMcpHttpRequest(c.req.raw); - return response ?? Response.json({ error: "Not found" }, { status: 404 }); - }); -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1441dc4..b0628a4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -2,13 +2,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; -import { getStorageStatus, storagePull, storagePush, storageSync } from "../db/storage-sync.js"; -import { createConfig, getConfig, getConfigStats, listConfigs, updateConfig } from "../db/configs.js"; -import { applyConfig } from "../lib/apply.js"; +import { resolveConfigStore } from "../data/config-store.js"; +import { applyConfig, applyConfigs } from "../lib/apply.js"; import { syncFromDir, syncToDir } from "../lib/sync-dir.js"; -import { getProfile, listProfiles, getProfileConfigs, resolveProfileForMachine } from "../db/profiles.js"; -import { applyConfigs } from "../lib/apply.js"; -import { listSnapshots, getSnapshotByVersion } from "../db/snapshots.js"; import { detectMachineContext, resolveProfileVariables } from "../lib/machine.js"; import { pagedPayload, summarizeApplyResult, summarizeConfig, summarizeProfile } from "../lib/compact-output.js"; import type { ConfigAgent, ConfigCategory, ConfigFormat, ConfigKind, ConfigOutput } from "../types/index.js"; @@ -67,10 +63,6 @@ const ALL_LEAN_TOOLS = [ { name: "set_focus", description: "Set active project context.", inputSchema: { type: "object", properties: { agent_id: { type: "string" }, project_id: { type: "string" } }, required: ["agent_id"] } }, { name: "list_agents", description: "List all registered agents.", inputSchema: { type: "object", properties: {} } }, { name: "send_feedback", description: "Send feedback about this service", inputSchema: { type: "object", properties: { message: { type: "string" }, email: { type: "string" }, category: { type: "string", enum: ["bug", "feature", "general"] } }, required: ["message"] } }, - { name: "storage_status", description: "Show storage sync configuration and local sync history.", inputSchema: { type: "object", properties: {} } }, - { name: "storage_push", description: "Push local configs data to storage PostgreSQL.", inputSchema: { type: "object", properties: { tables: { type: "array", items: { type: "string" } } } } }, - { name: "storage_pull", description: "Pull configs data from storage PostgreSQL to local SQLite.", inputSchema: { type: "object", properties: { tables: { type: "array", items: { type: "string" } } } } }, - { name: "storage_sync", description: "Bidirectional configs sync: pull then push.", inputSchema: { type: "object", properties: { tables: { type: "array", items: { type: "string" } } } } }, ]; function ok(data: unknown) { @@ -96,10 +88,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: LEAN_TOOL server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args = {} } = req.params; + const store = resolveConfigStore(); try { switch (name) { case "list_configs": { - const configs = listConfigs({ + const configs = await store.listConfigs({ category: (args["category"] as ConfigCategory) || undefined, agent: (args["agent"] as ConfigAgent) || undefined, kind: (args["kind"] as ConfigKind) || undefined, @@ -113,11 +106,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { })); } case "get_config": { - const c = getConfig(args["id_or_slug"] as string); + const c = await store.getConfig(args["id_or_slug"] as string); return ok(c); } case "create_config": { - const c = createConfig({ + const c = await store.createConfig({ name: args["name"] as string, content: args["content"] as string, category: args["category"] as ConfigCategory, @@ -133,7 +126,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok({ id: c.id, slug: c.slug, name: c.name }); } case "update_config": { - const c = updateConfig(args["id_or_slug"] as string, { + const c = await store.updateConfig(args["id_or_slug"] as string, { content: args["content"] as string | undefined, name: args["name"] as string | undefined, tags: args["tags"] as string[] | undefined, @@ -146,25 +139,24 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok({ id: c.id, slug: c.slug, version: c.version }); } case "delete_config": { - const { deleteConfig } = await import("../db/configs.js"); - deleteConfig(args["id_or_slug"] as string); + await store.deleteConfig(args["id_or_slug"] as string); return ok({ deleted: true }); } case "apply_config": { - const config = getConfig(args["id_or_slug"] as string); - const result = await applyConfig(config, { dryRun: args["dry_run"] as boolean }); + const config = await store.getConfig(args["id_or_slug"] as string); + const result = await applyConfig(config, { dryRun: args["dry_run"] as boolean, store }); return ok(args["verbose"] ? result : summarizeApplyResult(result)); } case "sync_directory": { const dir = args["dir"] as string; const direction = (args["direction"] as string) || "from_disk"; const result = direction === "to_disk" - ? await syncToDir(dir) - : await syncFromDir(dir); + ? await syncToDir(dir, { store }) + : await syncFromDir(dir, { store }); return ok(result); } case "list_profiles": { - const profiles = listProfiles().map((profile) => summarizeProfile(profile, { verbose: Boolean(args["verbose"]) })); + const profiles = (await store.listProfiles()).map((profile) => summarizeProfile(profile, { verbose: Boolean(args["verbose"]) })); return ok(pagedPayload(profiles, { limit: args["limit"], cursor: args["cursor"], @@ -179,12 +171,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { }); if (!args["auto"] && !args["id_or_slug"]) return err("id_or_slug is required unless auto=true"); const profile = args["auto"] - ? resolveProfileForMachine(machine) - : getProfile(args["id_or_slug"] as string); + ? await store.resolveProfileForMachine(machine) + : await store.getProfile(args["id_or_slug"] as string); if (!profile) return err("No matching machine-aware profile found"); - const configs = getProfileConfigs(profile.id); + const configs = await store.getProfileConfigs(profile.id); const vars = resolveProfileVariables(profile, machine); - const results = await applyConfigs(configs, { dryRun: args["dry_run"] as boolean, vars }); + const results = await applyConfigs(configs, { dryRun: args["dry_run"] as boolean, vars, store }); return ok({ profile: summarizeProfile(profile, { verbose: Boolean(args["verbose"]) }), machine: { @@ -199,17 +191,17 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { }); } case "get_snapshot": { - const config = getConfig(args["config_id_or_slug"] as string); + const config = await store.getConfig(args["config_id_or_slug"] as string); if (args["version"]) { - const snap = getSnapshotByVersion(config.id, args["version"] as number); + const snap = await store.getSnapshotByVersion(config.id, args["version"] as number); return snap ? ok(snap) : err("Snapshot not found"); } - const snaps = listSnapshots(config.id); + const snaps = await store.listSnapshots(config.id); return ok(snaps[0] ?? null); } case "get_status": { - const stats = getConfigStats(); - const allConfigs = listConfigs({ kind: "file" }); + const stats = await store.getConfigStats(); + const allConfigs = await store.listConfigs({ kind: "file" }); const { existsSync: ex, readFileSync: rf } = await import("node:fs"); const { expandPath } = await import("../lib/apply.js"); const { redactContent } = await import("../lib/redact.js"); @@ -238,6 +230,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { case "sync_known": { const { syncKnown } = await import("../lib/sync.js"); const result = await syncKnown({ + store, agent: (args["agent"] as ConfigAgent) || undefined, category: (args["category"] as ConfigCategory) || undefined, }); @@ -246,12 +239,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { case "sync_project": { const { syncProject } = await import("../lib/sync.js"); const dir = (args["project_dir"] as string) || process.cwd(); - const result = await syncProject({ projectDir: dir }); + const result = await syncProject({ store, projectDir: dir }); return ok(result); } case "render_template": { const { renderTemplate } = await import("../lib/template.js"); - const config = getConfig(args["id_or_slug"] as string); + const config = await store.getConfig(args["id_or_slug"] as string); const vars: Record = (args["vars"] as Record) || {}; // Fill from env if requested if (args["use_env"]) { @@ -268,8 +261,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { case "scan_secrets": { const { scanSecrets, redactContent } = await import("../lib/redact.js"); const configs = args["id_or_slug"] - ? [getConfig(args["id_or_slug"] as string)] - : listConfigs({ kind: "file" }); + ? [await store.getConfig(args["id_or_slug"] as string)] + : await store.listConfigs({ kind: "file" }); const findings: Array<{ slug: string; secrets: number; vars: string[] }> = []; for (const c of configs) { const fmt = c.format as "shell" | "json" | "toml" | "ini" | "markdown" | "text"; @@ -278,7 +271,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { findings.push({ slug: c.slug, secrets: secrets.length, vars: secrets.map((s) => s.varName) }); if (args["fix"]) { const { content, isTemplate } = redactContent(c.content, fmt); - updateConfig(c.id, { content, is_template: isTemplate }); + await store.updateConfig(c.id, { content, is_template: isTemplate }); } } } @@ -331,30 +324,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok([..._cfgAgents.values()]); } case "send_feedback": { - const { getDatabase } = await import("../db/database.js"); - const db = getDatabase(); const pkg = require("../../package.json"); - db.run( - "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", - [args["message"] as string, (args["email"] as string) || null, (args["category"] as string) || "general", pkg.version] - ); + await store.sendFeedback({ + message: args["message"] as string, + email: (args["email"] as string) || null, + category: (args["category"] as string) || "general", + version: pkg.version, + }); return ok({ message: "Feedback saved. Thank you!" }); } - case "storage_status": { - return ok(getStorageStatus()); - } - case "storage_push": { - const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; - return ok(await storagePush(tables ? { tables } : undefined)); - } - case "storage_pull": { - const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; - return ok(await storagePull(tables ? { tables } : undefined)); - } - case "storage_sync": { - const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; - return ok(await storageSync(tables ? { tables } : undefined)); - } default: return err(`Unknown tool: ${name}`); } diff --git a/src/server/index.ts b/src/server/index.ts index 29a8c58..4070712 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,16 +1,6 @@ #!/usr/bin/env bun import { Hono } from "hono"; import { cors } from "hono/cors"; -import { createConfig, deleteConfig, getConfig, getConfigById, getConfigStats, listConfigs, updateConfig } from "../db/configs.js"; -import { createProfile, deleteProfile, getProfile, getProfileConfigs, listProfiles, resolveProfileForMachine, updateProfile } from "../db/profiles.js"; -import { listSnapshots, createSnapshot } from "../db/snapshots.js"; -import { listMachines, registerMachine } from "../db/machines.js"; -import { applyConfig, applyConfigs } from "../lib/apply.js"; -import { syncKnown } from "../lib/sync.js"; -import { syncFromDir, syncToDir } from "../lib/sync-dir.js"; -import { detectMachineContext, resolveProfileVariables } from "../lib/machine.js"; -import type { ConfigAgent, ConfigCategory, ConfigFormat, ConfigKind } from "../types/index.js"; -import { mountMcpHttpRoutes } from "../mcp/http.js"; import { getPackageVersion } from "../lib/package-version.js"; import { handleV1Request } from "./v1.js"; import { @@ -48,13 +38,6 @@ const PORT = Number( process.env["PORT"] ?? process.env["INSTRUCTIONS_PORT"] ?? process.env["CONFIGS_PORT"] ?? 3457, ); -function pickFields(obj: T, fields?: string): Partial | T { - if (!fields) return obj; - const keys = fields.split(",").map((f) => f.trim()); - return Object.fromEntries(keys.filter((k) => k in obj).map((k) => [k, (obj as Record)[k]])) as Partial; -} - - const app = new Hono(); app.use("*", cors()); @@ -104,317 +87,17 @@ app.all("/v1/*", async (c) => { return res ?? c.json({ error: "Not found" }, 404); }); -// ── Status + Stats ──────────────────────────────────────────────────────────── -app.get("/api/stats", (c) => c.json(getConfigStats())); - -app.get("/api/status", (c) => { - const stats = getConfigStats(); - const allConfigs = listConfigs({ kind: "file" }); - let templates = 0; - for (const cfg of allConfigs) { if (cfg.is_template) templates++; } - return c.json({ - total: stats["total"] || 0, - by_category: Object.fromEntries(Object.entries(stats).filter(([k]) => k !== "total")), - templates, - db_path: process.env["CONFIGS_DB_PATH"] || "~/.hasna/configs/configs.db", - }); -}); - -// ── Sync known ──────────────────────────────────────────────────────────────── -app.post("/api/sync-known", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const result = await syncKnown({ agent: body.agent, category: body.category, dryRun: body.dry_run }); - return c.json(result); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/sync-project", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const { syncProject } = await import("../lib/sync.js"); - const result = await syncProject({ projectDir: body.project_dir || process.cwd() }); - return c.json(result); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/render-template", async (c) => { - try { - const body = await c.req.json(); - const { renderTemplate } = await import("../lib/template.js"); - const config = getConfig(body.id_or_slug); - const vars: Record = body.vars || {}; - if (body.use_env) { - const { extractTemplateVars } = await import("../lib/template.js"); - for (const v of extractTemplateVars(config.content)) { - if (!(v.name in vars) && process.env[v.name]) vars[v.name] = process.env[v.name]!; - } - } - return c.json({ rendered: renderTemplate(config.content, vars), config_id: config.id, slug: config.slug }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/scan-secrets", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const { scanSecrets, redactContent } = await import("../lib/redact.js"); - const configs = body.id_or_slug ? [getConfig(body.id_or_slug)] : listConfigs({ kind: "file" }); - const findings: Array<{ slug: string; secrets: number; vars: string[] }> = []; - for (const cfg of configs) { - const fmt = cfg.format as "shell" | "json" | "toml" | "ini" | "markdown" | "text"; - const secrets = scanSecrets(cfg.content, fmt); - if (secrets.length > 0) { - findings.push({ slug: cfg.slug, secrets: secrets.length, vars: secrets.map((s) => s.varName) }); - if (body.fix) { - const { content, isTemplate } = redactContent(cfg.content, fmt); - updateConfig(cfg.id, { content, is_template: isTemplate }); - } - } - } - return c.json({ clean: findings.length === 0, findings, fixed: !!body.fix }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -// ── Configs ─────────────────────────────────────────────────────────────────── -app.get("/api/configs", (c) => { - const { category, agent, kind, search, fields } = c.req.query(); - const configs = listConfigs({ - category: category as ConfigCategory || undefined, - agent: agent as ConfigAgent || undefined, - kind: kind as ConfigKind || undefined, - search: search || undefined, - }); - return c.json(fields ? configs.map((cfg) => pickFields(cfg, fields)) : configs); -}); - -app.post("/api/configs", async (c) => { - try { - const body = await c.req.json(); - const config = createConfig({ - name: body.name, - content: body.content ?? "", - category: body.category, - agent: body.agent, - kind: body.kind, - target_path: body.target_path, - outputs: body.outputs, - format: body.format, - tags: body.tags, - description: body.description, - is_template: body.is_template, - }); - return c.json(config, 201); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.get("/api/configs/:id", (c) => { - try { - const { fields } = c.req.query(); - const config = getConfig(c.req.param("id")); - return c.json(pickFields(config, fields)); - } catch { - return c.json({ error: "Not found" }, 404); - } -}); - -app.put("/api/configs/:id", async (c) => { - try { - const body = await c.req.json(); - const config = updateConfig(c.req.param("id"), body); - return c.json(config); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.delete("/api/configs/:id", (c) => { - try { - deleteConfig(c.req.param("id")); - return c.json({ ok: true }); - } catch { - return c.json({ error: "Not found" }, 404); - } -}); - -app.post("/api/configs/:id/apply", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const config = getConfig(c.req.param("id")); - const result = await applyConfig(config, { dryRun: body.dry_run }); - return c.json(result); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/configs/:id/snapshot", async (c) => { - try { - const config = getConfig(c.req.param("id")); - const snap = createSnapshot(config.id, config.content, config.version); - return c.json(snap, 201); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.get("/api/configs/:id/snapshots", (c) => { - try { - const { fields } = c.req.query(); - const config = getConfig(c.req.param("id")); - const snaps = listSnapshots(config.id); - return c.json(fields ? snaps.map((s) => pickFields(s, fields)) : snaps); - } catch { - return c.json({ error: "Not found" }, 404); - } -}); - -// ── Sync ────────────────────────────────────────────────────────────────────── -app.post("/api/sync", async (c) => { - try { - const body = await c.req.json(); - const dir = body.dir || "~/.claude"; - // SECURITY: restrict to home directory paths only - const { resolve } = require("node:path"); - const { homedir: hd } = require("node:os"); - const absDir = dir.startsWith("~/") ? resolve(hd(), dir.slice(2)) : resolve(dir); - if (!absDir.startsWith(hd())) { - return c.json({ error: "Sync restricted to home directory paths" }, 403); - } - const direction = body.direction || "from_disk"; - const result = direction === "to_disk" - ? await syncToDir(dir, { dryRun: body.dry_run }) - : await syncFromDir(dir, { dryRun: body.dry_run }); - return c.json(result); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -// ── Profiles ────────────────────────────────────────────────────────────────── -app.get("/api/profiles", (c) => { - const { fields } = c.req.query(); - const profiles = listProfiles(); - return c.json(fields ? profiles.map((p) => pickFields(p, fields)) : profiles); -}); - -app.post("/api/profiles", async (c) => { - try { - const body = await c.req.json(); - const profile = createProfile({ - name: body.name, - description: body.description, - selectors: body.selectors, - variables: body.variables, - }); - return c.json(profile, 201); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.get("/api/profiles/:id", (c) => { - try { - const profile = getProfile(c.req.param("id")); - const configs = getProfileConfigs(c.req.param("id")); - return c.json({ ...profile, configs }); - } catch { - return c.json({ error: "Not found" }, 404); - } -}); - -app.put("/api/profiles/:id", async (c) => { - try { - const body = await c.req.json(); - const profile = updateProfile(c.req.param("id"), body); - return c.json(profile); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.delete("/api/profiles/:id", (c) => { - try { - deleteProfile(c.req.param("id")); - return c.json({ ok: true }); - } catch { - return c.json({ error: "Not found" }, 404); - } -}); - -app.post("/api/profiles/:id/apply", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const machine = detectMachineContext({ hostname: body.hostname, os: body.os, arch: body.arch }); - const profile = getProfile(c.req.param("id")); - const configs = getProfileConfigs(profile.id); - const results = await applyConfigs(configs, { - dryRun: body.dry_run, - vars: resolveProfileVariables(profile, machine), - }); - return c.json({ profile, machine, results }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/profiles/resolve", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const machine = detectMachineContext({ hostname: body.hostname, os: body.os, arch: body.arch }); - const profile = resolveProfileForMachine(machine); - if (!profile) return c.json({ error: "No matching machine-aware profile found" }, 404); - return c.json({ profile, machine, variables: resolveProfileVariables(profile, machine) }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -app.post("/api/profiles/apply-auto", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const machine = detectMachineContext({ hostname: body.hostname, os: body.os, arch: body.arch }); - const profile = resolveProfileForMachine(machine); - if (!profile) return c.json({ error: "No matching machine-aware profile found" }, 404); - const configs = getProfileConfigs(profile.id); - const results = await applyConfigs(configs, { - dryRun: body.dry_run, - vars: resolveProfileVariables(profile, machine), - }); - return c.json({ profile, machine, results }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -// ── Machines ────────────────────────────────────────────────────────────────── -app.get("/api/machines", (c) => { - const { fields } = c.req.query(); - const machines = listMachines(); - return c.json(fields ? machines.map((m) => pickFields(m, fields)) : machines); -}); - -app.post("/api/machines", async (c) => { - try { - const body = await c.req.json().catch(() => ({})); - const machine = registerMachine(body.hostname, body.os, body.arch); - return c.json(machine, 201); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : String(e) }, 422); - } -}); - -// ── MCP Streamable HTTP + health ───────────────────────────────────────────── -mountMcpHttpRoutes(app); +// ── MCP is a CLIENT transport, never mounted on the cloud server ───────────── +// The MCP server (src/mcp) runs on the operator's machine (stdio or the local +// `instructions mcp --http` process on 127.0.0.1). Its tools resolve the Store +// from the client env, so with HASNA_INSTRUCTIONS_API_URL/KEY set they route to +// this server's authenticated /v1 API — the same path the CLI/SDK use. +// +// It is deliberately NOT mounted here: on ECS the container holds a DATABASE_URL +// (not the client API env), so a server-mounted /mcp would resolve to an +// ephemeral on-container SQLite store instead of RDS (split-brain), and being +// outside the /v1/* auth middleware it would be unauthenticated. Only /v1/* (and +// the unauthenticated health/version probes above) are exposed by the server. // ── Dashboard (serve static files from dashboard/dist/) ────────────────────── import { existsSync, readFileSync } from "node:fs"; diff --git a/src/server/server.test.ts b/src/server/server.test.ts deleted file mode 100644 index 25149be..0000000 --- a/src/server/server.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { Hono } from "hono"; -import { getDatabase, resetDatabase } from "../db/database"; -import { createConfig } from "../db/configs"; -import { createProfile } from "../db/profiles"; - -// Inline minimal test server to avoid global singleton issues -function makeTestApp() { - const app = new Hono(); - const { cors } = require("hono/cors"); - const { getConfigStats, listConfigs, getConfig, updateConfig, deleteConfig } = require("../db/configs"); - const { listProfiles, getProfile, createProfile: cp, getProfileConfigs } = require("../db/profiles"); - const { listMachines, registerMachine } = require("../db/machines"); - - app.use("*", cors()); - app.get("/health", (c) => c.json({ ok: true, version: "0.1.0" })); - app.get("/api/stats", (c) => c.json(getConfigStats())); - app.get("/api/configs", (c) => c.json(listConfigs())); - app.get("/api/configs/:id", (c) => { - try { return c.json(getConfig(c.req.param("id"))); } - catch { return c.json({ error: "Not found" }, 404); } - }); - app.put("/api/configs/:id", async (c) => { - try { return c.json(updateConfig(c.req.param("id"), await c.req.json())); } - catch (e) { return c.json({ error: String(e) }, 422); } - }); - app.delete("/api/configs/:id", (c) => { - try { deleteConfig(c.req.param("id")); return c.json({ ok: true }); } - catch { return c.json({ error: "Not found" }, 404); } - }); - app.get("/api/profiles", (c) => c.json(listProfiles())); - app.get("/api/machines", (c) => c.json(listMachines())); - return app; -} - -beforeEach(() => { - resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; - getDatabase(); -}); - -describe("REST server", () => { - test("GET /health returns ok", async () => { - const app = makeTestApp(); - const res = await app.request("/health"); - expect(res.status).toBe(200); - const body = await res.json() as { ok: boolean }; - expect(body.ok).toBe(true); - }); - - test("GET /api/stats returns counts", async () => { - const db = getDatabase(); - createConfig({ name: "X", category: "rules", content: "" }, db); - const app = makeTestApp(); - const res = await app.request("/api/stats"); - expect(res.status).toBe(200); - const body = await res.json() as Record; - expect(body["total"]).toBe(1); - }); - - test("GET /api/configs returns list", async () => { - const db = getDatabase(); - createConfig({ name: "C1", category: "rules", content: "" }, db); - const app = makeTestApp(); - const res = await app.request("/api/configs"); - const body = await res.json() as unknown[]; - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(1); - }); - - test("GET /api/configs/:id returns 404 for missing", async () => { - const app = makeTestApp(); - const res = await app.request("/api/configs/nope"); - expect(res.status).toBe(404); - }); - - test("GET /api/configs/:id returns config", async () => { - const db = getDatabase(); - const c = createConfig({ name: "C", category: "rules", content: "hi" }, db); - const app = makeTestApp(); - const res = await app.request(`/api/configs/${c.id}`); - expect(res.status).toBe(200); - const body = await res.json() as { id: string }; - expect(body.id).toBe(c.id); - }); - - test("DELETE /api/configs/:id deletes it", async () => { - const db = getDatabase(); - const c = createConfig({ name: "C", category: "rules", content: "" }, db); - const app = makeTestApp(); - const res = await app.request(`/api/configs/${c.id}`, { method: "DELETE" }); - expect(res.status).toBe(200); - expect(listConfigs(undefined, db).length).toBe(0); - }); - - test("GET /api/profiles returns list", async () => { - const db = getDatabase(); - createProfile({ name: "P" }, db); - const app = makeTestApp(); - const res = await app.request("/api/profiles"); - const body = await res.json() as unknown[]; - expect(body.length).toBe(1); - }); - - test("GET /api/machines returns list", async () => { - const app = makeTestApp(); - const res = await app.request("/api/machines"); - expect(res.status).toBe(200); - }); - test("GET /api/status returns stats with total", async () => { - const db = getDatabase(); - createConfig({ name: "X", category: "rules", content: "" }, db); - createConfig({ name: "Y", category: "agent", content: "", is_template: true }, db); - const app = makeTestApp(); - // Need to add /api/status to test app - const { getConfigStats } = require("../db/configs"); - app.get("/api/status", (c: any) => { - const stats = getConfigStats(); - const configs = listConfigs({ kind: "file" }); - let templates = 0; - for (const cfg of configs) if (cfg.is_template) templates++; - return c.json({ total: stats["total"] || 0, templates }); - }); - const res = await app.request("/api/status"); - expect(res.status).toBe(200); - const body = await res.json() as { total: number; templates: number }; - expect(body.total).toBe(2); - expect(body.templates).toBe(1); - }); - - test("POST /api/configs creates a config", async () => { - const app = makeTestApp(); - const { createConfig: cc } = require("../db/configs"); - app.post("/api/configs", async (c: any) => { - try { - const body = await c.req.json(); - const config = cc({ name: body.name, content: body.content ?? "", category: body.category }); - return c.json(config, 201); - } catch (e: any) { return c.json({ error: e.message }, 422); } - }); - const res = await app.request("/api/configs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "New Config", content: "hello", category: "rules" }), - }); - expect(res.status).toBe(201); - const body = await res.json() as { slug: string }; - expect(body.slug).toBe("new-config"); - }); - - test("PUT /api/configs/:id updates content", async () => { - const db = getDatabase(); - const c = createConfig({ name: "Updatable", category: "tools", content: "v1" }, db); - const app = makeTestApp(); - const { updateConfig } = require("../db/configs"); - app.put("/api/configs/:id", async (ctx: any) => { - try { - const body = await ctx.req.json(); - return ctx.json(updateConfig(ctx.req.param("id"), body)); - } catch (e: any) { return ctx.json({ error: e.message }, 422); } - }); - const res = await app.request(`/api/configs/${c.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: "v2" }), - }); - expect(res.status).toBe(200); - const body = await res.json() as { content: string; version: number }; - expect(body.content).toBe("v2"); - expect(body.version).toBe(2); - }); -}); - -// Import listConfigs for use in tests -import { listConfigs } from "../db/configs"; diff --git a/src/server/v1.ts b/src/server/v1.ts index ae129cb..565b159 100644 --- a/src/server/v1.ts +++ b/src/server/v1.ts @@ -84,14 +84,30 @@ export async function handleV1Request(req: Request, url: URL): Promise(req); + const pruned = await store.pruneSnapshots(client, id, body?.keep ?? 10); + return json({ pruned }); + } + if (sub !== undefined) { + if (method !== "GET") return errorResponse(405, `method ${method} not allowed on /v1/configs/:id/snapshots/:version`); + const snapshot = await store.getSnapshotByVersion(client, id, Number(sub)); + if (!snapshot) return errorResponse(404, `snapshot version not found: ${sub}`); + return json({ snapshot }); + } if (method === "GET") { const snapshots = await store.listSnapshots(client, id); return json({ snapshots, count: snapshots.length }); } if (method === "POST") { - const snapshot = await store.createSnapshot(client, id); + const body = await readJson<{ content?: string; version?: number }>(req); + const snapshot = body && typeof body.content === "string" && typeof body.version === "number" + ? await store.createSnapshotContent(client, id, body.content, body.version) + : await store.createSnapshot(client, id); return json({ snapshot }, 201); } return errorResponse(405, `method ${method} not allowed on /v1/configs/:id/snapshots`); @@ -133,6 +149,33 @@ export async function handleV1Request(req: Request, url: URL): Promise(req); + if (!body?.config_id) return errorResponse(400, "config_id is required"); + await store.addConfigToProfile(client, id, body.config_id); + return json({ added: true }); + } + if (method === "DELETE" && configId) { + await store.removeConfigFromProfile(client, id, configId); + return json({ removed: true }); + } + return errorResponse(405, `method ${method} not allowed on /v1/profiles/:id/configs`); + } + if (action) return errorResponse(404, `unknown profile action: ${action}`); if (method === "GET") { const profile = await store.getProfile(client, id); const configs = await store.getProfileConfigs(client, id); @@ -156,6 +199,54 @@ export async function handleV1Request(req: Request, url: URL): Promise(req); + if (!body?.hostname) return errorResponse(400, "hostname is required"); + await store.updateMachineApplied(client, body.hostname); + return json({ updated: true }); + } + if (!id) { + if (method === "GET") { + const machines = await store.listMachines(client); + return json({ machines, count: machines.length }); + } + if (method === "POST") { + const body = await readJson<{ hostname?: string; os?: string | null; arch?: string | null }>(req); + if (!body?.hostname) return errorResponse(400, "hostname is required"); + const machine = await store.registerMachine(client, body.hostname, body.os ?? null, body.arch ?? null); + return json({ machine }, 201); + } + return errorResponse(405, `method ${method} not allowed on /v1/machines`); + } + return errorResponse(404, `unknown machines route`); + } + + // ── /v1/feedback ── + if (resource === "feedback" && !id) { + if (method !== "POST") return errorResponse(405, `method ${method} not allowed on /v1/feedback`); + const body = await readJson<{ message?: string; email?: string; category?: string; version?: string }>(req); + if (!body?.message) return errorResponse(400, "message is required"); + await store.insertFeedback(client, { + message: body.message, + email: body.email ?? null, + category: body.category ?? null, + version: body.version ?? null, + }); + return json({ ok: true }, 201); + } + return errorResponse(404, `unknown /v1 resource: ${resource ?? "(root)"}`); } catch (e) { if (e instanceof ConfigNotFoundError || e instanceof ProfileNotFoundError) { diff --git a/src/status.test.ts b/src/status.test.ts index 467b7d4..b31ae71 100644 --- a/src/status.test.ts +++ b/src/status.test.ts @@ -1,3 +1,4 @@ +import { LocalConfigStore } from "./data/config-store"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -24,7 +25,7 @@ afterEach(() => { }); describe("getConfigsStatus", () => { - test("reports metadata-only counts without config values, paths, or hostnames", () => { + test("reports metadata-only counts without config values, paths, or hostnames", async () => { const db = getDatabase(); const privateTarget = join(tempDir, "private-host.internal", "agent.conf"); mkdirSync(join(tempDir, "private-host.internal"), { recursive: true }); @@ -52,7 +53,7 @@ describe("getConfigsStatus", () => { addConfigToProfile(profile.id, config.id, db); registerMachine("private-host.internal", "Linux", "x64", db); - const status = getConfigsStatus(db); + const status = await getConfigsStatus(new LocalConfigStore(db)); const serialized = JSON.stringify(status); expect(status).toMatchObject({ diff --git a/src/status.ts b/src/status.ts index e2ce09a..bd4317c 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,9 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; -import type { Database } from "bun:sqlite"; -import { getDatabase } from "./db/database.js"; -import { getConfigStats, listConfigs } from "./db/configs.js"; -import { listProfiles } from "./db/profiles.js"; -import { listMachines } from "./db/machines.js"; +import { resolveConfigStore, type ConfigStore } from "./data/config-store.js"; +import type { Config } from "./types/index.js"; import { expandPath } from "./lib/apply.js"; import { redactContent, scanSecrets, type RedactFormat } from "./lib/redact.js"; @@ -85,23 +82,16 @@ function countBy(items: T[], getValue: (item: T) => string | null | undefined return counts; } -function tableCount(db: Database, table: string): number { - try { - const row = db.query<{ count: number }, []>(`SELECT COUNT(*) AS count FROM ${table}`).get(); - return Number(row?.count ?? 0); - } catch { - return 0; - } -} - -export function getConfigsStatus(db: Database = getDatabase()): ConfigsStatusContract { +export async function getConfigsStatus( + store: ConfigStore = resolveConfigStore(), +): Promise { let databaseReachable = true; - let configs: ReturnType = []; + let configs: Config[] = []; let categoryStats: Record = { total: 0 }; try { - configs = listConfigs(undefined, db); - categoryStats = getConfigStats(db); + configs = await store.listConfigs(); + categoryStats = await store.getConfigStats(); } catch { databaseReachable = false; } @@ -130,10 +120,25 @@ export function getConfigsStatus(db: Database = getDatabase()): ConfigsStatusCon } } - const profiles = databaseReachable ? listProfiles(db).length : 0; - const machines = databaseReachable ? listMachines(db).length : 0; - const profileLinks = databaseReachable ? tableCount(db, "profile_configs") : 0; - const snapshots = databaseReachable ? tableCount(db, "config_snapshots") : 0; + let profiles = 0; + let machines = 0; + let profileLinks = 0; + let snapshots = 0; + if (databaseReachable) { + try { + const profileList = await store.listProfiles(); + profiles = profileList.length; + machines = (await store.listMachines()).length; + for (const profile of profileList) { + profileLinks += (await store.getProfileConfigs(profile.id)).length; + } + for (const config of configs) { + snapshots += (await store.listSnapshots(config.id)).length; + } + } catch { + databaseReachable = false; + } + } const byCategory = Object.fromEntries(Object.entries(categoryStats).filter(([key]) => key !== "total")); const status: ContractStatus = diff --git a/src/storage.ts b/src/storage.ts deleted file mode 100644 index 7218b7d..0000000 --- a/src/storage.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { PG_MIGRATIONS } from "./db/pg-migrations.js"; -export { PgAdapterAsync } from "./db/remote-storage.js"; -export { - CONFIGS_STORAGE_ENV, - CONFIGS_STORAGE_FALLBACK_ENV, - CONFIGS_STORAGE_MODE_ENV, - CONFIGS_STORAGE_MODE_FALLBACK_ENV, - CONFIGS_STORAGE_TABLES, - STORAGE_DATABASE_ENV, - STORAGE_MODE_ENV, - STORAGE_TABLES, - getStorageDatabaseEnvName, - getStorageDatabaseUrl, - getStorageMode, - getStoragePg, - getStorageStatus, - getStorageSyncMetaAll, - getSyncMetaAll, - resolveTables, - runStorageMigrations, - storagePull, - storagePush, - storageSync, -} from "./db/storage-sync.js"; -export type { - StorageMode, - StorageStatus, - SyncMeta, - SyncResult, -} from "./db/storage-sync.js"; diff --git a/src/storage/cloud-store.ts b/src/storage/cloud-store.ts index 622b428..555a83f 100644 --- a/src/storage/cloud-store.ts +++ b/src/storage/cloud-store.ts @@ -18,6 +18,7 @@ import { type ConfigSnapshot, type CreateConfigInput, type CreateProfileInput, + type Machine, type Profile, type ProfileSelector, type ProfileVariables, @@ -270,6 +271,27 @@ export async function createSnapshot( return { id: row.id, config_id: row.config_id, content: row.content, version: Number(row.version), created_at: toIso(row.created_at) }; } +export async function createSnapshotContent( + client: TypedQueryClient, + idOrSlug: string, + content: string, + version: number, +): Promise { + const config = await getConfig(client, idOrSlug); + const id = randomUUID(); + await client.execute( + `INSERT INTO config_snapshots (id, config_id, content, version, created_at) + VALUES ($1,$2,$3,$4,now())`, + [id, config.id, content, version], + ); + const row = await client.get<{ id: string; config_id: string; content: string; version: number; created_at: unknown }>( + "SELECT id, config_id, content, version, created_at FROM config_snapshots WHERE id = $1", + [id], + ); + if (!row) throw new Error("snapshot insert failed"); + return { id: row.id, config_id: row.config_id, content: row.content, version: Number(row.version), created_at: toIso(row.created_at) }; +} + export async function listSnapshots( client: TypedQueryClient, idOrSlug: string, @@ -282,6 +304,45 @@ export async function listSnapshots( return rows.map((r) => ({ id: r.id, config_id: r.config_id, content: r.content, version: Number(r.version), created_at: toIso(r.created_at) })); } +export async function getSnapshotById( + client: TypedQueryClient, + snapshotId: string, +): Promise { + const row = await client.get<{ id: string; config_id: string; content: string; version: number; created_at: unknown }>( + "SELECT id, config_id, content, version, created_at FROM config_snapshots WHERE id = $1", + [snapshotId], + ); + return row ? { id: row.id, config_id: row.config_id, content: row.content, version: Number(row.version), created_at: toIso(row.created_at) } : null; +} + +export async function getSnapshotByVersion( + client: TypedQueryClient, + idOrSlug: string, + version: number, +): Promise { + const config = await getConfig(client, idOrSlug); + const row = await client.get<{ id: string; config_id: string; content: string; version: number; created_at: unknown }>( + "SELECT id, config_id, content, version, created_at FROM config_snapshots WHERE config_id = $1 AND version = $2", + [config.id, version], + ); + return row ? { id: row.id, config_id: row.config_id, content: row.content, version: Number(row.version), created_at: toIso(row.created_at) } : null; +} + +export async function pruneSnapshots( + client: TypedQueryClient, + idOrSlug: string, + keep = 10, +): Promise { + const config = await getConfig(client, idOrSlug); + const result = await client.query( + `DELETE FROM config_snapshots WHERE config_id = $1 AND id NOT IN ( + SELECT id FROM config_snapshots WHERE config_id = $1 ORDER BY version DESC LIMIT $2 + )`, + [config.id, keep], + ); + return result.rowCount ?? 0; +} + // ── Profiles ───────────────────────────────────────────────────────────────── interface ProfileDbRow { @@ -396,3 +457,135 @@ export async function deleteProfile(client: TypedQueryClient, idOrSlug: string): const existing = await getProfile(client, idOrSlug); await client.execute("DELETE FROM profiles WHERE id = $1", [existing.id]); } + +// ── Profile membership ─────────────────────────────────────────────────────── + +export async function addConfigToProfile( + client: TypedQueryClient, + profileIdOrSlug: string, + configId: string, +): Promise { + const profile = await getProfile(client, profileIdOrSlug); + const maxRow = await client.get<{ max_order: number | null }>( + "SELECT MAX(sort_order) AS max_order FROM profile_configs WHERE profile_id = $1", + [profile.id], + ); + const order = (maxRow?.max_order ?? -1) + 1; + await client.execute( + `INSERT INTO profile_configs (profile_id, config_id, sort_order) + VALUES ($1,$2,$3) ON CONFLICT (profile_id, config_id) DO NOTHING`, + [profile.id, configId, order], + ); +} + +export async function removeConfigFromProfile( + client: TypedQueryClient, + profileIdOrSlug: string, + configId: string, +): Promise { + const profile = await getProfile(client, profileIdOrSlug); + await client.execute( + "DELETE FROM profile_configs WHERE profile_id = $1 AND config_id = $2", + [profile.id, configId], + ); +} + +// ── Profile resolution (machine-aware) ─────────────────────────────────────── + +function profileHasSelectors(selectors: ProfileSelector): boolean { + return (selectors.os?.length ?? 0) > 0 + || (selectors.arch?.length ?? 0) > 0 + || (selectors.hostnames?.length ?? 0) > 0; +} + +export async function resolveProfileForMachine( + client: TypedQueryClient, + machine: { hostname?: string; os?: string; arch?: string }, +): Promise { + const profiles = (await listProfiles(client)).filter((p) => profileHasSelectors(p.selectors)); + const host = (machine.hostname ?? "").trim().toLowerCase(); + const os = (machine.os ?? "").trim().toLowerCase(); + const arch = (machine.arch ?? "").trim().toLowerCase(); + const matches = profiles + .filter((p) => { + const s = p.selectors; + const osOk = !s.os?.length || s.os.some((c) => c.trim().toLowerCase() === os); + const archOk = !s.arch?.length || s.arch.some((c) => c.trim().toLowerCase() === arch); + const hostOk = !s.hostnames?.length || s.hostnames.some((c) => c.trim().toLowerCase() === host); + return osOk && archOk && hostOk; + }) + .map((p) => ({ + profile: p, + score: (p.selectors.hostnames?.length ? 100 : 0) + (p.selectors.os?.length ? 10 : 0) + (p.selectors.arch?.length ? 10 : 0), + })) + .sort((a, b) => b.score - a.score || a.profile.name.localeCompare(b.profile.name)); + return matches[0]?.profile ?? null; +} + +// ── Machines ───────────────────────────────────────────────────────────────── + +interface MachineDbRow { + id: string; + hostname: string; + os: string | null; + arch: string | null; + last_applied_at: unknown; + created_at: unknown; +} + +function rowToMachine(row: MachineDbRow): Machine { + return { + id: row.id, + hostname: row.hostname, + os: row.os, + arch: row.arch, + last_applied_at: row.last_applied_at == null ? null : toIso(row.last_applied_at), + created_at: toIso(row.created_at), + }; +} + +export async function listMachines(client: TypedQueryClient): Promise { + const rows = await client.many( + "SELECT * FROM machines ORDER BY last_applied_at DESC NULLS LAST", + ); + return rows.map(rowToMachine); +} + +export async function registerMachine( + client: TypedQueryClient, + hostname: string, + os: string | null, + arch: string | null, +): Promise { + const existing = await client.get("SELECT * FROM machines WHERE hostname = $1", [hostname]); + if (existing) { + if (existing.os !== os || existing.arch !== arch) { + await client.execute("UPDATE machines SET os = $1, arch = $2 WHERE hostname = $3", [os, arch, hostname]); + } + const row = await client.get("SELECT * FROM machines WHERE hostname = $1", [hostname]); + return rowToMachine(row!); + } + const id = randomUUID(); + await client.execute( + "INSERT INTO machines (id, hostname, os, arch, last_applied_at, created_at) VALUES ($1,$2,$3,$4,NULL,now())", + [id, hostname, os, arch], + ); + const row = await client.get("SELECT * FROM machines WHERE id = $1", [id]); + return rowToMachine(row!); +} + +export async function updateMachineApplied(client: TypedQueryClient, hostname: string): Promise { + await client.execute("UPDATE machines SET last_applied_at = now() WHERE hostname = $1", [hostname]); +} + +// ── Feedback ───────────────────────────────────────────────────────────────── + +export async function insertFeedback( + client: TypedQueryClient, + input: { message: string; email?: string | null; category?: string | null; version?: string | null }, +): Promise { + await client.execute( + "INSERT INTO feedback (id, message, email, category, version, created_at) VALUES ($1,$2,$3,$4,$5,now())", + [randomUUID(), input.message, input.email ?? null, input.category ?? "general", input.version ?? null], + ); +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index a20f979..786a9c5 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -64,5 +64,13 @@ export function instructionsSchemaSql(): string[] { last_applied_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() )`, + `CREATE TABLE IF NOT EXISTS feedback ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + message TEXT NOT NULL, + email TEXT, + category TEXT DEFAULT 'general', + version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, ]; } From e9e10ca2e880bf757119d760a096472295998b76 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 14:59:21 +0300 Subject: [PATCH 2/8] fix(feedback): backfill missing feedback columns on legacy sqlite stores insertFeedback wrote a category column that CREATE TABLE IF NOT EXISTS never added to pre-existing feedback tables, so and the send_feedback MCP tool failed in local mode with "table feedback has no column named category". ensureFeedbackTable now ALTERs in any missing column idempotently. Adds a regression test for the legacy-table shape. --- src/db/database.test.ts | 32 +++++++++++++++++++++++++++++++- src/db/database.ts | 20 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/db/database.test.ts b/src/db/database.test.ts index 20936ec..f57188d 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getDatabase, resetDatabase, uuid, now, slugify } from "./database"; +import { Database } from "bun:sqlite"; +import { getDatabase, resetDatabase, insertFeedback, uuid, now, slugify } from "./database"; let originalHome: string | undefined; let tempHome: string | null = null; @@ -120,6 +121,35 @@ describe("database", () => { expect(readFileSync(join(home, ".hasna", "configs", "legacy.txt"), "utf8")).toBe("legacy"); }); + test("feedback insert works on a fresh database", () => { + const db = getDatabase(); + expect(() => insertFeedback({ message: "hi", category: "bug", version: "9.9.9" }, db)).not.toThrow(); + const row = db.query<{ message: string; category: string }, []>( + "SELECT message, category FROM feedback LIMIT 1", + ).get(); + expect(row?.message).toBe("hi"); + expect(row?.category).toBe("bug"); + }); + + test("ensureFeedbackTable backfills category on a legacy feedback table", () => { + const home = useTempHome(); + const dbPath = join(home, "legacy.db"); + // Simulate a pre-existing store whose feedback table predates the + // category/version columns (the exact shape that produced + // "table feedback has no column named category"). + const legacy = new Database(dbPath); + legacy.exec("CREATE TABLE feedback (id TEXT PRIMARY KEY, message TEXT NOT NULL, email TEXT)"); + legacy.close(); + + process.env["CONFIGS_DB_PATH"] = dbPath; + resetDatabase(); + const db = getDatabase(dbPath); + const columns = db.query<{ name: string }, []>("PRAGMA table_info(feedback)").all().map((r) => r.name); + expect(columns).toContain("category"); + expect(columns).toContain("version"); + expect(() => insertFeedback({ message: "legacy ok", category: "feature" }, db)).not.toThrow(); + }); + test("does not copy legacy data over an existing canonical directory", () => { const home = useTempHome(); mkdirSync(join(home, ".open-configs"), { recursive: true }); diff --git a/src/db/database.ts b/src/db/database.ts index e7e2c23..2d719e2 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -179,6 +179,26 @@ function ensureFeedbackTable(db: Database): void { created_at TEXT NOT NULL DEFAULT (datetime('now')) ) `); + // Older databases created the feedback table before these columns existed. + // CREATE TABLE IF NOT EXISTS won't backfill them, so insertFeedback would fail + // with "table feedback has no column named category". Add any missing column + // idempotently so both fresh and legacy on-disk stores accept the same insert. + const existing = new Set( + db + .query<{ name: string }, []>("PRAGMA table_info(feedback)") + .all() + .map((r) => r.name), + ); + const required: Array<[string, string]> = [ + ["email", "TEXT"], + ["category", "TEXT DEFAULT 'general'"], + ["version", "TEXT"], + ["machine_id", "TEXT"], + ["created_at", "TEXT"], + ]; + for (const [name, def] of required) { + if (!existing.has(name)) db.exec(`ALTER TABLE feedback ADD COLUMN ${name} ${def}`); + } } export interface FeedbackInput { From ede5d093528c062c17778e96147dfe42ea884573 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 14:59:21 +0300 Subject: [PATCH 3/8] fix(cli): guarantee complete stdout for piped JSON output Large JSON payloads (e.g. `instructions list --json | jq`) exceeding the 64KB pipe buffer were silently truncated: console.log to a pipe is async in Bun/Node and the process exited before the buffer drained. Route JSON and large content dumps through a blocking writeSync loop that retries on EAGAIN and stops on EPIPE, guaranteeing full delivery to pipe consumers. --- src/cli/index.tsx | 59 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 330e0fb..1305dff 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -2,7 +2,7 @@ import { registerEventsCommands } from "@hasna/events/commander"; import { program } from "commander"; import chalk from "chalk"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeSync } from "node:fs"; import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { applyConfig, applyConfigs, expandPath } from "../lib/apply.js"; @@ -25,6 +25,41 @@ import type { Config, ConfigAgent, ConfigCategory, ConfigFormat, ConfigKind, Pro import { createRequire } from "node:module"; const pkg = createRequire(import.meta.url)("../../package.json") as { version: string }; +// Blocking, complete write to stdout (fd 1). Fixes the pipe-truncation bug: +// console.log/process.stdout.write to a pipe is asynchronous in Bun/Node, so a +// large payload (e.g. `instructions list --json | jq`) that exceeds the 64KB +// pipe buffer is silently dropped when the process exits before the buffer +// drains. writeSync loops until every byte is delivered, retrying on EAGAIN +// (pipe full) and giving up cleanly on EPIPE (consumer closed). +const EAGAIN_SLEEP = new Int32Array(new SharedArrayBuffer(4)); +function writeStdout(text: string): void { + const buf = Buffer.from(text, "utf8"); + let offset = 0; + while (offset < buf.length) { + try { + offset += writeSync(1, buf, offset); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === "EAGAIN") { + Atomics.wait(EAGAIN_SLEEP, 0, 0, 1); // wait ~1ms for the consumer to drain + continue; + } + if (code === "EPIPE") return; // downstream closed the pipe; stop writing + throw e; + } + } +} + +/** Print a line to stdout with a guaranteed-complete blocking write. */ +function printLine(text = ""): void { + writeStdout(`${text}\n`); +} + +/** Pretty-print a JSON value to stdout with a guaranteed-complete write. */ +function printJson(value: unknown): void { + printLine(JSON.stringify(value, null, 2)); +} + function fmtConfig(c: Config, format: string) { if (format === "json") return JSON.stringify(c, null, 2); if (format === "compact") return `${c.slug} [${c.category}/${c.agent}] ${c.kind === "reference" ? "(ref)" : truncateMiddle(c.target_path ?? "(no path)", 72)}`; @@ -233,7 +268,7 @@ program return; } if (fmt === "json") { - console.log(JSON.stringify(configs, null, 2)); + printJson(configs); return; } const page = paginate(configs, { limit: opts.limit, cursor: opts.cursor }); @@ -257,13 +292,13 @@ program .action(async (id, opts) => { try { const c = await resolveConfigStore().getConfig(id); - if (opts.format === "json") { console.log(JSON.stringify(c, null, 2)); return; } - if (opts.format === "content") { console.log(c.content); return; } + if (opts.format === "json") { printJson(c); return; } + if (opts.format === "content") { printLine(c.content); return; } console.log(fmtConfig(c, "table")); console.log(); console.log(chalk.bold("Content:")); console.log(chalk.dim("─".repeat(60))); - console.log(c.content); + printLine(c.content); } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); @@ -319,7 +354,7 @@ program const store = resolveConfigStore(); const config = await store.getConfig(id); await store.deleteConfig(config.id); - if (opts.json) { console.log(JSON.stringify({ deleted: true, id: config.id, slug: config.slug }, null, 2)); return; } + if (opts.json) { printJson({ deleted: true, id: config.id, slug: config.slug }); return; } console.log(chalk.green("✓") + ` Deleted: ${chalk.bold(config.name)} ${chalk.dim(`(${config.slug})`)}`); } catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); @@ -529,7 +564,7 @@ profileCmd.command("list").description("List all profiles") const store = resolveConfigStore(); const profiles = await store.listProfiles(); if (profiles.length === 0) { console.log(chalk.dim("No profiles.")); return; } - if (fmt === "json") { console.log(JSON.stringify(profiles, null, 2)); return; } + if (fmt === "json") { printJson(profiles); return; } const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor }); if (fmt === "compact") console.log(`${pad("slug", 28)} ${pad("configs", 8)} ${pad("match", 36)} vars`); for (const p of page.items) { @@ -698,7 +733,7 @@ sessionCmd.command("plan") sources, }); if (opts.json) { - console.log(JSON.stringify(planJsonForOutput(plan), null, 2)); + printJson(planJsonForOutput(plan)); return; } console.log(chalk.bold(`${plan.tool} session render plan`) + chalk.dim(` (${plan.adapter.mode})`)); @@ -757,7 +792,7 @@ sessionCmd.command("apply") }); const result = applySessionRender(plan, { dryRun: opts.dryRun, force: opts.force }); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + printJson(result); if (result.conflicts.length > 0) process.exitCode = 1; return; } @@ -810,7 +845,7 @@ snapshotCmd.command("list ").description("List snapshots for a config") snapshotCmd.command("show ").description("Show a snapshot's content").action(async (id) => { const snap = await resolveConfigStore().getSnapshot(id); if (!snap) { console.error(chalk.red("Snapshot not found: " + id)); process.exit(1); } - console.log(snap.content); + printLine(snap.content); }); snapshotCmd.command("restore ").description("Restore a config to a snapshot version").action(async (configId, snapId) => { @@ -978,7 +1013,7 @@ program const omitted = Math.max(0, result.findings.length - visible.length); if (opts.json) { - console.log(JSON.stringify(result, null, 2)); + printJson(result); } else if (result.findings.length === 0) { console.log(chalk.green("✓") + ` Package-manager scan clean (${result.scannedFiles} file(s)).`); } else { @@ -1142,7 +1177,7 @@ program const status = await getConfigsStatus(resolveConfigStore()); if (opts.json) { - console.log(JSON.stringify(status, null, 2)); + printJson(status); return; } From 72700bf4e2ea0c9a346d208d2d3270f6e2b9d4f6 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 14:59:33 +0300 Subject: [PATCH 4/8] chore(release): 0.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ee1e22..3165624 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/instructions", - "version": "0.4.0", + "version": "0.4.1", "description": "AI coding agent instruction & configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", From 983b695e592861df2804835d1137bfd06e43448a Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 15:45:51 +0300 Subject: [PATCH 5/8] fix(instructions): remove @hasna/configs legacy compat shims and align on ~/.hasna/instructions - update: target @hasna/instructions (was @hasna/configs) for version check/install - db: sole HASNA_INSTRUCTIONS_DB_PATH env, store at ~/.hasna/instructions/instructions.db - db: delete CONFIGS_DB_PATH backward-compat fallback and migrateDotfile() legacy copy - server: drop CONFIGS_PORT/CONFIGS_HOST env fallbacks - mcp: CONFIGS_PROFILE -> INSTRUCTIONS_PROFILE; fix db_path display string - cli: whoami/init/backup dirs + brand strings -> ~/.hasna/instructions, @hasna/instructions - status: single-env contract (drop fallback field) - tests: rename env + drop legacy dotfile-migration tests --- src/cli/index.tsx | 24 +++++------ src/cli/output.test.ts | 8 ++-- src/db/configs.test.ts | 2 +- src/db/database.test.ts | 49 ++++------------------ src/db/database.ts | 35 +++------------- src/db/machines.test.ts | 2 +- src/db/profiles.test.ts | 2 +- src/db/snapshots.test.ts | 2 +- src/lib/apply-batch.test.ts | 4 +- src/lib/apply.test.ts | 4 +- src/lib/export-import.test.ts | 6 +-- src/lib/project-dashboard-standard.test.ts | 2 +- src/lib/sync-dir.test.ts | 4 +- src/lib/sync-known.test.ts | 4 +- src/lib/sync.test.ts | 4 +- src/mcp/http.test.ts | 4 +- src/mcp/mcp.test.ts | 4 +- src/mcp/server.ts | 6 +-- src/server/index.ts | 4 +- src/status.test.ts | 4 +- src/status.ts | 13 +++--- 21 files changed, 63 insertions(+), 124 deletions(-) diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 1305dff..abeb5ea 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -529,9 +529,9 @@ program const store = resolveConfigStore(); const dbPath = isCloudMode() ? `${process.env["HASNA_INSTRUCTIONS_API_URL"]}/v1 (self_hosted)` - : process.env["CONFIGS_DB_PATH"] || join(homedir(), ".hasna", "configs", "configs.db"); + : process.env["HASNA_INSTRUCTIONS_DB_PATH"] || join(homedir(), ".hasna", "instructions", "instructions.db"); const stats = await store.getConfigStats(); - console.log(chalk.bold("@hasna/configs") + chalk.dim(" v" + pkg.version)); + console.log(chalk.bold("@hasna/instructions") + chalk.dim(" v" + pkg.version)); console.log(chalk.cyan(isCloudMode() ? "API:" : "DB:") + " " + dbPath); console.log(chalk.cyan("Total configs:") + " " + (stats["total"] || 0)); console.log(); @@ -1042,7 +1042,7 @@ mcpCmd.command("install") .option("--codex", "install into Codex") .option("--gemini", "install into Gemini") .option("--all", "install into all agents") - .option("--profile ", "set CONFIGS_PROFILE (minimal|standard|full)", "standard") + .option("--profile ", "set INSTRUCTIONS_PROFILE (minimal|standard|full)", "standard") .action(async (opts) => { const targets = opts.all ? ["claude", "codex", "gemini"] : [ ...(opts.claude ? ["claude"] : []), @@ -1059,7 +1059,7 @@ mcpCmd.command("install") const mcpBinary = `${vars["BUN_BIN_DIR"]}/configs-mcp`; if (target === "claude") { const cmd = opts.profile && opts.profile !== "full" - ? ["claude", "mcp", "add", "--transport", "stdio", "--scope", "user", "configs", "--", "env", `CONFIGS_PROFILE=${opts.profile}`, mcpBinary] + ? ["claude", "mcp", "add", "--transport", "stdio", "--scope", "user", "configs", "--", "env", `INSTRUCTIONS_PROFILE=${opts.profile}`, mcpBinary] : ["claude", "mcp", "add", "--transport", "stdio", "--scope", "user", "configs", "--", mcpBinary]; const proc = Bun.spawn(cmd, { stdout: "inherit", stderr: "inherit" }); await proc.exited; @@ -1124,7 +1124,7 @@ program await store.reset(); console.log(chalk.dim("Reset local store.")); } - console.log(chalk.bold("@hasna/configs — initializing\n")); + console.log(chalk.bold("@hasna/instructions — initializing\n")); // Sync known configs const result = await syncKnown({ store }); @@ -1164,7 +1164,7 @@ program } const location = isCloudMode() ? `${process.env["HASNA_INSTRUCTIONS_API_URL"]}/v1 (self_hosted)` - : process.env["CONFIGS_DB_PATH"] || join(homedir(), ".hasna", "configs", "configs.db"); + : process.env["HASNA_INSTRUCTIONS_DB_PATH"] || join(homedir(), ".hasna", "instructions", "instructions.db"); console.log(chalk.dim(`\n${isCloudMode() ? "API" : "DB"}: ${location}`)); }); @@ -1181,7 +1181,7 @@ program return; } - console.log(chalk.bold("@hasna/configs") + chalk.dim(` v${pkg.version}`)); + console.log(chalk.bold("@hasna/instructions") + chalk.dim(` v${pkg.version}`)); console.log(chalk.cyan("Database:") + ` ${status.env.database.kind} (${status.env.database.active ?? "default"})`); console.log(chalk.cyan("Total:") + ` ${status.counts.configs.total} configs\n`); console.log(chalk.cyan("Drifted:") + ` ${status.health.driftedTargets === 0 ? chalk.green("0") : chalk.yellow(String(status.health.driftedTargets))} (stored differs from disk)`); @@ -1199,7 +1199,7 @@ program .description("Export configs to a timestamped backup file") .action(async () => { const { mkdirSync: mk } = await import("node:fs"); - const backupDir = join(homedir(), ".hasna", "configs", "backups"); + const backupDir = join(homedir(), ".hasna", "instructions", "backups"); mk(backupDir, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").slice(0, 19); const outPath = join(backupDir, `configs-${ts}.tar.gz`); @@ -1366,7 +1366,7 @@ program const { statSync: st } = await import("node:fs"); const { expandPath } = await import("../lib/apply.js"); - console.log(chalk.bold("@hasna/configs watch") + chalk.dim(` — polling every ${interval}ms`)); + console.log(chalk.bold("@hasna/instructions watch") + chalk.dim(` — polling every ${interval}ms`)); console.log(chalk.dim("Watching known config files for changes…\n")); // Build file → mtime map @@ -1545,7 +1545,7 @@ program { name: "@hasna/brains", bin: "brains", mcp: "brains-mcp" }, ]; - console.log(chalk.bold("@hasna/configs bootstrap") + chalk.dim(` — installing ${packages.length} ecosystem packages\n`)); + console.log(chalk.bold("@hasna/instructions bootstrap") + chalk.dim(` — installing ${packages.length} ecosystem packages\n`)); // 1. Install global packages console.log(chalk.cyan("Installing CLI tools:")); @@ -1613,7 +1613,7 @@ program .option("--check", "only check, don't install") .action(async (opts) => { try { - const proc = Bun.spawn(["npm", "view", "@hasna/configs", "version"], { stdout: "pipe", stderr: "pipe" }); + const proc = Bun.spawn(["npm", "view", "@hasna/instructions", "version"], { stdout: "pipe", stderr: "pipe" }); const latest = (await new Response(proc.stdout).text()).trim(); await proc.exited; if (latest === pkg.version) { @@ -1622,7 +1622,7 @@ program console.log(`Current: ${chalk.dim(pkg.version)} → Latest: ${chalk.green(latest)}`); if (!opts.check) { console.log(chalk.dim("Installing...")); - const install = Bun.spawn(["bun", "install", "-g", `@hasna/configs@${latest}`], { stdout: "inherit", stderr: "inherit" }); + const install = Bun.spawn(["bun", "install", "-g", `@hasna/instructions@${latest}`], { stdout: "inherit", stderr: "inherit" }); await install.exited; console.log(chalk.green("✓") + ` Updated to ${latest}`); } diff --git a/src/cli/output.test.ts b/src/cli/output.test.ts index 3030b03..f59eb15 100644 --- a/src/cli/output.test.ts +++ b/src/cli/output.test.ts @@ -16,7 +16,7 @@ function runCli(args: string[], dbPath: string) { encoding: "utf8", env: { ...process.env, - CONFIGS_DB_PATH: dbPath, + HASNA_INSTRUCTIONS_DB_PATH: dbPath, NO_COLOR: "1", FORCE_COLOR: "0", }, @@ -27,7 +27,7 @@ function seedConfigs(count: number): { home: string; dbPath: string } { const home = mkdtempSync(join(tmpdir(), "open-configs-output-cli-")); tempDirs.push(home); const dbPath = join(home, "configs.db"); - process.env["CONFIGS_DB_PATH"] = dbPath; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = dbPath; resetDatabase(); const db = getDatabase(); for (let i = 1; i <= count; i++) { @@ -45,13 +45,13 @@ function seedConfigs(count: number): { home: string; dbPath: string } { }, db); } resetDatabase(); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; return { home, dbPath }; } afterEach(() => { resetDatabase(); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); diff --git a/src/db/configs.test.ts b/src/db/configs.test.ts index 2cdb2e3..339486a 100644 --- a/src/db/configs.test.ts +++ b/src/db/configs.test.ts @@ -7,7 +7,7 @@ let db: Database; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; db = getDatabase(); }); diff --git a/src/db/database.test.ts b/src/db/database.test.ts index f57188d..15fefc3 100644 --- a/src/db/database.test.ts +++ b/src/db/database.test.ts @@ -11,15 +11,15 @@ let tempHome: string | null = null; beforeEach(() => { resetDatabase(); originalHome = process.env["HOME"]; - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; }); afterEach(() => { resetDatabase(); if (originalHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = originalHome; - delete process.env["HASNA_CONFIGS_DB_PATH"]; - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; if (tempHome) rmSync(tempHome, { recursive: true, force: true }); tempHome = null; }); @@ -27,8 +27,8 @@ afterEach(() => { function useTempHome(): string { tempHome = mkdtempSync(join(tmpdir(), "configs-home-")); process.env["HOME"] = tempHome; - delete process.env["CONFIGS_DB_PATH"]; - delete process.env["HASNA_CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; return tempHome; } @@ -47,7 +47,7 @@ describe("database", () => { test("resetDatabase clears singleton", () => { const db1 = getDatabase(); resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; const db2 = getDatabase(); expect(db1).not.toBe(db2); }); @@ -99,28 +99,6 @@ describe("database", () => { expect(configColumns).toContain("outputs"); }); - test("migrates legacy ~/.open-configs into ~/.hasna/configs", () => { - const home = useTempHome(); - mkdirSync(join(home, ".open-configs", "nested"), { recursive: true }); - writeFileSync(join(home, ".open-configs", "config.json"), "{\"ok\":true}"); - writeFileSync(join(home, ".open-configs", "nested", "profile.txt"), "profile"); - - getDatabase(); - - expect(readFileSync(join(home, ".hasna", "configs", "config.json"), "utf8")).toBe("{\"ok\":true}"); - expect(readFileSync(join(home, ".hasna", "configs", "nested", "profile.txt"), "utf8")).toBe("profile"); - }); - - test("migrates legacy ~/.configs when ~/.open-configs is absent", () => { - const home = useTempHome(); - mkdirSync(join(home, ".configs"), { recursive: true }); - writeFileSync(join(home, ".configs", "legacy.txt"), "legacy"); - - getDatabase(); - - expect(readFileSync(join(home, ".hasna", "configs", "legacy.txt"), "utf8")).toBe("legacy"); - }); - test("feedback insert works on a fresh database", () => { const db = getDatabase(); expect(() => insertFeedback({ message: "hi", category: "bug", version: "9.9.9" }, db)).not.toThrow(); @@ -141,7 +119,7 @@ describe("database", () => { legacy.exec("CREATE TABLE feedback (id TEXT PRIMARY KEY, message TEXT NOT NULL, email TEXT)"); legacy.close(); - process.env["CONFIGS_DB_PATH"] = dbPath; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = dbPath; resetDatabase(); const db = getDatabase(dbPath); const columns = db.query<{ name: string }, []>("PRAGMA table_info(feedback)").all().map((r) => r.name); @@ -149,17 +127,4 @@ describe("database", () => { expect(columns).toContain("version"); expect(() => insertFeedback({ message: "legacy ok", category: "feature" }, db)).not.toThrow(); }); - - test("does not copy legacy data over an existing canonical directory", () => { - const home = useTempHome(); - mkdirSync(join(home, ".open-configs"), { recursive: true }); - mkdirSync(join(home, ".hasna", "configs"), { recursive: true }); - writeFileSync(join(home, ".open-configs", "legacy.txt"), "legacy"); - writeFileSync(join(home, ".hasna", "configs", "current.txt"), "current"); - - getDatabase(); - - expect(readFileSync(join(home, ".hasna", "configs", "current.txt"), "utf8")).toBe("current"); - expect(existsSync(join(home, ".hasna", "configs", "legacy.txt"))).toBe(false); - }); }); diff --git a/src/db/database.ts b/src/db/database.ts index 2d719e2..3907913 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -1,20 +1,16 @@ import { Database } from "bun:sqlite"; -import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; function getDbPath(): string { - if (process.env["HASNA_CONFIGS_DB_PATH"]) { - return process.env["HASNA_CONFIGS_DB_PATH"]; + if (process.env["HASNA_INSTRUCTIONS_DB_PATH"]) { + return process.env["HASNA_INSTRUCTIONS_DB_PATH"]; } - if (process.env["CONFIGS_DB_PATH"]) { - return process.env["CONFIGS_DB_PATH"]; // backward compat - } - migrateDotfile(); const home = process.env["HOME"] || process.env["USERPROFILE"] || "~"; - const dir = join(home, ".hasna", "configs"); + const dir = join(home, ".hasna", "instructions"); mkdirSync(dir, { recursive: true }); - return join(dir, "configs.db"); + return join(dir, "instructions.db"); } export function uuid(): string { @@ -136,7 +132,7 @@ export function resetDatabase(): void { /** * Destroy the on-disk local database: close the handle and delete the db file * plus its WAL/SHM sidecars. Used by `init --force`. Resolves the path from the - * db module (honoring HASNA_CONFIGS_DB_PATH / CONFIGS_DB_PATH); a no-op for the + * db module (honoring HASNA_INSTRUCTIONS_DB_PATH); a no-op for the * in-memory (`:memory:`) database. Local-only — the CloudConfigStore never calls * this (destroying the shared cloud store from a client is forbidden). */ @@ -215,22 +211,3 @@ export function insertFeedback(input: FeedbackInput, db?: Database): void { [input.message, input.email ?? null, input.category ?? "general", input.version ?? null], ); } - -function migrateDotfile(): void { - const home = process.env["HOME"] || process.env["USERPROFILE"] || "~"; - const oldDirs = [join(home, ".open-configs"), join(home, ".configs")]; - const newDir = join(home, ".hasna", "configs"); - if (existsSync(newDir)) return; - - for (const oldDir of oldDirs) { - if (!existsSync(oldDir)) continue; - try { - if (!statSync(oldDir).isDirectory()) continue; - mkdirSync(join(home, ".hasna"), { recursive: true }); - cpSync(oldDir, newDir, { recursive: true, force: false }); - return; - } catch { - // Ignore legacy directories that cannot be copied. - } - } -} diff --git a/src/db/machines.test.ts b/src/db/machines.test.ts index 26b31c5..d9c02ab 100644 --- a/src/db/machines.test.ts +++ b/src/db/machines.test.ts @@ -7,7 +7,7 @@ let db: Database; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; db = getDatabase(); }); diff --git a/src/db/profiles.test.ts b/src/db/profiles.test.ts index ec7151b..1b5b279 100644 --- a/src/db/profiles.test.ts +++ b/src/db/profiles.test.ts @@ -9,7 +9,7 @@ let db: Database; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; db = getDatabase(); }); diff --git a/src/db/snapshots.test.ts b/src/db/snapshots.test.ts index 6849d31..971ca9c 100644 --- a/src/db/snapshots.test.ts +++ b/src/db/snapshots.test.ts @@ -8,7 +8,7 @@ let db: Database; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; db = getDatabase(); }); diff --git a/src/lib/apply-batch.test.ts b/src/lib/apply-batch.test.ts index 39145f6..b54c232 100644 --- a/src/lib/apply-batch.test.ts +++ b/src/lib/apply-batch.test.ts @@ -11,14 +11,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-batch-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("applyConfigs (batch)", () => { diff --git a/src/lib/apply.test.ts b/src/lib/apply.test.ts index 3d902f0..13f660b 100644 --- a/src/lib/apply.test.ts +++ b/src/lib/apply.test.ts @@ -12,14 +12,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("applyConfig", () => { diff --git a/src/lib/export-import.test.ts b/src/lib/export-import.test.ts index 1f57f8b..95692b1 100644 --- a/src/lib/export-import.test.ts +++ b/src/lib/export-import.test.ts @@ -12,14 +12,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-export-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("export + import roundtrip", () => { @@ -35,7 +35,7 @@ describe("export + import roundtrip", () => { // Import into fresh DB resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; const db2 = getDatabase(); const importResult = await importConfigs(outPath, { store: new LocalConfigStore(db2) }); expect(importResult.created).toBe(2); diff --git a/src/lib/project-dashboard-standard.test.ts b/src/lib/project-dashboard-standard.test.ts index 0c4b930..47f313f 100644 --- a/src/lib/project-dashboard-standard.test.ts +++ b/src/lib/project-dashboard-standard.test.ts @@ -16,7 +16,7 @@ let db: Database; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; db = getDatabase(); }); diff --git a/src/lib/sync-dir.test.ts b/src/lib/sync-dir.test.ts index e8d33ae..3e42534 100644 --- a/src/lib/sync-dir.test.ts +++ b/src/lib/sync-dir.test.ts @@ -11,14 +11,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-syncdir-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("syncToDir", () => { diff --git a/src/lib/sync-known.test.ts b/src/lib/sync-known.test.ts index fc2a434..9b09d68 100644 --- a/src/lib/sync-known.test.ts +++ b/src/lib/sync-known.test.ts @@ -13,14 +13,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-known-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; delete process.env["CONFIGS_HOME"]; }); diff --git a/src/lib/sync.test.ts b/src/lib/sync.test.ts index f67c50e..a86a0dd 100644 --- a/src/lib/sync.test.ts +++ b/src/lib/sync.test.ts @@ -12,14 +12,14 @@ let tmpDir: string; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tmpDir = join(tmpdir(), `configs-sync-test-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); }); afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("detectCategory", () => { diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts index f792d46..eb00171 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -13,7 +13,7 @@ import { const servers: Array<{ stop: () => void }> = []; beforeEach(() => { - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; resetDatabase(); getDatabase(); }); @@ -23,7 +23,7 @@ afterEach(() => { servers.pop()?.stop(); } resetDatabase(); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; }); describe("configs MCP HTTP transport", () => { diff --git a/src/mcp/mcp.test.ts b/src/mcp/mcp.test.ts index 240f44e..c29271c 100644 --- a/src/mcp/mcp.test.ts +++ b/src/mcp/mcp.test.ts @@ -6,7 +6,7 @@ import { createProfile, addConfigToProfile } from "../db/profiles"; // Test MCP tool logic directly by re-implementing the dispatch beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; getDatabase(); }); @@ -127,7 +127,7 @@ describe("MCP tool logic — new tools", () => { expect(total).toBe(0); }); - test("CONFIGS_PROFILE filters tools correctly", () => { + test("INSTRUCTIONS_PROFILE filters tools correctly", () => { // Simulate profile filtering logic const ALL_TOOLS = [ { name: "get_status" }, { name: "get_config" }, { name: "sync_known" }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b0628a4..8419309 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -29,14 +29,14 @@ const TOOL_DOCS: Record = { describe_tools: "Get full descriptions for tools. Params: names? (array). Returns tool docs.", }; -// ── Agent profiles — CONFIGS_PROFILE env var controls which tools are exposed ─ +// ── Agent profiles — INSTRUCTIONS_PROFILE env var controls which tools are exposed ─ const PROFILES: Record = { minimal: ["get_status", "get_config", "sync_known"], standard: ["list_configs", "get_config", "create_config", "update_config", "apply_config", "sync_known", "get_status", "render_template", "scan_secrets", "list_profiles", "apply_profile", "search_tools", "describe_tools"], full: [], // empty = all tools }; -const activeProfile = process.env["CONFIGS_PROFILE"] || "full"; +const activeProfile = process.env["INSTRUCTIONS_PROFILE"] || "full"; const profileFilter = PROFILES[activeProfile]; // ── Lean stubs (minimal schema, no descriptions) ───────────────────────────── @@ -224,7 +224,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { drifted, drifted_configs: driftedSlugs.slice(0, 5), missing, - db_path: process.env["CONFIGS_DB_PATH"] || "~/.hasna/configs/configs.db", + db_path: process.env["HASNA_INSTRUCTIONS_DB_PATH"] || "~/.hasna/instructions/instructions.db", }); } case "sync_known": { diff --git a/src/server/index.ts b/src/server/index.ts index 4070712..61f6640 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -35,7 +35,7 @@ if (process.argv.includes("--version") || process.argv.includes("-V")) { } const PORT = Number( - process.env["PORT"] ?? process.env["INSTRUCTIONS_PORT"] ?? process.env["CONFIGS_PORT"] ?? 3457, + process.env["PORT"] ?? process.env["INSTRUCTIONS_PORT"] ?? 3457, ); const app = new Hono(); @@ -141,6 +141,6 @@ if (dashDir) { }); } -const HOST = process.env["HOST"] ?? process.env["INSTRUCTIONS_HOST"] ?? process.env["CONFIGS_HOST"] ?? "localhost"; +const HOST = process.env["HOST"] ?? process.env["INSTRUCTIONS_HOST"] ?? "localhost"; console.log(`instructions-serve listening on http://${HOST}:${PORT} (mode: ${serviceMode()})${dashDir ? " (dashboard: /)" : " (no dashboard)"}`); export default { port: PORT, hostname: HOST, fetch: app.fetch }; diff --git a/src/status.test.ts b/src/status.test.ts index b31ae71..fbfa1c8 100644 --- a/src/status.test.ts +++ b/src/status.test.ts @@ -13,14 +13,14 @@ let tempDir = ""; beforeEach(() => { resetDatabase(); - process.env["CONFIGS_DB_PATH"] = ":memory:"; + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; tempDir = join(tmpdir(), `configs-status-${Date.now()}-${Math.random().toString(16).slice(2)}`); mkdirSync(tempDir, { recursive: true }); }); afterEach(() => { resetDatabase(); - delete process.env["CONFIGS_DB_PATH"]; + delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/src/status.ts b/src/status.ts index bd4317c..3f09e98 100644 --- a/src/status.ts +++ b/src/status.ts @@ -7,7 +7,7 @@ import { redactContent, scanSecrets, type RedactFormat } from "./lib/redact.js"; const PACKAGE_NAME = "@hasna/instructions"; const PACKAGE_VERSION = "0.3.0"; -type ActiveDbEnv = "HASNA_CONFIGS_DB_PATH" | "CONFIGS_DB_PATH" | null; +type ActiveDbEnv = "HASNA_INSTRUCTIONS_DB_PATH" | null; type DatabaseKind = "memory" | "file"; type ContractStatus = "ok" | "warn"; @@ -20,8 +20,7 @@ export interface ConfigsStatusContract { }; env: { database: { - primary: "HASNA_CONFIGS_DB_PATH"; - fallback: "CONFIGS_DB_PATH"; + primary: "HASNA_INSTRUCTIONS_DB_PATH"; active: ActiveDbEnv; kind: DatabaseKind; }; @@ -62,13 +61,12 @@ export interface ConfigsStatusContract { } function activeDatabaseEnv(): ActiveDbEnv { - if (process.env["HASNA_CONFIGS_DB_PATH"]) return "HASNA_CONFIGS_DB_PATH"; - if (process.env["CONFIGS_DB_PATH"]) return "CONFIGS_DB_PATH"; + if (process.env["HASNA_INSTRUCTIONS_DB_PATH"]) return "HASNA_INSTRUCTIONS_DB_PATH"; return null; } function configuredDatabaseKind(): DatabaseKind { - const value = process.env["HASNA_CONFIGS_DB_PATH"] ?? process.env["CONFIGS_DB_PATH"] ?? ""; + const value = process.env["HASNA_INSTRUCTIONS_DB_PATH"] ?? ""; return value === ":memory:" || value.startsWith("file::memory:") ? "memory" : "file"; } @@ -158,8 +156,7 @@ export async function getConfigsStatus( }, env: { database: { - primary: "HASNA_CONFIGS_DB_PATH", - fallback: "CONFIGS_DB_PATH", + primary: "HASNA_INSTRUCTIONS_DB_PATH", active: activeDatabaseEnv(), kind: configuredDatabaseKind(), }, From 481bc9e416cd678cd1746f59db110604bcc9636a Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 16:33:20 +0300 Subject: [PATCH 6/8] fix(cli): emit valid JSON on empty list/profile queries + release 0.4.3 list and profile list with --json/--format json printed the human 'No configs found.'/'No profiles.' line on an empty store, breaking JSON parsers. Route the format check before the empty-guard so both commands always emit '[]' (exit 0) when empty. --- package.json | 2 +- src/cli/index.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3165624..bd552f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/instructions", - "version": "0.4.1", + "version": "0.4.3", "description": "AI coding agent instruction & configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", diff --git a/src/cli/index.tsx b/src/cli/index.tsx index abeb5ea..aa903f5 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -263,14 +263,14 @@ program tags: opts.tag ? [opts.tag] : undefined, search: opts.search, }); - if (configs.length === 0) { - console.log(chalk.dim("No configs found.")); - return; - } if (fmt === "json") { printJson(configs); return; } + if (configs.length === 0) { + console.log(chalk.dim("No configs found.")); + return; + } const page = paginate(configs, { limit: opts.limit, cursor: opts.cursor }); if (fmt === "compact") { printConfigRows(page.items); @@ -563,8 +563,8 @@ profileCmd.command("list").description("List all profiles") const fmt = opts.json ? "json" : opts.verbose ? "table" : opts.brief ? "compact" : opts.format; const store = resolveConfigStore(); const profiles = await store.listProfiles(); - if (profiles.length === 0) { console.log(chalk.dim("No profiles.")); return; } if (fmt === "json") { printJson(profiles); return; } + if (profiles.length === 0) { console.log(chalk.dim("No profiles.")); return; } const page = paginate(profiles, { limit: opts.limit, cursor: opts.cursor }); if (fmt === "compact") console.log(`${pad("slug", 28)} ${pad("configs", 8)} ${pad("match", 36)} vars`); for (const p of page.items) { From 04b7060cb886e8849f86175100daa6aced857d3b Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Wed, 8 Jul 2026 17:49:22 +0300 Subject: [PATCH 7/8] =?UTF-8?q?chore(release):=200.4.4=20=E2=80=94=20ship?= =?UTF-8?q?=20verified=20cloud-mode=20fixes=20for=20feedback=20+=20profile?= =?UTF-8?q?=20add/resolve/apply/delete=20+=20MCP=20apply=5Fconfig/apply=5F?= =?UTF-8?q?profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Republish from HEAD source that already routes every CLI command and MCP tool through the Store (ApiStore /v1 + bearer). The previously-published 0.4.3 dist was stale (MCP apply paths reached the local getDatabase() guard in cloud mode; live ECS still runs 0.3.0 without the feedback/profile routes). Verified all 7 flows end-to-end against a real instructions-serve + Postgres in cloud (api) mode. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd552f4..205391e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/instructions", - "version": "0.4.3", + "version": "0.4.4", "description": "AI coding agent instruction & configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", From be14d293e9f09672fe74d60a0f7f34cc2a681b89 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 9 Jul 2026 22:40:16 +0300 Subject: [PATCH 8/8] feat(prompts): add Antigravity managed prompt rules --- AGENTS.md | 2 +- README.md | 26 ++-- docs/managed-prompt-hierarchy.md | 105 +++++++++++++++++ package.json | 6 +- scripts/seed.ts | 19 +-- sdk/src/index.ts | 2 +- src/cli/index.tsx | 65 ++++++---- src/cli/session.test.ts | 4 +- src/index.ts | 7 ++ src/lib/apply.test.ts | 54 ++++++++- src/lib/apply.ts | 22 +++- src/lib/config-agents.ts | 23 ++++ src/lib/global-agent-rules-standard.test.ts | 106 +++++++++++++++++ src/lib/global-agent-rules-standard.ts | 86 ++++++++++++++ src/lib/session-render.test.ts | 95 ++++++++++++++- src/lib/session-render.ts | 124 +++++++++++++++++--- src/lib/sync-known.test.ts | 66 +++++++++-- src/lib/sync.test.ts | 44 ++++++- src/lib/sync.ts | 49 +++++--- src/status.test.ts | 34 +++++- src/status.ts | 15 ++- src/types/index.ts | 2 +- 22 files changed, 858 insertions(+), 98 deletions(-) create mode 100644 docs/managed-prompt-hierarchy.md create mode 100644 src/lib/config-agents.ts create mode 100644 src/lib/global-agent-rules-standard.test.ts create mode 100644 src/lib/global-agent-rules-standard.ts diff --git a/AGENTS.md b/AGENTS.md index b99a177..75e9811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,7 @@ keeps the token value in the runtime environment or secret manager. | Category | What's stored | |----------|--------------| | `agent` | settings.json, keybindings.json, config.toml | -| `rules` | CLAUDE.md, AGENTS.md, GEMINI.md, rules/*.md | +| `rules` | CLAUDE.md, AGENTS.md, AICOPILOT.md, .agents/rules/*.md, rules/*.md | | `mcp` | ~/.claude.json (MCP server entries) | | `shell` | .zshrc | | `git` | .gitconfig | diff --git a/README.md b/README.md index 5c4b863..3d84a8c 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,12 @@ Local data is stored in `~/.hasna/configs/` (unchanged, for fleet continuity). `instructions session plan` and `instructions session apply` render OpenIdentities and instruction sources into provider-native files for Claude, -Codex, Cursor, OpenCode, and Codewith. +Codex, Cursor, OpenCode, Codewith, aicopilot, and Google Antigravity. +The old Google agent target is removed; Antigravity is the only Google coding +agent render target. Antigravity workspace rules are rendered to +`.agents/rules/*.md`; its current global rules and MCP files use Google's +legacy-named `~/.gemini/GEMINI.md` and `~/.gemini/config/mcp_config.json` +paths but remain owned by the `antigravity` target. ```bash instructions session plan \ @@ -173,12 +178,19 @@ instructions session apply \ --identity-export ./instructions.json ``` -Accepted source layers are `global`, `provider`/`tool`, `account`, -`identity`/`agent`, `project`, and `local`. Empty renders fail closed unless -`--allow-empty-sources` is passed. Apply writes generated manifests with file -hashes, checks previous manifests for drift, refuses unmanaged file conflicts -unless `--force` is passed, removes stale managed mirrors only when safe, and -writes local snapshots before mutating managed files. +Accepted source layers are `global`, `provider`/`tool`, `account`, `machine`, +`division`, `workspace`, `project`/`repo`, `path`, `identity`/`agent`, +`session`, and `local`. Empty renders fail closed unless `--allow-empty-sources` +is passed. Apply writes generated manifests with file hashes, checks previous +manifests for drift, refuses unmanaged file conflicts unless `--force` is +passed, removes stale managed mirrors only when safe, and writes local snapshots +before mutating managed files. + +`instructions init` and `bun run seed` also seed +`global-agent-rules-standard`, the managed global/system prompt source for +session renaming, task-scoped worktrees, PR-first landing, protected-branch +push safety, autonomous repair, Hasna CLI source-of-truth usage, conversation +surface routing, and unbudgeted Codewith goals unless a user asks for budgets. ## Machine-aware Profiles diff --git a/docs/managed-prompt-hierarchy.md b/docs/managed-prompt-hierarchy.md new file mode 100644 index 0000000..28f274d --- /dev/null +++ b/docs/managed-prompt-hierarchy.md @@ -0,0 +1,105 @@ +# Managed Prompt Hierarchy + +## Goal + +`open-instructions` is the source of truth for Hasna coding-agent prompts. It +stores canonical instruction fragments, renders them into each agent's native +files, records provenance, and detects drift. + +The system must support every active coding-agent surface while keeping one +canonical source per rule. Google Antigravity is the only active Google coding +agent target. + +## Layers + +Render sources are composed in this order: + +| Layer | Purpose | +| --- | --- | +| `global` | Non-overridable Hasna rules and default agent behavior. | +| `tool` | Tool/provider-specific behavior such as Codewith, Claude, Codex, Cursor, OpenCode, Antigravity, or aicopilot. | +| `account` | Account or auth-profile overlays such as `live-codewith`. | +| `machine` | Host-specific rules for machines such as `spark01`, Apple laptops, or fleet nodes. | +| `division` | Area-level rules for repo families such as `opensource`, `hasnastudio`, `hasnatools`, or `infra`. | +| `workspace` | Rules for ephemeral or project workspaces such as `~/.hasna/projects/workspaces/wks_*`. | +| `repo` | Repository purpose, commands, ownership, deployment mode, boundaries, and validation. | +| `path` | Subfolder/module rules for large repos. | +| `agent` | Persona or named-agent behavior. | +| `session` | Temporary task/session-specific overlays. | +| `local` | Highest-precedence local override for explicit one-off use. | + +`provider` remains a CLI alias for `tool`, and `identity` remains a CLI alias +for `agent`. Legacy identity exports that call a source `project-overlay` +continue to map to `repo`; `machine-overlay` maps to `machine`; and +`session-overlay` maps to `session`. + +## Active Targets + +| Tool | Managed output | +| --- | --- | +| `codewith` | `CODEWITH.md`, with optional native imports into `.hasna/instructions`. | +| `claude` | `CLAUDE.md`, with native imports into `.hasna/instructions`. | +| `codex` | Flattened `AGENTS.md`. | +| `cursor` | Project-owned `.cursor/rules/*.mdc`. | +| `opencode` | `AGENTS.md`, `opencode.json`, and `.hasna/instructions` fragments. | +| `aicopilot` | `AICOPILOT.md`, with optional `aicopilot.json` instructions in a later pass. | +| `antigravity` | Project-owned `.agents/rules/*.md`, workspace MCP at `.agents/mcp_config.json`, and Google's current legacy-named global Antigravity files at `~/.gemini/GEMINI.md` and `~/.gemini/config/mcp_config.json`. | + +The retired Google coding-agent target has no active render path. Do not create +new `agent=gemini` records, do not render project `GEMINI.md` for that retired +target, and do not add new global rules that target it. Existing legacy project +files should be treated only as migration inputs into Antigravity or `AGENTS.md`. + +Antigravity compatibility note: Google's current Antigravity documentation still +uses legacy-named `~/.gemini/...` paths for Antigravity global rules and global +MCP configuration. `open-instructions` should treat those paths as Antigravity +outputs only; there must be no active `gemini` agent target. + +## Required Global Rules + +The managed global prompt must include these rules: + +1. Agents that support session naming must rename the session early to match + the task. If the task materially pivots, rename it again. +2. Repo mutation must happen in a task-scoped worktree. First inspect the + canonical worktree root `$HOME/.hasna/repos/worktrees`; prefer Hasna + repo/project worktree mechanisms when available; otherwise use + `git worktree`. Never mutate shared checkouts. +3. PR-first landing: normal changes go through branch/worktree plus PR. +4. Never push directly to `main`, the default branch, or any protected branch + unless the user explicitly instructs that exact repo and operation. +5. Agents act autonomously: diagnose, repair, validate, and iterate on the + owning CLIs, packages, and workflows before asking the user. Ask only when + blocked by destructive decisions, secret-bearing decisions, user-only + authority, or external state the agent cannot safely obtain. +6. Use Hasna CLIs/packages as source of truth: `todos`, `conversations`, + `mementos`, `knowledge`, `projects`, `repos`, `accounts`, `instructions`, + `machines`, `secrets`, and `access`. +7. Use the fleet conversation surfaces correctly: + `announcements` for `[FREEZE]`, `[UNFREEZE]`, `[BREAKING]`, `[CUTOVER]`, + `[POLICY]`, and `[RELEASE]`; `incidents` for outages, crash loops, data risk, + or security exposure; `git-publishing` before and after package publishes; + `git-prs`, `git-commits`, and `git-releases` for repository landing events; + `hq` for broad coordination; `agent-policy` for agent operating-rule + discussion; project/product channels for normal work; and `conversations + blockers` for blocker discovery. Do not invent or refer to a literal + blockers channel. +8. Never set Codewith goal/token budgets or goal-plan budgets unless the user + explicitly asks for a budget. Durable goals and goal plans should be + unbudgeted by default. +9. Never expose secrets in prompts, tasks, memories, conversations, manifests, + reports, logs, or PR text. Reference vault item names and grants only. + +## Implementation Notes + +The implementation is incremental: + +1. Extend the session render layer model and aliases. +2. Add active Antigravity support and remove the retired Google agent from active config sync. +3. Keep Codewith behavior compatible with existing flattened renders and the + `HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS` gate. +4. Seed the managed `global-agent-rules-standard` reference so canonical global + prompt content includes the required operating rules. +5. Add tests that prove layer ordering, Antigravity output paths, the + Antigravity 12,000-character rule-file limit, active agent target coverage, + and the seeded global rules content. diff --git a/package.json b/package.json index 205391e..1da065e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hasna/instructions", - "version": "0.4.4", - "description": "AI coding agent instruction & configuration manager — store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", + "version": "0.4.5", + "description": "AI coding agent instruction & configuration manager \u2014 store, version, apply, and share all your AI coding configs. CLI + MCP + HTTP API (instructions-serve) + generated SDK + Dashboard.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -48,7 +48,7 @@ "agent-config", "claude", "codex", - "gemini", + "antigravity", "mcp", "ai", "coding-agent", diff --git a/scripts/seed.ts b/scripts/seed.ts index 41754ef..0656f0d 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -6,16 +6,18 @@ import { getDatabase } from "../src/db/database"; import { getConfigStats } from "../src/db/configs"; import { syncKnown } from "../src/lib/sync"; -import { createConfig, getConfig } from "../src/db/configs"; +import { LocalConfigStore } from "../src/data/config-store"; import { ensurePlatformProfiles } from "../src/lib/platform-profiles"; import { ensureProjectDashboardStandardConfig } from "../src/lib/project-dashboard-standard"; +import { ensureGlobalAgentRulesStandardConfig } from "../src/lib/global-agent-rules-standard"; const db = getDatabase(); +const store = new LocalConfigStore(db); console.log("\n@hasna/configs — seeding initial configurations\n"); -// Sync all known configs (CLAUDE.md, rules/*.md, settings.json, codex, gemini, zshrc, gitconfig, npmrc, etc.) -const result = await syncKnown({ db }); +// Sync all known configs (CLAUDE.md, rules/*.md, settings.json, codex, antigravity, zshrc, gitconfig, npmrc, etc.) +const result = await syncKnown({ store }); console.log(`Synced known configs: +${result.added} updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); if (result.skipped.length > 0) { console.log(" skipped (not found):", result.skipped.join(", ")); @@ -74,18 +76,21 @@ Format: export KEY_NAME="value" console.log("\nReference docs:"); for (const ref of refs) { try { - getConfig(ref.slug, db); + await store.getConfig(ref.slug); console.log(` = ${ref.slug}`); } catch { - const c = createConfig({ name: ref.name, category: ref.category, agent: "global", format: "markdown", content: ref.content, kind: "reference", description: ref.description }, db); + const c = await store.createConfig({ name: ref.name, category: ref.category, agent: "global", format: "markdown", content: ref.content, kind: "reference", description: ref.description }); console.log(` + ${c.slug} (reference)`); } } -const projectDashboardStandard = ensureProjectDashboardStandardConfig(db); +const globalAgentRulesStandard = await ensureGlobalAgentRulesStandardConfig(store); +console.log(` = ${globalAgentRulesStandard.slug}`); + +const projectDashboardStandard = await ensureProjectDashboardStandardConfig(store); console.log(` = ${projectDashboardStandard.slug}`); -const machineProfiles = ensurePlatformProfiles(db); +const machineProfiles = await ensurePlatformProfiles(store); console.log(`\nMachine-aware profiles: ${machineProfiles.map((profile) => profile.slug).join(", ")}`); const stats = getConfigStats(db); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index afca649..325e763 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -7,7 +7,7 @@ export interface Config { slug: string; kind: "file" | "reference"; category: "agent" | "rules" | "mcp" | "shell" | "secrets_schema" | "workspace" | "git" | "tools"; - agent: "claude" | "codex" | "gemini" | "opencode" | "cursor" | "codewith" | "aicopilot" | "zsh" | "git" | "npm" | "global"; + agent: "claude" | "codex" | "antigravity" | "opencode" | "cursor" | "codewith" | "aicopilot" | "zsh" | "git" | "npm" | "global"; target_path: string | null; outputs: ConfigOutput[]; format: "text" | "json" | "toml" | "yaml" | "markdown" | "ini"; diff --git a/src/cli/index.tsx b/src/cli/index.tsx index aa903f5..3c9d478 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -14,9 +14,10 @@ import { importConfigs } from "../lib/import.js"; import { extractTemplateVars } from "../lib/template.js"; import { detectMachineContext, resolveProfileVariables } from "../lib/machine.js"; import { applySessionRender } from "../lib/session-apply.js"; -import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js"; +import { planSessionRender, resolveSessionPath, sourceFromConfig, sourceFromFilePath, sourcesFromIdentityExport, SESSION_INSTRUCTION_LAYERS, SESSION_RENDER_TOOLS, type SessionInstructionLayer, type SessionInstructionSource, type SessionRenderFile, type SessionRenderPlan, type SessionRenderTool } from "../lib/session-render.js"; import { ensurePlatformProfiles } from "../lib/platform-profiles.js"; import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js"; +import { ensureGlobalAgentRulesStandardConfig } from "../lib/global-agent-rules-standard.js"; import { getConfigsStatus } from "../status.js"; import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; import { DEFAULT_LIST_LIMIT, paginate, parseLimit, truncateMiddle, truncateText } from "../lib/compact-output.js"; @@ -102,11 +103,13 @@ function collectOption(value: string, previous: string[]): string[] { return [...previous, value]; } -const SESSION_SOURCE_LAYERS = new Set(["global", "tool", "account", "agent", "project", "local"]); +const SESSION_SOURCE_LAYERS = new Set(SESSION_INSTRUCTION_LAYERS); +const SESSION_SOURCE_LAYER_HELP = "global|provider|tool|account|machine|division|workspace|project|repo|path|identity|agent|session|local"; function parseSessionLayer(value: string): SessionInstructionLayer { if (value === "provider") return "tool"; if (value === "identity") return "agent"; + if (value === "project") return "repo"; if (SESSION_SOURCE_LAYERS.has(value as SessionInstructionLayer)) return value as SessionInstructionLayer; throw new Error(`Invalid source layer "${value}"`); } @@ -141,7 +144,7 @@ function parseLayeredReference(value: string): { layer?: SessionInstructionLayer const idx = trimmed.indexOf(":"); if (idx > 0) { const candidate = trimmed.slice(0, idx); - if (candidate === "provider" || candidate === "identity" || SESSION_SOURCE_LAYERS.has(candidate as SessionInstructionLayer)) { + if (candidate === "provider" || candidate === "identity" || candidate === "project" || SESSION_SOURCE_LAYERS.has(candidate as SessionInstructionLayer)) { const id = trimmed.slice(idx + 1).trim(); if (!id) throw new Error(`Invalid layered reference "${value}"`); return { layer: parseSessionLayer(candidate), id }; @@ -422,8 +425,8 @@ program // ── sync ───────────────────────────────────────────────────────────────────── program .command("sync") - .description("Sync known AI coding configs from disk into DB (claude, codex, opencode, cursor, codewith, aicopilot, gemini, zsh, git, npm)") - .option("-a, --agent ", "only sync configs for this agent (claude|codex|opencode|cursor|codewith|aicopilot|gemini|zsh|git|npm)") + .description("Sync known AI coding configs from disk into DB (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, zsh, git, npm)") + .option("-a, --agent ", "only sync configs for this agent (claude|codex|opencode|cursor|codewith|aicopilot|antigravity|zsh|git|npm)") .option("-c, --category ", "only sync configs in this category") .option("-p, --project [dir]", "sync project-scoped configs (CLAUDE.md, .mcp.json, etc.) from a project dir") .option("--all", "with --project: scan all subdirs for projects to sync") @@ -452,18 +455,29 @@ program if (opts.project) { const dir = typeof opts.project === "string" ? opts.project : process.cwd(); - // --project --all: find all project dirs with CLAUDE.md and sync each + // --project --all: find all project dirs with active agent config markers and sync each if (opts.all) { - const { readdirSync, statSync: st } = await import("node:fs"); + const { readdirSync } = await import("node:fs"); const absDir = expandPath(dir); const entries = readdirSync(absDir, { withFileTypes: true }); let totalAdded = 0, totalUpdated = 0, totalUnchanged = 0, projects = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; const projDir = join(absDir, entry.name); - // Only sync dirs that have CLAUDE.md, .mcp.json, or .claude/ - const hasClaude = existsSync(join(projDir, "CLAUDE.md")) || existsSync(join(projDir, ".mcp.json")) || existsSync(join(projDir, ".claude")); - if (!hasClaude) continue; + const hasAgentConfig = [ + "CLAUDE.md", + ".mcp.json", + ".claude", + "AGENTS.md", + ".codex", + ".opencode", + ".codewith", + "AICOPILOT.md", + ".aicopilot", + ".cursor", + ".agents", + ].some((marker) => existsSync(join(projDir, marker))); + if (!hasAgentConfig) continue; const result = await syncProject({ projectDir: projDir, dryRun: opts.dryRun, store }); if (result.added + result.updated > 0) { console.log(` ${chalk.green("✓")} ${entry.name}: +${result.added} updated:${result.updated}`); @@ -709,7 +723,7 @@ sessionCmd.command("plan") .option("--target-home ", "override generated profile-scoped target home") .option("--project-root ", "repository root for project-scoped adapters such as Cursor") .option("--session-id ", "session id to include in the manifest") - .option("--source ", "instruction source file; layers: global|provider|tool|account|identity|agent|project|local", collectOption, []) + .option("--source ", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, []) .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) @@ -765,7 +779,7 @@ sessionCmd.command("apply") .option("--target-home ", "override generated profile-scoped target home") .option("--project-root ", "repository root for project-scoped adapters such as Cursor") .option("--session-id ", "session id to include in the manifest") - .option("--source ", "instruction source file; layers: global|provider|tool|account|identity|agent|project|local", collectOption, []) + .option("--source ", `instruction source file; layers: ${SESSION_SOURCE_LAYER_HELP}`, collectOption, []) .option("--config ", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, []) .option("--identity-export ", "OpenIdentities configs instruction export JSON; repeatable", collectOption, []) .option("--replace-source ", "source id that replaces earlier layers instead of appending", collectOption, []) @@ -1040,17 +1054,17 @@ mcpCmd.command("install") .description("Install configs MCP server into an agent") .option("--claude", "install into Claude Code") .option("--codex", "install into Codex") - .option("--gemini", "install into Gemini") + .option("--antigravity", "install into Google Antigravity") .option("--all", "install into all agents") .option("--profile ", "set INSTRUCTIONS_PROFILE (minimal|standard|full)", "standard") .action(async (opts) => { - const targets = opts.all ? ["claude", "codex", "gemini"] : [ + const targets = opts.all ? ["claude", "codex", "antigravity"] : [ ...(opts.claude ? ["claude"] : []), ...(opts.codex ? ["codex"] : []), - ...(opts.gemini ? ["gemini"] : []), + ...(opts.antigravity ? ["antigravity"] : []), ]; if (targets.length === 0) { - console.log(chalk.dim("Specify --claude, --codex, --gemini, or --all")); + console.log(chalk.dim("Specify --claude, --codex, --antigravity, or --all")); return; } for (const target of targets) { @@ -1078,19 +1092,24 @@ mcpCmd.command("install") } appendFileSync(configPath, block); console.log(chalk.green("✓") + " Installed into Codex"); - } else if (target === "gemini") { - const { readFileSync: rf, writeFileSync: wf, existsSync: ex } = await import("node:fs"); - const { join: j } = await import("node:path"); - const configPath = j(homedir(), ".gemini", "settings.json"); + } else if (target === "antigravity") { + const { mkdirSync: md, readFileSync: rf, writeFileSync: wf, existsSync: ex } = await import("node:fs"); + const { dirname: dn, join: j } = await import("node:path"); + const configPath = j(homedir(), ".gemini", "config", "mcp_config.json"); let settings: Record = {}; if (ex(configPath)) { try { settings = JSON.parse(rf(configPath, "utf-8")); } catch { /* empty */ } } const mcpServers = (settings["mcpServers"] ?? {}) as Record; - mcpServers["configs"] = { command: mcpBinary, args: [] }; + mcpServers["configs"] = { + command: mcpBinary, + args: [], + ...(opts.profile && opts.profile !== "full" ? { env: { INSTRUCTIONS_PROFILE: opts.profile } } : {}), + }; settings["mcpServers"] = mcpServers; + md(dn(configPath), { recursive: true }); wf(configPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); - console.log(chalk.green("✓") + " Installed into Gemini"); + console.log(chalk.green("✓") + " Installed into Antigravity"); } } catch (e) { console.error(chalk.red(`✗ Failed to install into ${target}: ${e instanceof Error ? e.message : String(e)}`)); @@ -1143,6 +1162,7 @@ program await store.createConfig({ name: ref.name, category: ref.category, agent: "global", format: "markdown", content: ref.content, kind: "reference", description: ref.desc }); } } + await ensureGlobalAgentRulesStandardConfig(store); await ensureProjectDashboardStandardConfig(store); // Create default profile @@ -1187,6 +1207,7 @@ program console.log(chalk.cyan("Drifted:") + ` ${status.health.driftedTargets === 0 ? chalk.green("0") : chalk.yellow(String(status.health.driftedTargets))} (stored differs from disk)`); console.log(chalk.cyan("Missing:") + ` ${status.health.missingTargets === 0 ? chalk.green("0") : chalk.yellow(String(status.health.missingTargets))} (file not on disk)`); console.log(chalk.cyan("Secrets:") + ` ${status.health.unredactedSecretFindings === 0 ? chalk.green("0 ✓") : chalk.red(String(status.health.unredactedSecretFindings) + " ⚠")} unredacted`); + console.log(chalk.cyan("Retired agents:") + ` ${status.health.retiredAgentRows === 0 ? chalk.green("0") : chalk.yellow(String(status.health.retiredAgentRows))} row(s)`); console.log(chalk.cyan("Templates:") + ` ${status.counts.configs.templates} (with {{VAR}} placeholders)`); }); diff --git a/src/cli/session.test.ts b/src/cli/session.test.ts index 47f7b61..89994cb 100644 --- a/src/cli/session.test.ts +++ b/src/cli/session.test.ts @@ -25,7 +25,7 @@ describe("configs session CLI", () => { const result = runCli(["session", "plan", "--help"]); expect(result.status).toBe(0); - expect(result.stdout).toContain("global|provider|tool|account|identity|agent|project|local"); + expect(result.stdout).toContain("global|provider|tool|account|machine|division|workspace|project|repo|path|identity|agent|session|local"); expect(result.stdout).toContain("--project-root"); expect(result.stdout).toContain("--allow-empty-sources"); }); @@ -209,7 +209,7 @@ describe("configs session CLI", () => { expect(result.status).toBe(0); const plan = JSON.parse(result.stdout) as { manifest: { sources: Array<{ id: string; layer: string }> } }; expect(plan.manifest.sources.map((source) => source.id)).toEqual(["provider-codewith", "project-cli"]); - expect(plan.manifest.sources.map((source) => source.layer)).toEqual(["tool", "project"]); + expect(plan.manifest.sources.map((source) => source.layer)).toEqual(["tool", "repo"]); expect(result.stdout).not.toContain("Claude only."); } finally { rmSync(home, { recursive: true, force: true }); diff --git a/src/index.ts b/src/index.ts index 77e5442..612903f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export type { ApplyOptions } from "./lib/apply.js"; export { CODEWITH_NATIVE_IMPORTS_ENV, RAW_STORE_ROOT_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_LAYER_RANK, SESSION_RENDER_MANAGED_MARKER, SESSION_RENDER_SCHEMA, SESSION_RENDER_TOOLS, @@ -93,6 +95,11 @@ export { PROJECT_DASHBOARD_STANDARD_SLUG, ensureProjectDashboardStandardConfig, } from "./lib/project-dashboard-standard.js"; +export { + GLOBAL_AGENT_RULES_STANDARD_CONTENT, + GLOBAL_AGENT_RULES_STANDARD_SLUG, + ensureGlobalAgentRulesStandardConfig, +} from "./lib/global-agent-rules-standard.js"; // Lib — sync export { syncKnown, syncToDisk, syncProject, diffConfig, detectCategory, detectAgent, detectFormat, KNOWN_CONFIGS, PROJECT_CONFIG_FILES } from "./lib/sync.js"; diff --git a/src/lib/apply.test.ts b/src/lib/apply.test.ts index 13f660b..11cd7df 100644 --- a/src/lib/apply.test.ts +++ b/src/lib/apply.test.ts @@ -5,8 +5,10 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { getDatabase, resetDatabase } from "../db/database"; import { createConfig } from "../db/configs"; -import { applyConfig } from "./apply"; +import { applyConfig, applyConfigs } from "./apply"; +import { ANTIGRAVITY_RULE_FILE_CHAR_LIMIT } from "./session-render"; import { detectMachineContext, resolveProfileVariables } from "./machine"; +import type { ConfigAgent } from "../types"; let tmpDir: string; @@ -20,6 +22,7 @@ beforeEach(() => { afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["CONFIGS_HOME"]; }); describe("applyConfig", () => { @@ -155,6 +158,55 @@ describe("applyConfig", () => { expect(cursor).toContain("Shared system guidance."); }); + test("refuses oversized Antigravity generated global rules", async () => { + const db = getDatabase(); + const antigravityTarget = join(tmpDir, ".gemini", "GEMINI.md"); + const c = createConfig({ + name: "Claude Prompt", + category: "rules", + agent: "claude", + content: "x".repeat(ANTIGRAVITY_RULE_FILE_CHAR_LIMIT + 1), + target_path: join(tmpDir, ".claude", "CLAUDE.md"), + format: "markdown", + outputs: [ + { agent: "antigravity", target_path: antigravityTarget, transform: "codex-flat" }, + ], + }, db); + + await expect(applyConfig(c, { store: new LocalConfigStore(db) })).rejects.toThrow("Antigravity limits rule files"); + expect(existsSync(antigravityTarget)).toBe(false); + }); + + test("bulk apply skips retired Gemini rows", async () => { + const db = getDatabase(); + process.env["CONFIGS_HOME"] = tmpDir; + const geminiTarget = join(tmpDir, ".gemini", "GEMINI.md"); + const antigravityTarget = join(tmpDir, ".gemini", "ANTIGRAVITY.md"); + const stale = createConfig({ + name: "Stale Gemini Global Rules", + category: "rules", + agent: "gemini" as ConfigAgent, + content: "retired gemini content", + target_path: "~/.gemini/GEMINI.md", + format: "markdown", + }, db); + const active = createConfig({ + name: "Active Antigravity Rules", + category: "rules", + agent: "antigravity", + content: "active antigravity content", + target_path: "~/.gemini/ANTIGRAVITY.md", + format: "markdown", + }, db); + + const results = await applyConfigs([stale, active], { store: new LocalConfigStore(db) }); + + expect(results.length).toBe(1); + expect(results[0]?.config_id).toBe(active.id); + expect(existsSync(geminiTarget)).toBe(false); + expect(readFileSync(antigravityTarget, "utf-8")).toBe("active antigravity content"); + }); + test("refuses to apply stale rows targeting generated fan-out outputs", async () => { const db = getDatabase(); const codexTarget = join(tmpDir, ".codex", "AGENTS.md"); diff --git a/src/lib/apply.ts b/src/lib/apply.ts index cc621cf..e4a4d83 100644 --- a/src/lib/apply.ts +++ b/src/lib/apply.ts @@ -5,7 +5,9 @@ import type { ApplyResult, Config, ConfigOutput } from "../types/index.js"; import { ConfigApplyError } from "../types/index.js"; import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import type { ProfileVariables } from "../types/index.js"; +import { isRetiredOrUnsupportedConfigAgent } from "./config-agents.js"; import { renderMachineAwareContent } from "./machine.js"; +import { ANTIGRAVITY_RULE_FILE_CHAR_LIMIT } from "./session-render.js"; import { applyTransform } from "./transforms.js"; export function getConfigHome(): string { @@ -64,6 +66,12 @@ async function writeConfigResult( const renderedContent = opts.vars ? renderMachineAwareContent(content, opts.vars) : content; + const targetAgent = meta.agent ?? config.agent; + if (isAntigravityRuleTarget(targetAgent, renderedTargetPath) && renderedContent.length > ANTIGRAVITY_RULE_FILE_CHAR_LIMIT) { + throw new ConfigApplyError( + `Antigravity rule file ${renderedTargetPath} is ${renderedContent.length} characters; split it before applying because Antigravity limits rule files to ${ANTIGRAVITY_RULE_FILE_CHAR_LIMIT} characters.` + ); + } const path = expandPath(renderedTargetPath); const previousContent = existsSync(path) ? readFileSync(path, "utf-8") @@ -95,6 +103,10 @@ async function writeConfigResult( }; } +function isAntigravityRuleTarget(agent: Config["agent"] | undefined, targetPath: string): boolean { + return agent === "antigravity" && /\.(md|mdc|markdown)$/i.test(targetPath); +} + function isGeneratedOutputTarget(config: Config, configs: Config[]): boolean { if (!config.target_path) return false; const targetPath = normalizeTargetPath(config.target_path); @@ -108,9 +120,16 @@ export async function applyConfig( config: Config, opts: ApplyOptions = {} ): Promise { + if (opts.outputAgent && isRetiredOrUnsupportedConfigAgent(opts.outputAgent)) { + throw new ConfigApplyError(`Config output agent "${opts.outputAgent}" is retired or unsupported — cannot apply to disk.`); + } + if (isRetiredOrUnsupportedConfigAgent(config.agent)) { + throw new ConfigApplyError(`Config "${config.name}" uses retired or unsupported agent "${config.agent}" — cannot apply to disk.`); + } + const selectedOutputs = opts.outputAgent ? config.outputs.filter((output) => output.agent === opts.outputAgent) - : config.outputs; + : config.outputs.filter((output) => !isRetiredOrUnsupportedConfigAgent(output.agent)); const shouldApplyPrimary = !opts.outputAgent || config.agent === opts.outputAgent; if (config.kind === "reference" || ((!config.target_path || !shouldApplyPrimary) && selectedOutputs.length === 0)) { @@ -173,6 +192,7 @@ export async function applyConfigs( const results: ApplyResult[] = []; for (const config of configs) { if (config.kind === "reference") continue; + if (isRetiredOrUnsupportedConfigAgent(config.agent)) continue; results.push(await applyConfig(config, opts)); } return results; diff --git a/src/lib/config-agents.ts b/src/lib/config-agents.ts new file mode 100644 index 0000000..463217c --- /dev/null +++ b/src/lib/config-agents.ts @@ -0,0 +1,23 @@ +import { CONFIG_AGENTS } from "../types/index.js"; + +export const DEPRECATED_CONFIG_AGENTS = ["gemini"] as const; + +const ACTIVE_CONFIG_AGENT_SET = new Set(CONFIG_AGENTS); +const DEPRECATED_CONFIG_AGENT_SET = new Set(DEPRECATED_CONFIG_AGENTS); + +export function isDeprecatedConfigAgent(agent: string | null | undefined): boolean { + return !!agent && DEPRECATED_CONFIG_AGENT_SET.has(agent); +} + +export function isSupportedConfigAgent(agent: string | null | undefined): boolean { + return !!agent && ACTIVE_CONFIG_AGENT_SET.has(agent) && !isDeprecatedConfigAgent(agent); +} + +export function isRetiredOrUnsupportedConfigAgent(agent: string | null | undefined): boolean { + return !!agent && !isSupportedConfigAgent(agent); +} + +export function retiredOrUnsupportedAgentReason(agent: string | null | undefined): string { + if (!agent) return "missing agent"; + return isDeprecatedConfigAgent(agent) ? `deprecated agent: ${agent}` : `unsupported agent: ${agent}`; +} diff --git a/src/lib/global-agent-rules-standard.test.ts b/src/lib/global-agent-rules-standard.test.ts new file mode 100644 index 0000000..0de4d6d --- /dev/null +++ b/src/lib/global-agent-rules-standard.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { LocalConfigStore } from "../data/config-store"; +import { createConfig, getConfig } from "../db/configs"; +import { getDatabase, resetDatabase } from "../db/database"; +import { getProfileConfigs } from "../db/profiles"; +import { ensurePlatformProfiles } from "./platform-profiles"; +import { planSessionRender, sourceFromConfig } from "./session-render"; +import { + GLOBAL_AGENT_RULES_STANDARD_CONTENT, + GLOBAL_AGENT_RULES_STANDARD_SLUG, + ensureGlobalAgentRulesStandardConfig, +} from "./global-agent-rules-standard"; + +let db: Database; + +beforeEach(() => { + resetDatabase(); + process.env["HASNA_INSTRUCTIONS_DB_PATH"] = ":memory:"; + db = getDatabase(); +}); + +describe("global agent rules standard", () => { + test("seeds managed global/system prompt rules with the required policy clauses", async () => { + const config = await ensureGlobalAgentRulesStandardConfig(new LocalConfigStore(db)); + + expect(config.slug).toBe(GLOBAL_AGENT_RULES_STANDARD_SLUG); + expect(config.kind).toBe("reference"); + expect(config.category).toBe("rules"); + expect(config.agent).toBe("global"); + expect(config.tags).toEqual(expect.arrayContaining(["global-agent-rules", "system-prompt"])); + + const content = config.content; + expect(content).toContain("automatic session renaming"); + expect(content).toContain("Repo mutation must happen in a task-scoped worktree"); + expect(content).toContain("$HOME/.hasna/repos/worktrees"); + expect(content).toContain("Hasna repo/project worktree"); + expect(content).toContain("mechanisms when available"); + expect(content).toContain("git worktree"); + expect(content).toContain("Never mutate shared checkouts"); + expect(content).toContain("PR-first landing"); + expect(content).toMatch(/Never push directly to `main`, the default branch, or any protected branch/); + expect(content).toContain("Act autonomously"); + expect(content).toContain("owning\n CLIs, packages, and workflows"); + expect(content).toContain("destructive decisions, secret-bearing decisions, user-only authority"); + expect(content).toContain("`todos`, `conversations`,\n `mementos`, `knowledge`, `projects`, `repos`, `accounts`,"); + expect(content).toContain("`instructions`, `machines`, `secrets`, and `access`"); + expect(content).toContain("Secrets safety is mandatory"); + expect(content).toContain("Never expose secrets in prompts, tasks"); + expect(content).toContain("Reference vault item names, secret identifiers, and\n access grants only"); + expect(content).toContain("`announcements`"); + expect(content).toContain("`incidents`"); + expect(content).toContain("`git-publishing`"); + expect(content).toContain("`git-prs`, `git-commits`, and `git-releases`"); + expect(content).toContain("`hq`"); + expect(content).toContain("`agent-policy`"); + expect(content).toContain("project/product\n channels"); + expect(content).toContain("`conversations blockers`"); + expect(content).toContain("Do not invent or refer to a literal blockers channel"); + expect(content).toContain("Never set Codewith goal/token budgets or goal-plan budgets"); + expect(content).not.toContain("#blockers"); + }); + + test("updates stale seeded global rules instead of creating a duplicate", async () => { + createConfig({ + name: "Global Agent Rules Standard", + category: "rules", + agent: "global", + format: "markdown", + kind: "reference", + content: "old content", + }, db); + + const config = await ensureGlobalAgentRulesStandardConfig(new LocalConfigStore(db)); + const stored = getConfig(GLOBAL_AGENT_RULES_STANDARD_SLUG, db); + + expect(config.id).toBe(stored.id); + expect(stored.content).toBe(GLOBAL_AGENT_RULES_STANDARD_CONTENT); + expect(stored.version).toBe(2); + }); + + test("renders the seeded global rules when used as a session source", async () => { + const config = await ensureGlobalAgentRulesStandardConfig(new LocalConfigStore(db)); + const plan = planSessionRender({ + tool: "codex", + profile: "account999", + targetHome: "/tmp/codex-account999", + sources: [sourceFromConfig(config)], + }); + + expect(plan.files[0]?.relativePath).toBe("AGENTS.md"); + expect(plan.files[0]?.content).toContain("Global Coding Agent Rules Standard"); + expect(plan.files[0]?.content).toContain("Never mutate shared checkouts"); + expect(plan.files[0]?.content).toContain("conversations blockers"); + expect(plan.manifest.sources[0]?.layer).toBe("global"); + }); + + test("platform profiles link the global rules standard when present", async () => { + const standard = await ensureGlobalAgentRulesStandardConfig(new LocalConfigStore(db)); + const profiles = await ensurePlatformProfiles(new LocalConfigStore(db)); + + for (const profile of profiles) { + expect(getProfileConfigs(profile.id, db).map((config) => config.id)).toContain(standard.id); + } + }); +}); diff --git a/src/lib/global-agent-rules-standard.ts b/src/lib/global-agent-rules-standard.ts new file mode 100644 index 0000000..976a07c --- /dev/null +++ b/src/lib/global-agent-rules-standard.ts @@ -0,0 +1,86 @@ +import type { Config } from "../types/index.js"; +import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; + +export const GLOBAL_AGENT_RULES_STANDARD_SLUG = "global-agent-rules-standard"; + +export const GLOBAL_AGENT_RULES_STANDARD_CONTENT = `# Global Coding Agent Rules Standard + +This standard is managed global/system prompt source content for Hasna coding +agents. Rendered agents must receive these rules unless a newer authorized +policy source supersedes them. + +## Session and Planning Defaults + +1. Use automatic session renaming when the agent supports it. Rename the + session early to match the active task; if the task materially pivots, rename + it again so operators can identify the run. +2. Never set Codewith goal/token budgets or goal-plan budgets unless the user + explicitly asks for a budget. Durable goals and goal plans are unbudgeted by + default. + +## Repository Mutation and Landing + +3. Repo mutation must happen in a task-scoped worktree. First inspect the canonical worktree root + \`$HOME/.hasna/repos/worktrees\`; prefer Hasna repo/project worktree + mechanisms when available; otherwise use \`git worktree\`. Never mutate shared checkouts. +4. PR-first landing: normal changes go through a branch/worktree and pull + request before landing. +5. Never push directly to \`main\`, the default branch, or any protected branch + unless the user explicitly instructs that exact repo and operation. + +## Autonomy and Source-of-Truth Tools + +6. Act autonomously. Diagnose, repair, validate, and iterate on the owning + CLIs, packages, and workflows before asking the user. Ask only when blocked + by destructive decisions, secret-bearing decisions, user-only authority, or + external state the agent cannot safely obtain. +7. Use Hasna CLIs/packages as the source of truth: \`todos\`, \`conversations\`, + \`mementos\`, \`knowledge\`, \`projects\`, \`repos\`, \`accounts\`, + \`instructions\`, \`machines\`, \`secrets\`, and \`access\`. +8. Secrets safety is mandatory. Never expose secrets in prompts, tasks, + memories, conversations, manifests, reports, logs, PR text, or any other + agent-visible output. Reference vault item names, secret identifiers, and + access grants only; never print credential values. + +## Conversation Surfaces + +9. Use default conversation surfaces correctly: \`announcements\` for policy, + freeze, breaking, cutover, and release notices; \`incidents\` for outages, + crash loops, data risk, or security exposure; \`git-publishing\` before and + after package publishes; \`git-prs\`, \`git-commits\`, and \`git-releases\` + for repository landing events; \`hq\` for broad coordination; + \`agent-policy\` for agent operating-rule discussion; project/product + channels for normal work; and \`conversations blockers\` for blocker + discovery. Do not invent or refer to a literal blockers channel. +`; + +export async function ensureGlobalAgentRulesStandardConfig(store: ConfigStore = resolveConfigStore()): Promise { + const input = { + name: "Global Agent Rules Standard", + category: "rules" as const, + agent: "global" as const, + format: "markdown" as const, + content: GLOBAL_AGENT_RULES_STANDARD_CONTENT, + kind: "reference" as const, + description: "Managed global/system prompt rules for Hasna coding agents", + tags: ["global-agent-rules", "system-prompt", "coding-agent-rules"], + }; + + try { + const existing = await store.getConfig(GLOBAL_AGENT_RULES_STANDARD_SLUG); + if ( + existing.content !== input.content + || existing.description !== input.description + || existing.category !== input.category + || existing.agent !== input.agent + || existing.format !== input.format + || existing.kind !== input.kind + || JSON.stringify(existing.tags) !== JSON.stringify(input.tags) + ) { + return await store.updateConfig(existing.id, input); + } + return existing; + } catch { + return await store.createConfig(input); + } +} diff --git a/src/lib/session-render.test.ts b/src/lib/session-render.test.ts index bf4a0a5..456fe19 100644 --- a/src/lib/session-render.test.ts +++ b/src/lib/session-render.test.ts @@ -4,7 +4,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + ANTIGRAVITY_RULE_FILE_CHAR_LIMIT, CODEWITH_NATIVE_IMPORTS_ENV, + SESSION_INSTRUCTION_LAYERS, + SESSION_LAYER_RANK, planSessionRender, resolveSessionPath, sourcesFromIdentityExport, @@ -175,6 +178,55 @@ describe("session render planner", () => { expect(plan.files[0]?.path).toBe(join(projectRoot, ".cursor", "rules", "01-global-codewith.mdc")); }); + test("plans Antigravity as project-owned .agents rules", () => { + const projectRoot = join(tmpRoot, "repo"); + const plan = planSessionRender({ + tool: "antigravity", + profile: "account999", + projectRoot, + sources: [globalIdentity, agentIdentity], + }); + + expect(plan.adapter.mode).toBe("antigravity-rules"); + expect(plan.targetKind).toBe("project-root"); + expect(plan.targetOwner.kind).toBe("project"); + expect(plan.files.map((file) => file.relativePath)).toEqual([ + ".agents/rules/01-global-codewith.md", + ".agents/rules/02-agent-marcus.md", + ]); + expect(plan.files[0]?.content).toContain("Global Codewith Identity"); + }); + + test("blocks Antigravity planning until a repository root is explicit", () => { + const plan = planSessionRender({ + tool: "antigravity", + profile: "account999", + targetHome: join(tmpRoot, "not-a-repo-root"), + sources: [globalIdentity], + }); + + expect(plan.blocked).toBe(true); + expect(plan.writable).toBe(false); + expect(plan.targetKind).toBe("blocked"); + expect(plan.files).toEqual([]); + expect(plan.blockers.join("\n")).toContain("Antigravity rules are project-scoped"); + }); + + test("rejects Antigravity rules over the provider file-size limit", () => { + expect(() => + planSessionRender({ + tool: "antigravity", + profile: "account999", + projectRoot: join(tmpRoot, "repo"), + sources: [{ + id: "oversized", + content: "x".repeat(ANTIGRAVITY_RULE_FILE_CHAR_LIMIT + 1), + layer: "global", + }], + }) + ).toThrow("limits rule files"); + }); + test("blocks Cursor planning until a repository root is explicit", () => { const plan = planSessionRender({ tool: "cursor", @@ -230,6 +282,45 @@ describe("session render planner", () => { expect(plan.files[0]?.content).toContain("Global Codewith Identity"); }); + test("orders the managed prompt hierarchy from global to local", () => { + expect(SESSION_INSTRUCTION_LAYERS).toEqual([ + "global", + "tool", + "account", + "machine", + "division", + "workspace", + "repo", + "path", + "agent", + "session", + "local", + ]); + expect(SESSION_LAYER_RANK.global).toBeLessThan(SESSION_LAYER_RANK.machine); + expect(SESSION_LAYER_RANK.machine).toBeLessThan(SESSION_LAYER_RANK.repo); + expect(SESSION_LAYER_RANK.repo).toBeLessThan(SESSION_LAYER_RANK.session); + expect(SESSION_LAYER_RANK.session).toBeLessThan(SESSION_LAYER_RANK.local); + }); + + test("normalizes legacy public layer aliases at render time", () => { + const plan = planSessionRender({ + tool: "codex", + profile: "account999", + targetHome: "/tmp/codex-account999", + sources: [ + { id: "provider-alias", content: "provider alias", layer: "provider" }, + { id: "project-alias", content: "project alias", layer: "project" }, + { id: "identity-alias", content: "identity alias", layer: "identity" }, + ], + }); + + expect(plan.manifest.sources.map((source) => [source.id, source.layer])).toEqual([ + ["provider-alias", "tool"], + ["project-alias", "repo"], + ["identity-alias", "agent"], + ]); + }); + test("plans Codewith native imports only when the runtime gate is enabled", () => { process.env[CODEWITH_NATIVE_IMPORTS_ENV] = "1"; const plan = planSessionRender({ @@ -326,7 +417,7 @@ describe("session render planner", () => { sources, }); - expect(plan.manifest.sources.map((source) => source.layer)).toEqual(["tool", "project"]); + expect(plan.manifest.sources.map((source) => source.layer)).toEqual(["tool", "repo"]); expect(plan.manifest.sources[0]?.sourcePaths[0]?.path).toBe("providers/codewith.md"); expect(plan.manifest.sources[1]?.owner).toMatchObject({ kind: "project" }); }); @@ -398,7 +489,7 @@ describe("session render planner", () => { expect(sources[0]).toMatchObject({ id: "kind-project-overlay", label: "Kind Project Overlay", - layer: "project", + layer: "repo", merge: "replace", order: 700, }); diff --git a/src/lib/session-render.ts b/src/lib/session-render.ts index c344977..2eaec03 100644 --- a/src/lib/session-render.ts +++ b/src/lib/session-render.ts @@ -8,6 +8,7 @@ export const CODEWITH_NATIVE_IMPORTS_ENV = "HASNA_CONFIGS_CODEWITH_NATIVE_IMPORT export const SESSION_RENDER_MANAGED_MARKER = "Managed by @hasna/configs session render"; export const SESSION_RENDER_SCHEMA = "hasna.configs.session-render/v1"; export const RAW_STORE_ROOT_ENV = "HASNA_CONFIGS_HOME"; +export const ANTIGRAVITY_RULE_FILE_CHAR_LIMIT = 12_000; export const SESSION_RENDER_TOOLS = [ "claude", @@ -15,11 +16,27 @@ export const SESSION_RENDER_TOOLS = [ "cursor", "opencode", "codewith", + "aicopilot", + "antigravity", ] as const; export type SessionRenderTool = (typeof SESSION_RENDER_TOOLS)[number]; -export type SessionRenderMode = "native-imports" | "flattened-markdown" | "cursor-mdc" | "opencode-instructions"; -export type SessionInstructionLayer = "global" | "tool" | "account" | "agent" | "project" | "local"; +export type SessionRenderMode = "native-imports" | "flattened-markdown" | "cursor-mdc" | "opencode-instructions" | "antigravity-rules"; +export const SESSION_INSTRUCTION_LAYERS = [ + "global", + "tool", + "account", + "machine", + "division", + "workspace", + "repo", + "path", + "agent", + "session", + "local", +] as const; +export type SessionInstructionLayer = (typeof SESSION_INSTRUCTION_LAYERS)[number]; +export type SessionInstructionLayerAlias = SessionInstructionLayer | "provider" | "identity" | "project"; export type SessionInstructionMerge = "append" | "replace"; export type SessionRenderFileRole = "index" | "fragment" | "rule" | "config" | "manifest"; export type SessionRenderTargetKind = "session-home" | "project-root" | "blocked"; @@ -62,7 +79,7 @@ export interface SessionInstructionSource { id: string; content: string; label?: string; - layer?: SessionInstructionLayer; + layer?: SessionInstructionLayerAlias; merge?: SessionInstructionMerge; order?: number; path?: string; @@ -255,18 +272,59 @@ export const SESSION_TOOL_ADAPTERS: Record = { +export const SESSION_LAYER_RANK: Record = { global: 10, tool: 20, account: 30, - agent: 40, - project: 50, - local: 60, + machine: 40, + division: 50, + workspace: 60, + repo: 70, + path: 80, + agent: 90, + session: 100, + local: 110, }; +export function normalizeSessionInstructionLayer(value: unknown): SessionInstructionLayer { + if (value === "provider") return "tool"; + if (value === "identity") return "agent"; + if (value === "project") return "repo"; + if ( + value === "global" || + value === "tool" || + value === "account" || + value === "machine" || + value === "division" || + value === "workspace" || + value === "repo" || + value === "path" || + value === "agent" || + value === "session" || + value === "local" + ) return value; + throw new Error(`Invalid session instruction layer: ${String(value)}`); +} + function ensureTrailingNewline(content: string): string { return content.endsWith("\n") ? content : `${content}\n`; } @@ -339,7 +397,7 @@ function normalizeSources( content, normalizedId: slug(source.id), resolvedLabel: source.label ?? source.id, - resolvedLayer: source.layer ?? "agent", + resolvedLayer: source.layer === undefined ? "agent" : normalizeSessionInstructionLayer(source.layer), resolvedMerge: source.merge ?? "append", resolvedOrder: source.order ?? index, resolvedRules: normalizeInstructionRules(source, tool), @@ -351,7 +409,7 @@ function normalizeSources( return normalized; }) .sort((a, b) => - LAYER_RANK[a.resolvedLayer] - LAYER_RANK[b.resolvedLayer] || + SESSION_LAYER_RANK[a.resolvedLayer] - SESSION_LAYER_RANK[b.resolvedLayer] || a.resolvedOrder - b.resolvedOrder || a.id.localeCompare(b.id) ); @@ -586,6 +644,38 @@ function buildOpenCodeFiles( ]; } +function buildAntigravityRuleFiles( + targetHome: string, + adapter: SessionToolAdapter, + sources: OrderedSessionInstructionSource[], +): SessionRenderFile[] { + return sources.flatMap((source, index) => { + const n = String(index + 1).padStart(2, "0"); + const sourcePath = posix.join(adapter.managedDir, `${n}-${source.normalizedId}.md`); + const sourceFile = makeAntigravityRuleFile(targetHome, sourcePath, sectionForSource(source), [source.id]); + const ruleFiles = source.resolvedRules.map((rule) => { + const rulePath = posix.join(adapter.managedDir, `${n}-${source.normalizedId}-${rule.resolvedPath}`); + return makeAntigravityRuleFile(targetHome, rulePath, sectionForRule(source, rule), [source.id, rule.id]); + }); + return [sourceFile, ...ruleFiles]; + }); +} + +function makeAntigravityRuleFile( + targetHome: string, + relativePath: string, + content: string, + sourceIds: string[], +): SessionRenderFile { + const file = makeFile(targetHome, relativePath, "rule", content, sourceIds); + if (file.content.length > ANTIGRAVITY_RULE_FILE_CHAR_LIMIT) { + throw new Error( + `Antigravity rule file ${file.relativePath} is ${file.content.length} characters; split it before rendering because Antigravity limits rule files to ${ANTIGRAVITY_RULE_FILE_CHAR_LIMIT} characters.` + ); + } + return file; +} + function buildFiles( targetHome: string, adapter: SessionToolAdapter, @@ -601,6 +691,8 @@ function buildFiles( return buildCursorRuleFiles(targetHome, adapter, sources); case "opencode-instructions": return buildOpenCodeFiles(targetHome, adapter, profile, sources); + case "antigravity-rules": + return buildAntigravityRuleFiles(targetHome, adapter, sources); } } @@ -665,13 +757,15 @@ function resolveRenderTarget(input: SessionRenderInput): { targetKind: SessionRenderTargetKind; blockers: string[]; } { - if (input.tool === "cursor") { + if (input.tool === "cursor" || input.tool === "antigravity") { if (!input.projectRoot) { + const label = input.tool === "cursor" ? "Cursor rules" : "Antigravity rules"; + const path = input.tool === "cursor" ? ".cursor/rules files" : ".agents/rules files"; return { targetHome: defaultTargetHome(input.tool, input.profile, input.sessionId), targetKind: "blocked", blockers: [ - "Cursor rules are project-scoped; pass --project-root (or projectRoot) before applying .cursor/rules files. --target-home is not treated as a repository root for Cursor.", + `${label} are project-scoped; pass --project-root (or projectRoot) before applying ${path}. --target-home is not treated as a repository root for ${input.tool}.`, ], }; } @@ -1015,10 +1109,11 @@ function layerFromIdentityKind(kind: string | undefined, exportShape: IdentityEx case "account-overlay": return "account"; case "project-overlay": - return "project"; + return "repo"; case "machine-overlay": + return "machine"; case "session-overlay": - return "local"; + return "session"; default: throw new Error(`Invalid identity instruction source kind: ${kind}`); } @@ -1127,8 +1222,7 @@ function normalizeSourcePaths(value: unknown): SessionInstructionSourcePath[] { } function requireLayer(value: unknown): SessionInstructionLayer { - if (value === "global" || value === "tool" || value === "account" || value === "agent" || value === "project" || value === "local") return value; - throw new Error(`Invalid session instruction layer: ${String(value)}`); + return normalizeSessionInstructionLayer(value); } function requireMerge(value: unknown): SessionInstructionMerge { diff --git a/src/lib/sync-known.test.ts b/src/lib/sync-known.test.ts index 9b09d68..f312cff 100644 --- a/src/lib/sync-known.test.ts +++ b/src/lib/sync-known.test.ts @@ -25,33 +25,57 @@ afterEach(() => { }); describe("KNOWN_CONFIGS", () => { - test("has required configs (claude, codex, gemini, opencode, cursor, codewith, aicopilot, shell, git, tools)", () => { + test("has required configs (claude, codex, opencode, cursor, codewith, aicopilot, antigravity, shell, git, tools)", () => { const agents = new Set(KNOWN_CONFIGS.map((k) => k.agent)); expect(agents.has("claude")).toBe(true); expect(agents.has("codex")).toBe(true); - expect(agents.has("gemini")).toBe(true); expect(agents.has("opencode")).toBe(true); expect(agents.has("cursor")).toBe(true); expect(agents.has("codewith")).toBe(true); expect(agents.has("aicopilot")).toBe(true); + expect(agents.has("antigravity")).toBe(true); expect(agents.has("zsh")).toBe(true); expect(agents.has("git")).toBe(true); expect(agents.has("npm")).toBe(true); + expect([...agents].sort()).toEqual([ + "aicopilot", + "antigravity", + "claude", + "codewith", + "codex", + "cursor", + "git", + "global", + "npm", + "opencode", + "zsh", + ]); }); - test("CONFIG_AGENTS includes all coding agents", () => { - expect(CONFIG_AGENTS).toContain("opencode"); - expect(CONFIG_AGENTS).toContain("cursor"); - expect(CONFIG_AGENTS).toContain("codewith"); - expect(CONFIG_AGENTS).toContain("aicopilot"); + test("CONFIG_AGENTS includes exactly the active config owners", () => { + expect([...CONFIG_AGENTS].sort()).toEqual([ + "aicopilot", + "antigravity", + "claude", + "codewith", + "codex", + "cursor", + "git", + "global", + "npm", + "opencode", + "zsh", + ]); }); test("registers new coding agent rule and MCP targets", () => { const paths = new Set(KNOWN_CONFIGS.map((k) => k.rulesDir ?? k.path)); expect(paths.has("~/.config/opencode/AGENTS.md")).toBe(true); expect(paths.has("~/.config/opencode/opencode.json")).toBe(true); - expect(paths.has("~/.config/aicopilot/AGENTS.md")).toBe(true); - expect(paths.has("~/.config/aicopilot/opencode.json")).toBe(true); + expect(paths.has("~/.config/aicopilot/AICOPILOT.md")).toBe(true); + expect(paths.has("~/.config/aicopilot/aicopilot.json")).toBe(true); + expect(paths.has("~/.gemini/GEMINI.md")).toBe(true); + expect(paths.has("~/.gemini/config/mcp_config.json")).toBe(true); expect(paths.has("~/.codewith/CODEWITH.md")).toBe(true); expect(paths.has("~/.codewith/config.toml")).toBe(true); expect(paths.has("~/.cursor/rules")).toBe(true); @@ -129,7 +153,8 @@ describe("syncKnown", () => { { agent: "codex", target_path: "~/.codex/AGENTS.md", transform: "codex-flat" }, { agent: "codewith", target_path: "~/.codewith/CODEWITH.md", transform: "codex-flat" }, { agent: "opencode", target_path: "~/.config/opencode/AGENTS.md", transform: "opencode-flat" }, - { agent: "aicopilot", target_path: "~/.config/aicopilot/AGENTS.md", transform: "opencode-flat" }, + { agent: "aicopilot", target_path: "~/.config/aicopilot/AICOPILOT.md", transform: "codex-flat" }, + { agent: "antigravity", target_path: "~/.gemini/GEMINI.md", transform: "codex-flat" }, { agent: "cursor", target_path: "~/.cursor/rules/claude.mdc", transform: "cursor-mdc" }, ]); } finally { @@ -158,7 +183,7 @@ describe("syncKnown", () => { const config = getConfig("claude-claude-md", db); expect(result.updated).toBe(1); - expect(config.outputs.map((output) => output.agent)).toEqual(["codex", "codewith", "opencode", "aicopilot", "cursor"]); + expect(config.outputs.map((output) => output.agent)).toEqual(["codex", "codewith", "opencode", "aicopilot", "antigravity", "cursor"]); }); }); @@ -184,6 +209,19 @@ describe("syncProject", () => { expect(result.added).toBe(1); }); + test("syncs Antigravity workspace MCP config from a project dir", async () => { + const db = getDatabase(); + const projDir = join(tmpDir, "antigravity-mcp-project"); + mkdirSync(join(projDir, ".agents"), { recursive: true }); + writeFileSync(join(projDir, ".agents", "mcp_config.json"), '{"mcpServers":{}}'); + const result = await syncProject({ store: new LocalConfigStore(db), projectDir: projDir }); + const configs = listConfigs(undefined, db); + expect(result.added).toBe(1); + expect(configs[0]!.agent).toBe("antigravity"); + expect(configs[0]!.category).toBe("mcp"); + expect(configs[0]!.target_path).toBe(join(projDir, ".agents", "mcp_config.json")); + }); + test("syncs project rules/*.md", async () => { const db = getDatabase(); const projDir = join(tmpDir, "rules-project"); @@ -229,12 +267,14 @@ describe("syncProject", () => { }); describe("PROJECT_CONFIG_FILES", () => { - test("includes CLAUDE.md, .mcp.json, AGENTS.md, GEMINI.md", () => { + test("includes active project config files only", () => { const files = PROJECT_CONFIG_FILES.map((f) => f.file); expect(files).toContain("CLAUDE.md"); expect(files).toContain(".mcp.json"); expect(files).toContain("AGENTS.md"); - expect(files).toContain("GEMINI.md"); + expect(files).toContain(".codewith/CODEWITH.md"); + expect(files).toContain("AICOPILOT.md"); + expect(files).toContain(".agents/mcp_config.json"); }); }); diff --git a/src/lib/sync.test.ts b/src/lib/sync.test.ts index a86a0dd..8b829ea 100644 --- a/src/lib/sync.test.ts +++ b/src/lib/sync.test.ts @@ -1,12 +1,13 @@ import { LocalConfigStore } from "../data/config-store"; import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; +import { writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { getDatabase, resetDatabase } from "../db/database"; import { createConfig, listConfigs } from "../db/configs"; -import { diffConfig, detectCategory, detectAgent, detectFormat } from "./sync"; +import { diffConfig, detectCategory, detectAgent, detectFormat, syncToDisk } from "./sync"; import { syncFromDir } from "./sync-dir"; +import type { ConfigAgent } from "../types"; let tmpDir: string; @@ -20,11 +21,13 @@ beforeEach(() => { afterEach(() => { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }); delete process.env["HASNA_INSTRUCTIONS_DB_PATH"]; + delete process.env["CONFIGS_HOME"]; }); describe("detectCategory", () => { test("detects rules for claude.md", () => expect(detectCategory("/home/user/.claude/CLAUDE.md")).toBe("rules")); test("detects rules for rules dir", () => expect(detectCategory("/home/user/.claude/rules/git.md")).toBe("rules")); + test("detects rules for Antigravity rule files whose names contain mcp", () => expect(detectCategory("/home/user/repo/.agents/rules/mcp.md")).toBe("rules")); test("detects agent for .claude dir", () => expect(detectCategory("/home/user/.claude/settings.json")).toBe("agent")); test("detects shell for .zshrc", () => expect(detectCategory("/home/user/.zshrc")).toBe("shell")); test("detects git for .gitconfig", () => expect(detectCategory("/home/user/.gitconfig")).toBe("git")); @@ -136,3 +139,40 @@ describe("diffConfig", () => { expect(diff).toContain("+# stale"); }); }); + +describe("syncToDisk", () => { + test("skips stale retired Gemini rows and still applies active Antigravity rows", async () => { + const db = getDatabase(); + const store = new LocalConfigStore(db); + process.env["CONFIGS_HOME"] = tmpDir; + const antigravityTarget = join(tmpDir, ".gemini", "GEMINI.md"); + + createConfig({ + name: "Stale Gemini Global Rules", + category: "rules", + agent: "gemini" as ConfigAgent, + target_path: "~/.gemini/GEMINI.md", + format: "markdown", + content: "retired gemini content", + }, db); + + const retiredOnlyResult = await syncToDisk({ store }); + expect(retiredOnlyResult.skipped.length).toBe(1); + expect(retiredOnlyResult.skipped[0]).toContain("deprecated agent: gemini"); + expect(existsSync(antigravityTarget)).toBe(false); + + createConfig({ + name: "Active Antigravity Global Rules", + category: "rules", + agent: "antigravity", + target_path: "~/.gemini/GEMINI.md", + format: "markdown", + content: "active antigravity content", + }, db); + + const result = await syncToDisk({ store }); + expect(result.updated).toBe(1); + expect(result.skipped.length).toBe(1); + expect(readFileSync(antigravityTarget, "utf-8")).toBe("active antigravity content"); + }); +}); diff --git a/src/lib/sync.ts b/src/lib/sync.ts index d320593..1b8f74b 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -3,6 +3,7 @@ import { basename, extname, join } from "node:path"; import type { Config, ConfigAgent, ConfigCategory, ConfigFormat, ConfigOutput, SyncResult } from "../types/index.js"; import { resolveConfigStore, type ConfigStore } from "../data/config-store.js"; import { applyConfig, expandPath, getConfigHome, normalizeTargetPath } from "./apply.js"; +import { isRetiredOrUnsupportedConfigAgent, retiredOrUnsupportedAgentReason } from "./config-agents.js"; import { redactContent } from "./redact.js"; import { detectMachineContext, templateizeMachineContent } from "./machine.js"; import { applyTransform } from "./transforms.js"; @@ -30,7 +31,8 @@ export const CLAUDE_PROMPT_OUTPUTS: ConfigOutput[] = [ { agent: "codex", target_path: "~/.codex/AGENTS.md", transform: "codex-flat" }, { agent: "codewith", target_path: "~/.codewith/CODEWITH.md", transform: "codex-flat" }, { agent: "opencode", target_path: "~/.config/opencode/AGENTS.md", transform: "opencode-flat" }, - { agent: "aicopilot", target_path: "~/.config/aicopilot/AGENTS.md", transform: "opencode-flat" }, + { agent: "aicopilot", target_path: "~/.config/aicopilot/AICOPILOT.md", transform: "codex-flat" }, + { agent: "antigravity", target_path: "~/.gemini/GEMINI.md", transform: "codex-flat" }, { agent: "cursor", target_path: "~/.cursor/rules/claude.mdc", transform: "cursor-mdc" }, ]; @@ -114,12 +116,15 @@ export const KNOWN_CONFIGS: KnownConfig[] = [ { path: "~/.codewith/config.toml", name: "codewith-config", category: "mcp", agent: "codewith", format: "toml", optional: true, description: "codewith config (Codex fork, includes Skills MCP server entries)" }, // ── aicopilot ────────────────────────────────────────────────────────────── - { path: "~/.config/aicopilot/AGENTS.md", name: "aicopilot-agents-md", category: "rules", agent: "aicopilot", format: "markdown", optional: true }, - { path: "~/.config/aicopilot/opencode.json", name: "aicopilot-config", category: "mcp", agent: "aicopilot", format: "json", optional: true, description: "AI Copilot config (OpenCode fork, includes Skills MCP server entries)" }, + { path: "~/.config/aicopilot/AICOPILOT.md", name: "aicopilot-aicopilot-md", category: "rules", agent: "aicopilot", format: "markdown", optional: true }, + { path: "~/.config/aicopilot/aicopilot.json", name: "aicopilot-config", category: "mcp", agent: "aicopilot", format: "json", optional: true, description: "AI Copilot config (includes instructions and MCP server entries)" }, - // ── Gemini ───────────────────────────────────────────────────────────────── - { path: "~/.gemini/settings.json", name: "gemini-settings", category: "agent", agent: "gemini", format: "json" }, - { path: "~/.gemini/GEMINI.md", name: "gemini-gemini-md", category: "rules", agent: "gemini", format: "markdown", optional: true }, + // ── Antigravity ──────────────────────────────────────────────────────────── + // Google Antigravity's current docs keep the global rules/MCP files under + // legacy-named ~/.gemini paths. These entries belong to the antigravity + // agent target; they do not re-enable a retired gemini target. + { path: "~/.gemini/GEMINI.md", name: "antigravity-global-rules", category: "rules", agent: "antigravity", format: "markdown", optional: true, description: "Google Antigravity global rules file" }, + { path: "~/.gemini/config/mcp_config.json", name: "antigravity-global-mcp", category: "mcp", agent: "antigravity", format: "json", optional: true, description: "Google Antigravity global MCP server entries" }, // ── MCP ──────────────────────────────────────────────────────────────────── { path: "~/.claude.json", name: "claude-json", category: "mcp", agent: "claude", format: "json", description: "Claude Code global config (includes MCP server entries)" }, @@ -148,10 +153,12 @@ export const PROJECT_CONFIG_FILES = [ { file: ".mcp.json", category: "mcp" as ConfigCategory, agent: "claude" as ConfigAgent, format: "json" as ConfigFormat }, { file: "AGENTS.md", category: "rules" as ConfigCategory, agent: "codex" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".codex/AGENTS.md", category: "rules" as ConfigCategory, agent: "codex" as ConfigAgent, format: "markdown" as ConfigFormat }, - { file: "GEMINI.md", category: "rules" as ConfigCategory, agent: "gemini" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".opencode/AGENTS.md", category: "rules" as ConfigCategory, agent: "opencode" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".codewith/CODEWITH.md", category: "rules" as ConfigCategory, agent: "codewith" as ConfigAgent, format: "markdown" as ConfigFormat }, + { file: ".aicopilot/AICOPILOT.md", category: "rules" as ConfigCategory, agent: "aicopilot" as ConfigAgent, format: "markdown" as ConfigFormat }, + { file: "AICOPILOT.md", category: "rules" as ConfigCategory, agent: "aicopilot" as ConfigAgent, format: "markdown" as ConfigFormat }, { file: ".cursor/mcp.json", category: "mcp" as ConfigCategory, agent: "cursor" as ConfigAgent, format: "json" as ConfigFormat }, + { file: ".agents/mcp_config.json", category: "mcp" as ConfigCategory, agent: "antigravity" as ConfigAgent, format: "json" as ConfigFormat }, ]; export interface SyncProjectOptions { @@ -196,23 +203,25 @@ export async function syncProject(opts: SyncProjectOptions): Promise } catch { result.skipped.push(pf.file); } } - // Also sync .claude/rules/*.md or *.mdc if exists - const rulesDir = join(absDir, ".claude", "rules"); - if (existsSync(rulesDir)) { - const mdFiles = readdirSync(rulesDir).filter((f) => f.endsWith(".md") || f.endsWith(".mdc")); + for (const ruleDir of [ + { dir: join(absDir, ".claude", "rules"), agent: "claude" as ConfigAgent, namePrefix: "rules" }, + { dir: join(absDir, ".agents", "rules"), agent: "antigravity" as ConfigAgent, namePrefix: "antigravity-rules" }, + ]) { + if (!existsSync(ruleDir.dir)) continue; + const mdFiles = readdirSync(ruleDir.dir).filter((f) => f.endsWith(".md") || f.endsWith(".mdc")); for (const f of mdFiles) { - const abs = join(rulesDir, f); + const abs = join(ruleDir.dir, f); const raw = readFileSync(abs, "utf-8"); const redacted = redactContent(raw, "markdown"); const machineAware = templateizeMachineContent(redacted.content, machine); const content = machineAware.content; const isTemplate = redacted.isTemplate || machineAware.changed; - const name = `${projectName}/rules/${f}`; + const name = `${projectName}/${ruleDir.namePrefix}/${f}`; const targetPath = abs.startsWith(getConfigHome()) ? abs.replace(getConfigHome(), "~") : abs; const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); const existing = allConfigs.find((c) => c.target_path === targetPath || c.slug === slug); if (!existing) { - if (!opts.dryRun) await store.createConfig({ name, category: "rules", agent: "claude", format: "markdown", content, target_path: targetPath, is_template: isTemplate }); + if (!opts.dryRun) await store.createConfig({ name, category: "rules", agent: ruleDir.agent, format: "markdown", content, target_path: targetPath, is_template: isTemplate }); result.added++; } else if (existing.content !== content) { if (!opts.dryRun) await store.updateConfig(existing.id, { content, is_template: isTemplate }); @@ -357,6 +366,10 @@ export async function syncToDisk(opts: SyncToDiskOptions = {}): Promise { const status = await getConfigsStatus(new LocalConfigStore(db)); const serialized = JSON.stringify(status); + const { name, version } = JSON.parse(readFileSync("package.json", "utf-8")) as { name: string; version: string }; + expect(status).toMatchObject({ service: "configs", schemaVersion: "1.0", + package: { name, version }, counts: { configs: { total: 2, file: 1, reference: 1, templates: 1, + retiredAgentRows: 0, }, profiles: 1, profileLinks: 1, @@ -74,6 +79,7 @@ describe("getConfigsStatus", () => { health: { status: "warn", driftedTargets: 1, + retiredAgentRows: 0, }, safety: { includesConfigValues: false, @@ -94,4 +100,30 @@ describe("getConfigsStatus", () => { expect(serialized).not.toContain("sk-private-disk-token"); expect(serialized).not.toContain(reference.content); }); + + test("surfaces retired agent rows as metadata-only status", async () => { + const db = getDatabase(); + createConfig({ + name: "Stale Gemini Global Rules", + kind: "file", + category: "rules", + agent: "gemini" as ConfigAgent, + target_path: "~/.gemini/GEMINI.md", + format: "markdown", + content: "stale retired content", + }, db); + + const status = await getConfigsStatus(new LocalConfigStore(db)); + const serialized = JSON.stringify(status); + + expect(status.counts.configs.retiredAgentRows).toBe(1); + expect(status.health.retiredAgentRows).toBe(1); + expect(status.health.hasRetiredAgentRows).toBe(true); + expect(status.health.status).toBe("warn"); + expect(status.health.missingTargets).toBe(0); + expect(status.counts.knownTargets).toBe(0); + expect(status.counts.byAgent.gemini).toBe(1); + expect(serialized).not.toContain("~/.gemini/GEMINI.md"); + expect(serialized).not.toContain("stale retired content"); + }); }); diff --git a/src/status.ts b/src/status.ts index 3f09e98..faeb4c7 100644 --- a/src/status.ts +++ b/src/status.ts @@ -2,10 +2,12 @@ import { existsSync, readFileSync } from "node:fs"; import { resolveConfigStore, type ConfigStore } from "./data/config-store.js"; import type { Config } from "./types/index.js"; import { expandPath } from "./lib/apply.js"; +import { isRetiredOrUnsupportedConfigAgent } from "./lib/config-agents.js"; +import { getPackageVersion } from "./lib/package-version.js"; import { redactContent, scanSecrets, type RedactFormat } from "./lib/redact.js"; const PACKAGE_NAME = "@hasna/instructions"; -const PACKAGE_VERSION = "0.3.0"; +const PACKAGE_VERSION = getPackageVersion(); type ActiveDbEnv = "HASNA_INSTRUCTIONS_DB_PATH" | null; type DatabaseKind = "memory" | "file"; @@ -31,6 +33,7 @@ export interface ConfigsStatusContract { file: number; reference: number; templates: number; + retiredAgentRows: number; }; byCategory: Record; byAgent: Record; @@ -47,9 +50,11 @@ export interface ConfigsStatusContract { driftedTargets: number; missingTargets: number; unredactedSecretFindings: number; + retiredAgentRows: number; hasDrift: boolean; hasMissingTargets: boolean; hasUnredactedSecrets: boolean; + hasRetiredAgentRows: boolean; }; safety: { includesConfigValues: false; @@ -95,6 +100,7 @@ export async function getConfigsStatus( } const fileConfigs = configs.filter((config) => config.kind === "file"); + const retiredAgentRows = configs.filter((config) => isRetiredOrUnsupportedConfigAgent(config.agent)).length; let driftedTargets = 0; let missingTargets = 0; let unredactedSecretFindings = 0; @@ -102,6 +108,7 @@ export async function getConfigsStatus( for (const config of fileConfigs) { unredactedSecretFindings += scanSecrets(config.content, config.format as RedactFormat).length; + if (isRetiredOrUnsupportedConfigAgent(config.agent)) continue; if (!config.target_path) continue; knownTargets += 1; @@ -143,7 +150,8 @@ export async function getConfigsStatus( databaseReachable && driftedTargets === 0 && missingTargets === 0 && - unredactedSecretFindings === 0 + unredactedSecretFindings === 0 && + retiredAgentRows === 0 ? "ok" : "warn"; @@ -167,6 +175,7 @@ export async function getConfigsStatus( file: fileConfigs.length, reference: configs.filter((config) => config.kind === "reference").length, templates: configs.filter((config) => config.is_template).length, + retiredAgentRows, }, byCategory, byAgent: countBy(configs, (config) => config.agent), @@ -183,9 +192,11 @@ export async function getConfigsStatus( driftedTargets, missingTargets, unredactedSecretFindings, + retiredAgentRows, hasDrift: driftedTargets > 0, hasMissingTargets: missingTargets > 0, hasUnredactedSecrets: unredactedSecretFindings > 0, + hasRetiredAgentRows: retiredAgentRows > 0, }, safety: { includesConfigValues: false, diff --git a/src/types/index.ts b/src/types/index.ts index 8738413..a266ed2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,11 +19,11 @@ export type ConfigCategory = (typeof CONFIG_CATEGORIES)[number]; export const CONFIG_AGENTS = [ "claude", "codex", - "gemini", "opencode", "cursor", "codewith", "aicopilot", + "antigravity", "zsh", "git", "npm",