Perform code optimization and document cleanup (#1)
## Summary - add repository-wide quality tooling and verification scaffolding, including CI workflows, pnpm workspace setup, ESLint/Prettier/markdown checks, and generated-output verification helpers - reorganize skill sources and generation flow by introducing canonical `_source` variants, generator/manifests, reusable helper abstractions, and shared web-automation/browser utilities - clean up and expand documentation so the root README flows into docs and skill docs, with clearer development, reviewer, installer, and workflow guidance ## Notable changes - docs flow and consistency cleanup across `README.md`, `docs/README.md`, and related docs - new scripts for `check`, docs verification, generated-file verification, shell portability, and safe directory replacement - refactors in Atlassian and web-automation skill runtimes to reduce duplication and centralize reusable code - changelog, development documentation, and CI surface updates ## Test Plan - [ ] `pnpm run check` - [ ] review generated/manifests and skill sync outputs - [ ] smoke-check docs flow from `README.md` to `docs/README.md` to skill docs ## Notes - this branch currently includes tracked `skills/web-automation/shared/node_modules` content that should be reviewed carefully as potentially noisy/accidental committed artifacts Co-authored-by: Stefano Fiorini <stefano.fiorini@firsthorizon.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* assert-no-pnpm-version-pin.mjs — CI regression guard (followup: fix pnpm version conflict)
|
||||
*
|
||||
* Ensures no .github/workflows/*.yml file pins pnpm via a `version:` key
|
||||
* under a `pnpm/action-setup` step. The canonical version source is
|
||||
* `package.json#packageManager`, which carries an exact version + integrity
|
||||
* hash. Duplicating the version in the workflow creates a conflict that
|
||||
* pnpm/action-setup@v4 treats as an error.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lib/assert-no-pnpm-version-pin.mjs
|
||||
* pnpm run verify:ci
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — no version pin found
|
||||
* 1 — one or more violations found (details on stderr)
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows");
|
||||
|
||||
let violations = 0;
|
||||
|
||||
// Read workflow files — silently pass if directory doesn't exist
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(WORKFLOWS_DIR).filter(
|
||||
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
|
||||
);
|
||||
} catch {
|
||||
process.stdout.write("OK: no .github/workflows directory found; nothing to check.\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(WORKFLOWS_DIR, file);
|
||||
const content = readFileSync(fullPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Locate a step that uses pnpm/action-setup
|
||||
if (!lines[i].includes("pnpm/action-setup")) continue;
|
||||
|
||||
// Look ahead up to 10 lines for a `version:` key in the same step
|
||||
const end = Math.min(i + 10, lines.length);
|
||||
for (let j = i + 1; j < end; j++) {
|
||||
const ahead = lines[j];
|
||||
// A new step begins at a `- name:` or `- uses:` list item → stop
|
||||
if (/^\s*-\s+(name|uses)\s*:/.test(ahead)) break;
|
||||
// `version:` key found inside this step → violation
|
||||
if (/^\s+version\s*:/.test(ahead)) {
|
||||
process.stderr.write(
|
||||
`ERROR: ${file}:${j + 1}: 'version:' key found under pnpm/action-setup step.\n` +
|
||||
` Remove 'with.version'; let package.json#packageManager be the single\n` +
|
||||
` source of truth for the pnpm version (exact version + integrity hash).\n\n`
|
||||
);
|
||||
violations++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations > 0) {
|
||||
process.stderr.write(`${violations} violation(s) found.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write("OK: no pnpm version pins found in workflow files.\n");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# portable.sh — POSIX-safe helper functions for BSD/GNU shell portability
|
||||
#
|
||||
# Source this file in scripts that need cross-platform variants of:
|
||||
# - stat(1) — BSD uses -f, GNU uses -c
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/portable.sh"
|
||||
# portable_stat_perms "$file" # -> octal permissions string, e.g. "755"
|
||||
#
|
||||
# Supported platforms:
|
||||
# - macOS (BSD stat)
|
||||
# - Linux/Ubuntu (GNU stat)
|
||||
|
||||
# portable_stat_perms <path>
|
||||
# Outputs the file's permission bits as an octal string (e.g. "755").
|
||||
# Exits non-zero if stat fails.
|
||||
portable_stat_perms() {
|
||||
local path="$1"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
stat -f '%Lp' "$path"
|
||||
;;
|
||||
*)
|
||||
stat -c '%a' "$path"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-check.mjs — aggregate quality check runner (M1, S-106)
|
||||
*
|
||||
* Runs every quality gate in sequence and reports a summary.
|
||||
* All steps run even if earlier steps fail, so you get a complete
|
||||
* picture of the repository health in one pass.
|
||||
*
|
||||
* Transitional contract (M1):
|
||||
* This script may exit non-zero. Pre-existing failures are recorded in
|
||||
* docs/CLEANUP-BASELINE.md. Only issues introduced by new changes (not
|
||||
* listed in the baseline) constitute a regression.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lib/run-check.mjs # full check
|
||||
* pnpm run check # same, via pnpm
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
// ── Steps ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEPS = [
|
||||
{ label: "lint", cmd: "pnpm", args: ["run", "lint"] },
|
||||
{ label: "typecheck", cmd: "pnpm", args: ["run", "typecheck"] },
|
||||
{ label: "test", cmd: "pnpm", args: ["run", "test"] },
|
||||
{ label: "verify:pi", cmd: "pnpm", args: ["run", "verify:pi"] },
|
||||
{ label: "verify:reviewers", cmd: "pnpm", args: ["run", "verify:reviewers"] },
|
||||
{ label: "verify:docs", cmd: "pnpm", args: ["run", "verify:docs"] },
|
||||
{ label: "verify:generated", cmd: "pnpm", args: ["run", "verify:generated"] },
|
||||
{ label: "verify:ci", cmd: "pnpm", args: ["run", "verify:ci"] },
|
||||
];
|
||||
|
||||
// ── Runner ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const RESET = "\x1b[0m";
|
||||
const GREEN = "\x1b[32m";
|
||||
const RED = "\x1b[31m";
|
||||
const BOLD = "\x1b[1m";
|
||||
const DIM = "\x1b[2m";
|
||||
|
||||
function colorize(color, text) {
|
||||
// Respect NO_COLOR env variable
|
||||
if (process.env.NO_COLOR || process.env.CI) return text;
|
||||
return `${color}${text}${RESET}`;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const step of STEPS) {
|
||||
process.stdout.write(`\n${colorize(BOLD, `=== ${step.label} ===`)}\n`);
|
||||
const result = spawnSync(step.cmd, step.args, {
|
||||
cwd: REPO_ROOT,
|
||||
stdio: "inherit",
|
||||
encoding: "utf8",
|
||||
shell: false,
|
||||
});
|
||||
const ok = result.status === 0 && !result.error;
|
||||
results.push({ label: step.label, ok, status: result.status ?? -1 });
|
||||
}
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
|
||||
process.stdout.write(`\n${colorize(BOLD, "=== check summary ===")}\n`);
|
||||
|
||||
const failures = [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
process.stdout.write(
|
||||
` ${colorize(GREEN, "PASS")} ${r.label}\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
` ${colorize(RED, "FAIL")} ${r.label} ${colorize(DIM, `(exit ${r.status})`)} — see docs/CLEANUP-BASELINE.md if pre-existing\n`
|
||||
);
|
||||
failures.push(r.label);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write("\n");
|
||||
|
||||
if (failures.length === 0) {
|
||||
process.stdout.write(colorize(GREEN, "All checks passed.\n"));
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
colorize(
|
||||
RED,
|
||||
`${failures.length} check(s) failed: ${failures.join(", ")}\n`
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-link-check.mjs — markdown link-check runner (M1, S-104)
|
||||
*
|
||||
* Runs markdown-link-check across README.md, docs/, and every SKILL.md
|
||||
* (excluding node_modules and generated agent-variant directories).
|
||||
*
|
||||
* Modes:
|
||||
* --offline (default) — checks only repo-relative links and #anchor links.
|
||||
* All http/https links are ignored. Safe for CI and local dev
|
||||
* without network access.
|
||||
* --online — checks all links, including external URLs, with timeouts
|
||||
* and retries as configured in markdown-link-check.online.json.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all checked links are alive (or ignored in offline mode)
|
||||
* 1 — one or more broken links found
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync, existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
// ── CLI arguments ──────────────────────────────────────────────────────────
|
||||
|
||||
const online = process.argv.includes("--online");
|
||||
const configFile = online
|
||||
? path.join(REPO_ROOT, "markdown-link-check.online.json")
|
||||
: path.join(REPO_ROOT, "markdown-link-check.json");
|
||||
|
||||
// ── File discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
const SKIP_PATHS = new Set([
|
||||
"skills/atlassian/codex",
|
||||
"skills/atlassian/claude-code",
|
||||
"skills/atlassian/cursor",
|
||||
"skills/atlassian/opencode",
|
||||
"skills/atlassian/pi",
|
||||
"skills/web-automation/claude-code",
|
||||
"skills/web-automation/cursor",
|
||||
"skills/web-automation/opencode",
|
||||
"skills/web-automation/pi",
|
||||
"pi-package",
|
||||
]);
|
||||
|
||||
// Also skip any _source/ subdirectory within skills — canonical source files
|
||||
// use relative paths calibrated to the generated location (one level shallower).
|
||||
const SKIP_SEGMENT = "_source";
|
||||
|
||||
function shouldSkip(absPath) {
|
||||
const rel = path.relative(REPO_ROOT, absPath);
|
||||
for (const skip of SKIP_PATHS) {
|
||||
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
|
||||
}
|
||||
const parts = rel.split(path.sep);
|
||||
if (parts.includes("node_modules")) return true;
|
||||
// Skip canonical _source/ directories (links calibrated to generated location)
|
||||
if (parts.includes(SKIP_SEGMENT)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectMarkdownFiles(dir) {
|
||||
const found = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return found;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (shouldSkip(full)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...collectMarkdownFiles(full));
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith(".md") || entry.name === "SKILL.md")
|
||||
) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── Collect target files ───────────────────────────────────────────────────
|
||||
|
||||
const files = [
|
||||
path.join(REPO_ROOT, "README.md"),
|
||||
...collectMarkdownFiles(path.join(REPO_ROOT, "docs")),
|
||||
...collectMarkdownFiles(path.join(REPO_ROOT, "skills")),
|
||||
].filter(existsSync);
|
||||
|
||||
// De-duplicate (README.md could appear twice)
|
||||
const uniqueFiles = [...new Set(files)];
|
||||
|
||||
if (uniqueFiles.length === 0) {
|
||||
console.log("link-check: no markdown files found — nothing to check.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`link-check: checking ${uniqueFiles.length} file(s) ` +
|
||||
`[mode: ${online ? "online" : "offline"}]…`
|
||||
);
|
||||
|
||||
// ── Run markdown-link-check ────────────────────────────────────────────────
|
||||
|
||||
const mlcBin = path.join(
|
||||
REPO_ROOT,
|
||||
"node_modules",
|
||||
".bin",
|
||||
"markdown-link-check"
|
||||
);
|
||||
|
||||
let failures = 0;
|
||||
for (const file of uniqueFiles.sort()) {
|
||||
const result = spawnSync(
|
||||
mlcBin,
|
||||
["--config", configFile, "--quiet", file],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||
);
|
||||
const output = (result.stdout + result.stderr).trim();
|
||||
const rel = path.relative(REPO_ROOT, file);
|
||||
if (result.status !== 0) {
|
||||
failures += 1;
|
||||
console.error(`\n--- ${rel} ---`);
|
||||
if (output) console.error(output);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(
|
||||
`\nlink-check: ${failures} file(s) have broken links. ` +
|
||||
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("link-check: all links OK.");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* run-shellcheck.mjs — shell script quality wrapper (M1, S-105)
|
||||
*
|
||||
* Discovers *.sh files under scripts/ and skills/ (excluding node_modules
|
||||
* and generated agent-variant directories), then runs shellcheck on each.
|
||||
*
|
||||
* shellcheck is a REQUIRED prerequisite. The script fails immediately when
|
||||
* shellcheck is not found on PATH. Install it with:
|
||||
* macOS: brew install shellcheck
|
||||
* Debian: apt-get install shellcheck
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — all files passed shellcheck
|
||||
* 1 — one or more files have shellcheck findings
|
||||
* 2 — shellcheck is missing from PATH
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
// ── Prerequisites check ────────────────────────────────────────────────────
|
||||
|
||||
function checkShellcheck() {
|
||||
const result = spawnSync("shellcheck", ["--version"], { encoding: "utf8" });
|
||||
if (result.error || result.status === null || result.status > 1) {
|
||||
console.error(
|
||||
[
|
||||
"ERROR: shellcheck is not available on PATH.",
|
||||
"",
|
||||
"shellcheck is a required prerequisite for this repository.",
|
||||
"Install it before running lint:",
|
||||
"",
|
||||
" macOS: brew install shellcheck",
|
||||
" Debian/Ubuntu: sudo apt-get install shellcheck",
|
||||
" Other: https://github.com/koalaman/shellcheck#installing",
|
||||
].join("\n")
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ── File discovery ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Directories to scan for *.sh files.
|
||||
* Relative paths from REPO_ROOT.
|
||||
*/
|
||||
const SCAN_DIRS = ["scripts", "skills"];
|
||||
|
||||
/**
|
||||
* Path segments that indicate a directory should be skipped entirely.
|
||||
* Matches generated agent-variant bundles and npm/pnpm install artifacts.
|
||||
*/
|
||||
const SKIP_SEGMENTS = new Set([
|
||||
"node_modules",
|
||||
// Generated agent-variant directories (excluded per M1 workspace policy)
|
||||
// skills/<skill>/codex — except web-automation/codex which is canonical
|
||||
// We skip all codex variants to be safe; shellcheck only cares about .sh files
|
||||
// and all variants share the same scripts anyway.
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exact relative paths (from REPO_ROOT) to skip, matched after normalisation.
|
||||
* These are the generated variants that duplicate canonical source.
|
||||
*/
|
||||
const SKIP_PATHS = new Set([
|
||||
"skills/atlassian/codex",
|
||||
"skills/atlassian/claude-code",
|
||||
"skills/atlassian/cursor",
|
||||
"skills/atlassian/opencode",
|
||||
"skills/atlassian/pi",
|
||||
"skills/web-automation/claude-code",
|
||||
"skills/web-automation/cursor",
|
||||
"skills/web-automation/opencode",
|
||||
"skills/web-automation/pi",
|
||||
"pi-package",
|
||||
]);
|
||||
|
||||
function shouldSkip(absPath) {
|
||||
const rel = path.relative(REPO_ROOT, absPath);
|
||||
// Check exact-prefix matches (directory and its children)
|
||||
for (const skip of SKIP_PATHS) {
|
||||
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
|
||||
}
|
||||
// Check path-segment matches (e.g. node_modules anywhere in path)
|
||||
for (const seg of rel.split(path.sep)) {
|
||||
if (SKIP_SEGMENTS.has(seg)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectShellFiles(dir) {
|
||||
const found = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return found; // directory does not exist — skip silently
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (shouldSkip(full)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
found.push(...collectShellFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".sh")) {
|
||||
found.push(full);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
checkShellcheck();
|
||||
|
||||
const files = SCAN_DIRS.flatMap((d) =>
|
||||
collectShellFiles(path.join(REPO_ROOT, d))
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("shellcheck: no .sh files found — nothing to check.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`shellcheck: scanning ${files.length} file(s)…`);
|
||||
|
||||
let failures = 0;
|
||||
for (const file of files.sort()) {
|
||||
const result = spawnSync("shellcheck", ["-x", "--source-path=SCRIPTDIR", file], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const output = (result.stdout + result.stderr).trim();
|
||||
const rel = path.relative(REPO_ROOT, file);
|
||||
if (result.status !== 0) {
|
||||
failures += 1;
|
||||
// Print findings prefixed with the relative path so output is reproducible
|
||||
// regardless of cwd.
|
||||
console.error(`\n--- ${rel} ---`);
|
||||
if (output) console.error(output);
|
||||
} else {
|
||||
// Quiet on success — only show problems
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(
|
||||
`\nshellcheck: ${failures} file(s) have findings. ` +
|
||||
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("shellcheck: all files passed.");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -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
|
||||
@@ -279,7 +279,7 @@ async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
|
||||
|
||||
async function findCursorSuperpowersPluginRoots(homeDir) {
|
||||
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
|
||||
let entries = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(pluginRoot, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
@@ -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);
|
||||
@@ -598,7 +616,8 @@ export async function executeOperation(op) {
|
||||
if (op.action === "unsupported" || op.status === "skipped") return { ...op, status: "skipped" };
|
||||
if (op.kind === "package-skill") return { ...op, status: "included" };
|
||||
if (op.kind === "sync-pi-package") {
|
||||
runCommand(path.join(op.repoRoot, "scripts", "sync-pi-package-skills.sh"), [], { cwd: op.repoRoot });
|
||||
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs).
|
||||
runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot });
|
||||
return { ...op, status: "ok" };
|
||||
}
|
||||
if (op.kind === "pi-package") {
|
||||
@@ -607,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 });
|
||||
|
||||
Reference in New Issue
Block a user