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