UNPKG

@brianlovin/notion-skills

Version:

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

154 lines 6.12 kB
import { existsSync, lstatSync } from "node:fs"; import { rename as fsRename } from "node:fs/promises"; import { join } from "node:path"; import { slugify } from "./convert.js"; import { readTitle } from "./notion.js"; import { ensureSymlink, removeSymlink, targetSkillPath, targetsForKeys, } from "./targets.js"; /** * Detect rename events for one source. Compares the manifest's * `source_slug` per-entry against the current Notion page slug * (matched by stable `page_id`). Pure: no I/O, no mutation. */ export function detectRenames(manifest, sourceKey, pages) { const currentSlugByPageId = new Map(); for (const page of pages) { if (page.archived || page.in_trash) continue; const title = readTitle(page.properties); if (!title) continue; currentSlugByPageId.set(page.id, slugify(title)); } const ops = []; for (const [localSlug, entry] of Object.entries(manifest.skills)) { if (entry.source_key !== sourceKey) continue; const newSlug = currentSlugByPageId.get(entry.page_id); if (!newSlug) continue; // page archived / not found — handled elsewhere // Detect any disagreement between Notion's current slug and what's // on disk. Two sources of disagreement: // (1) Notion-side title change since last sync → entry.source_slug // still points at the old slug; on-disk local_slug also old. // (2) Stuck state from the legacy "pinned local_slug" code: // entry.source_slug already updated, but local_slug never // was. Fires only on the first sync after upgrading. // Both reduce to "current source slug doesn't match local dir // name" and want the same fix: rename locally if no collision. if (newSlug === localSlug && newSlug === entry.source_slug) continue; ops.push({ pageId: entry.page_id, localSlug, oldSourceSlug: entry.source_slug, newSourceSlug: newSlug, }); } return ops.sort((a, b) => a.localSlug.localeCompare(b.localSlug)); } /** * Pure-fn variant: classify what would happen for each op without * touching the filesystem. The collision check looks at the manifest * (other tracked skills) AND any caller-provided "exists on disk" * predicate so this stays mockable in tests. */ export function classifyRenameOps(ops, manifest, diskExists) { // Track local_slugs we'll reassign during this pass so we don't let // two ops in the same batch race for the same target name. const inFlight = new Set(); const outcomes = []; for (const op of ops) { const target = op.newSourceSlug; // No actual local-name change requested. if (target === op.localSlug) { outcomes.push({ status: "renamed", op, newLocalSlug: target }); continue; } if (manifest.skills[target] && target !== op.localSlug) { outcomes.push({ status: "source-only", op, reason: { kind: "collision-manifest", conflictWith: target }, }); continue; } if (inFlight.has(target)) { outcomes.push({ status: "source-only", op, reason: { kind: "collision-manifest", conflictWith: target }, }); continue; } if (diskExists(target)) { outcomes.push({ status: "source-only", op, reason: { kind: "collision-disk", path: target }, }); continue; } inFlight.add(target); outcomes.push({ status: "renamed", op, newLocalSlug: target }); } return outcomes; } /** * Apply rename ops in lockstep across the manifest, the central skill * dir, and every configured agent target's symlink. Returns one * outcome per op so the caller can log granularly. * * Always updates `entry.source_slug` (cheap and mismatch-free). * Renames on disk + remaps the manifest key only when the proposed * `newSourceSlug` is free; otherwise keeps `local_slug` and surfaces * the conflict so the user can free up the name. */ export async function applyRenames(manifest, ops, contentRoot, targetKeys) { const outcomes = classifyRenameOps(ops, manifest, (slug) => existsLocally(join(contentRoot, slug))); for (const outcome of outcomes) { const entry = manifest.skills[outcome.op.localSlug]; if (!entry) continue; entry.source_slug = outcome.op.newSourceSlug; if (outcome.status !== "renamed") continue; if (outcome.newLocalSlug === outcome.op.localSlug) continue; await applyOneRename(manifest, outcome.op.localSlug, outcome.newLocalSlug, contentRoot, targetKeys); } return outcomes; } async function applyOneRename(manifest, oldSlug, newSlug, contentRoot, targetKeys) { // 1. Move the central-store dir. const oldDir = join(contentRoot, oldSlug); const newDir = join(contentRoot, newSlug); if (existsSync(oldDir)) { await fsRename(oldDir, newDir); } // 2. Move the manifest entry to the new key. const entry = manifest.skills[oldSlug]; if (entry) { delete manifest.skills[oldSlug]; manifest.skills[newSlug] = entry; } // 3. Rebuild every agent target's symlink to point at the new path // under the new name. Old link removed, new link created. const targets = targetsForKeys(targetKeys); for (const t of targets) { const oldLink = targetSkillPath(t, oldSlug); await removeSymlink(oldLink); await ensureSymlink(newDir, targetSkillPath(t, newSlug)); } } function existsLocally(path) { // Use lstat semantics: a dangling symlink also "exists" in the // collision sense — we shouldn't try to claim the name. try { lstatSync(path); return true; } catch { return false; } } //# sourceMappingURL=renames.js.map