feat(M4): Reusable code abstractions and dead-code removal
This commit is contained in:
@@ -487,8 +487,12 @@ async function generateReviewerRuntimePi(repoRoot, writeRoot) {
|
||||
|
||||
/**
|
||||
* Clear generated content in a root, preserving:
|
||||
* - node_modules (installed by pnpm)
|
||||
* - node_modules (installed by pnpm) — at any depth
|
||||
* - .generated-manifest.json (will be rewritten after generation)
|
||||
*
|
||||
* Subdirectories are always recursed into before removal so that
|
||||
* node_modules trees nested at any depth (e.g. scripts/node_modules inside
|
||||
* atlassian or web-automation variants) are preserved.
|
||||
*/
|
||||
async function clearGeneratedRoot(rootDir) {
|
||||
let entries;
|
||||
@@ -501,7 +505,18 @@ async function clearGeneratedRoot(rootDir) {
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules") continue;
|
||||
if (entry.name === MANIFEST_FILENAME) continue;
|
||||
await rm(path.join(rootDir, entry.name), { recursive: true, force: true });
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
// Always recurse so node_modules at any depth is preserved.
|
||||
await clearGeneratedRoot(fullPath);
|
||||
// Remove the directory only if nothing protected remains inside it.
|
||||
const remaining = await readdir(fullPath).catch(() => []);
|
||||
if (remaining.length === 0) {
|
||||
await rm(fullPath, { force: true });
|
||||
}
|
||||
} else {
|
||||
await rm(fullPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,6 +536,7 @@ const SCRIPTS_SKILL_CONFIGS = {
|
||||
"check-install.js",
|
||||
"extract.js",
|
||||
"flow.ts",
|
||||
"lib",
|
||||
"scan-local-app.ts",
|
||||
"scrape.ts",
|
||||
"test-full.ts",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* safe-replace-dir.mjs — safely replace a directory within a safety-root boundary
|
||||
*
|
||||
* Exports:
|
||||
* safeReplaceDir(source, target, safetyRoot) → Promise<void>
|
||||
*
|
||||
* Usage:
|
||||
* import { safeReplaceDir } from "./lib/safe-replace-dir.mjs";
|
||||
* await safeReplaceDir("/path/to/source", "/safe/root/target", "/safe/root");
|
||||
*
|
||||
* Safety contract:
|
||||
* - `target` must be a strict descendant of `safetyRoot` (not equal to it).
|
||||
* - `target` must be a non-empty path.
|
||||
* - Throws with a descriptive message if either constraint is violated.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Removes any existing content at `target` (rm -rf equivalent).
|
||||
* - Creates `target` (and any missing parent directories).
|
||||
* - Copies all files from `source` into `target`.
|
||||
*/
|
||||
|
||||
import { cp, mkdir, realpath, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Safely replace `target` with the contents of `source`, enforcing that
|
||||
* `target` is a strict descendant of `safetyRoot`.
|
||||
*
|
||||
* @param {string} source - Directory to copy from.
|
||||
* @param {string} target - Directory to replace (will be removed then recreated).
|
||||
* @param {string} safetyRoot - Ancestor boundary; `target` must be inside this.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeReplaceDir(source, target, safetyRoot) {
|
||||
if (!target || target === "") {
|
||||
throw new Error(`Refusing to replace unsafe target: (empty string)`);
|
||||
}
|
||||
|
||||
const resolvedSafety = path.resolve(safetyRoot);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
|
||||
// Lexical check: target must be a strict descendant of safetyRoot.
|
||||
const relative = path.relative(resolvedSafety, resolvedTarget);
|
||||
if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
|
||||
throw new Error(`Refusing to replace target outside safety root: ${target}`);
|
||||
}
|
||||
|
||||
// Real-path check: resolve the deepest existing ancestor of target's parent
|
||||
// and verify it lies inside the real (symlink-resolved) safety root.
|
||||
// This blocks a symlinked parent directory from redirecting outside the boundary.
|
||||
const realSafety = await realpath(resolvedSafety);
|
||||
let checkPath = path.dirname(resolvedTarget);
|
||||
for (;;) {
|
||||
try {
|
||||
const realAncestor = await realpath(checkPath);
|
||||
const realRel = path.relative(realSafety, realAncestor);
|
||||
if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
|
||||
throw new Error(`Refusing to replace target outside safety root: ${target}`);
|
||||
}
|
||||
break; // validation passed
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") {
|
||||
const parent = path.dirname(checkPath);
|
||||
if (parent === checkPath) {
|
||||
throw new Error(`Refusing to replace target outside safety root: ${target}`, { cause: err });
|
||||
}
|
||||
checkPath = parent;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await rm(resolvedTarget, { recursive: true, force: true });
|
||||
await mkdir(resolvedTarget, { recursive: true });
|
||||
await cp(source, resolvedTarget, { recursive: true, force: true });
|
||||
}
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# safe-replace-dir.sh — safely replace a directory within a safety-root boundary
|
||||
#
|
||||
# Provides safe_replace_dir() for sourcing, or run standalone:
|
||||
# ./scripts/lib/safe-replace-dir.sh <source> <target> <safety_root>
|
||||
#
|
||||
# Safety contract (mirrors safe-replace-dir.mjs):
|
||||
# - <target> must be a non-empty path.
|
||||
# - <target> must be a strict descendant of <safety_root> (not equal to it).
|
||||
# - Prints an error and returns/exits 1 if either constraint is violated.
|
||||
#
|
||||
# Usage (sourced):
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/safe-replace-dir.sh"
|
||||
# safe_replace_dir "$source" "$target" "$safety_root"
|
||||
#
|
||||
# Usage (standalone):
|
||||
# ./scripts/lib/safe-replace-dir.sh /path/to/source /safe/root/target /safe/root
|
||||
|
||||
safe_replace_dir() {
|
||||
local source=$1
|
||||
local target=$2
|
||||
local safety_root=$3
|
||||
|
||||
if [[ -z "$target" ]]; then
|
||||
echo "safe_replace_dir: refusing to replace unsafe target: (empty string)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Resolve the real (symlink-resolved) safety root.
|
||||
local abs_safety
|
||||
abs_safety=$(cd "$safety_root" 2>/dev/null && pwd -P) || {
|
||||
echo "safe_replace_dir: safety root does not exist: $safety_root" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Build an absolute lexical path for target's parent directory.
|
||||
local target_parent target_base
|
||||
target_base=$(basename "$target")
|
||||
target_parent=$(dirname "$target")
|
||||
# Make target_parent absolute without relying on cd (target may not exist yet).
|
||||
if [[ "$target_parent" != /* ]]; then
|
||||
target_parent="${PWD}/${target_parent}"
|
||||
fi
|
||||
|
||||
# Walk up from target_parent to find the deepest existing directory,
|
||||
# accumulating the non-existing path suffix as we go.
|
||||
local suffix=""
|
||||
local walk="$target_parent"
|
||||
while [[ ! -d "$walk" ]]; do
|
||||
local component
|
||||
component=$(basename "$walk")
|
||||
if [[ -z "$suffix" ]]; then
|
||||
suffix="$component"
|
||||
else
|
||||
suffix="${component}/${suffix}"
|
||||
fi
|
||||
local next
|
||||
next=$(dirname "$walk")
|
||||
if [[ "$next" == "$walk" ]]; then
|
||||
echo "safe_replace_dir: could not find existing ancestor for: $target" >&2
|
||||
return 1
|
||||
fi
|
||||
walk="$next"
|
||||
done
|
||||
|
||||
# Resolve the real path of the existing ancestor (follows symlinks).
|
||||
local abs_parent
|
||||
abs_parent=$(cd "$walk" && pwd -P) || {
|
||||
echo "safe_replace_dir: could not resolve parent directory: $walk" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reconstruct the full absolute target path.
|
||||
local abs_target
|
||||
if [[ -n "$suffix" ]]; then
|
||||
abs_target="${abs_parent}/${suffix}/${target_base}"
|
||||
else
|
||||
abs_target="${abs_parent}/${target_base}"
|
||||
fi
|
||||
|
||||
# Check that abs_target is strictly inside abs_safety
|
||||
case "$abs_target" in
|
||||
"${abs_safety}/"*) ;;
|
||||
*)
|
||||
echo "safe_replace_dir: refusing to replace target outside safety root: $target" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$abs_target"
|
||||
mkdir -p "$abs_target"
|
||||
cp -R "${source}/." "$abs_target/"
|
||||
}
|
||||
|
||||
# Allow standalone use
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "Usage: $0 <source> <target> <safety_root>" >&2
|
||||
exit 1
|
||||
fi
|
||||
safe_replace_dir "$1" "$2" "$3" || exit 1
|
||||
fi
|
||||
@@ -532,6 +532,24 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
|
||||
return { operations, prompts, reportRows, assumeYes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the target of an operation (skill, helper, or superpowers).
|
||||
*
|
||||
* Validates that the target is within the skills root before removing.
|
||||
* Handles both regular directories and symbolic links.
|
||||
* Idempotent: succeeds even when the target does not exist.
|
||||
*
|
||||
* @param {object} op - Operation object with at least `target` and `skillsRoot`.
|
||||
* @returns {Promise<object>} Operation with `status: "ok"`.
|
||||
*/
|
||||
export async function removeTarget(op) {
|
||||
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
|
||||
const info = existsSync(op.target) ? await lstat(op.target) : null;
|
||||
if (info?.isSymbolicLink()) await unlink(op.target);
|
||||
else await rm(op.target, { recursive: true, force: true });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
|
||||
export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) {
|
||||
const resolvedRoot = path.resolve(skillsRoot);
|
||||
const resolvedTarget = path.resolve(target);
|
||||
@@ -599,8 +617,6 @@ export async function executeOperation(op) {
|
||||
if (op.kind === "package-skill") return { ...op, status: "included" };
|
||||
if (op.kind === "sync-pi-package") {
|
||||
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs).
|
||||
// The legacy sync-pi-package-skills.sh is retired in M3; it bypassed the
|
||||
// generator and copied skills/*/pi into pi-package directly, corrupting manifests.
|
||||
runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
@@ -610,33 +626,19 @@ export async function executeOperation(op) {
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "skill") {
|
||||
if (op.action === "remove") {
|
||||
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
|
||||
const info = existsSync(op.target) ? await lstat(op.target) : null;
|
||||
if (info?.isSymbolicLink()) await unlink(op.target);
|
||||
else await rm(op.target, { recursive: true, force: true });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.action === "remove") return removeTarget(op);
|
||||
await copyDirectoryReplacing(op.source, op.target);
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "helper") {
|
||||
if (op.action === "remove") {
|
||||
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
|
||||
const info = existsSync(op.target) ? await lstat(op.target) : null;
|
||||
if (info?.isSymbolicLink()) await unlink(op.target);
|
||||
else await rm(op.target, { recursive: true, force: true });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.action === "remove") return removeTarget(op);
|
||||
await installHelperAllowlist(op);
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "superpowers") {
|
||||
if (op.action === "remove") return removeTarget(op);
|
||||
await mkdir(path.dirname(op.target), { recursive: true });
|
||||
if (op.action === "remove") {
|
||||
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
|
||||
await rm(op.target, { recursive: true, force: true });
|
||||
} else if (op.mode === "copy") {
|
||||
if (op.mode === "copy") {
|
||||
await copyDirectoryReplacing(op.source, op.target);
|
||||
} else {
|
||||
await rm(op.target, { recursive: true, force: true });
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
TARGET_ROOT="${ROOT_DIR}/pi-package/skills"
|
||||
SKILL_FAMILIES=(
|
||||
"atlassian"
|
||||
"create-plan"
|
||||
"do-task"
|
||||
"implement-plan"
|
||||
"web-automation"
|
||||
)
|
||||
|
||||
extract_skill_name() {
|
||||
local skill_md=$1
|
||||
awk '/^name:/ { print $2; exit }' "$skill_md"
|
||||
}
|
||||
|
||||
replace_dir() {
|
||||
local source=$1
|
||||
local target=$2
|
||||
|
||||
if [[ -z "$target" || "$target" == "/" || "$target" == "." || "$target" == ".." ]]; then
|
||||
echo "Refusing to sync into unsafe target: $target" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$target" in
|
||||
"${ROOT_DIR}"/*) ;;
|
||||
*)
|
||||
echo "Refusing to remove target outside repo root: $target" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$target"
|
||||
mkdir -p "$target"
|
||||
cp -R "${source}/." "$target/"
|
||||
}
|
||||
|
||||
rm -rf "$TARGET_ROOT"
|
||||
mkdir -p "$TARGET_ROOT"
|
||||
|
||||
for family in "${SKILL_FAMILIES[@]}"; do
|
||||
source_dir="${ROOT_DIR}/skills/${family}/pi"
|
||||
skill_md="${source_dir}/SKILL.md"
|
||||
|
||||
if [[ ! -f "$skill_md" ]]; then
|
||||
echo "Missing source SKILL.md: $skill_md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
skill_name=$(extract_skill_name "$skill_md")
|
||||
if [[ -z "$skill_name" ]]; then
|
||||
echo "Could not derive skill name from $skill_md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
replace_dir "$source_dir" "${TARGET_ROOT}/${skill_name}"
|
||||
done
|
||||
|
||||
echo "Synced pi package skill mirror into ${TARGET_ROOT}."
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { safeReplaceDir } from "../lib/safe-replace-dir.mjs";
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
|
||||
test("safeReplaceDir copies source content into the target", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-copy-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
const target = path.join(safetyRoot, "target");
|
||||
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, "file.txt"), "hello");
|
||||
await mkdir(safetyRoot, { recursive: true });
|
||||
|
||||
await safeReplaceDir(source, target, safetyRoot);
|
||||
|
||||
const content = await readFile(path.join(target, "file.txt"), "utf8");
|
||||
assert.equal(content, "hello");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("safeReplaceDir removes existing content before replacing", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-stale-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
const target = path.join(safetyRoot, "target");
|
||||
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(path.join(target, "old.txt"), "stale");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, "new.txt"), "fresh");
|
||||
|
||||
await safeReplaceDir(source, target, safetyRoot);
|
||||
|
||||
const files = await readdir(target);
|
||||
assert.deepEqual(files.sort(), ["new.txt"]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("safeReplaceDir creates target parent directories if they do not exist", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-mkdir-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
const target = path.join(safetyRoot, "nested", "target");
|
||||
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, "data.txt"), "data");
|
||||
await mkdir(safetyRoot, { recursive: true });
|
||||
// nested parent does NOT exist yet
|
||||
|
||||
await safeReplaceDir(source, target, safetyRoot);
|
||||
|
||||
const content = await readFile(path.join(target, "data.txt"), "utf8");
|
||||
assert.equal(content, "data");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("safeReplaceDir creates deeply nested parent directories (2+ levels missing)", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-deep-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
// two parent levels (a/b) do NOT exist under safetyRoot
|
||||
const target = path.join(safetyRoot, "a", "b", "target");
|
||||
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(path.join(source, "deep.txt"), "deep");
|
||||
await mkdir(safetyRoot, { recursive: true });
|
||||
// a/ and a/b/ intentionally NOT created
|
||||
|
||||
await safeReplaceDir(source, target, safetyRoot);
|
||||
|
||||
const content = await readFile(path.join(target, "deep.txt"), "utf8");
|
||||
assert.equal(content, "deep");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Safety checks ─────────────────────────────────────────────────────────
|
||||
|
||||
test("safeReplaceDir refuses when target is outside the safety root", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-outside-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
const outside = path.join(dir, "outside");
|
||||
|
||||
await mkdir(source, { recursive: true });
|
||||
await mkdir(safetyRoot, { recursive: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => safeReplaceDir(source, outside, safetyRoot),
|
||||
/outside safety root/,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("safeReplaceDir refuses when target equals the safety root", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-same-"));
|
||||
try {
|
||||
const safetyRoot = path.join(dir, "root");
|
||||
const source = path.join(dir, "source");
|
||||
|
||||
await mkdir(source, { recursive: true });
|
||||
await mkdir(safetyRoot, { recursive: true });
|
||||
|
||||
await assert.rejects(
|
||||
() => safeReplaceDir(source, safetyRoot, safetyRoot),
|
||||
/outside safety root/,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("safeReplaceDir refuses an empty target string", async () => {
|
||||
await assert.rejects(
|
||||
() => safeReplaceDir("/any", "", "/root"),
|
||||
/unsafe target/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile, rm, lstat, symlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { removeTarget } from "../lib/skill-manager-core.mjs";
|
||||
|
||||
// ── Happy path: remove existing directory ─────────────────────────────────
|
||||
|
||||
test("removeTarget removes an installed skill directory", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-dir-"));
|
||||
try {
|
||||
const skillsRoot = path.join(dir, "skills");
|
||||
const target = path.join(skillsRoot, "create-plan");
|
||||
await mkdir(target, { recursive: true });
|
||||
await writeFile(path.join(target, "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||
|
||||
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||
const result = await removeTarget(op);
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
let exists = true;
|
||||
try {
|
||||
await lstat(target);
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
assert.equal(exists, false, "target directory should be gone");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Happy path: remove symbolic link ─────────────────────────────────────
|
||||
|
||||
test("removeTarget removes a symlink without following it", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-sym-"));
|
||||
try {
|
||||
const skillsRoot = path.join(dir, "skills");
|
||||
const realDir = path.join(dir, "real-skill");
|
||||
const target = path.join(skillsRoot, "create-plan");
|
||||
|
||||
await mkdir(skillsRoot, { recursive: true });
|
||||
await mkdir(realDir, { recursive: true });
|
||||
await writeFile(path.join(realDir, "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||
await symlink(realDir, target, "dir");
|
||||
|
||||
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||
const result = await removeTarget(op);
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
|
||||
// symlink itself should be gone
|
||||
let symlinkExists = true;
|
||||
try {
|
||||
await lstat(target);
|
||||
} catch {
|
||||
symlinkExists = false;
|
||||
}
|
||||
assert.equal(symlinkExists, false, "symlink should be removed");
|
||||
|
||||
// real directory should still exist
|
||||
const realStat = await lstat(realDir);
|
||||
assert.ok(realStat.isDirectory(), "real directory must not be touched");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Missing skill (partial state): target does not exist ─────────────────
|
||||
|
||||
test("removeTarget succeeds when target does not exist (idempotent)", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-missing-"));
|
||||
try {
|
||||
const skillsRoot = path.join(dir, "skills");
|
||||
const target = path.join(skillsRoot, "create-plan");
|
||||
await mkdir(skillsRoot, { recursive: true });
|
||||
// target intentionally NOT created
|
||||
|
||||
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||
const result = await removeTarget(op);
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Partial state: directory exists but is empty ─────────────────────────
|
||||
|
||||
test("removeTarget removes an empty skill directory", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-empty-"));
|
||||
try {
|
||||
const skillsRoot = path.join(dir, "skills");
|
||||
const target = path.join(skillsRoot, "create-plan");
|
||||
await mkdir(target, { recursive: true });
|
||||
// directory exists but has no SKILL.md (partial install state)
|
||||
|
||||
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||
const result = await removeTarget(op);
|
||||
|
||||
assert.equal(result.status, "ok");
|
||||
let exists = true;
|
||||
try {
|
||||
await lstat(target);
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
assert.equal(exists, false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Safety: refuses to remove path outside skills root ────────────────────
|
||||
|
||||
test("removeTarget refuses to remove a path outside the skills root", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-outside-"));
|
||||
try {
|
||||
const skillsRoot = path.join(dir, "skills");
|
||||
const outsideTarget = path.join(dir, "outside");
|
||||
await mkdir(skillsRoot, { recursive: true });
|
||||
await mkdir(outsideTarget, { recursive: true });
|
||||
|
||||
const op = {
|
||||
kind: "skill",
|
||||
action: "remove",
|
||||
target: outsideTarget,
|
||||
skillsRoot,
|
||||
};
|
||||
|
||||
await assert.rejects(() => removeTarget(op), /outside skills root/);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user