UNPKG

@brianlovin/notion-skills

Version:

Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.

218 lines 8.28 kB
import { spawn } from "node:child_process"; import { appendFileSync, mkdirSync } from "node:fs"; import { LOGS_DIR, NTN_ERROR_LOG } from "./paths.js"; export class NtnNotInstalledError extends Error { constructor() { super("`ntn` is not installed.\n" + "notion-skills uses Notion's official CLI for authentication.\n\n" + "Install: https://github.com/makenotion/cli\n" + "Then run: ntn login"); this.name = "NtnNotInstalledError"; } } export class NtnAuthError extends Error { constructor() { super("`ntn` is installed but not authenticated.\n" + "Run `ntn login` and try again."); this.name = "NtnAuthError"; } } export class NtnApiError extends Error { exitCode; stderr; constructor(message, exitCode, stderr) { super(message); this.exitCode = exitCode; this.stderr = stderr; this.name = "NtnApiError"; } } async function spawnNtn(args, stdin) { return new Promise((resolve, reject) => { const child = spawn("ntn", args, { stdio: ["pipe", "pipe", "pipe"], env: process.env, }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (err) => { if (err.code === "ENOENT") { reject(new NtnNotInstalledError()); } else { reject(err); } }); child.on("close", (code) => { const result = { stdout, stderr, code: code ?? 0 }; if (result.code !== 0) { logNtnFailure(args, stdin, result); } resolve(result); }); if (stdin !== undefined) { child.stdin.write(stdin); } child.stdin.end(); }); } /** * Append a JSONL record to ~/.notion-skills/logs/ntn-errors.log on every * non-zero ntn exit. Captures argv, stdin shape, exit code, full stderr * — enough postmortem signal that "what was the actual command?" is * recoverable after the terminal scrolls. Truncates large fields to * keep the log readable; the user can re-run with NOTION_SKILLS_DEBUG=1 * for live verbose output. */ function logNtnFailure(args, stdin, result) { try { mkdirSync(LOGS_DIR, { recursive: true }); const entry = { ts: new Date().toISOString(), argv: args.map((a) => (a.length > 500 ? a.slice(0, 500) + `…(+${a.length - 500} chars)` : a)), stdin_bytes: stdin === undefined ? null : stdin.length, exit: result.code, stderr: result.stderr.length > 4000 ? result.stderr.slice(0, 4000) + "…(truncated)" : result.stderr, }; appendFileSync(NTN_ERROR_LOG, JSON.stringify(entry) + "\n"); } catch { // Logging is best-effort. A read-only home dir or full disk // shouldn't turn a recoverable ntn error into an unrelated crash. } } /** * Call a Notion public API endpoint via `ntn api`. * Returns parsed JSON. Throws NtnAuthError on auth failure or NtnApiError * otherwise. Retries on 429 / rate_limited with exponential backoff. */ export async function ntnApi(method, path, body, notionVersion) { const args = ["api", "-X", method.toUpperCase(), path]; if (notionVersion) { args.push("--notion-version", notionVersion); } // Body goes via stdin to avoid argv length limits and shell quoting issues. const stdin = body === undefined ? undefined : JSON.stringify(body); // 3 attempts total: 0s, 1s, 4s — matches Notion's typical rate-limit window. const delays = [0, 1000, 4000]; let lastErr = null; for (const delay of delays) { if (delay > 0) await sleep(delay); const result = await spawnNtn(args, stdin); if (result.code === 4 || /API token is invalid/i.test(result.stderr)) { throw new NtnAuthError(); } if (result.code === 0) { if (!result.stdout.trim()) return {}; try { return JSON.parse(result.stdout); } catch { throw new NtnApiError(`ntn returned non-JSON stdout for ${method} ${path}: ${result.stdout.slice(0, 200)}`, result.code, result.stderr); } } lastErr = new NtnApiError(`ntn api ${method} ${path} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}\n full argv + stderr: ${NTN_ERROR_LOG}`, result.code, result.stderr); // Only retry on rate limits — every other failure mode is permanent // for this attempt set. if (!isRateLimited(result.stderr)) break; } throw lastErr ?? new NtnApiError(`ntn api ${method} ${path} failed`, -1, ""); } function isRateLimited(stderr) { return /\b(429|rate[_ -]?limit)/i.test(stderr); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Minimum ntn version we require. ntn 0.12 introduced data-source APIs and * the `pages update --content` flag we depend on. */ const MIN_NTN_VERSION = [0, 12, 0]; /** * Verify ntn is installed AND new enough. Doesn't probe auth — let the * first real API call surface auth errors with a clear message. */ export async function assertNtnInstalled() { let result; try { result = await spawnNtn(["--version"]); } catch (err) { if (err instanceof NtnNotInstalledError) throw err; throw new NtnNotInstalledError(); } if (result.code !== 0) { throw new NtnNotInstalledError(); } const parsed = parseSemver(result.stdout.trim()); if (parsed && compareSemver(parsed, MIN_NTN_VERSION) < 0) { throw new Error(`\`ntn\` is too old (${parsed.join(".")} < ${MIN_NTN_VERSION.join(".")}). ` + `notion-skills requires ntn ${MIN_NTN_VERSION.join(".")} or newer for ` + `data-source APIs. Run \`ntn update\` and try again.`); } } function parseSemver(text) { // Accept "ntn 0.12.0", "0.12.0", "v0.12.0", and trailing pre-release tags. const m = text.match(/(\d+)\.(\d+)\.(\d+)/); if (!m) return null; return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]; } function compareSemver(a, b) { for (let i = 0; i < 3; i++) { if (a[i] < b[i]) return -1; if (a[i] > b[i]) return 1; } return 0; } export async function ntnDoctor() { try { const result = await spawnNtn(["doctor"]); return { ok: result.code === 0, output: result.stdout + result.stderr }; } catch (err) { if (err instanceof NtnNotInstalledError) { return { ok: false, output: "ntn not installed" }; } throw err; } } export async function ntnVersion() { try { const result = await spawnNtn(["--version"]); if (result.code !== 0) return null; return result.stdout.trim(); } catch { return null; } } /** * Replace a page's content with markdown. ntn does the markdown → blocks * conversion server-side. Used by `migrate` to push a local SKILL.md body * into a freshly-created Notion page. * * The markdown is glued onto `--content=` as a single argv token rather * than passed as a separate value. ntn is built on Rust's clap, which * rejects flag values that start with `-` ("unexpected argument '- '") * unless they're attached with `=`. This bites multi-file skills whose * sibling files lead with a markdown bullet (`- item`). */ export async function ntnSetPageMarkdown(pageId, markdown) { const result = await spawnNtn(["pages", "update", pageId, `--content=${markdown}`, "--allow-deleting-content"]); if (result.code === 4 || /API token is invalid/i.test(result.stderr)) { throw new NtnAuthError(); } if (result.code !== 0) { throw new NtnApiError(`ntn pages update ${pageId} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}\n full argv + stderr: ${NTN_ERROR_LOG}`, result.code, result.stderr); } } //# sourceMappingURL=ntn.js.map