(opts: CommitSkillOptions)
| 117 | * - resolved destination escapes the tier root (defense in depth) |
| 118 | */ |
| 119 | export function commitSkill(opts: CommitSkillOptions): string { |
| 120 | validateSkillName(opts.name); |
| 121 | |
| 122 | const tiers = opts.tiers ?? defaultTierPaths(); |
| 123 | const tierRoot = opts.tier === 'project' ? tiers.project : tiers.global; |
| 124 | if (!tierRoot) { |
| 125 | throw new Error(`commitSkill: tier "${opts.tier}" has no resolved path.`); |
| 126 | } |
| 127 | |
| 128 | // Refuse to follow a symlinked staging dir — caller should hand us the path |
| 129 | // returned by stageSkill, which is always a real directory. |
| 130 | let stagedStat: fs.Stats; |
| 131 | try { |
| 132 | stagedStat = fs.lstatSync(opts.stagedDir); |
| 133 | } catch (err: any) { |
| 134 | throw new Error(`commitSkill: staged dir "${opts.stagedDir}" not accessible: ${err.code ?? err.message}`); |
| 135 | } |
| 136 | if (stagedStat.isSymbolicLink()) { |
| 137 | throw new Error(`commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.`); |
| 138 | } |
| 139 | if (!stagedStat.isDirectory()) { |
| 140 | throw new Error(`commitSkill: staged path "${opts.stagedDir}" is not a directory.`); |
| 141 | } |
| 142 | |
| 143 | // Ensure the tier root exists, then resolve its real path so the final |
| 144 | // destination check defends against tierRoot itself being a symlink. |
| 145 | fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 }); |
| 146 | const realTierRoot = fs.realpathSync(tierRoot); |
| 147 | |
| 148 | const dest = path.join(realTierRoot, opts.name); |
| 149 | if (!isPathWithin(dest, realTierRoot)) { |
| 150 | // Should be impossible after validateSkillName, but defense in depth. |
| 151 | throw new Error(`commitSkill: destination "${dest}" escapes tier root.`); |
| 152 | } |
| 153 | |
| 154 | // Refuse to clobber. Both regular dirs and symlinks count. |
| 155 | let destExists = false; |
| 156 | try { |
| 157 | fs.lstatSync(dest); |
| 158 | destExists = true; |
| 159 | } catch (err: any) { |
| 160 | if (err.code !== 'ENOENT') throw err; |
| 161 | } |
| 162 | if (destExists) { |
| 163 | throw new Error( |
| 164 | `commitSkill: a skill named "${opts.name}" already exists at ${dest}. ` + |
| 165 | `Pick a different name or remove the existing skill first ` + |
| 166 | `($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).`, |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | fs.renameSync(opts.stagedDir, dest); |
| 171 | return dest; |
| 172 | } |
| 173 | |
| 174 | // ─── Discard (cleanup on failure or reject) ───────────────────── |
| 175 |
no test coverage detected