@brianlovin/notion-skills
Version:
Sync agent skills from a Notion database to Claude Code, Codex, OpenCode, Cursor, Gemini CLI.
218 lines • 8.28 kB
JavaScript
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