mirror of
https://github.com/warpdotdev/common-skills.git
synced 2026-09-14 19:03:05 +08:00
4b3668c60a
Review of the previous revision found that pruning by checking whether a copied path existed under the temp dir failed open: if skills add exited 0 without actually copying anything (e.g. a broken CLI run), every warpdotdev/common-skills entry looked stale and got deleted, while the one entry from another source survived. That result is worse than the original bug and would auto-merge into warp and warp-server. Restructure the fix: regenerate the warpdotdev/common-skills portion of the lock from scratch in a clean, unseeded temp directory instead of seeding it with the existing lock and pruning what didn't reappear. skills add can then only ever emit entries for skills it actually finds, so nothing stale can survive the merge in the first place. Entries from any other source are carried over from the existing lock unmodified. As a backstop, fail closed with a non-zero exit and leave the existing lock untouched if regenerating produces no warpdotdev/common-skills entries at all while the existing lock has some - that is a sign the run didn't discover or copy anything, not that every skill vanished upstream. Add scripts/tests/update_common_skills_lock_test.sh, a self-contained regression suite that runs the real script against a fake npx (scripts/tests/fake-bin/npx) modeling `skills add ... --copy` deterministically from a local fixture directory, with no network access. Covers a rename, a plain deletion, foreign-source preservation, idempotence, and the fail-closed guard. Wire it into a new CI workflow that runs on push to main and on every pull request. Co-Authored-By: Warp Agent <agent@warp.dev>
165 lines
4.8 KiB
Bash
Executable File
165 lines
4.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -eo pipefail
|
|
|
|
REPO_ROOT="$(pwd)"
|
|
LOCK_FILE=""
|
|
TEMP_DIR=""
|
|
SKILLS_CLI_VERSION="1.5.6"
|
|
COMMON_SKILLS_SOURCE="warpdotdev/common-skills"
|
|
SKILLS_CLI_AGENT="warp"
|
|
COMMON_SKILL_SELECTOR="*"
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: scripts/update_common_skills_lock [options]
|
|
|
|
Regenerate an existing skills-lock.json from warpdotdev/common-skills.
|
|
|
|
Options:
|
|
--repo-root <path> Repository containing skills-lock.json.
|
|
-h, --help Show this help message.
|
|
EOF
|
|
}
|
|
|
|
cleanup() {
|
|
if [[ -n "${TEMP_DIR}" ]]; then
|
|
rm -rf "${TEMP_DIR}"
|
|
fi
|
|
}
|
|
|
|
validate_required_commands() {
|
|
local missing=0
|
|
local tool=""
|
|
|
|
for tool in "$@"; do
|
|
if ! command -v "${tool}" >/dev/null 2>&1; then
|
|
echo "error: required command '${tool}' is not installed or not on PATH." >&2
|
|
missing=1
|
|
fi
|
|
done
|
|
|
|
if [[ "${missing}" -eq 1 ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--repo-root)
|
|
if [[ $# -lt 2 ]]; then
|
|
echo "error: --repo-root requires a path." >&2
|
|
usage >&2
|
|
exit 1
|
|
fi
|
|
REPO_ROOT="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "error: unrecognized argument: $1" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
validate_required_commands git node npm npx
|
|
|
|
if ! REPO_ROOT="$(cd "${REPO_ROOT}" && pwd)"; then
|
|
echo "error: repository root does not exist: ${REPO_ROOT}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
LOCK_FILE="${REPO_ROOT}/skills-lock.json"
|
|
if [[ ! -f "${LOCK_FILE}" ]]; then
|
|
echo "error: missing skills lock file: ${LOCK_FILE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
trap cleanup EXIT
|
|
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/common-skills-lock.XXXXXX")"
|
|
CANDIDATE_LOCK="${TEMP_DIR}/skills-lock.json"
|
|
git -C "${TEMP_DIR}" init --quiet
|
|
|
|
# Regenerate in a clean directory: do not seed it with the existing lock.
|
|
# skills add merges into whatever lock it finds and never removes an entry
|
|
# whose skill is no longer present in ${COMMON_SKILLS_SOURCE}, so seeding
|
|
# from the existing lock is what let a rename or deletion upstream leave a
|
|
# stale entry behind, pointing at a skillPath that no longer exists. A
|
|
# clean directory has nothing to merge stale entries from: skills add can
|
|
# only ever emit entries for skills it actually finds in the source.
|
|
if ! (
|
|
cd "${TEMP_DIR}"
|
|
npx --yes "skills@${SKILLS_CLI_VERSION}" add "${COMMON_SKILLS_SOURCE}" -a "${SKILLS_CLI_AGENT}" -s "${COMMON_SKILL_SELECTOR}" -y --copy
|
|
) < /dev/null; then
|
|
echo "error: failed to generate an updated skills-lock.json from ${COMMON_SKILLS_SOURCE}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "${CANDIDATE_LOCK}" ]]; then
|
|
echo "error: common skills update did not produce a candidate skills-lock.json." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Merge the freshly regenerated ${COMMON_SKILLS_SOURCE} entries with any
|
|
# entries from other sources already in ${LOCK_FILE}; those are never
|
|
# touched by the command above and are carried over unmodified. Fail
|
|
# closed, rather than silently pruning, if the source produced no entries
|
|
# at all while the existing lock has some: that is a sign this run didn't
|
|
# actually discover or copy anything, not that every skill vanished
|
|
# upstream, and writing that result would delete every locked skill.
|
|
if ! node - "${LOCK_FILE}" "${CANDIDATE_LOCK}" "${COMMON_SKILLS_SOURCE}" <<'NODE'
|
|
const fs = require("fs");
|
|
|
|
const existingLockFile = process.argv[2];
|
|
const candidateLockFile = process.argv[3];
|
|
const source = process.argv[4];
|
|
|
|
const existing = JSON.parse(fs.readFileSync(existingLockFile, "utf8"));
|
|
const candidate = JSON.parse(fs.readFileSync(candidateLockFile, "utf8"));
|
|
const existingSkills = existing.skills || {};
|
|
const candidateSkills = candidate.skills || {};
|
|
|
|
const existingSourceCount = Object.values(existingSkills).filter((entry) => entry.source === source).length;
|
|
const freshSourceEntries = Object.entries(candidateSkills).filter(([, entry]) => entry.source === source);
|
|
|
|
if (existingSourceCount > 0 && freshSourceEntries.length === 0) {
|
|
console.error(`error: regenerating from ${source} produced no entries, but the existing lock has ${existingSourceCount}; refusing to prune all of them.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const merged = {};
|
|
for (const [name, entry] of Object.entries(existingSkills)) {
|
|
if (entry.source !== source) {
|
|
merged[name] = entry;
|
|
}
|
|
}
|
|
for (const [name, entry] of freshSourceEntries) {
|
|
merged[name] = entry;
|
|
}
|
|
|
|
const sortedSkills = {};
|
|
for (const name of Object.keys(merged).sort()) {
|
|
sortedSkills[name] = merged[name];
|
|
}
|
|
|
|
const result = { ...candidate, skills: sortedSkills };
|
|
fs.writeFileSync(candidateLockFile, JSON.stringify(result, null, 2) + "\n");
|
|
NODE
|
|
then
|
|
echo "error: failed to merge the regenerated ${COMMON_SKILLS_SOURCE} entries into skills-lock.json." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if cmp -s "${LOCK_FILE}" "${CANDIDATE_LOCK}"; then
|
|
echo "skills-lock.json is already up to date."
|
|
exit 0
|
|
fi
|
|
|
|
cp "${CANDIDATE_LOCK}" "${LOCK_FILE}"
|
|
echo "Updated ${LOCK_FILE}."
|