UNPKG

@brianlovin/notion-skills

Version:

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

141 lines 5.19 kB
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { createHash } from "node:crypto"; import { MANIFEST_FILE } from "./paths.js"; import { defaultSource } from "./sources.js"; function isV2(m) { return m.version === 2; } /** * Promote a v1 manifest to v2 by attaching every entry to the given * default source key. The source_slug for each entry is the existing * key (which was the slug under v1's flat keying). * * The caller passes the default source key; we don't read scope from * disk here so manifest migration stays a pure function. */ export function migrateV1ToV2(v1, defaultSourceKey) { const skills = {}; for (const [slug, entry] of Object.entries(v1.skills ?? {})) { skills[slug] = { source_key: defaultSourceKey, source_slug: slug, page_id: entry.page_id, last_edited_time: entry.last_edited_time, props_hash: entry.props_hash, body_hash: entry.body_hash, local_hash: entry.local_hash, files: entry.files, }; } return { version: 2, last_synced_at: v1.last_synced_at ?? new Date(0).toISOString(), hash_v: v1.hash_v ?? 2, skills, }; } export function emptyManifest() { return { version: 2, last_synced_at: new Date(0).toISOString(), hash_v: 3, skills: {}, }; } export async function readManifest(file, defaultSourceKey) { try { const raw = await readFile(file, "utf8"); const parsed = JSON.parse(raw); if (isV2(parsed)) return parsed; return migrateV1ToV2(parsed, defaultSourceKey); } catch (err) { if (err.code === "ENOENT") return null; throw err; } } /** * Read the manifest from its canonical location, deriving the v1→v2 * migration default-source-key from the scope's configured sources. * * Most commands want this; only commands that operate against a * specific Source for migration purposes (`migrate`, `source rename`) * should call `readManifest` directly with an explicit key. */ export async function loadManifest(sources) { const defaultKey = defaultSource(sources)?.key ?? sources[0]?.key ?? "default"; return readManifest(MANIFEST_FILE, defaultKey); } /** * Atomic write: serialise to a sibling .tmp file, fsync-after-rename via * the kernel's atomic-replace semantics. A crash mid-write leaves either * the previous manifest or the new one — never a half-written file. */ export async function writeManifest(file, manifest) { await mkdir(dirname(file), { recursive: true }); const tmp = file + ".tmp"; await writeFile(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf8"); await rename(tmp, file); } export function hashContent(body) { return createHash("sha256").update(body).digest("hex").slice(0, 16); } /** * Lightweight identity test before we trust an old entry. Older manifests * may be missing props_hash entirely — those count as "needs refetch". */ function entryMatches(old, current) { return (old.last_edited_time === current.lastEditedTime && old.props_hash === current.propsHash); } /** * Diff a v2 manifest against a list of currently-visible pages, scoped * to the source(s) the pages came from. Pages that aren't in the * provided source set are left alone (their entries stay in the * manifest untouched — could belong to a different source not being * synced this round). */ export function diffManifest(oldManifest, current, scopedSourceKeys) { const currentByLocal = new Map(); // Match an installed entry to a current page by source_key + source_slug. for (const c of current) { for (const [localSlug, old] of Object.entries(oldManifest.skills)) { if (old.source_key === c.source_key && old.source_slug === c.name) { currentByLocal.set(localSlug, c); } } } const toFetch = []; const unchanged = []; // For currently-visible pages: are they installed? If not, leave alone // (sync is install-narrowed). If yes, decide unchanged vs toFetch. for (const [localSlug, old] of Object.entries(oldManifest.skills)) { if (!scopedSourceKeys.has(old.source_key)) continue; const c = currentByLocal.get(localSlug); if (!c) { // Installed but no longer visible in source — remove. continue; } if (old.page_id !== c.pageId || !entryMatches(old, c)) { toFetch.push(c.pageId); } else { unchanged.push(localSlug); } } // Removals: installed entries from a scoped source whose page is gone. const visibleByLocal = new Set(currentByLocal.keys()); const toRemove = []; for (const [localSlug, entry] of Object.entries(oldManifest.skills)) { if (!scopedSourceKeys.has(entry.source_key)) continue; if (!visibleByLocal.has(localSlug)) toRemove.push(localSlug); } return { toFetch, toRemove, unchanged }; } //# sourceMappingURL=manifest.js.map