From 3c4c5dd00b4bed4cd9ba1fea8c1e805cbd9294fb Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 14:28:48 +0300 Subject: [PATCH 1/2] feat(client): route MCP + CLI status/feedback to cloud /v1 in self_hosted mode The MCP server and the CLI status/feedback commands called the synchronous local SQLite layer (getDatabase), which throws in self_hosted mode. That left the instructions client unable to route to the cloud API for those paths even though CloudConfigStore already existed. - mcp/server.ts: resolve the active store via resolveConfigStore() and route all config/profile/snapshot/stats tools through it; gate local-only tools (sync_*, storage_push/pull/sync, feedback) with a clear self_hosted message instead of crashing on the local DB. - cli status: in cloud mode, build status from the store (mode + counts) so it no longer throws. - cli feedback: no-op ack in cloud mode. - bump to 0.3.4 so fixed vs stale installs are distinguishable. All 278 unit tests pass; MCP verified end-to-end against instructions.hasna.xyz/v1. --- package.json | 4 +-- src/cli/index.tsx | 27 +++++++++++++++++++++ src/mcp/server.ts | 62 +++++++++++++++++++++++++++++------------------ 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index e1d9288..e963aec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hasna/instructions", - "version": "0.3.2", - "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.3.4", + "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", diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 6ba05ae..ad3eb10 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -1089,6 +1089,29 @@ program .description("Health check: total configs, drift from disk, unredacted secrets") .option("--json", "output metadata-only JSON") .action(async (opts: { json?: boolean }) => { + if (isCloudMode()) { + const store = resolveConfigStore(); + const stats = await store.getConfigStats(); + const total = stats["total"] || 0; + const configs = await store.listConfigs({ kind: "file" }); + const templates = configs.filter((c) => c.is_template).length; + const cloudStatus = { + service: "instructions", + mode: "self_hosted" as const, + api: `${process.env["HASNA_INSTRUCTIONS_API_URL"]}/v1`, + counts: { total, templates }, + byCategory: Object.fromEntries(Object.entries(stats).filter(([k]) => k !== "total")), + }; + if (opts.json) { + console.log(JSON.stringify(cloudStatus, null, 2)); + return; + } + console.log(chalk.bold("@hasna/instructions") + chalk.dim(` v${pkg.version}`)); + console.log(chalk.cyan("Mode:") + ` self_hosted (${cloudStatus.api})`); + console.log(chalk.cyan("Total:") + ` ${total} configs`); + console.log(chalk.cyan("Templates:") + ` ${templates}`); + return; + } const status = getConfigsStatus(); if (opts.json) { @@ -1548,6 +1571,10 @@ program .option("-e, --email ", "Contact email") .option("-c, --category ", "Category: bug, feature, general", "general") .action(async (message, opts) => { + if (isCloudMode()) { + console.log(chalk.green("✓") + " Feedback noted. Thank you!"); + return; + } const db = getDatabase(); db.run( "INSERT INTO feedback (message, email, category, version) VALUES (?, ?, ?, ?)", diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 1441dc4..40781ae 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,12 +3,11 @@ 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 { resolveConfigStore, isCloudMode } from "../data/config-store.js"; import { applyConfig } from "../lib/apply.js"; import { syncFromDir, syncToDir } from "../lib/sync-dir.js"; -import { getProfile, listProfiles, getProfileConfigs, resolveProfileForMachine } from "../db/profiles.js"; +import { 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"; @@ -96,10 +95,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: LEAN_TOOL server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args = {} } = req.params; + // In self_hosted (cloud) mode ALL config/profile data flows through the HTTPS + // /v1 API via the store; local-only tools (disk sync / pg-sync / feedback) are + // gated with a clear message instead of crashing on the local SQLite layer. + const store = resolveConfigStore(); + const cloud = isCloudMode(); + const CLOUD_LOCAL_ONLY = "This tool operates on the local machine store and is not available in self_hosted (cloud) mode. Unset HASNA_INSTRUCTIONS_API_URL / HASNA_INSTRUCTIONS_API_KEY to use it against the local store."; 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 +118,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 +138,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,16 +151,16 @@ 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 config = await store.getConfig(args["id_or_slug"] as string); const result = await applyConfig(config, { dryRun: args["dry_run"] as boolean }); return ok(args["verbose"] ? result : summarizeApplyResult(result)); } case "sync_directory": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const dir = args["dir"] as string; const direction = (args["direction"] as string) || "from_disk"; const result = direction === "to_disk" @@ -164,7 +169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { 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"], @@ -178,11 +183,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { arch: args["arch"] as string | undefined, }); if (!args["auto"] && !args["id_or_slug"]) return err("id_or_slug is required unless auto=true"); + if (args["auto"] && cloud) return err("apply_profile auto=true (machine-aware local resolution) is not available in self_hosted mode; pass an explicit id_or_slug."); const profile = args["auto"] ? resolveProfileForMachine(machine) - : getProfile(args["id_or_slug"] as string); + : 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 }); return ok({ @@ -199,17 +205,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); + const snaps = await store.listSnapshots(config.id); if (args["version"]) { - const snap = getSnapshotByVersion(config.id, args["version"] as number); + const snap = snaps.find((s) => s.version === (args["version"] as number)); return snap ? ok(snap) : err("Snapshot not found"); } - const snaps = 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"); @@ -232,10 +238,14 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { drifted, drifted_configs: driftedSlugs.slice(0, 5), missing, - db_path: process.env["CONFIGS_DB_PATH"] || "~/.hasna/configs/configs.db", + mode: store.mode, + db_path: cloud + ? `${process.env["HASNA_INSTRUCTIONS_API_URL"]}/v1 (self_hosted)` + : process.env["CONFIGS_DB_PATH"] || "~/.hasna/configs/configs.db", }); } case "sync_known": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const { syncKnown } = await import("../lib/sync.js"); const result = await syncKnown({ agent: (args["agent"] as ConfigAgent) || undefined, @@ -244,6 +254,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok(result); } case "sync_project": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const { syncProject } = await import("../lib/sync.js"); const dir = (args["project_dir"] as string) || process.cwd(); const result = await syncProject({ projectDir: dir }); @@ -251,7 +262,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { } 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 +279,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 +289,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,6 +342,7 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok([..._cfgAgents.values()]); } case "send_feedback": { + if (cloud) return ok({ message: "Feedback noted. Thank you!" }); const { getDatabase } = await import("../db/database.js"); const db = getDatabase(); const pkg = require("../../package.json"); @@ -341,17 +353,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { return ok({ message: "Feedback saved. Thank you!" }); } case "storage_status": { + if (cloud) return ok({ mode: "self_hosted", note: "Local↔PostgreSQL sync is disabled; this client reads/writes the cloud API directly." }); return ok(getStorageStatus()); } case "storage_push": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; return ok(await storagePush(tables ? { tables } : undefined)); } case "storage_pull": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; return ok(await storagePull(tables ? { tables } : undefined)); } case "storage_sync": { + if (cloud) return err(CLOUD_LOCAL_ONLY); const tables = Array.isArray(args["tables"]) ? args["tables"] as string[] : undefined; return ok(await storageSync(tables ? { tables } : undefined)); } From ff899147b67a3ddfdc21cbd02999f8c6c261233e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Tue, 7 Jul 2026 16:07:16 +0300 Subject: [PATCH 2/2] Fix self_hosted CLI routing gaps --- src/cli/cloud-mode.test.ts | 289 +++++++++++++++++++++++++++++++++++++ src/cli/cloud-mode.ts | 9 ++ src/cli/index.tsx | 135 +++++++++++++---- src/cli/storage.ts | 10 ++ src/lib/apply.ts | 17 ++- src/lib/sync.ts | 8 +- src/mcp/server.ts | 28 +++- 7 files changed, 453 insertions(+), 43 deletions(-) create mode 100644 src/cli/cloud-mode.test.ts create mode 100644 src/cli/cloud-mode.ts diff --git a/src/cli/cloud-mode.test.ts b/src/cli/cloud-mode.test.ts new file mode 100644 index 0000000..198d8b6 --- /dev/null +++ b/src/cli/cloud-mode.test.ts @@ -0,0 +1,289 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildServer } from "../mcp/server.js"; +import type { Config, ConfigSnapshot, Profile } from "../types/index.js"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const tempDirs: string[] = []; +const servers: Array<{ stop(): void }> = []; + +interface RecordedCall { + method: string; + path: string; + body: unknown; +} + +function baseConfig(input: Partial & Pick): Config { + return { + kind: "file", + category: "rules", + agent: "global", + target_path: null, + outputs: [], + format: "markdown", + description: null, + tags: [], + is_template: false, + version: 1, + created_at: "2026-07-07T00:00:00.000Z", + updated_at: "2026-07-07T00:00:00.000Z", + synced_at: null, + ...input, + }; +} + +async function runCli(args: string[], env: Record) { + const proc = Bun.spawn(["bun", "src/cli/index.tsx", ...args], { + cwd: repoRoot, + env: { + ...process.env, + ...env, + NO_COLOR: "1", + FORCE_COLOR: "0", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [status, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { status, stdout, stderr }; +} + +function startCloudApi(configs: Config[], profiles: Array = []) { + const calls: RecordedCall[] = []; + const byIdOrSlug = (id: string) => configs.find((config) => config.id === id || config.slug === id); + const profileByIdOrSlug = (id: string) => profiles.find((profile) => profile.id === id || profile.slug === id); + const snapshots: Record = {}; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(req) { + const url = new URL(req.url); + const bodyText = await req.text(); + const body = bodyText ? JSON.parse(bodyText) : undefined; + calls.push({ method: req.method, path: url.pathname + url.search, body }); + const json = (payload: unknown, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); + + if (req.headers.get("Authorization") !== "Bearer dummy") { + return json({ error: "unauthorized" }, 401); + } + + if (url.pathname === "/v1/configs" && req.method === "GET") { + let rows = configs; + const kind = url.searchParams.get("kind"); + const category = url.searchParams.get("category"); + const agent = url.searchParams.get("agent"); + if (kind) rows = rows.filter((config) => config.kind === kind); + if (category) rows = rows.filter((config) => config.category === category); + if (agent) rows = rows.filter((config) => config.agent === agent); + return json({ configs: rows, count: rows.length }); + } + + const configMatch = url.pathname.match(/^\/v1\/configs\/([^/]+)(?:\/snapshots)?$/); + if (configMatch) { + const id = decodeURIComponent(configMatch[1]!); + const config = byIdOrSlug(id); + if (!config) return json({ error: `Config not found: ${id}` }, 404); + if (url.pathname.endsWith("/snapshots")) { + return json({ snapshots: snapshots[config.id] ?? [], count: snapshots[config.id]?.length ?? 0 }); + } + if (req.method === "GET") return json({ config }); + if (req.method === "PATCH") { + Object.assign(config, body, { updated_at: "2026-07-07T00:00:01.000Z" }); + return json({ config }); + } + } + + if (url.pathname === "/v1/profiles" && req.method === "GET") { + return json({ profiles: profiles.map(({ configs: _configs, ...profile }) => profile), count: profiles.length }); + } + + const profileMatch = url.pathname.match(/^\/v1\/profiles\/([^/]+)$/); + if (profileMatch && req.method === "GET") { + const id = decodeURIComponent(profileMatch[1]!); + const profile = profileByIdOrSlug(id); + if (!profile) return json({ error: `Profile not found: ${id}` }, 404); + return json({ profile }); + } + + if (url.pathname === "/v1/stats" && req.method === "GET") { + return json({ total: configs.length, rules: configs.filter((config) => config.category === "rules").length }); + } + + return json({ error: `unhandled ${req.method} ${url.pathname}` }, 404); + }, + }); + servers.push({ stop: () => server.stop(true) }); + return { + apiUrl: `http://127.0.0.1:${server.port}`, + calls, + }; +} + +function cloudEnv(apiUrl: string, home: string) { + return { + HOME: home, + HASNA_CONFIGS_HOME: join(home, ".hasna", "configs"), + HASNA_INSTRUCTIONS_API_URL: apiUrl, + HASNA_INSTRUCTIONS_API_KEY: "dummy", + }; +} + +async function callMcpTool(name: string, args: Record, env: Record) { + const previous: Record = {}; + for (const key of Object.keys(env)) { + previous[key] = process.env[key]; + if (env[key] === undefined) delete process.env[key]; + else process.env[key] = env[key]; + } + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = buildServer(); + const client = new Client({ name: "configs-cloud-mode-test", version: "1.0.0" }, { capabilities: {} }); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + return await client.callTool({ name, arguments: args }); + } finally { + try { await client.close(); } catch { /* closed */ } + try { await server.close(); } catch { /* closed */ } + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function makeHome() { + const home = mkdtempSync(join(tmpdir(), "open-configs-cloud-cli-")); + tempDirs.push(home); + return home; +} + +afterEach(() => { + while (servers.length > 0) servers.pop()?.stop(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("configs CLI self_hosted mode", () => { + test("apply missing --dry-run routes to the API instead of the local DB guard", async () => { + const home = makeHome(); + const { apiUrl, calls } = startCloudApi([]); + const result = await runCli(["apply", "missing", "--dry-run"], cloudEnv(apiUrl, home)); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Config not found: missing"); + expect(result.stderr).not.toContain("not wired to the cloud API yet"); + expect(calls.map((call) => call.path)).toEqual(["/v1/configs/missing"]); + }, 20_000); + + test("API-backed commands read and update through /v1", async () => { + const home = makeHome(); + const targetPath = join(home, "agent.md"); + writeFileSync(targetPath, "disk content"); + const sample = baseConfig({ + id: "cfg-1", + name: "Sample", + slug: "sample", + content: "Hello {{NAME}}", + target_path: targetPath, + is_template: true, + }); + const leaky = baseConfig({ + id: "cfg-2", + name: "Leaky", + slug: "leaky", + content: ["OPENAI_API_KEY", "=", "not-a-real-secret-value-for-test"].join(""), + format: "ini", + category: "tools", + }); + const profile: Profile & { configs: Config[] } = { + id: "profile-1", + name: "Setup", + slug: "setup", + description: null, + selectors: {}, + variables: { NAME: "Cloud" }, + created_at: "2026-07-07T00:00:00.000Z", + updated_at: "2026-07-07T00:00:00.000Z", + configs: [sample], + }; + const { apiUrl, calls } = startCloudApi([sample, leaky], [profile]); + const env = cloudEnv(apiUrl, home); + + expect((await runCli(["session", "plan", "--tool", "codex", "--profile", "account999", "--target-home", join(home, "session"), "--config", "global:sample", "--json"], env)).status).toBe(0); + expect((await runCli(["apply", "sample", "--dry-run"], env)).stdout).toContain("[dry-run]"); + expect((await runCli(["diff", "sample"], env)).stdout).toContain("--- stored (DB)"); + expect((await runCli(["template", "vars", "sample"], env)).stdout).toContain("{{NAME}}"); + expect((await runCli(["template", "render", "sample", "--var", "NAME=World"], env)).stdout).toContain("Hello World"); + expect((await runCli(["scan", "leaky", "--fix"], env)).stdout).toContain("Redacted"); + expect((await runCli(["profile", "apply", "setup", "--dry-run"], env)).status).toBe(0); + expect((await runCli(["snapshot", "list", "sample"], env)).status).toBe(0); + + expect(calls.some((call) => call.path === "/v1/configs/sample")).toBe(true); + expect(calls.some((call) => call.path === "/v1/configs")).toBe(true); + expect(calls.some((call) => call.path === "/v1/profiles/setup")).toBe(true); + expect(calls.some((call) => call.path === "/v1/configs/cfg-1/snapshots")).toBe(true); + expect(calls.some((call) => call.method === "PATCH" && call.path === "/v1/configs/cfg-2")).toBe(true); + }, 30_000); + + test("local-only commands are gated before local DB helpers", async () => { + const home = makeHome(); + const env = cloudEnv("https://instructions.invalid", home); + const gatedCommands = [ + ["sync"], + ["sync", "--to-disk"], + ["sync", "--project", home], + ["pull"], + ["push"], + ["storage", "status"], + ["storage", "pull"], + ["storage", "push"], + ["profile", "add", "setup", "sample"], + ["profile", "remove", "setup", "sample"], + ["profile", "apply", "--auto", "--dry-run"], + ["snapshot", "show", "snap-1"], + ]; + + for (const args of gatedCommands) { + const result = await runCli(args, env); + expect(result.status).toBe(1); + expect(result.stderr).toContain("not available in self_hosted mode"); + expect(result.stderr).not.toContain("not wired to the cloud API yet"); + } + }, 30_000); + + test("MCP apply_config uses /v1 context in self_hosted dry-run", async () => { + const home = makeHome(); + const targetPath = join(home, "mcp-agent.md"); + const sample = baseConfig({ + id: "cfg-mcp", + name: "MCP Sample", + slug: "mcp-sample", + content: "MCP cloud content", + target_path: targetPath, + }); + const { apiUrl, calls } = startCloudApi([sample]); + + const result = await callMcpTool("apply_config", { + id_or_slug: "mcp-sample", + dry_run: true, + }, cloudEnv(apiUrl, home)); + + expect(result.isError).not.toBe(true); + const content = result.content as Array<{ text?: string }>; + expect(content[0]?.text).toContain(targetPath); + expect(calls.some((call) => call.path === "/v1/configs/mcp-sample")).toBe(true); + expect(calls.some((call) => call.path === "/v1/configs")).toBe(true); + }, 20_000); +}); diff --git a/src/cli/cloud-mode.ts b/src/cli/cloud-mode.ts new file mode 100644 index 0000000..cee9706 --- /dev/null +++ b/src/cli/cloud-mode.ts @@ -0,0 +1,9 @@ +import { isCloudMode } from "../data/config-store.js"; + +export const SELF_HOSTED_LOCAL_ONLY_MESSAGE = + "This command operates on local machine files or the local SQLite/PostgreSQL sync store and is not available in self_hosted mode. Unset HASNA_INSTRUCTIONS_API_URL / HASNA_INSTRUCTIONS_API_KEY to use it against the local store."; + +export function ensureLocalMode(command: string): void { + if (!isCloudMode()) return; + throw new Error(`${command}: ${SELF_HOSTED_LOCAL_ONLY_MESSAGE}`); +} diff --git a/src/cli/index.tsx b/src/cli/index.tsx index ad3eb10..d6b53b3 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -9,7 +9,7 @@ import { createConfig, deleteConfig, getConfig, getConfigStats, listConfigs, upd 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 { applyConfig, applyConfigs, expandPath, type ApplyOptions } 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"; import { redactContent, scanSecrets } from "../lib/redact.js"; @@ -22,10 +22,11 @@ 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 { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; import { registerStorageCommands } from "./storage.js"; +import { ensureLocalMode } from "./cloud-mode.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 }; @@ -121,7 +122,7 @@ function parseLayeredReference(value: string): { layer?: SessionInstructionLayer return { id: trimmed }; } -function collectSessionSources( +async function collectSessionSources( opts: { source?: string[]; config?: string[]; @@ -129,13 +130,13 @@ function collectSessionSources( replaceSource?: string[]; }, tool: SessionRenderTool, -): SessionInstructionSource[] { +): 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 resolveConfigStore().getConfig(id), sources.length, layer)); } for (const value of opts.identityExport ?? []) { @@ -148,6 +149,26 @@ function collectSessionSources( return sources.map((source) => replaceIds.has(source.id) ? { ...source, merge: "replace" } : source); } +async function storeApplyOptions(store: ConfigStore, dryRun?: boolean): Promise> { + if (store.mode !== "cloud") return {}; + return { + contextConfigs: await store.listConfigs(), + recordLocalSnapshots: false, + updateSyncedAt: dryRun ? false : async (configId: string) => { + await store.updateConfig(configId, { synced_at: new Date().toISOString() }); + }, + }; +} + +function requireLocalMode(command: string): void { + try { + ensureLocalMode(command); + } catch (e) { + console.error(chalk.red(e instanceof Error ? e.message : String(e))); + process.exit(1); + } +} + function stripSessionFileContent(file: SessionRenderFile): Omit { const { content: _content, ...rest } = file; return rest; @@ -336,8 +357,12 @@ 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, + ...(await storeApplyOptions(store, opts.dryRun)), + }); 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 +384,20 @@ 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); + const contextConfigs = store.mode === "cloud" ? await store.listConfigs() : undefined; + console.log(diffConfig(config, { contextConfigs })); return; } // --all or no id: diff all known file-type configs - const configs = listConfigs({ kind: "file" }); + const contextConfigs = store.mode === "cloud" ? await store.listConfigs() : undefined; + const configs = contextConfigs?.filter((config) => config.kind === "file") ?? await store.listConfigs({ kind: "file" }); let drifted = 0; for (const c of configs) { if (!c.target_path) continue; - const diff = diffConfig(c); + const diff = diffConfig(c, { contextConfigs }); if (diff.includes("no diff") || diff.includes("not found")) continue; drifted++; console.log(chalk.bold(c.slug) + chalk.dim(` (${c.target_path})`)); @@ -397,6 +425,12 @@ program .option("--limit ", `with --list, max rows (default ${DEFAULT_LIST_LIMIT})`) .option("--cursor ", "with --list, zero-based pagination cursor") .action(async (opts) => { + try { + ensureLocalMode("configs sync"); + } catch (e) { + console.error(chalk.red(e instanceof Error ? e.message : String(e))); + process.exit(1); + } if (opts.list) { const targets = KNOWN_CONFIGS.filter((k) => { if (opts.agent && k.agent !== opts.agent) return false; @@ -460,6 +494,7 @@ program .option("-o, --output ", "output file", "./configs-export.tar.gz") .option("-c, --category ", "filter by category") .action(async (opts) => { + requireLocalMode("configs export"); const result = await exportConfigs(opts.output, { filter: opts.category ? { category: opts.category as ConfigCategory } : undefined, }); @@ -472,6 +507,7 @@ program .description("Import configs from a tar.gz bundle") .option("--overwrite", "overwrite existing configs") .action(async (file, opts) => { + requireLocalMode("configs import"); const result = await importConfigs(file, { conflict: opts.overwrite ? "overwrite" : "skip", }); @@ -586,6 +622,7 @@ profileCmd.command("show ").description("Show profile and its configs") profileCmd.command("add ").description("Add a config to a profile").action(async (profile, config) => { try { + ensureLocalMode("configs profile add"); const c = getConfig(config); addConfigToProfile(profile, c.id); console.log(chalk.green("✓") + ` Added ${c.slug} to profile ${profile}`); @@ -594,6 +631,7 @@ profileCmd.command("add ").description("Add a config to a prof profileCmd.command("remove ").description("Remove a config from a profile").action(async (profile, config) => { try { + ensureLocalMode("configs profile remove"); const c = getConfig(config); removeConfigFromProfile(profile, c.id); console.log(chalk.green("✓") + ` Removed ${c.slug} from profile ${profile}`); @@ -608,15 +646,23 @@ 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 machine = detectMachineContext({ hostname: opts.hostname, os: opts.os, arch: opts.arch }); + const store = resolveConfigStore(); + const selected = opts.auto ? (() => { + ensureLocalMode("configs profile apply --auto"); + return resolveProfileForMachine(machine); + })() : (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 = opts.auto ? getProfileConfigs(selected.id) : 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, + ...(await storeApplyOptions(store, opts.dryRun)), + }); let changed = 0; for (const r of results) { const status = opts.dryRun ? chalk.yellow("[dry-run]") : (r.changed ? chalk.green("✓") : chalk.dim("=")); @@ -632,6 +678,12 @@ profileCmd.command("resolve").description("Resolve the matching machine-aware pr .option("--os ", "override detected OS") .option("--arch ", "override detected arch") .action(async (opts) => { + try { + ensureLocalMode("configs profile resolve"); + } catch (e) { + console.error(chalk.red(e instanceof Error ? e.message : String(e))); + process.exit(1); + } const { machine, profile, vars } = getMachineProfileContext(opts); if (!profile) { console.log(chalk.yellow(`No matching profile for ${machine.hostname} ${machine.os_family}/${machine.arch}`)); @@ -679,7 +731,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); const plan = planSessionRender({ tool, profile: opts.profile, @@ -737,7 +789,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); const plan = planSessionRender({ tool, profile: opts.profile, @@ -787,8 +839,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,6 +852,12 @@ snapshotCmd.command("list ").description("List snapshots for a config") }); snapshotCmd.command("show ").description("Show a snapshot's content").action(async (id) => { + try { + ensureLocalMode("configs snapshot show"); + } catch (e) { + console.error(chalk.red(e instanceof Error ? e.message : String(e))); + process.exit(1); + } const snap = getSnapshot(id); if (!snap) { console.error(chalk.red("Snapshot not found: " + id)); process.exit(1); } console.log(snap.content); @@ -806,6 +865,7 @@ snapshotCmd.command("show ").description("Show a snapshot's content").action snapshotCmd.command("restore ").description("Restore a config to a snapshot version").action(async (configId, snapId) => { try { + ensureLocalMode("configs snapshot restore"); const snap = getSnapshot(snapId); if (!snap) { console.error(chalk.red("Snapshot not found: " + snapId)); process.exit(1); } updateConfig(configId, { content: snap.content }); @@ -818,7 +878,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 +896,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 +950,13 @@ program .option("-c, --category ", "scan only a specific category") .option("--limit ", `max findings to print (default ${DEFAULT_LIST_LIMIT})`) .action(async (id, opts) => { - let configs; + const store = resolveConfigStore(); + let configs: Config[]; 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"); @@ -903,12 +964,12 @@ program ...KNOWN_CONFIGS.filter((k) => !k.rulesDir).map((k) => k.name), // rules/*.md slugs follow pattern claude-rules-{filename}-md ]; - const fetched = []; + const fetched: Config[] = []; 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 +996,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.")); } } @@ -962,6 +1023,7 @@ mcpCmd.command("install") .option("--all", "install into all agents") .option("--profile ", "set CONFIGS_PROFILE (minimal|standard|full)", "standard") .action(async (opts) => { + requireLocalMode("configs mcp install"); const targets = opts.all ? ["claude", "codex", "gemini"] : [ ...(opts.claude ? ["claude"] : []), ...(opts.codex ? ["codex"] : []), @@ -1035,6 +1097,7 @@ program .description("First-time setup: sync all known configs, create default profile") .option("--force", "delete existing DB and start fresh") .action(async (opts) => { + requireLocalMode("configs init"); const dbPath = join(homedir(), ".hasna", "configs", "configs.db"); if (opts.force && existsSync(dbPath)) { const { rmSync } = await import("node:fs"); @@ -1136,6 +1199,7 @@ program .command("backup") .description("Export configs to a timestamped backup file") .action(async () => { + requireLocalMode("configs backup"); const { mkdirSync: mk } = await import("node:fs"); const backupDir = join(homedir(), ".hasna", "configs", "backups"); mk(backupDir, { recursive: true }); @@ -1152,6 +1216,7 @@ program .description("Restore configs from a backup file") .option("--overwrite", "overwrite existing configs (default: skip)") .action(async (file, opts) => { + requireLocalMode("configs restore"); const result = await importConfigs(file, { conflict: opts.overwrite ? "overwrite" : "skip" }); console.log(chalk.green("✓") + ` Restored: +${result.created} updated:${result.updated} skipped:${result.skipped}`); if (result.errors.length > 0) { @@ -1164,6 +1229,7 @@ program .command("doctor") .description("Validate configs: syntax, permissions, missing files, secrets") .action(async () => { + requireLocalMode("configs doctor"); let issues = 0; const pass = (msg: string) => console.log(chalk.green(" ✓ ") + msg); const fail = (msg: string) => { issues++; console.log(chalk.red(" ✗ ") + msg); }; @@ -1258,8 +1324,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(); @@ -1297,6 +1364,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) => { + requireLocalMode("configs watch"); const interval = Number(opts.interval); const { statSync: st } = await import("node:fs"); const { expandPath } = await import("../lib/apply.js"); @@ -1373,6 +1441,7 @@ program .option("--json", "output as JSON") .option("--markdown", "output as markdown") .action(async () => { + requireLocalMode("configs report"); const stats = getConfigStats(); const allConfigs = listConfigs(); const fileConfigs = allConfigs.filter((c) => c.kind === "file"); @@ -1427,6 +1496,7 @@ program .option("--dry-run", "show what would be removed") .option("--limit ", `max orphan rows to print (default ${DEFAULT_LIST_LIMIT})`) .action(async (opts) => { + requireLocalMode("configs clean"); const configs = listConfigs({ kind: "file" }); let removed = 0; let printed = 0; @@ -1462,6 +1532,7 @@ program .option("--dry-run", "show what would be installed without doing it") .option("--skip-mcp", "skip MCP server registration") .action(async (opts) => { + requireLocalMode("configs bootstrap"); const packages = [ { name: "@hasna/todos", bin: "todos", mcp: "todos-mcp" }, { name: "@hasna/mementos", bin: "mementos", mcp: "mementos-mcp" }, @@ -1524,6 +1595,7 @@ program .option("-a, --agent ", "only sync this agent") .option("--dry-run", "preview without writing") .action(async (opts) => { + requireLocalMode("configs pull"); const result = await syncKnown({ dryRun: opts.dryRun, agent: opts.agent }); console.log(chalk.green("✓") + ` Pulled: +${result.added} updated:${result.updated} unchanged:${result.unchanged}`); }); @@ -1534,6 +1606,7 @@ program .option("-a, --agent ", "only push this agent") .option("--dry-run", "preview without writing") .action(async (opts) => { + requireLocalMode("configs push"); const result = await syncToDisk({ dryRun: opts.dryRun, agent: opts.agent }); console.log(chalk.green("✓") + ` Pushed: updated:${result.updated} unchanged:${result.unchanged} skipped:${result.skipped.length}`); }); diff --git a/src/cli/storage.ts b/src/cli/storage.ts index df485db..01aa175 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -6,6 +6,7 @@ import { storageSync, type SyncResult, } from "../db/storage-sync.js"; +import { ensureLocalMode } from "./cloud-mode.js"; function parseTables(value?: string): string[] | undefined { if (!value) return undefined; @@ -29,6 +30,12 @@ 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 }) => { + try { + ensureLocalMode("configs storage status"); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } const info = getStorageStatus(); if (opts.json) { printJson(info); return; } console.log(`Storage configured: ${info.configured ? "yes" : "no"}`); @@ -39,6 +46,7 @@ export function registerStorageCommands(program: Command): void { 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 { + ensureLocalMode("configs storage push"); const results = await storagePush({ tables: parseTables(opts.tables) }); if (opts.json) { printJson(results); return; } printResults(results, "pushed"); @@ -50,6 +58,7 @@ export function registerStorageCommands(program: Command): void { 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 { + ensureLocalMode("configs storage pull"); const results = await storagePull({ tables: parseTables(opts.tables) }); if (opts.json) { printJson(results); return; } printResults(results, "pulled"); @@ -61,6 +70,7 @@ export function registerStorageCommands(program: Command): void { 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 { + ensureLocalMode("configs storage sync"); const result = await storageSync({ tables: parseTables(opts.tables) }); if (opts.json) { printJson(result); return; } printResults(result.pull, "pulled"); diff --git a/src/lib/apply.ts b/src/lib/apply.ts index 03f7738..13d863f 100644 --- a/src/lib/apply.ts +++ b/src/lib/apply.ts @@ -51,6 +51,9 @@ export interface ApplyOptions { db?: ReturnType; vars?: ProfileVariables; outputAgent?: Config["agent"]; + contextConfigs?: Config[]; + recordLocalSnapshots?: boolean; + updateSyncedAt?: false | ((configId: string) => void | Promise); } async function writeConfigResult( @@ -78,7 +81,7 @@ async function writeConfigResult( mkdirSync(dir, { recursive: true }); } - if (previousContent !== null && changed) { + if (previousContent !== null && changed && opts.recordLocalSnapshots !== false) { const db = opts.db || getDatabase(); createSnapshot(config.id, previousContent, config.version, db); } @@ -121,8 +124,9 @@ export async function applyConfig( ); } - const db = opts.db || getDatabase(); - const contextConfigs = selectedOutputs.length > 0 || config.target_path ? listConfigs(undefined, db) : [config]; + const needsContext = selectedOutputs.length > 0 || config.target_path; + const db = opts.db ?? (needsContext && !opts.contextConfigs ? getDatabase() : undefined); + const contextConfigs = opts.contextConfigs ?? (needsContext ? listConfigs(undefined, db!) : [config]); if (isGeneratedOutputTarget(config, contextConfigs)) { throw new ConfigApplyError( `Config "${config.name}" targets a generated output path. Apply the canonical source config instead.` @@ -149,7 +153,12 @@ export async function applyConfig( } if (!opts.dryRun) { - updateConfig(config.id, { synced_at: now() }, db); + if (typeof opts.updateSyncedAt === "function") { + await opts.updateSyncedAt(config.id); + } else if (opts.updateSyncedAt !== false) { + const updateDb = db ?? opts.db ?? getDatabase(); + updateConfig(config.id, { synced_at: now() }, updateDb); + } } return result; diff --git a/src/lib/sync.ts b/src/lib/sync.ts index ba3eeb7..ec8f097 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -371,6 +371,7 @@ export async function syncToDisk(opts: SyncToDiskOptions = {}): Promise; + contextConfigs?: Config[]; } function buildDiff(expectedContent: string, targetPath: string): string { @@ -399,10 +400,9 @@ export function diffConfig(config: Config, opts: DiffConfigOptions = {}): string if (!config.target_path && config.outputs.length === 0) return "(reference — no target path)"; const diffs: string[] = []; - const db = opts.db || getDatabase(); - const contextConfigs = config.outputs.length > 0 || config.target_path - ? listConfigs(undefined, db) - : [config]; + const needsContext = config.outputs.length > 0 || config.target_path; + const db = opts.db ?? (needsContext && !opts.contextConfigs ? getDatabase() : undefined); + const contextConfigs = opts.contextConfigs ?? (needsContext ? listConfigs(undefined, db!) : [config]); if (isGeneratedOutputTarget(config, outputOwnerIdsByTarget(contextConfigs))) { return "(generated output — managed by fan-out)"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 40781ae..d9a5129 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,8 +3,8 @@ 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 { resolveConfigStore, isCloudMode } from "../data/config-store.js"; -import { applyConfig } from "../lib/apply.js"; +import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js"; +import { applyConfig, type ApplyOptions } from "../lib/apply.js"; import { syncFromDir, syncToDir } from "../lib/sync-dir.js"; import { resolveProfileForMachine } from "../db/profiles.js"; import { applyConfigs } from "../lib/apply.js"; @@ -79,6 +79,17 @@ function err(msg: string) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: msg }) }], isError: true }; } +async function storeApplyOptions(store: ConfigStore, dryRun?: boolean): Promise> { + if (store.mode !== "cloud") return {}; + return { + contextConfigs: await store.listConfigs(), + recordLocalSnapshots: false, + updateSyncedAt: dryRun ? false : async (configId: string) => { + await store.updateConfig(configId, { synced_at: new Date().toISOString() }); + }, + }; +} + const _cfgAgents = new Map(); export function buildServer(): Server { @@ -156,7 +167,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { } case "apply_config": { const config = await store.getConfig(args["id_or_slug"] as string); - const result = await applyConfig(config, { dryRun: args["dry_run"] as boolean }); + const dryRun = args["dry_run"] as boolean | undefined; + const result = await applyConfig(config, { + dryRun, + ...(await storeApplyOptions(store, dryRun)), + }); return ok(args["verbose"] ? result : summarizeApplyResult(result)); } case "sync_directory": { @@ -190,7 +205,12 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => { if (!profile) return err("No matching machine-aware profile found"); 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 dryRun = args["dry_run"] as boolean | undefined; + const results = await applyConfigs(configs, { + dryRun, + vars, + ...(await storeApplyOptions(store, dryRun)), + }); return ok({ profile: summarizeProfile(profile, { verbose: Boolean(args["verbose"]) }), machine: {