#!/usr/bin/env node // M0 migration: relocate per-user data from the plugin tree to the external data // root (getDataRoot()). Created at Step 4, WIRED into session-start at Step 12 — // this module only acts when called. Atomic, locked, idempotent, and // external-canonical (never overwrites an existing external file). // // Two move-classes: // (a) gitignored runtime data → atomic MOVE (source removed only after a // verified copy). // (b) tracked D2 scaffolds → COPY (tracked source preserved; Step 13 later // templatizes the in-plugin file). // // MOVE_FILES/COPY_FILES below are the SINGLE SOURCE OF TRUTH for external paths. // Step 7's personalization-score split MUST resolve the same scaffold dests // (import COPY_FILES from here, or mirror it) — keep them in lockstep. // External dest = in-plugin path minus 'assets/' (the brief 7.1 convention). import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync, rmSync, readdirSync, openSync, fsyncSync, closeSync, } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { getDataRoot } from './data-root.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); // [in-plugin (rel pluginRoot), external (rel dataRoot)] — atomic MOVE. const MOVE_FILES = [ ['assets/voice-samples/authentic-voice-samples.local.md', 'voice-samples/authentic-voice-samples.md'], // D6: drop .local ['assets/drafts/queue.json', 'drafts/queue.json'], ['config/user-profile.local.md', 'profile/user-profile.md'], // D1 (expected-absent today) ]; // Whole-directory MOVE: every file under src → matching path under dest. const MOVE_DIRS = [ ['assets/analytics/exports', 'analytics/exports'], ['assets/analytics/posts', 'analytics/posts'], ['assets/analytics/weekly-reports', 'analytics/weekly-reports'], ['assets/analytics/monthly-reports', 'analytics/monthly-reports'], // expected-absent ]; // drafts/week-* runtime subdirs → external drafts/week-*. const DRAFTS_WEEK = ['assets/drafts', 'drafts', /^week-/]; // move-class (b): tracked scaffolds → external instance (COPY, source kept). const COPY_FILES = [ ['assets/examples/high-engagement-posts.md', 'examples/high-engagement-posts.md'], ['assets/audience-insights/demographics.md', 'audience-insights/demographics.md'], ['assets/audience-insights/engagement-patterns.md', 'audience-insights/engagement-patterns.md'], ['assets/templates/my-post-templates.md', 'templates/my-post-templates.md'], ]; export { MOVE_FILES, COPY_FILES }; // Atomic copy: tmp in dest dir → fsync → byte-verify → rename. Returns false // (without touching anything) if dest already exists — external is canonical. function atomicCopy(src, dest) { if (existsSync(dest)) return false; mkdirSync(dirname(dest), { recursive: true }); const tmp = `${dest}.migrating.${process.pid}`; copyFileSync(src, tmp); const fd = openSync(tmp, 'r+'); try { fsyncSync(fd); } finally { closeSync(fd); } if (!readFileSync(src).equals(readFileSync(tmp))) { unlinkSync(tmp); throw new Error(`migrate verify failed: ${src}`); } renameSync(tmp, dest); return true; } function moveFile(srcAbs, destAbs, log) { if (!existsSync(srcAbs)) return; // expected-absent → no-op if (atomicCopy(srcAbs, destAbs)) { unlinkSync(srcAbs); // remove source only after verified copy log.moved.push(destAbs); } else { log.skipped.push(destAbs); // collision: external kept, source untouched } } function copyFile(srcAbs, destAbs, log) { if (!existsSync(srcAbs)) return; if (atomicCopy(srcAbs, destAbs)) log.copied.push(destAbs); else log.skipped.push(destAbs); // source always preserved for COPY class } function walkFiles(dir) { const out = []; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...walkFiles(p)); else if (e.isFile()) out.push(p); } return out; } function moveDir(srcDir, destDir, log) { if (!existsSync(srcDir)) return; for (const f of walkFiles(srcDir)) { moveFile(f, join(destDir, f.slice(srcDir.length + 1)), log); } } export function migrateData({ pluginRoot, dataRoot } = {}) { pluginRoot = pluginRoot || join(__dirname, '..', '..'); dataRoot = dataRoot || getDataRoot(); const marker = join(dataRoot, '.migrated'); if (existsSync(marker)) return { status: 'already-migrated', moved: [], copied: [], skipped: [] }; mkdirSync(dataRoot, { recursive: true }); const lockDir = join(dataRoot, '.migrate.lock'); try { mkdirSync(lockDir); // atomic; throws EEXIST if held } catch (e) { if (e.code === 'EEXIST') return { status: 'locked', moved: [], copied: [], skipped: [] }; throw e; } const log = { status: 'migrated', moved: [], copied: [], skipped: [] }; try { for (const [src, dest] of MOVE_FILES) moveFile(join(pluginRoot, src), join(dataRoot, dest), log); for (const [src, dest] of MOVE_DIRS) moveDir(join(pluginRoot, src), join(dataRoot, dest), log); const [wSrc, wDest, wRe] = DRAFTS_WEEK; const wSrcAbs = join(pluginRoot, wSrc); if (existsSync(wSrcAbs)) { for (const e of readdirSync(wSrcAbs, { withFileTypes: true })) { if (e.isDirectory() && wRe.test(e.name)) { moveDir(join(wSrcAbs, e.name), join(dataRoot, wDest, e.name), log); } } } for (const [src, dest] of COPY_FILES) copyFile(join(pluginRoot, src), join(dataRoot, dest), log); // Marker LAST → the whole run is idempotently skippable on re-entry. writeFileSync(marker, `linkedin-studio M0 data migration\nmoved=${log.moved.length} copied=${log.copied.length} skipped=${log.skipped.length}\n`); } finally { rmSync(lockDir, { recursive: true, force: true }); } return log; } // Standalone: run against the real roots. if (process.argv[1] === fileURLToPath(import.meta.url)) { const r = migrateData(); console.log(`Migration: ${r.status} — moved ${r.moved.length}, copied ${r.copied.length}, skipped ${r.skipped.length}`); }