#!/usr/bin/env node "use strict"; /** * scripts/migrate-control-plane-to-sqlite.js * * Phase 0 dry-run 迁移工具 — 读取本地 control-plane 数据和工作区清单, * 生成 migration-report.json 建议(不写 DB,不改现有文件)。 * * 用法: * node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/report.json * node scripts/migrate-control-plane-to-sqlite.js --dry-run --access-policy-path /custom/path/access-policy.json * node scripts/migrate-control-plane-to-sqlite.js --dry-run --users-root /custom/users/root * node scripts/migrate-control-plane-to-sqlite.js --dry-run --skip-users * * 参考设计稿: * design/02-convex-rust-long-term-architecture/process/2-8-convex-replace-with-rust-sqlite-control-plane-v1.md * * 如运行时文件系统扫描过慢,可使用 --from-snapshot 传入预收集的数据快照: * 1. 用 Reasonix 工具扫描:list_directory + read_file 收集所有用户 workpace.json * 2. 写入 JSON 文件(格式见 README) * 3. 传入工具: --from-snapshot /tmp/data-snapshot.json --skip-users */ const fs = require("node:fs"); const path = require("node:path"); // ─── 默认路径 ─────────────────────────────────────────────── const DEFAULT_DATA_ROOT = "/mnt/Data1T/Mnote_data"; const DEFAULT_ACCESS_POLICY = path.join(DEFAULT_DATA_ROOT, "control-plane", "access-policy.json"); const DEFAULT_USERS_ROOT = path.join(DEFAULT_DATA_ROOT, "users"); // ─── 版本标记 ─────────────────────────────────────────────── const TOOL_VERSION = "0.1.0"; const MIGRATION_PHASE = "Phase 0: dry-run & inventory"; // ─── CLI 参数解析 ──────────────────────────────────────────── function usage() { return [ `migrate-control-plane-to-sqlite.js v${TOOL_VERSION}`, "", "用法:", " node scripts/migrate-control-plane-to-sqlite.js --dry-run --out <路径>", "", "参数:", " --dry-run 必要参数。启动干跑模式(仅扫描、不写 DB)。", " --out <路径> 输出 migration-report.json 路径。", " --access-policy-path <路径> 自定义 access-policy.json 路径。", " --users-root <路径> 自定义用户根目录。", " --from-snapshot <路径> 从预收集的数据快照文件读取,替代直接文件系统扫描。", " --skip-users 跳过用户扫描(仅读取 access-policy)。", " --verbose 输出详细扫描日志。", " --help, -h 显示此帮助。", "", "示例:", " node scripts/migrate-control-plane-to-sqlite.js --dry-run --out /tmp/mnote-control-plane-migration-report.json", " node scripts/migrate-control-plane-to-sqlite.js --dry-run --out ./migration-report.json --verbose", " node scripts/migrate-control-plane-to-sqlite.js --dry-run --from-snapshot ./data-snapshot.json --out /tmp/report.json", ].join("\n"); } function parseArgs(argv) { const opts = { dryRun: false, outPath: null, accessPolicyPath: DEFAULT_ACCESS_POLICY, usersRoot: DEFAULT_USERS_ROOT, fromSnapshot: null, skipUsers: false, verbose: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--help" || arg === "-h") { console.log(usage()); process.exit(0); } if (arg === "--dry-run") { opts.dryRun = true; continue; } if (arg === "--out") { opts.outPath = argv[++i]; if (!opts.outPath) throw new Error("--out 需要参数"); continue; } if (arg === "--access-policy-path") { opts.accessPolicyPath = argv[++i]; if (!opts.accessPolicyPath) throw new Error("--access-policy-path 需要参数"); continue; } if (arg === "--users-root") { opts.usersRoot = argv[++i]; if (!opts.usersRoot) throw new Error("--users-root 需要参数"); continue; } if (arg === "--from-snapshot") { opts.fromSnapshot = argv[++i]; if (!opts.fromSnapshot) throw new Error("--from-snapshot 需要参数"); continue; } if (arg === "--skip-users") { opts.skipUsers = true; continue; } if (arg === "--verbose") { opts.verbose = true; continue; } throw new Error(`未知参数:${arg}\n\n${usage()}`); } if (!opts.dryRun) { throw new Error("必须指定 --dry-run(当前版本仅支持干跑模式)\n\n" + usage()); } return opts; } // ─── 扫描工具函数 ────────────────────────────────────────── function readJsonSafe(filePath, label) { try { if (!fs.existsSync(filePath)) { return { ok: false, error: `${label} 文件不存在:${filePath}` }; } const raw = fs.readFileSync(filePath, "utf8"); const data = JSON.parse(raw); return { ok: true, data }; } catch (err) { return { ok: false, error: `读取 ${label} 失败:${err.message}` }; } } function collectUsers(usersRoot) { const results = []; if (!fs.existsSync(usersRoot)) { return { ok: false, error: `用户目录不存在:${usersRoot}`, users: [] }; } const entries = fs.readdirSync(usersRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name.startsWith(".")) continue; const userId = entry.name; const userDir = path.join(usersRoot, userId); const workspacesDir = path.join(userDir, "workspaces"); const userEntry = { userId, userDir, workspacesDir, workspaceCount: 0, workspaces: [], errors: [], }; if (!fs.existsSync(workspacesDir)) { userEntry.errors.push("无 workspaces 目录"); results.push(userEntry); continue; } const wsEntries = fs.readdirSync(workspacesDir, { withFileTypes: true }); for (const ws of wsEntries) { if (!ws.isDirectory()) continue; if (ws.name.startsWith(".")) continue; const wsDir = path.join(workspacesDir, ws.name); const wsMetaDir = path.join(wsDir, ".mnote"); const wsMetaFile = path.join(wsMetaDir, "workspace.json"); const wsEntry = { workspaceName: ws.name, workspaceDir: wsDir, metaFile: wsMetaFile, meta: null, pageCount: 0, pages: [], }; // 读取 workspace.json const metaResult = readJsonSafe(wsMetaFile, `${userId}/${ws.name}/workspace.json`); if (metaResult.ok) { wsEntry.meta = metaResult.data; } else { userEntry.errors.push(`${ws.name}:${metaResult.error}`); } // 统计页面(一级 .md 文件) if (fs.existsSync(wsDir)) { const pageFiles = fs.readdirSync(wsDir).filter((f) => f.endsWith(".md")); wsEntry.pageCount = pageFiles.length; wsEntry.pages = pageFiles; } userEntry.workspaces.push(wsEntry); } userEntry.workspaceCount = userEntry.workspaces.length; results.push(userEntry); } return { ok: true, users: results }; } // ─── 构建迁移报告 ────────────────────────────────────────── function buildReport(opts, accessPolicyResult, usersResult) { const report = { meta: { toolVersion: TOOL_VERSION, migrationPhase: MIGRATION_PHASE, generatedAt: new Date().toISOString(), source: { accessPolicy: opts.accessPolicyPath, usersRoot: opts.usersRoot, }, flags: { dryRun: true, skipUsers: opts.skipUsers, }, }, accessPolicy: { path: opts.accessPolicyPath, readOk: accessPolicyResult.ok, error: accessPolicyResult.ok ? null : accessPolicyResult.error, admins: accessPolicyResult.ok ? (accessPolicyResult.data.admins || []) : [], grants: accessPolicyResult.ok ? (accessPolicyResult.data.grants || []) : [], }, users: { scanOk: usersResult.ok, error: usersResult.ok ? null : usersResult.error, total: usersResult.ok ? usersResult.users.length : 0, items: [], }, suggestions: { users: [], workspaces: [], grants: [], legacyIdMap: [], }, summary: { adminCount: 0, userCount: 0, workspaceCount: 0, grantCount: 0, legacyMappingCount: 0, errors: [], }, }; if (!usersResult.ok) { report.summary.errors.push(usersResult.error); return report; } for (const u of usersResult.users) { const userItem = { userId: u.userId, workspaceCount: u.workspaceCount, errors: u.errors, workspaces: [], }; for (const ws of u.workspaces) { const wsItem = { workspaceName: ws.workspaceName, meta: ws.meta, pageCount: ws.pageCount, }; userItem.workspaces.push(wsItem); // ── 生成 workspace 建议 ── if (ws.meta && ws.meta.workspaceId) { const parts = ws.meta.workspaceId.split(":"); const newWsId = `sqlite-ws:${u.userId}:${ws.workspaceName}`; report.suggestions.workspaces.push({ legacyWorkspaceId: ws.meta.workspaceId, suggestedNewId: newWsId, ownerId: u.userId, name: ws.workspaceName, rootPath: ws.workspaceDir, source: "workspace.json", }); // ── 生成 user 建议 ── const existingUser = report.suggestions.users.find((x) => x.legacyUserId === u.userId); if (!existingUser) { // 检查是否在 access-policy 的 admins 中 const isAdmin = accessPolicyResult.ok && (accessPolicyResult.data.admins || []).includes(u.userId); const newUserId = `usr_${u.userId.replace(/[^a-zA-Z0-9_-]/g, "_")}`; report.suggestions.users.push({ legacyUserId: u.userId, suggestedNewId: newUserId, role: isAdmin ? "admin" : "user", isAdmin, defaultWorkspace: ws.workspaceName, source: "workspace.json", }); // ── 生成 legacy_id_map 建议 ── report.suggestions.legacyIdMap.push({ legacySystem: "local_workspace_manifest", legacyKind: "user", legacyId: u.userId, newKind: "user", newId: newUserId, }); } } } report.users.items.push(userItem); } // ── 生成 grant 建议 ── if (accessPolicyResult.ok && accessPolicyResult.data.admins) { for (const adminId of accessPolicyResult.data.admins) { // 找到该 admin 对应的 user 建议 const adminUser = report.suggestions.users.find((x) => x.legacyUserId === adminId); report.suggestions.grants.push({ legacyAdminId: adminId, suggestedNewUserId: adminUser ? adminUser.suggestedNewId : adminId, type: "admin", rootUri: `mnote://users/${adminId}`, rootPath: path.join(opts.usersRoot, adminId), permission: "admin", capabilities: ["admin", "share", "ai"], source: "access-policy.json:admins", }); } } if (accessPolicyResult.ok && accessPolicyResult.data.grants) { for (const grant of accessPolicyResult.data.grants) { report.suggestions.grants.push({ legacySource: "access-policy.json:grants", source: grant, type: "directory_grant", ...grant, }); } } // ── 生成 legacy_id_map 建议(workspaces)── for (const ws of report.suggestions.workspaces) { report.suggestions.legacyIdMap.push({ legacySystem: "local_workspace_manifest", legacyKind: "workspace", legacyId: ws.legacyWorkspaceId, newKind: "workspace", newId: ws.suggestedNewId, }); } // ── 填写概要 ── report.summary.adminCount = report.accessPolicy.admins.length; report.summary.userCount = report.users.total; report.summary.workspaceCount = report.suggestions.workspaces.length; report.summary.grantCount = report.suggestions.grants.length; report.summary.legacyMappingCount = report.suggestions.legacyIdMap.length; // 收集所有错误 for (const u of usersResult.users) { for (const err of u.errors) { report.summary.errors.push(`[${u.userId}] ${err}`); } } return report; } // ─── 入口 ────────────────────────────────────────────────── function main() { const opts = parseArgs(process.argv.slice(2)); if (opts.verbose) { console.error(`[migrate-control-plane] 版本 ${TOOL_VERSION}`); console.error(`[migrate-control-plane] access-policy: ${opts.accessPolicyPath}`); console.error(`[migrate-control-plane] users-root: ${opts.usersRoot}`); console.error(`[migrate-control-plane] skip-users: ${opts.skipUsers}`); console.error(`[migrate-control-plane] dry-run: ${opts.dryRun}`); } // 1. 读取 access-policy.json(可能被快照覆盖,用 let) let accessPolicyResult = readJsonSafe(opts.accessPolicyPath, "access-policy.json"); if (opts.verbose) { if (accessPolicyResult.ok) { console.error(`[migrate-control-plane] access-policy.json 读取成功`); console.error(`[migrate-control-plane] admins: ${JSON.stringify(accessPolicyResult.data.admins)}`); console.error(`[migrate-control-plane] grants: ${accessPolicyResult.data.grants ? accessPolicyResult.data.grants.length : 0} 条`); } else { console.error(`[migrate-control-plane] access-policy.json 读取失败: ${accessPolicyResult.error}`); } } // 2. 扫描用户(支持从快照文件读取 vs 直接文件系统扫描) let usersResult = { ok: true, users: [] }; if (opts.fromSnapshot) { const snapshotResult = readJsonSafe(opts.fromSnapshot, "数据快照"); if (snapshotResult.ok) { const snapshot = snapshotResult.data; if (snapshot.users) { usersResult = { ok: true, users: snapshot.users }; if (opts.verbose) { console.error(`[migrate-control-plane] 从快照读取 ${snapshot.users.length} 个用户`); } // 如果快照中已有 accessPolicy,覆盖 access-policy.json 的读取 if (snapshot.accessPolicy && !opts.accessPolicyPath) { const policyData = { ok: true, data: snapshot.accessPolicy }; // 替换 accessPolicyResult accessPolicyResult = policyData; if (opts.verbose) { console.error(`[migrate-control-plane] 快照中的 accessPolicy 已生效`); } } } else { usersResult = { ok: false, error: "快照文件中缺少 users 字段", users: [] }; } } else { usersResult = { ok: false, error: snapshotResult.error, users: [] }; } } else if (!opts.skipUsers) { usersResult = collectUsers(opts.usersRoot); if (opts.verbose) { if (usersResult.ok) { console.error(`[migrate-control-plane] 用户扫描完成: ${usersResult.users.length} 个用户`); for (const u of usersResult.users) { console.error(`[migrate-control-plane] ${u.userId}: ${u.workspaceCount} 个工作区`); } } else { console.error(`[migrate-control-plane] 用户扫描失败: ${usersResult.error}`); } } } else { if (opts.verbose) { console.error(`[migrate-control-plane] 用户扫描已跳过 (--skip-users)`); } } // 3. 构建报告 const report = buildReport(opts, accessPolicyResult, usersResult); // 4. 输出 const json = JSON.stringify(report, null, 2); if (opts.outPath) { fs.mkdirSync(path.dirname(opts.outPath), { recursive: true }); fs.writeFileSync(opts.outPath, json, "utf8"); if (opts.verbose) { console.error(`[migrate-control-plane] 报告已写入: ${opts.outPath}`); } } // stdout 只输出概要(便于管道处理) const brief = { ok: true, tool: "migrate-control-plane-to-sqlite", dryRun: true, summary: report.summary, outPath: opts.outPath || "(stdout)", }; console.log(JSON.stringify(brief, null, 2)); // 如果有错误但不太严重,仍然 exit 0 // 严重错误(access-policy 读不到、用户目录不存在)通过 summary.errors 和 accessPolicy.error 体现 const fatalCount = report.summary.errors.length; if (fatalCount > 0) { console.error(`[migrate-control-plane] 警告:扫描中发现 ${fatalCount} 个非致命错误,详见报告 errors`); } } if (require.main === module) { try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); } }