Files
warpdotdev__common-skills/scripts/update_common_skills_lock
Oz 4b3668c60a Fail closed on an incomplete regen; add regression tests
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>
2026-08-13 15:45:21 +00:00

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}."