diff --git a/README.md b/README.md index 829de1a..704ab92 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,13 @@ skillsync target add codex ~/.codex/skills --no-auto-adopt If a different skill with the same name is already in the vault, SkillSync leaves both copies untouched and reports the conflict. +Inspect local targets without adopting, applying, committing, or pushing anything: + +```bash +skillsync scan +skillsync scan --json +``` + ## Manage another device List registered devices and their sync state: @@ -341,6 +348,32 @@ Run a sync immediately: skillsync sync ``` +Preview local skill projection changes without pulling or writing: + +```bash +skillsync sync --dry-run +``` + +SkillSync checks every destination before applying the plan. It backs up changed skill projections in local Git metadata and restores them if the apply or a later local reconciliation step fails. Copy-mode projections also record their deployed content hash. If a managed copy was edited locally, sync stops instead of overwriting it. After reviewing those edits, discard them explicitly with: + +```bash +skillsync sync --discard-local-changes +``` + +Restore the most recent successful local projection apply in an emergency: + +```bash +skillsync rollback +``` + +The next sync applies the current vault assignments again. + +Validate vault structure, registry hashes, JSON files, symlinks, and common credential formats without changing the vault: + +```bash +skillsync check +``` + Inspect the current configuration: ```bash @@ -406,8 +439,10 @@ skillsync target auto-adopt skillsync auto-adopt [show|on|off] skillsync policy show skillsync policy set delete-unassigned-skills -skillsync scan -skillsync sync +skillsync scan [--json] +skillsync sync [--dry-run] [--no-pull] [--discard-local-changes] +skillsync rollback +skillsync check skillsync service install skillsync doctor skillsync daemon @@ -416,6 +451,9 @@ skillsync daemon ## Safety - SkillSync will not silently overwrite an unmanaged local folder. +- SkillSync refuses to overwrite or remove a locally edited managed copy unless you explicitly discard the edits. +- Skill projection applies restore their previous state after a failure. +- Vault checks reject symlinks, malformed JSON, stale registry entries, reserved ownership markers, and common credential formats. - Symlinked content outside a configured target is not auto-adopted. - Different same-name skills require explicit conflict resolution. - Plugin sync is additive and never copies connector credentials. diff --git a/SECURITY.md b/SECURITY.md index cb321d3..130dcfa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,3 +23,5 @@ SkillSync uses a user-owned private GitHub repository as its control plane. Ever Device-local skill targets and global instruction paths are approved on that device and stored outside the synced vault. Pulled vault state cannot authorize new local filesystem locations. Skill folders can contain executable instructions or supporting scripts. Users should review third-party skills before adding them to a vault. + +SkillSync checks vault files for symlinks, malformed JSON, stale registry entries, reserved ownership markers, and common credential formats before syncing or pushing. This check reduces accidental exposure but cannot recognize every secret. Keep credentials, OAuth state, and session data out of skill folders and instruction profiles. diff --git a/src/cli.js b/src/cli.js index 3b50051..e7f3799 100755 --- a/src/cli.js +++ b/src/cli.js @@ -7,21 +7,24 @@ import path from 'node:path'; import { loadConfig, saveConfig, defaultRepoPath } from './core/config.js'; import { addTarget, - applyLinks, defaultDeviceId, + inspectTargets, installSkill, initializeLocalPathState, listDevices, loadLocalDevice, managedProjections, migrateLegacyLocalPathState, + planLinks, removeTargetAndPrune, + rollbackLinks, setDeviceAutoImport, setGlobalInstructionsProfile, setSkillTargets, setTargetAutoImport, uninstallSkillAndPrune, } from './core/device.js'; +import { checkVault } from './core/check.js'; import { matrixAssignmentChanges, renderSkillSelectionChanges, @@ -61,6 +64,7 @@ import { } from './core/instructions.js'; import { addSkillToVault, + buildRegistry, compareSkillToVault, deleteSkillFromVault, ensureSkillInRegistry, @@ -148,7 +152,11 @@ async function main() { case 'sync': return syncCommand(rest); case 'scan': - return scanCommand(); + return scanCommand(rest); + case 'check': + return checkCommand(rest); + case 'rollback': + return rollbackCommand(rest); case 'doctor': return doctor(); case 'service': @@ -394,6 +402,7 @@ async function maybeAddDetectedTargets(repoPath, deviceId, yes) { async function commitInitialVault(repoPath) { if (!await isGitRepo(repoPath)) return; + await checkVault(repoPath); await git(['add', 'README.md', 'registry.json', 'vault.json', 'skills', 'devices'], repoPath).catch(() => {}); const committed = await commitAllIfChanged(repoPath, 'chore: initialize skills vault'); if (committed) { @@ -523,6 +532,7 @@ async function groupsCommand(rest) { await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: true }); await refreshChangedRegistryEntries(config.repoPath); const result = await generateGroups({ vaultPath: config.repoPath, write: true }); + await checkVault(config.repoPath); await commitAllIfChanged(config.repoPath, 'docs: update skill groups'); await push(config.repoPath); console.log(`Generated skill groups for ${result.skillCount} skills across ${result.packCount} packs.`); @@ -581,7 +591,6 @@ async function packCommand(rest) { await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets }); console.log(`Installed ${skillName}${targets ? ` -> ${targets.join(', ')}` : ''}`); } - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); console.log(`Installed pack ${pack.name} (${pack.skills.length} skills).`); return; @@ -611,6 +620,10 @@ function projectionDetailLabel(projection) { const resolved = projection.resolved ? ` -> ${projection.resolved}` : ''; return `${prefix}${resolved} (wrong SkillSync source; run skillsync sync)`; } + if (projection.status === 'drifted') { + return `${prefix} (local changes; review them or run skillsync sync --discard-local-changes)`; + } + if (projection.status === 'outdated') return `${prefix} (outdated; run skillsync sync)`; if (projection.status === 'unmanaged') return `${prefix} (unmanaged path; SkillSync will not overwrite it automatically)`; if (projection.status === 'unknown-target') return `${projection.targetName}: unknown target in this device manifest`; if (projection.status === 'not-in-vault') return `${projection.targetName}: ${projection.skillName} is no longer in the vault`; @@ -1248,15 +1261,18 @@ async function targetsMatchingSourcePath({ config, skillName, sourcePath, prefer } async function installManagedSkill({ config, skillName, targets, sourcePath }) { - if (!targets?.length) return { installed: false, replacedTarget: null }; + if (!targets?.length) return { installed: false, replacedTarget: null, replaceUnmanagedPaths: [] }; await ensureSkillInRegistry(config.repoPath, skillName); await installSkill({ vaultPath: config.repoPath, deviceId: config.deviceId, skillName, targets }); const matchingTargets = sourcePath ? await targetsMatchingSourcePath({ config, skillName, sourcePath, preferredTargets: targets }) : []; - if (matchingTargets.length && await exists(sourcePath)) { - await removePath(sourcePath); - } - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); - return { installed: true, replacedTarget: matchingTargets[0] || null }; + const replaceUnmanagedPaths = matchingTargets.length && await exists(sourcePath) + ? [path.resolve(sourcePath)] + : []; + return { + installed: true, + replacedTarget: matchingTargets[0] || null, + replaceUnmanagedPaths, + }; } async function chooseConflictAction({ rest, skillName, canUseVault }) { @@ -1288,12 +1304,20 @@ async function addSkillWithConflictResolution({ config, sourcePath, name, rest = if (comparison.status === 'new') { const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); - return { name: added.name, status: managed.replacedTarget ? 'added-and-linked' : 'added' }; + return { + name: added.name, + status: managed.replacedTarget ? 'added-and-linked' : 'added', + replaceUnmanagedPaths: managed.replaceUnmanagedPaths, + }; } if (comparison.status === 'identical') { const managed = await installManagedSkill({ config, skillName: comparison.name, targets, sourcePath }); - return { name: comparison.name, status: managed.replacedTarget ? 'consolidated' : 'identical' }; + return { + name: comparison.name, + status: managed.replacedTarget ? 'consolidated' : 'identical', + replaceUnmanagedPaths: managed.replaceUnmanagedPaths, + }; } console.log(`\n${comparison.name} already exists in the vault with different content.`); @@ -1311,13 +1335,21 @@ async function addSkillWithConflictResolution({ config, sourcePath, name, rest = return { name: comparison.name, status: 'kept-vault' }; } const managed = await installManagedSkill({ config, skillName: comparison.name, targets, sourcePath }); - return { name: comparison.name, status: managed.replacedTarget ? 'replaced-local-with-vault' : 'kept-vault' }; + return { + name: comparison.name, + status: managed.replacedTarget ? 'replaced-local-with-vault' : 'kept-vault', + replaceUnmanagedPaths: managed.replaceUnmanagedPaths, + }; } if (action === 'overwrite-vault') { const added = await addSkillToVault({ vaultPath: config.repoPath, sourcePath, name, overwrite: true }); const managed = await installManagedSkill({ config, skillName: added.name, targets, sourcePath }); - return { name: added.name, status: managed.replacedTarget ? 'overwritten-and-linked' : 'overwritten' }; + return { + name: added.name, + status: managed.replacedTarget ? 'overwritten-and-linked' : 'overwritten', + replaceUnmanagedPaths: managed.replaceUnmanagedPaths, + }; } if (action === 'rename') { @@ -1362,7 +1394,12 @@ async function addSkill(rest) { rest, targets, }); - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: false, + replaceUnmanagedPaths: result.replaceUnmanagedPaths, + }); console.log(resultSummary(result)); } @@ -1406,7 +1443,12 @@ async function addRemoteSkills(source, rest, config) { }); results.push(result); } - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: false, + replaceUnmanagedPaths: results.flatMap((result) => result.replaceUnmanagedPaths || []), + }); console.log(`Processed ${results.length} skill${results.length === 1 ? '' : 's'} from ${source}:`); for (const result of results) console.log(`- ${resultSummary(result)}`); @@ -1447,7 +1489,12 @@ async function importSkills(rest) { results.push(result); console.log(resultSummary(result)); } - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: false, + replaceUnmanagedPaths: results.flatMap((result) => result.replaceUnmanagedPaths || []), + }); } async function chooseImportSource() { @@ -1472,9 +1519,6 @@ async function install(rest) { await requireKnownDevice(config.repoPath, deviceId); const targets = parseTargets(rest); await installSkill({ vaultPath: config.repoPath, deviceId, skillName, targets }); - if (deviceId === config.deviceId) { - await applyLinks({ vaultPath: config.repoPath, deviceId }); - } await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); console.log(deviceId === config.deviceId ? `Installed ${skillName} on this device.` @@ -1494,9 +1538,6 @@ async function uninstall(rest) { skillName, targets: parseTargets(rest), }); - if (deviceId === config.deviceId) { - await applyLinks({ vaultPath: config.repoPath, deviceId }); - } await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); console.log(deviceId === config.deviceId ? `Removed ${skillName} from this device.` @@ -1608,7 +1649,6 @@ async function target(rest) { deviceId: config.deviceId, name, }); - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); console.log(`Removed target ${name}`); return; @@ -1695,7 +1735,34 @@ async function policyCommand(rest) { async function syncCommand(rest) { const config = await configured(); - const result = await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: !hasFlag(rest, '--no-pull') }); + const allowed = new Set(['--discard-local-changes', '--dry-run', '--no-pull']); + const unsupported = rest.find((argument) => !allowed.has(argument)); + if (unsupported) throw new Error(`Unknown sync option: ${unsupported}`); + const discardLocalChanges = hasFlag(rest, '--discard-local-changes'); + if (hasFlag(rest, '--dry-run')) { + await checkVault(config.repoPath, { verifyRegistry: false }); + const plan = await planLinks({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + discardLocalChanges, + registry: await buildRegistry(config.repoPath), + }); + console.log('Dry run uses the current local vault and does not pull remote changes.'); + if (!plan.operations.length) { + console.log('No skill projection changes.'); + return; + } + for (const operation of plan.operations) { + console.log(`${operation.change} ${operation.type}: ${operation.destination}`); + } + return; + } + const result = await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: !hasFlag(rest, '--no-pull'), + discardLocalChanges, + }); for (const adopted of result.autoImported || []) { console.log(`Auto-adopted ${adopted.name} from ${adopted.target}.`); } @@ -1709,22 +1776,44 @@ async function syncCommand(rest) { console.log(result.pushed ? 'Synced and pushed changes.' : 'Synced. No local changes to push.'); } -async function scanCommand() { +async function scanCommand(rest = []) { + const allowed = new Set(['--json']); + const unsupported = rest.find((argument) => !allowed.has(argument)); + if (unsupported) throw new Error(`Unknown scan option: ${unsupported}`); const config = await configured(); - const result = await syncVault({ + await checkVault(config.repoPath, { verifyRegistry: false }); + const report = await inspectTargets({ vaultPath: config.repoPath, deviceId: config.deviceId, - pull: false, }); - const device = await loadLocalDevice(config.repoPath, config.deviceId); - for (const adopted of result.autoImported || []) { - console.log(`Auto-adopted ${adopted.name} from ${adopted.target}.`); + if (hasFlag(rest, '--json')) { + console.log(JSON.stringify(report, null, 2)); + return; } - for (const conflict of result.autoImportConflicts || []) { - console.log(`Skipped auto-adoption conflict for ${conflict.name} at ${conflict.path}.`); + console.log(`Detected ${report.skills.length} local skill${report.skills.length === 1 ? '' : 's'} on ${report.device_id}.`); + for (const skill of report.skills) { + const state = skill.managed ? 'managed' : skill.in_vault ? 'in vault' : 'local only'; + const discovered = skill.new ? ', new' : ''; + console.log(`- ${skill.name} [${skill.target}, ${state}${discovered}]`); } - printInstructionSyncMessages(result); - console.log(`Scanned local targets: ${countDetectedSkills(device)} skills detected.`); +} + +async function checkCommand(rest = []) { + if (rest.length) throw new Error('Usage: skillsync check'); + const config = await configured(); + const result = await checkVault(config.repoPath); + console.log(`Vault check passed: ${result.skills} skills, ${result.files} files.`); +} + +async function rollbackCommand(rest = []) { + if (rest.length) throw new Error('Usage: skillsync rollback'); + const config = await configured(); + const result = await rollbackLinks({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + }); + console.log(`Restored ${result.restored} skill projection${result.restored === 1 ? '' : 's'} from ${result.backupId}.`); + console.log('The next sync will apply the current vault assignments again.'); } async function doctor() { @@ -1740,8 +1829,8 @@ async function doctor() { const config = await loadConfig(); console.log(`✓ config: ${config.repoPath}`); console.log(`${await isGitRepo(config.repoPath) ? '✓' : '✗'} vault git repo`); - await refreshChangedRegistryEntries(config.repoPath); - console.log('✓ registry checked/rebuilt'); + const result = await checkVault(config.repoPath); + console.log(`✓ vault checked: ${result.skills} skills, ${result.files} files`); } catch (error) { console.log(`✗ config/vault: ${error.message}`); } @@ -1832,7 +1921,7 @@ async function runUi() { { name: 'Settings', value: 'settings', description: 'Manage vault-wide behavior.' }, { name: 'Add skill from folder', value: 'add', description: 'Copy a local SKILL.md folder into the vault.' }, { name: 'Import local agent skills', value: 'import-local', description: 'Import detected Hermes, Codex, or OpenCode skills into the vault.' }, - { name: 'Scan local targets', value: 'scan', description: 'Refresh detected local skills.' }, + { name: 'Inspect local targets', value: 'scan', description: 'Report detected skills without changing them.' }, { name: 'Sync now', value: 'sync', description: 'Pull, link, scan, commit, and push vault changes.' }, { name: 'Quit', value: 'quit' }, ]; @@ -1927,7 +2016,6 @@ async function skillsScreen(config) { for (const skill of localToRemove) { await uninstallLocalSkill({ config, device, skill }); } - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); const installedText = toInstall.length ? `Installed ${toInstall.join(', ')} to ${targets.join(', ')}.` : ''; @@ -1946,7 +2034,7 @@ async function installedScreen(config) { loop: false, pageSize: 1, choices: [ - { name: 'No local skills found. Run Scan local targets, then check again.', value: 'back' }, + { name: 'No recorded local skills. Run Sync now to refresh device state.', value: 'back' }, ], })); return; @@ -1997,7 +2085,6 @@ async function installedScreen(config) { for (const skill of selectedSkills) { await uninstallLocalSkill({ config, device, skill }); } - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); console.log(`\nRemoved ${selected.join(', ')} from this device.\n`); } @@ -2035,8 +2122,8 @@ function removableDetectedTargets(device, skill, { onlyInVault = false } = {}) { .map((target) => ({ ...target, ...detectedTargetLocation(device, target) })); } -async function removeDetectedTargetPaths(targets) { - const removed = []; +async function inspectRemovableTargetPaths(targets) { + const approved = []; const skipped = []; const seen = new Set(); for (const target of targets) { @@ -2060,9 +2147,15 @@ async function removeDetectedTargetPaths(targets) { skipped.push({ ...target, reason: 'SKILL.md was not found' }); continue; } - await removePath(target.absolutePath); - removed.push(target); + approved.push(target); } + return { approved, skipped }; +} + +async function removeDetectedTargetPaths(targets) { + const { approved, skipped } = await inspectRemovableTargetPaths(targets); + for (const target of approved) await removePath(target.absolutePath); + const removed = approved; return { removed, skipped }; } @@ -2101,13 +2194,15 @@ async function updateLocalSkillsFromVault({ config, device, selectedSkills }) { await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: true }); const replacements = updatable.flatMap((skill) => removableDetectedTargets(device, skill, { onlyInVault: true }).filter((target) => target.removable)); + let replaceUnmanagedPaths = []; if (replacements.length) { const confirmed = await confirm({ message: `Replace ${replacements.length} local detected skill folder${replacements.length === 1 ? '' : 's'} with vault-managed links/copies?`, default: false, }); if (!confirmed) return; - await removeDetectedTargetPaths(replacements); + const inspected = await inspectRemovableTargetPaths(replacements); + replaceUnmanagedPaths = inspected.approved.map((target) => target.absolutePath); } for (const skill of updatable) { @@ -2126,8 +2221,12 @@ async function updateLocalSkillsFromVault({ config, device, selectedSkills }) { } } - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); - await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, pull: false }); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: false, + replaceUnmanagedPaths, + }); console.log(`\nUpdated from vault: ${updatable.map((skill) => skill.name).join(', ')}.${skipped.length ? ` Skipped local-only: ${skipped.join(', ')}.` : ''}\n`); } @@ -2322,7 +2421,6 @@ async function applyMatrixDraft({ config, changes }) { } } } - let localChanged = false; for (const change of changes) { if (change.after.length) { await setSkillTargets({ @@ -2338,10 +2436,6 @@ async function applyMatrixDraft({ config, changes }) { skillName: change.skillName, }); } - if (change.deviceId === config.deviceId) localChanged = true; - } - if (localChanged) { - await applyLinks({ vaultPath: config.repoPath, deviceId: config.deviceId }); } return syncVault({ vaultPath: config.repoPath, @@ -2460,9 +2554,6 @@ async function applyDeviceAssignmentChanges({ config, deviceId, toInstall, toUni skillName, }); } - if (deviceId === config.deviceId) { - await applyLinks({ vaultPath: config.repoPath, deviceId }); - } const syncResult = await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, @@ -2582,9 +2673,6 @@ async function deviceSkillDestinationsScreen(config, deviceId) { skillName, }); } - if (deviceId === config.deviceId) { - await applyLinks({ vaultPath: config.repoPath, deviceId }); - } const syncResult = await syncVault({ vaultPath: config.repoPath, deviceId: config.deviceId, @@ -2804,5 +2892,5 @@ async function instructionProfileSettingsScreen(config, device) { } function help() { - console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]\n skillsync plugins use [--device id]\n skillsync plugins auto-adopt \n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); + console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]\n skillsync plugins use [--device id]\n skillsync plugins auto-adopt \n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan [--json]\n skillsync sync [--dry-run] [--no-pull] [--discard-local-changes]\n skillsync rollback\n skillsync check\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/check.js b/src/core/check.js new file mode 100644 index 0000000..f74f48a --- /dev/null +++ b/src/core/check.js @@ -0,0 +1,230 @@ +import { createReadStream } from 'node:fs'; +import { lstat, readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +import { assertSafePathSegment, hashDirectory } from './fs.js'; + +const IGNORED_ROOT_ENTRIES = new Set(['.git', '.skillsync-local']); +const OWNERSHIP_MARKER = '.skillsync-owned.json'; +const SECRET_PATTERNS = [ + ['private key', /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/], + ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/], + ['API key', /\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}\b/], + ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/], + ['Slack token', /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/], +]; + +async function pathInfo(filePath) { + return lstat(filePath).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); +} + +async function detectedSecret(filePath) { + let tail = ''; + for await (const chunk of createReadStream(filePath)) { + const text = tail + chunk.toString('utf8'); + const detected = SECRET_PATTERNS.find(([, pattern]) => pattern.test(text)); + if (detected) return detected[0]; + tail = text.slice(-512); + } + return null; +} + +async function inspectTree(rootPath, { + ignoreRootEntries = new Set(), + rejectOwnershipMarkers = false, +} = {}) { + const errors = []; + const files = []; + + async function walk(current, relative = '') { + const info = await pathInfo(current); + if (!info) { + errors.push(`Missing path: ${relative || current}`); + return; + } + if (info.isSymbolicLink()) { + errors.push(`Symlinks are not allowed: ${relative || current}`); + return; + } + if (info.isFile()) { + if (rejectOwnershipMarkers && path.basename(relative) === OWNERSHIP_MARKER) { + errors.push(`Reserved file name: ${relative}`); + return; + } + files.push({ path: current, relative }); + return; + } + if (!info.isDirectory()) { + errors.push(`Unsupported file type: ${relative || current}`); + return; + } + + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!relative && ignoreRootEntries.has(entry.name)) continue; + const childRelative = relative + ? path.posix.join(relative, entry.name) + : entry.name; + if (entry.name === '.git') { + errors.push(`Nested Git metadata is not allowed: ${childRelative}`); + continue; + } + await walk(path.join(current, entry.name), childRelative); + } + } + + await walk(rootPath); + for (const file of files) { + const secret = await detectedSecret(file.path); + if (secret) errors.push(`Possible ${secret}: ${file.relative}`); + if (path.extname(file.path) !== '.json') continue; + try { + JSON.parse(await readFile(file.path, 'utf8')); + } catch { + errors.push(`Invalid JSON: ${file.relative}`); + } + } + return { errors, files }; +} + +function validationError(label, errors) { + return new Error(`${label} failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); +} + +export async function checkSkillFolder(sourcePath) { + const root = path.resolve(sourcePath); + const rootInfo = await pathInfo(root); + const errors = []; + if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) { + throw validationError('Skill check', [`Skill path must be a regular directory: ${root}`]); + } + + const skillFile = path.join(root, 'SKILL.md'); + const skillInfo = await pathInfo(skillFile); + if (!skillInfo?.isFile() || skillInfo.isSymbolicLink()) { + errors.push(`Skill folder must contain a regular SKILL.md file: ${root}`); + } + const inspected = await inspectTree(root, { + ignoreRootEntries: new Set(['.git']), + rejectOwnershipMarkers: true, + }); + errors.push(...inspected.errors); + if (errors.length) throw validationError('Skill check', errors); + return { files: inspected.files.length }; +} + +async function readRequiredJson(vaultPath, relative, errors) { + try { + return JSON.parse(await readFile(path.join(vaultPath, relative), 'utf8')); + } catch { + errors.push(`Missing or invalid JSON: ${relative}`); + return null; + } +} + +function validateNamedJsonFiles(entries, directory, label, errors) { + for (const entry of entries) { + if (!entry.isFile() || path.extname(entry.name) !== '.json') continue; + const name = path.basename(entry.name, '.json'); + try { + assertSafePathSegment(name, label); + } catch (error) { + errors.push(`${directory}/${entry.name}: ${error.message}`); + } + } +} + +export async function checkVault(vaultPath, { verifyRegistry = true } = {}) { + const root = path.resolve(vaultPath); + const rootInfo = await pathInfo(root); + if (!rootInfo?.isDirectory()) { + throw validationError('Vault check', [`Vault is not a directory: ${root}`]); + } + + const inspected = await inspectTree(root, { + ignoreRootEntries: IGNORED_ROOT_ENTRIES, + rejectOwnershipMarkers: true, + }); + const errors = [...inspected.errors]; + const unsafeTree = inspected.errors.some((error) => ( + error.startsWith('Symlinks are not allowed:') + || error.startsWith('Unsupported file type:') + )); + const registry = await readRequiredJson(root, 'registry.json', errors); + const config = await readRequiredJson(root, 'vault.json', errors); + if (config && ( + config.version !== 1 + || !config.policies + || typeof config.policies !== 'object' + || typeof config.policies.delete_unassigned_skills !== 'boolean' + )) { + errors.push('vault.json has an unsupported shape'); + } + + const skillRoot = path.join(root, 'skills'); + const skillEntries = await readdir(skillRoot, { withFileTypes: true }).catch((error) => { + if (error.code === 'ENOENT') { + errors.push('Missing directory: skills'); + return []; + } + throw error; + }); + const skillNames = []; + for (const entry of skillEntries) { + if (!entry.isDirectory()) continue; + try { + skillNames.push(assertSafePathSegment(entry.name, 'Skill name')); + } catch (error) { + errors.push(`skills/${entry.name}: ${error.message}`); + continue; + } + const skillFile = path.join(skillRoot, entry.name, 'SKILL.md'); + const info = await pathInfo(skillFile); + if (!info?.isFile() || info.isSymbolicLink()) { + errors.push(`Skill folder must contain a regular SKILL.md file: skills/${entry.name}`); + } + } + + if (verifyRegistry && registry) { + if (registry.version !== 1 + || !registry.skills + || typeof registry.skills !== 'object' + || Array.isArray(registry.skills)) { + errors.push('registry.json has an unsupported shape'); + } else { + const actualNames = skillNames.sort(); + const registeredNames = Object.keys(registry.skills).sort(); + for (const name of actualNames) { + const entry = registry.skills[name]; + if (!entry || entry.path !== path.posix.join('skills', name)) { + errors.push(`Registry entry is missing or invalid: ${name}`); + continue; + } + if (!unsafeTree) { + const hash = await hashDirectory(path.join(skillRoot, name)); + if (entry.hash !== hash) errors.push(`Registry hash is stale: ${name}`); + } + } + for (const name of registeredNames) { + if (!actualNames.includes(name)) errors.push(`Registry entry has no skill folder: ${name}`); + } + } + } + + for (const directory of ['devices', 'state']) { + const entries = await readdir(path.join(root, directory), { withFileTypes: true }).catch((error) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + validateNamedJsonFiles(entries, directory, 'Device ID', errors); + } + + if (errors.length) throw validationError('Vault check', [...new Set(errors)]); + return { + files: inspected.files.length, + skills: skillNames.length, + }; +} diff --git a/src/core/device.js b/src/core/device.js index b4d9812..96a2776 100644 --- a/src/core/device.js +++ b/src/core/device.js @@ -1,15 +1,14 @@ -import { cp, lstat, mkdir, readlink, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readlink, readdir, realpath, rm, stat } from 'node:fs/promises'; import { hostname } from 'node:os'; import path from 'node:path'; import { assertSafePathSegment, - ensureDir, exists, expandHome, + hashDirectory, readJson, readSkillName, - removePath, slugifySkillName, writeJson, writePrivateJson, @@ -23,9 +22,15 @@ import { loadRegistry, loadVaultConfig, } from './registry.js'; +import { + applyFilesystemPlan, + rollbackFilesystemBackup, + rollbackLatestFilesystemBackup, +} from './transaction.js'; const GLOBAL_INSTALL_TARGET = 'global'; const LOCAL_PATH_STATE_VERSION = 1; +const OWNERSHIP_MARKER = '.skillsync-owned.json'; function isGlobalInstallTarget(target) { return target === GLOBAL_INSTALL_TARGET || target === '@global'; @@ -645,14 +650,19 @@ export async function addTarget({ ? autoImport : previousTarget?.auto_import ?? true, }; + let cleanup = null; if (JSON.stringify(previousTarget) !== JSON.stringify(nextTarget)) { if (previousTarget && (previousTarget.path !== nextTarget.path || previousTarget.mode !== nextTarget.mode)) { - await removeOwnedTargetProjections({ vaultPath, device, name }); + cleanup = await removeOwnedTargetProjections({ vaultPath, device, name }); } device.targets[name] = nextTarget; } - await saveLocalReportedDevice(vaultPath, device); + try { + await saveLocalReportedDevice(vaultPath, device); + } catch (error) { + await rollbackProjectionCleanup({ vaultPath, deviceId, cleanup, error }); + } return device; } @@ -660,7 +670,7 @@ export async function removeTarget({ vaultPath, deviceId = defaultDeviceId(), na assertSafePathSegment(name, 'Target name'); const device = await loadLocalDevice(vaultPath, deviceId); if (!device.targets[name]) return device; - await removeOwnedTargetProjections({ vaultPath, device, name }); + const cleanup = await removeOwnedTargetProjections({ vaultPath, device, name }); delete device.targets[name]; delete device.detected[name]; let assignmentsChanged = false; @@ -673,22 +683,66 @@ export async function removeTarget({ vaultPath, deviceId = defaultDeviceId(), na if (!targets.length) delete device.installed[skillName]; } if (assignmentsChanged) bumpDesiredGeneration(device); - await saveDevice(vaultPath, device); + try { + await saveDevice(vaultPath, device); + } catch (error) { + await rollbackProjectionCleanup({ vaultPath, deviceId, cleanup, error }); + } return device; } async function removeOwnedTargetProjections({ vaultPath, device, name }) { const targetConfig = device.targets[name]; - if (!targetConfig?.path) return; - const targetPath = expandHome(targetConfig.path); - for (const [skillName, targets] of Object.entries(device.installed || {})) { - if (!Array.isArray(targets) || !targets.includes(name)) continue; - const destination = path.join(targetPath, skillName); - const info = await projectionInfo(destination, vaultPath); - if (info.ownedSymlink || info.ownedCopy) { - await removePath(destination); - } + if (!targetConfig?.path) return null; + const targetPath = path.resolve(expandHome(targetConfig.path)); + const transaction = await applyFilesystemPlan({ + vaultPath, + deviceId: device.device_id, + roots: [targetPath], + plan: async () => { + const registry = await loadRegistry(vaultPath); + const operations = []; + for (const [skillName, targets] of Object.entries(device.installed || {})) { + if (!Array.isArray(targets) || !targets.includes(name)) continue; + const destination = path.join(targetPath, skillName); + const info = await projectionInfo(destination, vaultPath); + if (info.ownedCopy) { + await requireUnchangedCopy({ + destination, + marker: info.copyMarker, + sourceHash: registry.skills[skillName]?.hash, + }); + } + if (info.ownedSymlink || info.ownedCopy) { + operations.push({ + type: 'remove', + destination, + skillName, + targetNames: [name], + }); + } + } + return operations; + }, + }); + return { ...transaction, roots: [targetPath] }; +} + +async function rollbackProjectionCleanup({ vaultPath, deviceId, cleanup, error }) { + if (!cleanup?.backupId) throw error; + try { + await rollbackFilesystemBackup({ + vaultPath, + deviceId, + roots: cleanup.roots, + backupId: cleanup.backupId, + }); + } catch (rollbackError) { + throw new Error(`${error.message}\nProjection rollback also failed: ${rollbackError.message}`, { + cause: error, + }); } + throw error; } export async function setTargetAutoImport({ vaultPath, deviceId = defaultDeviceId(), name, enabled }) { @@ -864,9 +918,16 @@ export async function removeTargetAndPrune(options) { return { device, pruned: [] }; } -export async function applyLinks({ vaultPath, deviceId = defaultDeviceId() }) { +export async function planLinks({ + vaultPath, + deviceId = defaultDeviceId(), + discardLocalChanges = false, + replaceUnmanagedPaths = [], + registry: providedRegistry, +}) { const device = await loadLocalDevice(vaultPath, deviceId); - const registry = await loadRegistry(vaultPath); + const registry = providedRegistry || await loadRegistry(vaultPath); + const approvedReplacements = new Set(replaceUnmanagedPaths.map((targetPath) => path.resolve(targetPath))); const desiredByTarget = new Map(); for (const [skillName, targets] of Object.entries(device.installed)) { assertSafePathSegment(skillName, 'Skill name'); @@ -878,21 +939,184 @@ export async function applyLinks({ vaultPath, deviceId = defaultDeviceId() }) { } } + const targetGroups = new Map(); for (const [targetName, targetConfig] of Object.entries(device.targets)) { - const targetPath = expandHome(targetConfig.path); - await ensureDir(targetPath); - const desired = desiredByTarget.get(targetName) || new Set(); - await removeStaleOwnedProjections({ vaultPath, targetPath, desired }); - for (const skillName of desired) { + const targetPath = path.resolve(expandHome(targetConfig.path)); + const existing = targetGroups.get(targetPath); + if (existing && existing.mode !== targetConfig.mode) { + throw new Error(`Targets sharing ${targetPath} must use the same projection mode`); + } + const group = existing || { + targetPath, + mode: targetConfig.mode || 'symlink', + targetNames: [], + desired: new Set(), + }; + group.targetNames.push(targetName); + for (const skillName of desiredByTarget.get(targetName) || []) group.desired.add(skillName); + targetGroups.set(targetPath, group); + } + + const targetRoots = [...targetGroups.keys()].sort(); + for (const [index, root] of targetRoots.entries()) { + if (targetRoots.some((candidate, candidateIndex) => ( + candidateIndex !== index && root.startsWith(candidate + path.sep) + ))) { + throw new Error(`Configured skill targets cannot overlap: ${root}`); + } + } + + const operations = []; + for (const group of targetGroups.values()) { + const targetInfo = await stat(group.targetPath).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (targetInfo && !targetInfo.isDirectory()) { + throw new Error(`Skill target is not a directory: ${group.targetPath}`); + } + const entries = targetInfo + ? await readdir(group.targetPath, { withFileTypes: true }) + : []; + for (const entry of entries) { + if (group.desired.has(entry.name)) continue; + const destination = path.join(group.targetPath, entry.name); + const info = await projectionInfo(destination, vaultPath); + if (!info.ownedSymlink && !info.ownedCopy) continue; + if (info.ownedCopy) { + const state = await copyState({ + destination, + marker: info.copyMarker, + sourceHash: registry.skills[entry.name]?.hash, + }); + if (state.drifted && !discardLocalChanges) throw copyDriftError(destination); + } + operations.push({ + type: 'remove', + change: 'remove', + destination, + skillName: entry.name, + targetNames: group.targetNames, + }); + } + + for (const skillName of group.desired) { const source = path.join(vaultPath, 'skills', skillName); - const destination = path.join(targetPath, skillName); - if (targetConfig.mode === 'copy') { - await createCopyProjection(source, destination, skillName, vaultPath); - } else { - await createSymlinkProjection(source, destination); + const destination = path.join(group.targetPath, skillName); + const info = await projectionInfo(destination, vaultPath); + if (info.exists + && !info.ownedSymlink + && !info.ownedCopy + && !approvedReplacements.has(path.resolve(destination))) { + throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); + } + const sourceHash = registry.skills[skillName].hash; + if (group.mode === 'copy') { + if (info.ownedCopy) { + const state = await copyState({ + destination, + marker: info.copyMarker, + sourceHash, + }); + if (state.drifted && !discardLocalChanges) throw copyDriftError(destination); + if (!state.drifted + && state.localHash === sourceHash + && info.copyMarker?.source_hash === sourceHash) { + continue; + } + } + operations.push({ + type: 'copy', + change: info.exists ? 'replace' : 'create', + source, + destination, + skillName, + targetNames: group.targetNames, + marker: { + version: 1, + skill: skillName, + vault: path.resolve(vaultPath), + source_hash: sourceHash, + }, + }); + continue; + } + + if (info.ownedCopy) { + const state = await copyState({ + destination, + marker: info.copyMarker, + sourceHash, + }); + if (state.drifted && !discardLocalChanges) throw copyDriftError(destination); + } + if (info.ownedSymlink + && info.reachable + && info.resolved === await realpathOrResolve(source)) { + continue; } + operations.push({ + type: 'symlink', + change: info.exists ? 'replace' : 'create', + source, + destination, + skillName, + targetNames: group.targetNames, + }); } } + + operations.sort((left, right) => left.destination.localeCompare(right.destination)); + return { + deviceId, + roots: targetRoots, + operations, + }; +} + +export async function applyLinks(options) { + const roots = await approvedProjectionRoots(options.vaultPath, options.deviceId || defaultDeviceId()); + let preparedPlan = null; + const transaction = await applyFilesystemPlan({ + vaultPath: options.vaultPath, + deviceId: options.deviceId || defaultDeviceId(), + roots, + plan: async () => { + preparedPlan = await planLinks(options); + return preparedPlan.operations; + }, + fault: options.fault, + }); + return { ...preparedPlan, ...transaction }; +} + +async function approvedProjectionRoots(vaultPath, deviceId) { + const device = await loadLocalDevice(vaultPath, deviceId); + return Object.values(device.targets).map((target) => path.resolve(expandHome(target.path))); +} + +export async function rollbackLinkBackup({ + vaultPath, + deviceId = defaultDeviceId(), + backupId, +}) { + return rollbackFilesystemBackup({ + vaultPath, + deviceId, + roots: await approvedProjectionRoots(vaultPath, deviceId), + backupId, + }); +} + +export async function rollbackLinks({ + vaultPath, + deviceId = defaultDeviceId(), +}) { + return rollbackLatestFilesystemBackup({ + vaultPath, + deviceId, + roots: await approvedProjectionRoots(vaultPath, deviceId), + }); } export async function markDeviceApplied({ vaultPath, deviceId = defaultDeviceId() }) { @@ -929,7 +1153,20 @@ export async function managedProjections({ vaultPath, deviceId = defaultDeviceId if (!info.exists) { status = 'missing'; } else if (mode === 'copy') { - status = info.ownedCopy ? 'ok' : 'unmanaged'; + if (!info.ownedCopy) { + status = 'unmanaged'; + } else { + const state = await copyState({ + destination, + marker: info.copyMarker, + sourceHash: registryEntry.hash, + }); + status = state.drifted + ? 'drifted' + : state.localHash === registryEntry.hash + ? 'ok' + : 'outdated'; + } } else if (info.ownedSymlink && info.reachable) { status = info.resolved === await realpathOrResolve(source) ? 'ok' : 'wrong-source'; } else if (info.ownedSymlink || info.ownedCopy) { @@ -983,6 +1220,7 @@ export async function autoImportNewLocalSkills({ vaultPath, deviceId = defaultDe const currentDetected = await detectTargets({ vaultPath, device }); const adopted = []; const conflicts = []; + const replaceUnmanagedPaths = []; for (const [targetName, targetConfig] of Object.entries(device.targets)) { if (!targetConfig.auto_import) continue; @@ -1030,7 +1268,7 @@ export async function autoImportNewLocalSkills({ vaultPath, deviceId = defaultDe skillName: result.name, targets: [targetName], }); - await removePath(sourcePath); + replaceUnmanagedPaths.push(sourcePath); adopted.push({ name: result.name, target: targetName, @@ -1039,7 +1277,7 @@ export async function autoImportNewLocalSkills({ vaultPath, deviceId = defaultDe } } - return { adopted, conflicts }; + return { adopted, conflicts, replaceUnmanagedPaths }; } async function hasSymlinkBelowRoot(childPath, rootPath) { @@ -1064,6 +1302,35 @@ export async function scanTargets({ vaultPath, deviceId = defaultDeviceId() }) { return device; } +export async function inspectTargets({ vaultPath, deviceId = defaultDeviceId() }) { + const device = await loadLocalDevice(vaultPath, deviceId); + const detected = await detectTargets({ vaultPath, device }); + const skills = []; + for (const [targetName, entries] of Object.entries(detected)) { + const previous = new Set((device.detected[targetName] || []).map(detectedSkillKey)); + for (const skill of entries) { + skills.push({ + name: skill.name, + target: targetName, + path: skill.path, + in_vault: skill.in_vault, + managed: Boolean(device.installed[skill.name]?.includes(targetName)), + new: !previous.has(detectedSkillKey(skill)), + }); + } + } + skills.sort((left, right) => ( + left.name.localeCompare(right.name) + || left.target.localeCompare(right.target) + || left.path.localeCompare(right.path) + )); + return { + version: 1, + device_id: device.device_id, + skills, + }; +} + async function findLocalSkills(scanPath) { const root = path.resolve(scanPath); if (!await exists(root)) return []; @@ -1112,68 +1379,24 @@ async function symlinkedDirectory(child, entry) { } } -async function removeStaleOwnedProjections({ vaultPath, targetPath, desired }) { - const entries = await readdir(targetPath, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - if (desired.has(entry.name)) continue; - const fullPath = path.join(targetPath, entry.name); - if (await isOwnedSymlink(fullPath, vaultPath) || await isOwnedCopy(fullPath, vaultPath)) { - await removePath(fullPath); - } - } +function copyDriftError(destination) { + return new Error(`Managed copy has local changes: ${destination}. Review it or run skillsync sync --discard-local-changes.`); } -async function createSymlinkProjection(source, destination) { - const vaultPath = path.dirname(path.dirname(source)); - const [existing, canonicalSource] = await Promise.all([ - projectionInfo(destination, vaultPath), - realpath(source), - ]); - if (existing.exists) { - if (existing.ownedSymlink) { - if (existing.reachable && existing.resolved === canonicalSource) return; - await removePath(destination); - } else if (existing.ownedCopy) { - await removePath(destination); - } else { - throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); - } - } - await writeSymlinkProjection(source, destination, vaultPath); -} - -async function writeSymlinkProjection(source, destination, vaultPath) { - const [parent, target] = await Promise.all([ - realpath(path.dirname(destination)), - realpath(source), - ]); - const relativeSource = path.relative(parent, target); - try { - await symlink(relativeSource, destination, 'dir'); - } catch (error) { - if (error.code !== 'EEXIST') throw error; - const existing = await projectionInfo(destination, vaultPath); - if (existing.ownedSymlink && existing.reachable && existing.resolved === target) return; - if (existing.ownedSymlink || existing.ownedCopy) { - await removePath(destination); - await symlink(relativeSource, destination, 'dir'); - return; - } - throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); - } +async function copyState({ destination, marker, sourceHash }) { + const localHash = await hashDirectory(destination, { exclude: [OWNERSHIP_MARKER] }); + const expectedHash = marker?.source_hash || sourceHash || null; + return { + localHash, + expectedHash, + drifted: !expectedHash || localHash !== expectedHash, + }; } -async function createCopyProjection(source, destination, skillName, vaultPath) { - const existing = await projectionInfo(destination, vaultPath); - if (existing.exists) { - if (existing.ownedCopy || existing.ownedSymlink) { - await removePath(destination); - } else { - throw new Error(`Refusing to overwrite unmanaged target path: ${destination}`); - } - } - await cp(source, destination, { recursive: true, force: true, dereference: false }); - await writeFile(path.join(destination, '.skillsync-owned.json'), JSON.stringify({ skill: skillName, vault: vaultPath }, null, 2)); +async function requireUnchangedCopy({ destination, marker, sourceHash }) { + const state = await copyState({ destination, marker, sourceHash }); + if (state.drifted) throw copyDriftError(destination); + return state; } async function realpathOrResolve(targetPath) { @@ -1218,6 +1441,7 @@ async function projectionInfo(targetPath, vaultPath) { exists: false, ownedSymlink: false, ownedCopy: false, + copyMarker: null, reachable: false, resolved: null, }; @@ -1225,10 +1449,12 @@ async function projectionInfo(targetPath, vaultPath) { throw error; } const link = await symlinkInfo(targetPath, vaultPath); + const copyMarker = link ? null : await ownedCopyMarker(targetPath, vaultPath); return { exists: true, ownedSymlink: Boolean(link?.owned), - ownedCopy: link ? false : await isOwnedCopy(targetPath, vaultPath), + ownedCopy: Boolean(copyMarker), + copyMarker, reachable: Boolean(link?.reachable), resolved: link?.resolved || null, }; @@ -1239,17 +1465,19 @@ async function isOwnedSymlink(targetPath, vaultPath) { return Boolean(link?.owned); } -async function isOwnedCopy(targetPath, vaultPath) { - const markerPath = path.join(targetPath, '.skillsync-owned.json'); +async function ownedCopyMarker(targetPath, vaultPath) { + const markerPath = path.join(targetPath, OWNERSHIP_MARKER); try { const info = await lstat(markerPath); - if (!info.isFile()) return false; + if (!info.isFile() || info.isSymbolicLink()) return null; const marker = await readJson(markerPath); return marker?.skill === path.basename(targetPath) && typeof marker?.vault === 'string' - && path.resolve(marker.vault) === path.resolve(vaultPath); + && path.resolve(marker.vault) === path.resolve(vaultPath) + ? marker + : null; } catch (error) { - if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error instanceof SyntaxError) return false; + if (error.code === 'ENOENT' || error.code === 'ENOTDIR' || error instanceof SyntaxError) return null; throw error; } } diff --git a/src/core/fs.js b/src/core/fs.js index 741b822..13053ef 100644 --- a/src/core/fs.js +++ b/src/core/fs.js @@ -37,7 +37,16 @@ export async function readJson(filePath, fallback = undefined) { export async function writeJson(filePath, value) { await ensureDir(path.dirname(filePath)); - await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); + const temporary = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' }) + .then(() => rename(temporary, filePath)) + .catch(async (error) => { + await rm(temporary, { force: true }); + throw error; + }); } export async function writePrivateJson(filePath, value) { @@ -58,7 +67,16 @@ export async function writePrivateJson(filePath, value) { export async function copyDir(source, destination) { await rm(destination, { recursive: true, force: true }); await ensureDir(path.dirname(destination)); - await cp(source, destination, { recursive: true, force: true, dereference: false }); + const sourceRoot = path.resolve(source); + await cp(source, destination, { + recursive: true, + force: true, + dereference: false, + filter: (candidate) => ( + path.resolve(candidate) === sourceRoot + || !['.DS_Store', '.git'].includes(path.basename(candidate)) + ), + }); } export async function removePath(targetPath) { @@ -116,9 +134,11 @@ async function walk(dirPath, root = dirPath) { return files.sort(); } -export async function hashDirectory(dirPath) { +export async function hashDirectory(dirPath, { exclude = [] } = {}) { const hash = createHash('sha256'); - const files = await walk(dirPath); + const excluded = new Set(exclude.map((relativePath) => relativePath.split(path.sep).join(path.posix.sep))); + const files = (await walk(dirPath)) + .filter((relativePath) => !excluded.has(relativePath.split(path.sep).join(path.posix.sep))); for (const relativePath of files) { hash.update(relativePath); hash.update('\0'); diff --git a/src/core/git.js b/src/core/git.js index f85ee74..a9eee5f 100644 --- a/src/core/git.js +++ b/src/core/git.js @@ -110,14 +110,16 @@ export function isPushRejectedBecauseRemoteHasWork(error) { ); } -export async function pushWithPullRebaseRetry(repoPath) { +export async function pushWithPullRebaseRetry(repoPath, { beforePush } = {}) { if (!await isGitRepo(repoPath)) return { pushed: false, rebased: false }; try { + if (beforePush) await beforePush(); await push(repoPath); return { pushed: true, rebased: false }; } catch (error) { if (!isPushRejectedBecauseRemoteHasWork(error)) throw error; await pullRebase(repoPath); + if (beforePush) await beforePush(); await push(repoPath); return { pushed: true, rebased: true }; } diff --git a/src/core/registry.js b/src/core/registry.js index 700994b..40b5d7f 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -1,6 +1,7 @@ -import { mkdir, readdir, rm, stat } from 'node:fs/promises'; +import { mkdir, readdir, rm } from 'node:fs/promises'; import path from 'node:path'; +import { checkSkillFolder } from './check.js'; import { assertSafePathSegment, copyDir, @@ -93,16 +94,7 @@ export async function setVaultPolicy({ vaultPath, name, enabled }) { } export async function validateSkillFolder(sourcePath) { - const skillFile = path.join(sourcePath, 'SKILL.md'); - try { - const info = await stat(skillFile); - if (!info.isFile()) throw new Error(`SKILL.md is not a file: ${skillFile}`); - } catch (error) { - if (error.code === 'ENOENT') { - throw new Error(`Skill folder must contain SKILL.md: ${sourcePath}`); - } - throw error; - } + await checkSkillFolder(sourcePath); } export async function compareSkillToVault({ vaultPath, sourcePath, name }) { @@ -171,8 +163,7 @@ export async function registryEntryForSkill(vaultPath, skillName) { }; } -export async function rebuildRegistry(vaultPath) { - await ensureVault(vaultPath); +export async function buildRegistry(vaultPath) { const skillsDir = path.join(vaultPath, 'skills'); const entries = await readdir(skillsDir, { withFileTypes: true }); const registry = emptyRegistry(); @@ -182,6 +173,12 @@ export async function rebuildRegistry(vaultPath) { if (!await exists(path.join(skillsDir, skillName, 'SKILL.md'))) continue; registry.skills[skillName] = await registryEntryForSkill(vaultPath, skillName); } + return registry; +} + +export async function rebuildRegistry(vaultPath) { + await ensureVault(vaultPath); + const registry = await buildRegistry(vaultPath); await saveRegistry(vaultPath, registry); return registry; } diff --git a/src/core/sync.js b/src/core/sync.js index 13fdfa9..5e3bf93 100644 --- a/src/core/sync.js +++ b/src/core/sync.js @@ -3,50 +3,93 @@ import { autoImportNewLocalSkills, markDeviceApplied, migrateLegacyLocalPathState, + rollbackLinkBackup, scanTargets, sweepUnusedSkills, } from './device.js'; +import { checkVault } from './check.js'; import { commitAllIfChanged, hasLocalCommitsToPush, isGitRepo, pullRebase, pushWithPullRebaseRetry } from './git.js'; import { applyGlobalInstructions, reconcileGlobalInstructionProviders, } from './instructions.js'; import { syncCodexPlugins } from './plugins.js'; -import { refreshChangedRegistryEntries } from './registry.js'; +import { ensureVault, refreshChangedRegistryEntries } from './registry.js'; -export async function syncVault({ vaultPath, deviceId, pushChanges = true, pull = true } = {}) { +export async function syncVault({ + vaultPath, + deviceId, + pushChanges = true, + pull = true, + discardLocalChanges = false, + replaceUnmanagedPaths = [], +} = {}) { if (deviceId) { await migrateLegacyLocalPathState({ vaultPath, deviceId }); } if (pull && await isGitRepo(vaultPath)) { await pullRebase(vaultPath); } + await ensureVault(vaultPath); + await checkVault(vaultPath, { verifyRegistry: false }); await refreshChangedRegistryEntries(vaultPath); - let autoImport = { adopted: [], conflicts: [] }; + await checkVault(vaultPath); + let autoImport = { adopted: [], conflicts: [], replaceUnmanagedPaths: [] }; let instructionProviders = { changed: false, conflicts: [], backups: [] }; let prunedInstructionProfiles = []; let prunedSkills = []; let plugins = null; + let projectionApply = null; if (deviceId) { - autoImport = await autoImportNewLocalSkills({ vaultPath, deviceId }); - await applyLinks({ vaultPath, deviceId }); - instructionProviders = await reconcileGlobalInstructionProviders({ vaultPath, deviceId }); - const instructions = await applyGlobalInstructions({ vaultPath, deviceId }); - prunedInstructionProfiles = instructions.prunedProfiles || []; - plugins = await syncCodexPlugins({ vaultPath, deviceId }); - await markDeviceApplied({ vaultPath, deviceId }); - await scanTargets({ vaultPath, deviceId }); - if (pull) { - prunedSkills = await sweepUnusedSkills({ vaultPath }); + try { + autoImport = await autoImportNewLocalSkills({ vaultPath, deviceId }); + projectionApply = await applyLinks({ + vaultPath, + deviceId, + discardLocalChanges, + replaceUnmanagedPaths: [ + ...new Set([ + ...replaceUnmanagedPaths, + ...autoImport.replaceUnmanagedPaths, + ]), + ], + }); + instructionProviders = await reconcileGlobalInstructionProviders({ vaultPath, deviceId }); + const instructions = await applyGlobalInstructions({ vaultPath, deviceId }); + prunedInstructionProfiles = instructions.prunedProfiles || []; + plugins = await syncCodexPlugins({ vaultPath, deviceId }); + await scanTargets({ vaultPath, deviceId }); + if (pull) { + prunedSkills = await sweepUnusedSkills({ vaultPath }); + } + await checkVault(vaultPath); + await markDeviceApplied({ vaultPath, deviceId }); + } catch (error) { + if (!projectionApply?.backupId) throw error; + try { + await rollbackLinkBackup({ + vaultPath, + deviceId, + backupId: projectionApply.backupId, + }); + } catch (rollbackError) { + throw new Error(`${error.message}\nProjection rollback also failed: ${rollbackError.message}`, { + cause: error, + }); + } + throw error; } } let committed = false; let pushed = false; let rebasedBeforePush = false; if (pushChanges && await isGitRepo(vaultPath)) { + await checkVault(vaultPath); committed = await commitAllIfChanged(vaultPath, `sync: update skills from ${deviceId || 'device'}`); if (committed || await hasLocalCommitsToPush(vaultPath)) { - const pushResult = await pushWithPullRebaseRetry(vaultPath); + const pushResult = await pushWithPullRebaseRetry(vaultPath, { + beforePush: () => checkVault(vaultPath), + }); pushed = pushResult.pushed; rebasedBeforePush = pushResult.rebased; } @@ -60,6 +103,7 @@ export async function syncVault({ vaultPath, deviceId, pushChanges = true, pull instructionProviderConflicts: instructionProviders.conflicts, instructionProviderBackups: instructionProviders.backups || [], plugins, + projectionOperations: projectionApply?.operations || [], prunedInstructionProfiles, prunedSkills, }; diff --git a/src/core/transaction.js b/src/core/transaction.js new file mode 100644 index 0000000..2eb655f --- /dev/null +++ b/src/core/transaction.js @@ -0,0 +1,461 @@ +import { randomUUID } from 'node:crypto'; +import { + chmod, + copyFile, + cp, + lstat, + mkdir, + readdir, + readlink, + realpath, + rename, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; + +import { + assertSafePathSegment, + ensureDir, + readJson, + removePath, + writePrivateJson, +} from './fs.js'; +import { gitPrivatePath } from './git.js'; + +const BACKUP_VERSION = 1; +const BACKUP_LIMIT = 10; + +async function pathInfo(filePath) { + return lstat(filePath).catch((error) => { + if (error.code === 'ENOENT') return null; + throw error; + }); +} + +async function transactionRoot(vaultPath) { + return await gitPrivatePath(vaultPath, 'transactions') + || path.join(vaultPath, '.skillsync-local', 'transactions'); +} + +function processIsRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +async function acquireLock(root, recover) { + await ensureDir(root); + const lockPath = path.join(root, 'lock'); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await mkdir(lockPath, { mode: 0o700 }); + await writePrivateJson(path.join(lockPath, 'owner.json'), { + version: 1, + pid: process.pid, + started_at: new Date().toISOString(), + }); + await recover(); + return async () => removePath(lockPath); + } catch (error) { + if (error.code !== 'EEXIST') { + if (await pathInfo(lockPath)) await removePath(lockPath); + throw error; + } + const owner = await readJson(path.join(lockPath, 'owner.json'), null).catch(() => null); + if (processIsRunning(owner?.pid)) { + throw new Error('Another SkillSync apply is already running'); + } + const lockInfo = await pathInfo(lockPath); + if (!owner && lockInfo && Date.now() - lockInfo.mtimeMs < 60_000) { + throw new Error('Another SkillSync apply is already starting'); + } + await removePath(lockPath); + } + } + throw new Error('Could not acquire the SkillSync apply lock'); +} + +function normalizeRoots(roots) { + return [...new Set(roots.map((root) => path.resolve(root)))].sort(); +} + +function validateDestination(destination, roots) { + const resolved = path.resolve(destination); + if (!roots.some((root) => path.dirname(resolved) === root)) { + throw new Error(`Projection destination is outside an approved target: ${destination}`); + } + return resolved; +} + +function validateOperations(operations, roots) { + if (!Array.isArray(operations)) throw new Error('Projection plan must be an array'); + const destinations = new Set(); + for (const operation of operations) { + if (!['copy', 'remove', 'symlink'].includes(operation.type)) { + throw new Error(`Unsupported projection operation: ${operation.type}`); + } + const destination = validateDestination(operation.destination, roots); + if (destinations.has(destination)) { + throw new Error(`Duplicate projection destination: ${destination}`); + } + destinations.add(destination); + if (operation.type !== 'remove' && !operation.source) { + throw new Error(`Projection source is required: ${destination}`); + } + } +} + +function backupId() { + return `${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${randomUUID()}`; +} + +async function captureEntry(destination, backupPath, index) { + const info = await pathInfo(destination); + const entry = { destination }; + if (!info) return { ...entry, type: 'missing' }; + if (info.isSymbolicLink()) { + return { ...entry, type: 'symlink', link: await readlink(destination) }; + } + + const relative = path.posix.join('entries', String(index)); + const stored = path.join(backupPath, relative); + await ensureDir(path.dirname(stored)); + if (info.isDirectory()) { + await cp(destination, stored, { recursive: true, force: true, dereference: false }); + return { ...entry, type: 'directory', stored }; + } + if (info.isFile()) { + await copyFile(destination, stored); + await chmod(stored, info.mode & 0o777); + return { ...entry, type: 'file', stored, mode: info.mode & 0o777 }; + } + throw new Error(`Cannot back up unsupported projection path: ${destination}`); +} + +async function createBackup(root, deviceId, roots, operations) { + const id = backupId(); + const deviceRoot = path.join(root, 'backups', assertSafePathSegment(deviceId, 'Device ID')); + const backupPath = path.join(deviceRoot, id); + await mkdir(backupPath, { recursive: true, mode: 0o700 }); + const entries = []; + for (const [index, operation] of operations.entries()) { + entries.push(await captureEntry(operation.destination, backupPath, index)); + } + const manifest = { + version: BACKUP_VERSION, + id, + device_id: deviceId, + created_at: new Date().toISOString(), + state: 'in-progress', + roots, + entries, + }; + await writePrivateJson(path.join(backupPath, 'manifest.json'), manifest); + return { backupPath, manifest }; +} + +async function stagePath(destination) { + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.skillsync-${randomUUID()}.tmp`, + ); + if (await pathInfo(temporary)) throw new Error(`Temporary projection path already exists: ${temporary}`); + return temporary; +} + +async function replaceWithCopy(source, destination, marker) { + await ensureDir(path.dirname(destination)); + const temporary = await stagePath(destination); + try { + await cp(source, temporary, { recursive: true, force: true, dereference: false }); + if (marker !== undefined) { + await writeFile( + path.join(temporary, '.skillsync-owned.json'), + `${JSON.stringify(marker, null, 2)}\n`, + ); + } + await removePath(destination); + await rename(temporary, destination); + } finally { + await removePath(temporary); + } +} + +async function replaceWithFile(source, destination, mode) { + await ensureDir(path.dirname(destination)); + const temporary = await stagePath(destination); + try { + await copyFile(source, temporary); + await chmod(temporary, mode); + await removePath(destination); + await rename(temporary, destination); + } finally { + await removePath(temporary); + } +} + +async function replaceWithSymlink(source, destination, explicitLink = null) { + await ensureDir(path.dirname(destination)); + const temporary = await stagePath(destination); + try { + const link = explicitLink || path.relative( + await realpath(path.dirname(destination)), + await realpath(source), + ); + await symlink(link, temporary, 'dir'); + await removePath(destination); + await rename(temporary, destination); + } finally { + await removePath(temporary); + } +} + +async function applyOperation(operation) { + if (operation.type === 'remove') { + await removePath(operation.destination); + return; + } + if (operation.type === 'copy') { + await replaceWithCopy(operation.source, operation.destination, operation.marker); + return; + } + await replaceWithSymlink(operation.source, operation.destination); +} + +function validateManifest(manifest, deviceId, allowedRoots) { + if (!manifest + || manifest.version !== BACKUP_VERSION + || manifest.device_id !== deviceId + || !Array.isArray(manifest.roots) + || !Array.isArray(manifest.entries)) { + throw new Error('Invalid projection backup manifest'); + } + const roots = normalizeRoots(manifest.roots); + if (roots.some((root) => !allowedRoots.includes(root))) { + throw new Error('Projection backup references an unapproved target'); + } + const destinations = new Set(); + for (const entry of manifest.entries) { + if (!entry || !['directory', 'file', 'missing', 'symlink'].includes(entry.type)) { + throw new Error('Invalid projection backup entry'); + } + const destination = validateDestination(entry.destination, roots); + if (destinations.has(destination)) throw new Error('Duplicate projection backup entry'); + destinations.add(destination); + } + return roots; +} + +async function preflightBackup(backupPath, manifest, deviceId, allowedRoots) { + validateManifest(manifest, deviceId, allowedRoots); + if (manifest.id !== path.basename(backupPath)) { + throw new Error('Projection backup ID does not match its directory'); + } + for (const entry of manifest.entries) { + if (entry.type === 'missing') continue; + if (entry.type === 'symlink') { + if (typeof entry.link !== 'string' || !entry.link) { + throw new Error('Invalid projection backup symlink'); + } + continue; + } + const stored = path.resolve(entry.stored || ''); + if (!stored.startsWith(path.resolve(backupPath) + path.sep)) { + throw new Error('Projection backup entry escapes its backup directory'); + } + const info = await pathInfo(stored); + if (entry.type === 'file' && (!info?.isFile() || info.isSymbolicLink())) { + throw new Error('Projection backup file is missing or invalid'); + } + if (entry.type === 'file' + && (!Number.isInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777)) { + throw new Error('Projection backup file mode is invalid'); + } + if (entry.type === 'directory' && (!info?.isDirectory() || info.isSymbolicLink())) { + throw new Error('Projection backup directory is missing or invalid'); + } + } +} + +async function restoreBackup(backupPath, manifest, deviceId, allowedRoots) { + await preflightBackup(backupPath, manifest, deviceId, allowedRoots); + for (const entry of manifest.entries) { + if (entry.type === 'missing') { + await removePath(entry.destination); + continue; + } + if (entry.type === 'symlink') { + await replaceWithSymlink('', entry.destination, entry.link); + continue; + } + const stored = path.resolve(entry.stored || ''); + if (entry.type === 'file') { + await replaceWithFile(stored, entry.destination, entry.mode); + continue; + } + await replaceWithCopy(stored, entry.destination); + } +} + +async function updateManifest(backupPath, manifest, state) { + const updated = { ...manifest, state }; + await writePrivateJson(path.join(backupPath, 'manifest.json'), updated); + return updated; +} + +async function recoverInterrupted(root, allowedRoots) { + const markerPath = path.join(root, 'in-progress.json'); + const marker = await readJson(markerPath, null); + if (!marker) return; + if (marker.version !== 1) throw new Error('Invalid projection recovery marker'); + const deviceId = assertSafePathSegment(marker.device_id, 'Device ID'); + const id = assertSafePathSegment(marker.backup_id, 'Backup ID'); + const backupPath = path.join(root, 'backups', deviceId, id); + const manifest = await readJson(path.join(backupPath, 'manifest.json')); + await restoreBackup(backupPath, manifest, deviceId, allowedRoots); + await updateManifest(backupPath, manifest, 'recovered'); + await removePath(markerPath); +} + +async function pruneBackups(root, deviceId) { + const deviceRoot = path.join(root, 'backups', deviceId); + const entries = await readdir(deviceRoot, { withFileTypes: true }).catch((error) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + const old = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + .reverse() + .slice(BACKUP_LIMIT); + for (const name of old) await removePath(path.join(deviceRoot, name)); +} + +async function withLock(root, allowedRoots, callback) { + const release = await acquireLock( + root, + () => recoverInterrupted(root, allowedRoots), + ); + try { + return await callback(); + } finally { + await release(); + } +} + +export async function applyFilesystemPlan({ + vaultPath, + deviceId, + roots, + operations = [], + plan, + fault, +}) { + const allowedRoots = normalizeRoots(roots); + const root = await transactionRoot(vaultPath); + return withLock(root, allowedRoots, async () => { + const plannedOperations = plan ? await plan() : operations; + validateOperations(plannedOperations, allowedRoots); + if (!plannedOperations.length) { + return { applied: false, backupId: null, operations: plannedOperations }; + } + const { backupPath, manifest } = await createBackup( + root, + deviceId, + allowedRoots, + plannedOperations, + ); + const markerPath = path.join(root, 'in-progress.json'); + await writePrivateJson(markerPath, { + version: 1, + device_id: deviceId, + backup_id: manifest.id, + }); + try { + for (const [index, operation] of plannedOperations.entries()) { + await applyOperation(operation); + if (fault) await fault(index, operation); + } + await updateManifest(backupPath, manifest, 'applied'); + await removePath(markerPath); + await pruneBackups(root, deviceId); + return { applied: true, backupId: manifest.id, operations: plannedOperations }; + } catch (error) { + try { + await restoreBackup(backupPath, manifest, deviceId, allowedRoots); + await updateManifest(backupPath, manifest, 'rolled-back'); + await removePath(markerPath); + } catch (rollbackError) { + throw new Error(`Projection apply failed: ${error.message}\nProjection rollback also failed: ${rollbackError.message}`, { + cause: error, + }); + } + throw new Error(`Projection apply failed and was rolled back: ${error.message}`, { cause: error }); + } + }); +} + +async function rollbackBackup({ vaultPath, deviceId, allowedRoots, backupId: id }) { + const root = await transactionRoot(vaultPath); + const backupPath = path.join( + root, + 'backups', + assertSafePathSegment(deviceId, 'Device ID'), + assertSafePathSegment(id, 'Backup ID'), + ); + const manifest = await readJson(path.join(backupPath, 'manifest.json')); + if (manifest.state !== 'applied') throw new Error(`Projection backup is not available: ${id}`); + await restoreBackup(backupPath, manifest, deviceId, allowedRoots); + await updateManifest(backupPath, manifest, 'rolled-back'); + return { backupId: id, restored: manifest.entries.length }; +} + +export async function rollbackFilesystemBackup({ + vaultPath, + deviceId, + roots, + backupId: id, +}) { + const allowedRoots = normalizeRoots(roots); + const root = await transactionRoot(vaultPath); + return withLock(root, allowedRoots, () => rollbackBackup({ + vaultPath, + deviceId, + allowedRoots, + backupId: id, + })); +} + +export async function rollbackLatestFilesystemBackup({ vaultPath, deviceId, roots }) { + const allowedRoots = normalizeRoots(roots); + const root = await transactionRoot(vaultPath); + return withLock(root, allowedRoots, async () => { + const deviceRoot = path.join(root, 'backups', assertSafePathSegment(deviceId, 'Device ID')); + const entries = await readdir(deviceRoot, { withFileTypes: true }).catch((error) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + for (const entry of entries + .filter((candidate) => candidate.isDirectory()) + .sort((left, right) => right.name.localeCompare(left.name))) { + const manifest = await readJson( + path.join(deviceRoot, entry.name, 'manifest.json'), + null, + ).catch(() => null); + if (manifest?.state !== 'applied') continue; + return rollbackBackup({ + vaultPath, + deviceId, + allowedRoots, + backupId: entry.name, + }); + } + throw new Error('No projection backup is available to roll back'); + }); +} diff --git a/test/cli.test.js b/test/cli.test.js index 7d0165a..f5781a7 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -177,7 +177,7 @@ test('uninstall --device defers cleanup until the remote device reports the remo assert.ok((await loadRegistry(vault)).skills['temporary-skill']); }); -test('scan adopts a newly detected skill when target auto-adoption is enabled', async () => { +test('scan reports new skills without changing local or vault state', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo'); const target = path.join(home, '.codex', 'skills'); @@ -193,6 +193,7 @@ test('scan adopts a newly detected skill when target auto-adoption is enabled', enabled: true, }); await makeSkill(target, 'new-local', '# New\n'); + const statusBefore = (await git(['status', '--porcelain'], vault)).stdout; const { stdout } = await execFileAsync(process.execPath, [ path.resolve('src/cli.js'), @@ -202,11 +203,86 @@ test('scan adopts a newly detected skill when target auto-adoption is enabled', env: cliEnv(home), }); - assert.match(stdout, /Auto-adopted new-local from codex/); + assert.match(stdout, /new-local \[codex, local only, new\]/); + assert.equal((await loadRegistry(vault)).skills['new-local'], undefined); + assert.equal((await lstat(path.join(target, 'new-local'))).isDirectory(), true); + assert.equal((await git(['status', '--porcelain'], vault)).stdout, statusBefore); + + const json = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), + 'scan', + '--json', + ], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + assert.deepEqual(JSON.parse(json.stdout).skills, [{ + name: 'new-local', + target: 'codex', + path: 'new-local', + in_vault: false, + managed: false, + new: true, + }]); + + const synced = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), + 'sync', + '--no-pull', + ], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + assert.match(synced.stdout, /Auto-adopted new-local from codex/); assert.ok((await loadRegistry(vault)).skills['new-local']); assert.equal((await lstat(path.join(target, 'new-local'))).isSymbolicLink(), true); }); +test('sync dry-run reports projection changes without applying them', async () => { + const home = await tempDir(); + const vault = path.join(home, '.skillsync', 'repo'); + const target = path.join(home, '.codex', 'skills'); + const deviceId = 'test-device'; + + await writeConfig(home, vault, deviceId); + await makeSkill(path.join(vault, 'skills'), 'paper-mcp', '# Paper\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId, name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId, skillName: 'paper-mcp', targets: ['codex'] }); + + const { stdout } = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), + 'sync', + '--dry-run', + ], { + cwd: path.resolve('.'), + env: cliEnv(home), + }); + + assert.match(stdout, /create symlink:/); + await assert.rejects(() => lstat(path.join(target, 'paper-mcp'))); +}); + +test('check validates the vault without rewriting a stale registry', async () => { + const home = await tempDir(); + const vault = path.join(home, '.skillsync', 'repo'); + + await writeConfig(home, vault); + const skill = await makeSkill(path.join(vault, 'skills'), 'paper-mcp', '# First\n'); + await rebuildRegistry(vault); + await writeFile(path.join(skill, 'SKILL.md'), '# Second\n'); + const registryBefore = await readFile(path.join(vault, 'registry.json'), 'utf8'); + + await assert.rejects( + () => execFileAsync(process.execPath, [path.resolve('src/cli.js'), 'check'], { + cwd: path.resolve('.'), + env: cliEnv(home), + }), + /Registry hash is stale: paper-mcp/, + ); + assert.equal(await readFile(path.join(vault, 'registry.json'), 'utf8'), registryBefore); +}); + test('matrix shows cross-device assignments and device auto-adoption can be disabled', async () => { const home = await tempDir(); const vault = path.join(home, '.skillsync', 'repo'); diff --git a/test/core.test.js b/test/core.test.js index 1bdd6e4..f5defbb 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -22,9 +22,11 @@ import { rebuildRegistry, setVaultPolicy, } from '../src/core/registry.js'; +import { checkVault } from '../src/core/check.js'; import { addTarget, applyLinks, + configureGlobalInstructions, globalInstructionsAssignmentPath, initializeLocalPathState, installSkill, @@ -36,6 +38,7 @@ import { markDeviceApplied, migrateLegacyLocalPathState, removeTargetAndPrune, + rollbackLinks, scanTargets, setDeviceAutoImport, setGlobalInstructionsProfile, @@ -253,6 +256,24 @@ test('applyLinks repairs skill links through a symlinked target directory', asyn assert.equal(projection.status, 'ok'); }); +test('projection plans reject overlapping target roots', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'skills'); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'parent', targetPath: target }); + await addTarget({ + vaultPath: vault, + deviceId: 'macbook', + name: 'nested', + targetPath: path.join(target, 'nested'), + }); + + await assert.rejects( + () => applyLinks({ vaultPath: vault, deviceId: 'macbook' }), + /Configured skill targets cannot overlap/, + ); +}); + test('installSkill tracks device-global installs without an agent target', async () => { const root = await tempDir(); const vault = path.join(root, 'vault'); @@ -1306,6 +1327,265 @@ test('copy projection ownership requires a matching vault marker', async () => { assert.equal(await readFile(path.join(unmanaged, 'SKILL.md'), 'utf8'), '# Keep\n'); }); +test('approved local skill replacement is backed up without weakening unmanaged path protection', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + const destination = await makeSkill(target, 'shared-skill', '# Local\n'); + await makeSkill(path.join(vault, 'skills'), 'shared-skill', '# Vault\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'codex', targetPath: target }); + await installSkill({ + vaultPath: vault, + deviceId: 'macbook', + skillName: 'shared-skill', + targets: ['codex'], + }); + + await assert.rejects( + () => applyLinks({ vaultPath: vault, deviceId: 'macbook' }), + /Refusing to overwrite unmanaged target path/, + ); + + await applyLinks({ + vaultPath: vault, + deviceId: 'macbook', + replaceUnmanagedPaths: [destination], + }); + assert.equal(await readFile(path.join(destination, 'SKILL.md'), 'utf8'), '# Vault\n'); + + await rollbackLinks({ vaultPath: vault, deviceId: 'macbook' }); + assert.equal((await lstat(destination)).isDirectory(), true); + assert.equal(await readFile(path.join(destination, 'SKILL.md'), 'utf8'), '# Local\n'); + await assert.rejects(() => lstat(path.join(destination, '.skillsync-owned.json')), { code: 'ENOENT' }); +}); + +test('copy projections refuse local edits unless discarding them is explicit', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + await makeSkill(path.join(vault, 'skills'), 'shared-skill', '# Vault\n'); + await rebuildRegistry(vault); + await addTarget({ + vaultPath: vault, + deviceId: 'macbook', + name: 'codex', + targetPath: target, + mode: 'copy', + }); + await installSkill({ + vaultPath: vault, + deviceId: 'macbook', + skillName: 'shared-skill', + targets: ['codex'], + }); + await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + const localSkill = path.join(target, 'shared-skill', 'SKILL.md'); + await writeFile(localSkill, '# Local edit\n'); + + await assert.rejects( + () => applyLinks({ vaultPath: vault, deviceId: 'macbook' }), + /Managed copy has local changes/, + ); + assert.equal(await readFile(localSkill, 'utf8'), '# Local edit\n'); + + await applyLinks({ + vaultPath: vault, + deviceId: 'macbook', + discardLocalChanges: true, + }); + assert.equal(await readFile(localSkill, 'utf8'), '# Vault\n'); +}); + +test('copy projections update when the vault changes without local drift', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + const vaultSkill = await makeSkill(path.join(vault, 'skills'), 'shared-skill', '# First\n'); + await rebuildRegistry(vault); + await addTarget({ + vaultPath: vault, + deviceId: 'macbook', + name: 'codex', + targetPath: target, + mode: 'copy', + }); + await installSkill({ + vaultPath: vault, + deviceId: 'macbook', + skillName: 'shared-skill', + targets: ['codex'], + }); + await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + await writeFile(path.join(vaultSkill, 'SKILL.md'), '# Second\n'); + await rebuildRegistry(vault); + await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + assert.equal( + await readFile(path.join(target, 'shared-skill', 'SKILL.md'), 'utf8'), + '# Second\n', + ); +}); + +test('projection apply restores every destination after a write failure', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + await makeSkill(path.join(vault, 'skills'), 'alpha', '# Alpha\n'); + await makeSkill(path.join(vault, 'skills'), 'beta', '# Beta\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId: 'macbook', skillName: 'alpha', targets: ['codex'] }); + await installSkill({ vaultPath: vault, deviceId: 'macbook', skillName: 'beta', targets: ['codex'] }); + + await assert.rejects( + () => applyLinks({ + vaultPath: vault, + deviceId: 'macbook', + fault: async (index) => { + if (index === 0) throw new Error('simulated failure'); + }, + }), + /Projection apply failed and was rolled back/, + ); + await assert.rejects(() => lstat(path.join(target, 'alpha'))); + await assert.rejects(() => lstat(path.join(target, 'beta'))); +}); + +test('projection apply refuses concurrent writers', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + await makeSkill(path.join(vault, 'skills'), 'alpha', '# Alpha\n'); + await makeSkill(path.join(vault, 'skills'), 'beta', '# Beta\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId: 'macbook', skillName: 'alpha', targets: ['codex'] }); + await installSkill({ vaultPath: vault, deviceId: 'macbook', skillName: 'beta', targets: ['codex'] }); + + let releaseFirst; + let firstOperationApplied; + const started = new Promise((resolve) => { firstOperationApplied = resolve; }); + const release = new Promise((resolve) => { releaseFirst = resolve; }); + const first = applyLinks({ + vaultPath: vault, + deviceId: 'macbook', + fault: async (index) => { + if (index !== 0) return; + firstOperationApplied(); + await release; + }, + }); + await started; + try { + await assert.rejects( + () => applyLinks({ vaultPath: vault, deviceId: 'macbook' }), + /Another SkillSync apply is already running/, + ); + } finally { + releaseFirst(); + } + await first; +}); + +test('projection planning runs after interrupted apply recovery', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + await makeSkill(path.join(vault, 'skills'), 'alpha', '# Alpha\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'codex', targetPath: target }); + await installSkill({ vaultPath: vault, deviceId: 'macbook', skillName: 'alpha', targets: ['codex'] }); + const first = await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + const transactionRoot = path.join(vault, '.skillsync-local', 'transactions'); + const manifestPath = path.join(transactionRoot, 'backups', 'macbook', first.backupId, 'manifest.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, state: 'in-progress' }, null, 2)}\n`); + await writeFile(path.join(transactionRoot, 'in-progress.json'), `${JSON.stringify({ + version: 1, + device_id: 'macbook', + backup_id: first.backupId, + }, null, 2)}\n`); + + const second = await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + assert.equal(second.applied, true); + assert.notEqual(second.backupId, first.backupId); + assert.equal((await lstat(path.join(target, 'alpha'))).isSymbolicLink(), true); + assert.equal(JSON.parse(await readFile(manifestPath, 'utf8')).state, 'recovered'); +}); + +test('rollback restores the previous copy projection', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + const vaultSkill = await makeSkill(path.join(vault, 'skills'), 'shared-skill', '# First\n'); + await rebuildRegistry(vault); + await addTarget({ + vaultPath: vault, + deviceId: 'macbook', + name: 'codex', + targetPath: target, + mode: 'copy', + }); + await installSkill({ + vaultPath: vault, + deviceId: 'macbook', + skillName: 'shared-skill', + targets: ['codex'], + }); + await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + await writeFile(path.join(vaultSkill, 'SKILL.md'), '# Second\n'); + await rebuildRegistry(vault); + await applyLinks({ vaultPath: vault, deviceId: 'macbook' }); + + const result = await rollbackLinks({ vaultPath: vault, deviceId: 'macbook' }); + + assert.equal(result.restored, 1); + assert.equal( + await readFile(path.join(target, 'shared-skill', 'SKILL.md'), 'utf8'), + '# First\n', + ); +}); + +test('vault checks reject stale registry entries, secrets, and symlinks', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const skill = await makeSkill(path.join(vault, 'skills'), 'safe-skill', '# Safe\n'); + await rebuildRegistry(vault); + await checkVault(vault); + + await writeFile(path.join(skill, 'SKILL.md'), '# Changed\n'); + await assert.rejects(() => checkVault(vault), /Registry hash is stale: safe-skill/); + await rebuildRegistry(vault); + + await writeFile(path.join(skill, 'token.txt'), 'sk-proj-1234567890abcdefghijklmnop\n'); + await assert.rejects(() => checkVault(vault), /Possible API key: skills\/safe-skill\/token.txt/); + await unlink(path.join(skill, 'token.txt')); + await symlink(path.join(root, 'outside'), path.join(skill, 'outside')); + await assert.rejects(() => checkVault(vault), /Symlinks are not allowed: skills\/safe-skill\/outside/); + await unlink(path.join(skill, 'outside')); + await mkdir(path.join(vault, 'state'), { recursive: true }); + await writeFile(path.join(vault, 'state', 'broken.json'), '{not json}\n'); + await assert.rejects(() => checkVault(vault), /Invalid JSON: state\/broken.json/); +}); + +test('adding a skill rejects credentials before copying it into the vault', async () => { + const root = await tempDir(); + const source = await makeSkill(root, 'unsafe-skill', '# Unsafe\n'); + const vault = path.join(root, 'vault'); + await writeFile(path.join(source, 'credentials.txt'), 'ghp_1234567890abcdefghijklmnop\n'); + + await assert.rejects( + () => addSkillToVault({ vaultPath: vault, sourcePath: source }), + /Possible GitHub token/, + ); + await assert.rejects(() => lstat(path.join(vault, 'skills', 'unsafe-skill'))); +}); + test('unused-skill sweep is disabled by default', async () => { const root = await tempDir(); const vault = path.join(root, 'vault'); @@ -1498,3 +1778,43 @@ test('sync applies the latest desired generation on the target device', async () assert.equal(applied.applied_generation, applied.desired_generation); assert.equal((await lstat(path.join(target, 'paper-mcp'))).isSymbolicLink(), true); }); + +test('sync restores projection changes when a later step fails', async () => { + const root = await tempDir(); + const vault = path.join(root, 'vault'); + const target = path.join(root, 'codex-skills'); + const instructions = path.join(root, 'AGENTS.md'); + await makeSkill(path.join(vault, 'skills'), 'paper-mcp', '# Paper\n'); + await rebuildRegistry(vault); + await addTarget({ vaultPath: vault, deviceId: 'macbook', name: 'codex', targetPath: target }); + await installSkill({ + vaultPath: vault, + deviceId: 'macbook', + skillName: 'paper-mcp', + targets: ['codex'], + }); + await setGlobalInstructionsProfile({ + vaultPath: vault, + deviceId: 'macbook', + profile: 'missing-profile', + }); + await configureGlobalInstructions({ + vaultPath: vault, + deviceId: 'macbook', + targetPaths: [instructions], + appliedProfile: null, + }); + + await assert.rejects( + () => syncVault({ + vaultPath: vault, + deviceId: 'macbook', + pull: false, + pushChanges: false, + }), + /Unknown global instructions profile: missing-profile/, + ); + await assert.rejects(() => lstat(path.join(target, 'paper-mcp'))); + const device = await loadDevice(vault, 'macbook'); + assert.ok(device.desired_generation > device.applied_generation); +}); diff --git a/test/git.test.js b/test/git.test.js index 723baea..1ce8c2c 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -103,9 +103,13 @@ test('pushWithPullRebaseRetry rebases and retries after a fetch-first rejection' await commitFile(stale, 'local.txt', 'local change\n', 'local change'); - const result = await pushWithPullRebaseRetry(stale); + let validations = 0; + const result = await pushWithPullRebaseRetry(stale, { + beforePush: async () => { validations += 1; }, + }); assert.equal(result.rebased, true); + assert.equal(validations, 2); const { stdout } = await git(['log', '--oneline', '--format=%s'], stale); assert.match(stdout, /local change/); assert.match(stdout, /remote change/);