fix(dsh): explicit-стейджинг вне клона, вердикты --check/--skills совпадают, fetch от DSH_SUPERPOWERS_REPO (T3.1-T3.3, #69)

This commit is contained in:
Andrey Epifancev 2026-10-25 00:18:59 +04:00
parent 335e966cfc
commit 914a54c3e4
4 changed files with 149 additions and 34 deletions

View File

@ -218,11 +218,16 @@ Install-time source resolution is separate and stricter (change
`$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at
`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
`$DSH_SUPERPOWERS_REPO`) → the legacy cache → `~/.claude/skills`. Explicit
sources are synced in place with no network; the pinned clone is fetched or
updated to the ref and reused offline only when already on it. An invalid
explicit source is a hard `FAIL`; a machine offline without a clone is a
`FAIL` with the clone hint, falling back to legacy only when a legacy source
exists.
sources are synced in place with no network and must be a checkout root or
its `skills/` directory — a bare skills directory outside that layout is
invalid, and `--check` and `--skills` fail the same way (narrower than change
`dsh-install-source` #49; the spec delta records the MODIFIED). Explicit
syncs stage into `~/.dsh/.skills.synced` beside the clone dir, which stays
reserved for the pin; an existing clone is updated by fetching
`$DSH_SUPERPOWERS_REPO` (not its origin) and reused offline only when already
on the ref. An invalid explicit source is a hard `FAIL`; a machine offline
without a clone is a `FAIL` with the clone hint, falling back to legacy only
when a legacy source exists.
### OpenSpec skills

View File

@ -60,7 +60,11 @@ bash <harness>/dsh/install.sh --openspec
`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
`$DSH_SUPERPOWERS_REPO`; offline it is reused only when already on the ref) →
the Claude Code plugin cache (legacy, warns). Explicit sources are synced in
place with no network. Every sync runs `dsh/sync/sync-from-upstream.mjs` (DSH
place with no network; they must be a checkout root or its `skills/`
directory — a bare skills directory outside that layout is a hard `FAIL` in
both `--skills` and `--check`. Explicit syncs stage into
`~/.dsh/.skills.synced`, never into `~/.dsh/superpowers` (reserved for the
pinned clone). Every sync runs `dsh/sync/sync-from-upstream.mjs` (DSH
adaptations) and `dsh/sync/verify-skills.mjs`; a tree that fails verification
or holds zero skills is never installed. Legacy sources are copied raw. A
clean HOME with no source and no clone fails with the clone command instead of

View File

@ -32,8 +32,10 @@ DO_OPENSPEC=0
SUPERPOWERS_FROM=""
SUPERPOWERS_FROM_SET=0
CLONE_HINT="git clone --depth 1 --branch $DSH_SUPERPOWERS_REF $DSH_SUPERPOWERS_REPO $DSH_HOME/superpowers"
# Every sync writes here; the copy loop and --check read it back.
SUPERPOWERS_OUT="$DSH_HOME/superpowers/skills.synced"
# Synced staging trees, per source kind: $DSH_HOME/superpowers is reserved for
# the pinned clone (T3.1), so explicit sources stage beside it.
PINNED_OUT="$DSH_HOME/superpowers/skills.synced"
EXPLICIT_OUT="$DSH_HOME/.skills.synced"
usage() {
cat <<EOF
@ -132,21 +134,22 @@ checkout_root() {
}
# Resolve the superpowers install source without touching the network.
# Prints "<kind>\t<checkout-root-or--->\t<skills-dir>". Returns 2 when an
# explicitly configured source is invalid (never fall back silently), 1 when
# none is found.
# Prints "<kind>\t<checkout-root>\t<skills-dir>". Returns 2 when an explicitly
# configured source is invalid or is not a checkout layout (never fall back
# silently; --check and --skills must agree on the verdict), 1 when none is
# found.
superpowers_source() {
local skills="" checkout="" legacy=""
if [ -n "$SUPERPOWERS_FROM" ]; then
skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
checkout="$(checkout_root "$skills" 2>/dev/null || true)"
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills"
checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0
fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
checkout="$(checkout_root "$skills" 2>/dev/null || true)"
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills"
checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0
fi
if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
@ -169,9 +172,11 @@ superpowers_source() {
}
# Offline ref report for --check: the synced meta first, then git, then config.
# The meta path depends on the source kind (T3.1): pinned reads the clone's
# skills.synced, explicit reads the side staging tree.
report_checkout_ref() {
local checkout="${1:-}" meta="$SUPERPOWERS_OUT/.sync-meta.json" ref="" version="" tag="" sha=""
if [ -f "$meta" ]; then
local checkout="${1:-}" meta="${2:-}" ref="" version="" tag="" sha=""
if [ -n "$meta" ] && [ -f "$meta" ]; then
ref="$(sed -n 's/^[[:space:]]*"ref":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
if [ -n "$version" ]; then
@ -210,6 +215,15 @@ source_ref() {
printf 'unknown\n'
}
# The effective explicit source directory: the flag first, then the env var.
explicit_source_dir() {
if [ -n "$SUPERPOWERS_FROM" ]; then
printf '%s\n' "$SUPERPOWERS_FROM"
else
printf '%s\n' "${DSH_SUPERPOWERS_SOURCE:-}"
fi
}
# Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then
@ -219,7 +233,18 @@ explicit_source_name() {
fi
}
# Report an invalid explicit source with the accurate reason: either the
# source has no using-superpowers/SKILL.md, or it is not a checkout layout
# (both are verdict 2, and --check and --skills must fail the same way).
report_invalid_explicit_source() {
local dir="" skills=""
dir="$(explicit_source_dir)"
if [ -n "$dir" ] && skills="$(normalize_superpowers_dir "$dir")"; then
if ! checkout_root "$skills" >/dev/null 2>&1; then
echo "FAIL: explicit superpowers source must be a checkout root or its skills/ directory: $(explicit_source_name)" >&2
return 0
fi
fi
echo "FAIL: explicit superpowers source is invalid: $(explicit_source_name) has no using-superpowers/SKILL.md (expected <dir>/skills/... or <dir>/...)" >&2
}
@ -304,8 +329,10 @@ check() {
checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}"
if [ "$kind" = legacy ]; then
echo "WARN: legacy superpowers source ($skills_dir); prefer --superpowers-from or $DSH_HOME/superpowers" >&2
elif [ "$kind" = explicit ]; then
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$EXPLICIT_OUT/.sync-meta.json")"
else
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout")"
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$PINNED_OUT/.sync-meta.json")"
fi
fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
@ -385,11 +412,13 @@ ensure_pinned_clone() {
echo "FAIL: move it away and run: $CLONE_HINT" >&2
return 2
fi
# Online: refresh the pin. Fetching the tag ref itself and checking out
# FETCH_HEAD handles a moved tag, which `git checkout <ref>` (resolving the
# stale local ref) would miss.
if git -C "$dir" fetch -q --depth 1 origin "refs/tags/$ref" 2>/dev/null ||
git -C "$dir" fetch -q --depth 1 origin "$ref" 2>/dev/null; then
# Online: refresh the pin. Fetching from $DSH_SUPERPOWERS_REPO (not from
# the clone's origin) makes a repo change take effect on the next run
# (T3.3); fetching the tag ref itself and checking out FETCH_HEAD handles
# a moved tag, which `git checkout <ref>` (resolving the stale local ref)
# would miss.
if git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "refs/tags/$ref" 2>/dev/null ||
git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "$ref" 2>/dev/null; then
if ! git -C "$dir" checkout -q FETCH_HEAD; then
echo "FAIL: checkout of the fetched ref failed in $dir" >&2
return 2
@ -417,15 +446,15 @@ ensure_pinned_clone() {
return 0
}
# Sync + verify the checkout into $SUPERPOWERS_OUT. Returns 1 (nothing
# Sync + verify the checkout into $3 (staging out). Returns 1 (nothing
# copied) on any problem; the synced tree is the caller's copy source.
sync_skills() {
local checkout="$1" ref="$2" verified="" verified_n="" verify_rc=0
if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$SUPERPOWERS_OUT" --ref "$ref"; then
local checkout="$1" ref="$2" out="$3" verified="" verified_n="" verify_rc=0
if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$out" --ref "$ref"; then
echo "FAIL: superpowers sync failed ($checkout)" >&2
return 1
fi
verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$SUPERPOWERS_OUT" 2>&1)" || verify_rc=$?
verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$out" 2>&1)" || verify_rc=$?
printf '%s\n' "$verified"
if [ "$verify_rc" -ne 0 ]; then
echo "FAIL: superpowers verify failed; nothing copied" >&2
@ -475,13 +504,14 @@ install_skills() {
return 1
}
checkout="$(checkout_root "$skills_dir")" || {
echo "FAIL: explicit superpowers source must be a checkout root or its skills/ directory: $(explicit_source_name)" >&2
report_invalid_explicit_source
return 1
}
if ! sync_skills "$checkout" "$(source_ref "$checkout")"; then
# T3.1: the staging tree lives beside the clone dir, never inside it.
if ! sync_skills "$checkout" "$(source_ref "$checkout")" "$EXPLICIT_OUT"; then
return 1
fi
copy_skills_from "$SUPERPOWERS_OUT" "$checkout"
copy_skills_from "$EXPLICIT_OUT" "$checkout"
return 0
fi
@ -489,10 +519,10 @@ install_skills() {
local ensure_rc=0
ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$?
if [ "$ensure_rc" -eq 0 ]; then
if ! sync_skills "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF"; then
if ! sync_skills "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" "$PINNED_OUT"; then
return 1
fi
copy_skills_from "$SUPERPOWERS_OUT" "$DSH_HOME/superpowers"
copy_skills_from "$PINNED_OUT" "$DSH_HOME/superpowers"
return 0
fi
if [ "$ensure_rc" -ge 2 ]; then

View File

@ -95,9 +95,13 @@ function makeLegacyCache(home) {
}
/** A local git repo carrying the reduced upstream checkout on tag v7.0.1. */
function makePinnedRepo(work) {
const repo = join(work, 'upstream-repo')
function makePinnedRepo(work, marker = null, name = 'upstream-repo') {
const repo = join(work, name)
cpSync(fixture, repo, { recursive: true })
if (marker) {
const skill = join(repo, 'skills', 'using-superpowers', 'SKILL.md')
write(skill, `${readFileSync(skill, 'utf8')}\n<!-- pin-marker: ${marker} -->\n`)
}
try {
git(repo, ['init', '-b', 'main'])
} catch {
@ -213,6 +217,29 @@ test('--skills re-run picks up a moved tag', () => {
}
})
test('--skills updates an existing clone from DSH_SUPERPOWERS_REPO, not origin (T3.3)', () => {
const work = tempDir()
try {
const home = join(work, 'home')
const repoA = makePinnedRepo(work, 'from-a', 'upstream-a')
const repoB = makePinnedRepo(work, 'from-b', 'upstream-b')
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoA}` })
assert.equal(first.status, 0, first.stdout + first.stderr)
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-a/)
// Same ref, different repo: the update must come from the env repo, not
// from the clone's origin.
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoB}` })
assert.equal(second.status, 0, second.stdout + second.stderr)
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/)
assert.doesNotMatch(readFileSync(installed, 'utf8'), /pin-marker: from-a/)
} finally {
cleanup(work)
}
})
test('--skills removes a half-cloned dir after a failed fresh clone (RF5)', () => {
const home = tempDir()
try {
@ -269,6 +296,55 @@ test('--skills leaves no staging garbage and records the ref in .sync-meta.json'
}
})
test('explicit syncs stage outside the pinned clone; a later default run clones fresh (T3.1)', () => {
const work = tempDir()
try {
const home = join(work, 'home')
const repo = makePinnedRepo(work)
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
const first = runInstaller(home, ['--skills', '--superpowers-from', src])
assert.equal(first.status, 0, first.stdout + first.stderr)
assert.ok(
existsSync(join(home, '.dsh', '.skills.synced', 'using-superpowers', 'SKILL.md')),
'explicit staging lives outside $DSH_HOME/superpowers',
)
assert.equal(
existsSync(join(home, '.dsh', 'superpowers')),
false,
'an explicit run must not create the pinned-clone dir',
)
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` })
const out = second.stdout + second.stderr
assert.equal(second.status, 0, out)
assert.doesNotMatch(out, /no usable \.git/)
assert.match(second.stdout, /copied 15 superpowers skills/)
} finally {
cleanup(work)
}
})
test('a bare skills dir without a checkout root is invalid for --skills and --check alike (T3.2)', () => {
const home = tempDir()
try {
const bare = join(home, 'my-skills')
write(join(bare, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
const skills = runInstaller(home, ['--skills', '--superpowers-from', bare])
const check = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: bare })
for (const run of [skills, check]) {
const out = run.stdout + run.stderr
assert.equal(run.status, 1, out)
assert.match(out, /checkout root or its skills\/ directory/)
}
assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false)
} finally {
cleanup(home)
}
})
test('--check is offline and reports the configured ref', () => {
const home = tempDir()
try {