diff --git a/README.md b/README.md index 30b7694..31fcc83 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,10 @@ This project automates Daytona sandbox setup and OpenCode execution. - `DAYTONA_API_KEY` - `DAYTONA_API_URL` for self-hosted Daytona (example: `https://daytona.example.com/api`) - Optional but recommended: `OPENCODE_SERVER_PASSWORD` -- Optional: `obsidian` command in `PATH` (for Obsidian note cataloging/open) +- Obsidian Headless CLI in `PATH` (`ob`, installed via `npm install -g obsidian-headless`) for non-disruptive sync +- Obsidian Catalyst access (Headless Sync is currently open beta) +- Active Obsidian Sync subscription (required for `ob sync-*`) +- Optional: `obsidian` desktop CLI in `PATH` if you explicitly use desktop integration/open-after-catalog --- @@ -94,6 +97,9 @@ It sets up: - `~/.config/opencode/shpit.toml` for shared preferences - `~/.config/opencode/.env` for optional credential storage +- headless preflight checks when `obsidian.integration_mode = "headless"`: + - verifies `ob` command is installed + - runs `ob sync-list-remote` to validate account/login/sync access No provider API key is required if you only use free `opencode/*` models (for example `opencode/minimax-m2.5-free`). @@ -105,14 +111,31 @@ Example config: [obsidian] enabled = true command = "obsidian" +integration_mode = "headless" # headless | desktop +headless_command = "ob" vault_path = "/absolute/path/to/vault" notes_root = "Research/OpenCode" catalog_mode = "date" # date | repo -open_after_catalog = false +sync_after_catalog = true +sync_timeout_sec = 120 +open_after_catalog = false # desktop mode only ``` Project-level `shpit.toml` or `.shpit.toml` overrides global config. -The configured command must be `obsidian` (not `obs`). +The configured desktop command must be `obsidian` (not `obs`). + +Headless setup is one-time per local vault path: + +```bash +npm install -g obsidian-headless +ob login +ob sync-list-remote +mkdir -p ~/vaults/my-headless-vault +ob sync-setup --vault "My Vault" --path ~/vaults/my-headless-vault +ob sync --path ~/vaults/my-headless-vault +``` + +Do not run desktop Sync and Headless Sync on the same device for the same vault path; use a dedicated local path for headless workflows. --- @@ -121,7 +144,7 @@ The configured command must be `obsidian` (not `obs`). | Command | Purpose | |---|---| | `scripts/install-gh-package.sh` | Bootstrap install from GitHub Packages on a new machine | -| `bun run setup` | Guided setup for shared config/env and Obsidian cataloging | +| `bun run setup` | Guided setup for shared config/env, Obsidian mode selection, and headless preflight checks | | `bun run start` | Launch OpenCode web in a Daytona sandbox | | `bun run analyze -- --input example.md` | Analyze repos listed in a file | | `bun run analyze -- ` | Analyze direct repo URLs | @@ -162,7 +185,7 @@ bun run start -- --no-open - Auto-installs missing `git` and `node/npm` inside sandbox - Forwards provider env vars (`OPENAI_*`, `ANTHROPIC_*`, `XAI_*`, `OPENROUTER_*`, `ZHIPU_*`, `MINIMAX_*`, etc.) - Syncs local OpenCode config files from `~/.config/opencode` when present -- Auto-catalogs findings into Obsidian when enabled via `shpit.toml` +- Auto-catalogs findings into Obsidian when enabled via `shpit.toml`, with optional automatic `ob sync` in headless mode ### Examples diff --git a/src/install.ts b/src/install.ts index 17b1543..a7c492c 100644 --- a/src/install.ts +++ b/src/install.ts @@ -12,6 +12,9 @@ type CliOptions = { notesRoot?: string; catalogMode?: "date" | "repo"; openAfterCatalog?: boolean; + integrationMode?: "desktop" | "headless"; + syncAfterCatalog?: boolean; + syncTimeoutSec?: number; daytonaApiKey?: string; openaiApiKey?: string; zhipuApiKey?: string; @@ -27,7 +30,10 @@ function parseCliOptions(): CliOptions { "vault-path": { type: "string" }, "notes-root": { type: "string" }, "catalog-mode": { type: "string" }, - "open-after-catalog": { type: "boolean", default: false }, + "open-after-catalog": { type: "boolean" }, + "obsidian-integration": { type: "string" }, + "sync-after-catalog": { type: "boolean" }, + "sync-timeout-sec": { type: "string" }, "daytona-api-key": { type: "string" }, "openai-api-key": { type: "string" }, "zhipu-api-key": { type: "string" }, @@ -44,7 +50,10 @@ Options: --vault-path Obsidian vault path (absolute or ~/...) --notes-root Folder inside vault for audit notes (default: Research/OpenCode) --catalog-mode date | repo (default: date) - --open-after-catalog Open each new note via obsidian CLI after writing + --obsidian-integration headless | desktop (default: auto-detect) + --sync-after-catalog Run 'ob sync' after writing each note (headless mode) + --sync-timeout-sec Timeout for 'ob sync' (default: 120) + --open-after-catalog Open each new note via obsidian CLI (desktop mode) --daytona-api-key Seed DAYTONA_API_KEY into ~/.config/opencode/.env --openai-api-key Seed OPENAI_API_KEY into ~/.config/opencode/.env --zhipu-api-key Seed ZHIPU_API_KEY into ~/.config/opencode/.env @@ -58,12 +67,34 @@ Options: throw new Error(`--catalog-mode must be "date" or "repo". Received "${rawCatalogMode}".`); } + const rawIntegrationMode = values["obsidian-integration"]; + if (rawIntegrationMode && rawIntegrationMode !== "desktop" && rawIntegrationMode !== "headless") { + throw new Error( + `--obsidian-integration must be "desktop" or "headless". Received "${rawIntegrationMode}".`, + ); + } + + const rawSyncTimeoutSec = values["sync-timeout-sec"]; + let syncTimeoutSec: number | undefined; + if (rawSyncTimeoutSec !== undefined) { + const parsed = Number.parseInt(rawSyncTimeoutSec, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `--sync-timeout-sec must be a positive integer. Received "${rawSyncTimeoutSec}".`, + ); + } + syncTimeoutSec = parsed; + } + return { yes: values.yes, vaultPath: values["vault-path"], notesRoot: values["notes-root"], catalogMode: rawCatalogMode as "date" | "repo" | undefined, openAfterCatalog: values["open-after-catalog"], + integrationMode: rawIntegrationMode as "desktop" | "headless" | undefined, + syncAfterCatalog: values["sync-after-catalog"], + syncTimeoutSec, daytonaApiKey: values["daytona-api-key"], openaiApiKey: values["openai-api-key"], zhipuApiKey: values["zhipu-api-key"], @@ -84,9 +115,9 @@ function expandHomeDir(value: string | undefined): string | undefined { return path.join(home, value.slice(1)); } -async function detectObsidianBinary(): Promise { +async function detectCommandBinary(command: string): Promise { try { - const { stdout } = await execFileAsync("sh", ["-lc", "command -v obsidian"]); + const { stdout } = await execFileAsync("which", [command]); const resolved = stdout.trim(); return resolved || undefined; } catch { @@ -94,6 +125,36 @@ async function detectObsidianBinary(): Promise { } } +function countRemoteVaults(output: string): number { + return output.split(/\r?\n/).filter((line) => /^\s*[a-f0-9]{32}\s+"/.test(line)).length; +} + +function describeExecError(error: unknown): string { + if (!error || typeof error !== "object") { + return String(error); + } + + const message = "message" in error ? String(error.message) : "unknown error"; + const stdout = "stdout" in error ? String(error.stdout ?? "") : ""; + const stderr = "stderr" in error ? String(error.stderr ?? "") : ""; + const details = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); + return details ? `${message}\n${details}` : message; +} + +async function validateHeadlessSyncAccess(command: string): Promise { + try { + const { stdout, stderr } = await execFileAsync(command, ["sync-list-remote"], { + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + return countRemoteVaults(`${stdout}\n${stderr}`); + } catch (error) { + throw new Error( + `Headless Sync preflight failed. Ensure Obsidian Catalyst access, an active Obsidian Sync subscription, and successful \`ob login\`.\n${describeExecError(error)}`, + ); + } +} + function parseEnvFile(content: string): Map { const result = new Map(); const lines = content.split(/\r?\n/); @@ -178,6 +239,14 @@ async function askText(params: { return answer; } +function parsePositiveInteger(value: string, label: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer. Received "${value}".`); + } + return parsed; +} + async function main(): Promise { const options = parseCliOptions(); await loadConfiguredEnv(); @@ -192,19 +261,34 @@ async function main(): Promise { const configPath = path.join(configDir, "shpit.toml"); const envPath = path.join(configDir, ".env"); - const obsidianBinary = await detectObsidianBinary(); + const obsidianBinary = await detectCommandBinary("obsidian"); + const headlessBinary = await detectCommandBinary("ob"); console.log( obsidianBinary - ? `[install] Detected Obsidian CLI command at: ${obsidianBinary}` - : "[install] Obsidian CLI command not found in PATH. Expected command name: obsidian", + ? `[install] Detected Obsidian desktop CLI at: ${obsidianBinary}` + : "[install] Obsidian desktop CLI not found in PATH (command: obsidian)", ); console.log( - "[install] The installer will not execute Obsidian commands; it only configures them.", + headlessBinary + ? `[install] Detected Obsidian Headless CLI at: ${headlessBinary}` + : "[install] Obsidian Headless CLI not found in PATH (command: ob)", + ); + console.log( + "[install] Headless mode runs a real preflight against Obsidian Sync using `ob sync-list-remote`.", ); const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const nonInteractive = options.yes; + const hasExistingConfig = Boolean( + existingConfig.paths.globalConfigPath ?? existingConfig.paths.projectConfigPath, + ); + const recommendedIntegrationMode = hasExistingConfig + ? existingConfig.obsidian.integrationMode + : headlessBinary + ? "headless" + : "desktop"; + const headlessCommand = existingConfig.obsidian.headlessCommand; const enableObsidian = nonInteractive ? Boolean(options.vaultPath ?? existingConfig.obsidian.enabled) @@ -214,6 +298,21 @@ async function main(): Promise { defaultValue: existingConfig.obsidian.enabled, }); + const requestedIntegrationMode = + options.integrationMode ?? + (nonInteractive + ? recommendedIntegrationMode + : await askText({ + rl, + prompt: "Obsidian integration mode (headless|desktop)", + defaultValue: recommendedIntegrationMode, + })); + const integrationMode = + (requestedIntegrationMode ?? recommendedIntegrationMode).trim() || recommendedIntegrationMode; + if (integrationMode !== "headless" && integrationMode !== "desktop") { + throw new Error(`Invalid integration mode "${integrationMode}".`); + } + const vaultPath = expandHomeDir( options.vaultPath ?? (nonInteractive @@ -249,18 +348,70 @@ async function main(): Promise { throw new Error(`Invalid catalog mode "${catalogMode}".`); } - const openAfterCatalog = nonInteractive - ? Boolean(options.openAfterCatalog) - : await askYesNo({ - rl, - prompt: "Open each created note via obsidian command", - defaultValue: existingConfig.obsidian.openAfterCatalog, - }); + const openAfterCatalog = + integrationMode === "desktop" + ? nonInteractive + ? (options.openAfterCatalog ?? existingConfig.obsidian.openAfterCatalog) + : await askYesNo({ + rl, + prompt: "Open each created note via obsidian command", + defaultValue: existingConfig.obsidian.openAfterCatalog, + }) + : false; + + const syncAfterCatalog = + integrationMode === "headless" + ? nonInteractive + ? (options.syncAfterCatalog ?? existingConfig.obsidian.syncAfterCatalog) + : await askYesNo({ + rl, + prompt: "Run `ob sync` after each note write", + defaultValue: existingConfig.obsidian.syncAfterCatalog, + }) + : false; + + let syncTimeoutSec = options.syncTimeoutSec ?? existingConfig.obsidian.syncTimeoutSec; + if (integrationMode === "headless" && !nonInteractive && options.syncTimeoutSec === undefined) { + const entered = await askText({ + rl, + prompt: "Headless sync timeout in seconds", + defaultValue: String(existingConfig.obsidian.syncTimeoutSec), + }); + if (!entered) { + throw new Error("Headless sync timeout is required in headless mode."); + } + syncTimeoutSec = parsePositiveInteger(entered, "Headless sync timeout"); + } if (enableObsidian && !vaultPath) { throw new Error("Obsidian cataloging is enabled, but no vault path was provided."); } + if (enableObsidian && integrationMode === "desktop" && openAfterCatalog && !obsidianBinary) { + throw new Error( + "Desktop integration with open_after_catalog requires the `obsidian` command in PATH.", + ); + } + + if (enableObsidian && integrationMode === "headless") { + const resolvedHeadlessBinary = await detectCommandBinary(headlessCommand); + if (!resolvedHeadlessBinary) { + throw new Error( + "Headless integration requires `ob` in PATH. Install with: npm install -g obsidian-headless", + ); + } + const remoteVaultCount = await validateHeadlessSyncAccess(headlessCommand); + if (remoteVaultCount > 0) { + console.log( + `[install] Headless preflight passed. Remote vaults visible to this account: ${remoteVaultCount}`, + ); + } else { + console.warn( + '[install] Headless preflight succeeded but no remote vaults were found. Create one with `ob sync-create-remote --name "..."`.', + ); + } + } + await mkdir(configDir, { recursive: true }); const shpitTomlLines: string[] = []; @@ -269,6 +420,8 @@ async function main(): Promise { shpitTomlLines.push("[obsidian]"); shpitTomlLines.push(`enabled = ${enableObsidian ? "true" : "false"}`); shpitTomlLines.push('command = "obsidian"'); + shpitTomlLines.push(`integration_mode = ${JSON.stringify(integrationMode)}`); + shpitTomlLines.push(`headless_command = ${JSON.stringify(headlessCommand)}`); if (vaultPath) { shpitTomlLines.push(`vault_path = ${JSON.stringify(vaultPath)}`); } @@ -278,6 +431,8 @@ async function main(): Promise { shpitTomlLines.push( `catalog_mode = ${JSON.stringify(catalogMode ?? existingConfig.obsidian.catalogMode)}`, ); + shpitTomlLines.push(`sync_after_catalog = ${syncAfterCatalog ? "true" : "false"}`); + shpitTomlLines.push(`sync_timeout_sec = ${syncTimeoutSec}`); shpitTomlLines.push(`open_after_catalog = ${openAfterCatalog ? "true" : "false"}`); shpitTomlLines.push(""); diff --git a/src/obsidian-catalog.test.ts b/src/obsidian-catalog.test.ts index afceb81..5b2902a 100644 --- a/src/obsidian-catalog.test.ts +++ b/src/obsidian-catalog.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import path from "node:path"; import { __testables } from "./obsidian-catalog.js"; describe("obsidian catalog pathing", () => { @@ -27,4 +28,15 @@ describe("obsidian catalog pathing", () => { /^Research[\\/]OpenCode[\\/]owner-repo[\\/]\d{4}-\d{2}-\d{2}-01-owner-repo\.md$/, ); }); + + test("keeps resolved note path inside vault", () => { + const notePath = __testables.resolveNotePathWithinVault("/vault", "Research/OpenCode/note.md"); + expect(notePath).toBe(path.resolve("/vault", "Research/OpenCode/note.md")); + }); + + test("rejects note path traversal outside vault", () => { + expect(() => __testables.resolveNotePathWithinVault("/vault", "../../etc/passwd")).toThrow( + /outside Obsidian vault/, + ); + }); }); diff --git a/src/obsidian-catalog.ts b/src/obsidian-catalog.ts index b74bf49..610cfa7 100644 --- a/src/obsidian-catalog.ts +++ b/src/obsidian-catalog.ts @@ -3,6 +3,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { ResolvedShpitConfig } from "./shpit-config.js"; +type CommandResult = { + exitCode: number | null; + output: string; + timedOut: boolean; + error?: string; +}; + type CatalogInput = { config: ResolvedShpitConfig; slug: string; @@ -55,6 +62,16 @@ function buildRelativeNotePath(params: { return path.join(notesRoot, year, month, `${day}-${safeRunLabel}.md`); } +function resolveNotePathWithinVault(vaultPath: string, relativeNotePath: string): string { + const resolvedVaultPath = path.resolve(vaultPath); + const resolvedNotePath = path.resolve(resolvedVaultPath, relativeNotePath); + const relative = path.relative(resolvedVaultPath, resolvedNotePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Refusing to write outside Obsidian vault. Computed path: ${resolvedNotePath}`); + } + return resolvedNotePath; +} + function escapeYaml(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } @@ -63,6 +80,15 @@ function toMarkdownPath(value: string): string { return value.replace(/\\/g, "/"); } +function summarizeCommandOutput(output: string): string { + const trimmed = output.trim(); + if (!trimmed) { + return "no command output"; + } + const snippet = trimmed.split(/\r?\n/).slice(-8).join(" | "); + return snippet.length > 600 ? `${snippet.slice(0, 600)}...` : snippet; +} + function buildCatalogNote(input: { sourceUrl: string; findings: string; @@ -140,6 +166,78 @@ async function tryOpenInObsidian(params: { }); } +async function runForegroundCommand(params: { + command: string; + args: string[]; + timeoutMs: number; +}): Promise { + return await new Promise((resolve) => { + const child = spawn(params.command, params.args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + const chunks: string[] = []; + let timedOut = false; + + child.stdout.on("data", (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + child.stderr.on("data", (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, params.timeoutMs); + + child.once("error", (error) => { + clearTimeout(timer); + resolve({ + exitCode: null, + output: chunks.join(""), + timedOut, + error: String(error.message ?? error), + }); + }); + + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve({ + exitCode, + output: chunks.join(""), + timedOut, + }); + }); + }); +} + +async function trySyncWithHeadless(params: { + command: string; + vaultPath: string; + timeoutSec: number; +}): Promise { + const commandResult = await runForegroundCommand({ + command: params.command, + args: ["sync", "--path", params.vaultPath], + timeoutMs: params.timeoutSec * 1000, + }); + + if (commandResult.timedOut) { + return `Headless sync timed out after ${params.timeoutSec}s.`; + } + + if (commandResult.error) { + return `Failed to run ${params.command} sync: ${commandResult.error}`; + } + + if (commandResult.exitCode !== 0) { + return `Headless sync exited with code ${commandResult.exitCode}: ${summarizeCommandOutput(commandResult.output)}`; + } + + return undefined; +} + export async function catalogAnalysisResult(input: CatalogInput): Promise { const { obsidian } = input.config; @@ -165,7 +263,7 @@ export async function catalogAnalysisResult(input: CatalogInput): Promise 0 ? warnings.join(" ") : undefined, + }; + } + + if (obsidian.syncAfterCatalog) { + warnings.push('`sync_after_catalog` is ignored when `obsidian.integration_mode = "desktop"`.'); + } + if (!obsidian.openAfterCatalog) { return { attempted: true, written: true, notePath, + warning: warnings.length > 0 ? warnings.join(" ") : undefined, }; } - const warning = await tryOpenInObsidian({ + const openWarning = await tryOpenInObsidian({ command: obsidian.command, vaultPath: obsidian.vaultPath, relativeNotePath, }); + if (openWarning) { + warnings.push(openWarning); + } + return { attempted: true, written: true, notePath, - warning, + warning: warnings.length > 0 ? warnings.join(" ") : undefined, }; } export const __testables = { buildRelativeNotePath, + resolveNotePathWithinVault, }; diff --git a/src/shpit-config.test.ts b/src/shpit-config.test.ts index c3581e0..f01d9f0 100644 --- a/src/shpit-config.test.ts +++ b/src/shpit-config.test.ts @@ -12,6 +12,10 @@ describe("shpit config parsing", () => { 'notes_root = "Research/OpenCode"', 'catalog_mode = "repo"', "open_after_catalog = false", + 'integration_mode = "headless"', + 'headless_command = "ob"', + "sync_after_catalog = true", + "sync_timeout_sec = 180", ].join("\n"), "shpit.toml", ); @@ -24,6 +28,10 @@ describe("shpit config parsing", () => { expect(obsidian.notes_root).toBe("Research/OpenCode"); expect(obsidian.catalog_mode).toBe("repo"); expect(obsidian.open_after_catalog).toBe(false); + expect(obsidian.integration_mode).toBe("headless"); + expect(obsidian.headless_command).toBe("ob"); + expect(obsidian.sync_after_catalog).toBe(true); + expect(obsidian.sync_timeout_sec).toBe(180); }); test("rejects obs command alias", () => { @@ -43,6 +51,41 @@ describe("shpit config parsing", () => { expect(config.catalogMode).toBe("date"); expect(config.notesRoot).toBe("Research/OpenCode"); expect(config.openAfterCatalog).toBe(false); + expect(config.integrationMode).toBe("desktop"); + expect(config.headlessCommand).toBe("ob"); + expect(config.syncAfterCatalog).toBe(false); + expect(config.syncTimeoutSec).toBe(120); + }); + + test("enables sync_after_catalog by default in headless mode", () => { + const config = __testables.resolveFinalConfig({ + obsidian: { + integrationMode: "headless", + }, + }); + + expect(config.integrationMode).toBe("headless"); + expect(config.syncAfterCatalog).toBe(true); + }); + + test("rejects invalid integration mode", () => { + expect(() => + __testables.resolveFinalConfig({ + obsidian: { + integrationMode: "mobile" as "desktop" | "headless", + }, + }), + ).toThrow(/integration_mode/); + }); + + test("rejects non-positive sync timeout", () => { + expect(() => + __testables.resolveFinalConfig({ + obsidian: { + syncTimeoutSec: 0, + }, + }), + ).toThrow(/sync_timeout_sec/); }); }); diff --git a/src/shpit-config.ts b/src/shpit-config.ts index 701610b..6f5ae23 100644 --- a/src/shpit-config.ts +++ b/src/shpit-config.ts @@ -15,6 +15,10 @@ type PartialObsidianConfig = { notesRoot?: string; catalogMode?: "date" | "repo"; openAfterCatalog?: boolean; + integrationMode?: "desktop" | "headless"; + headlessCommand?: string; + syncAfterCatalog?: boolean; + syncTimeoutSec?: number; }; type PartialShpitConfig = { @@ -33,6 +37,10 @@ export type ResolvedShpitConfig = { notesRoot: string; catalogMode: "date" | "repo"; openAfterCatalog: boolean; + integrationMode: "desktop" | "headless"; + headlessCommand: string; + syncAfterCatalog: boolean; + syncTimeoutSec: number; }; }; @@ -221,6 +229,10 @@ function asBoolean(value: ParsedTomlValue | undefined): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +function asInteger(value: ParsedTomlValue | undefined): number | undefined { + return typeof value === "number" && Number.isInteger(value) ? value : undefined; +} + function toPartialConfig(parsed: ParsedTomlTable): PartialShpitConfig { const obsidian = asTable(parsed.obsidian); @@ -233,6 +245,13 @@ function toPartialConfig(parsed: ParsedTomlTable): PartialShpitConfig { notesRoot: asString(obsidian.notes_root), catalogMode: asString(obsidian.catalog_mode) as "date" | "repo" | undefined, openAfterCatalog: asBoolean(obsidian.open_after_catalog), + integrationMode: asString(obsidian.integration_mode) as + | "desktop" + | "headless" + | undefined, + headlessCommand: asString(obsidian.headless_command), + syncAfterCatalog: asBoolean(obsidian.sync_after_catalog), + syncTimeoutSec: asInteger(obsidian.sync_timeout_sec), } : undefined, }; @@ -263,6 +282,23 @@ function normalizeObsidianCommand(command: string | undefined): string { return normalized; } +function normalizeIntegrationMode( + mode: string | undefined, +): ResolvedShpitConfig["obsidian"]["integrationMode"] { + const normalized = (mode ?? "desktop").trim() || "desktop"; + if (normalized !== "desktop" && normalized !== "headless") { + throw new Error( + `Invalid obsidian.integration_mode: ${normalized}. Expected "desktop" or "headless".`, + ); + } + return normalized; +} + +function normalizeHeadlessCommand(command: string | undefined): string { + const normalized = (command ?? "ob").trim(); + return normalized || "ob"; +} + function resolveVaultPath(value: string | undefined): string | undefined { if (!value) { return undefined; @@ -333,6 +369,14 @@ function resolveFinalConfig(partial: PartialShpitConfig): ResolvedShpitConfig["o throw new Error(`Invalid obsidian.catalog_mode: ${catalogMode}. Expected "date" or "repo".`); } + const integrationMode = normalizeIntegrationMode(obsidian.integrationMode); + const syncTimeoutSec = obsidian.syncTimeoutSec ?? 120; + if (!Number.isInteger(syncTimeoutSec) || syncTimeoutSec <= 0) { + throw new Error( + `Invalid obsidian.sync_timeout_sec: ${syncTimeoutSec}. Expected a positive integer.`, + ); + } + return { enabled: obsidian.enabled ?? false, command: normalizeObsidianCommand(obsidian.command), @@ -340,6 +384,10 @@ function resolveFinalConfig(partial: PartialShpitConfig): ResolvedShpitConfig["o notesRoot: (obsidian.notesRoot ?? DEFAULT_NOTES_ROOT).trim() || DEFAULT_NOTES_ROOT, catalogMode, openAfterCatalog: obsidian.openAfterCatalog ?? false, + integrationMode, + headlessCommand: normalizeHeadlessCommand(obsidian.headlessCommand), + syncAfterCatalog: obsidian.syncAfterCatalog ?? integrationMode === "headless", + syncTimeoutSec, }; }