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_SOURCE` → the pinned clone `~/.dsh/superpowers` at
`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from `$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
`$DSH_SUPERPOWERS_REPO`) → the legacy cache → `~/.claude/skills`. Explicit `$DSH_SUPERPOWERS_REPO`) → the legacy cache → `~/.claude/skills`. Explicit
sources are synced in place with no network; the pinned clone is fetched or sources are synced in place with no network and must be a checkout root or
updated to the ref and reused offline only when already on it. An invalid its `skills/` directory — a bare skills directory outside that layout is
explicit source is a hard `FAIL`; a machine offline without a clone is a invalid, and `--check` and `--skills` fail the same way (narrower than change
`FAIL` with the clone hint, falling back to legacy only when a legacy source `dsh-install-source` #49; the spec delta records the MODIFIED). Explicit
exists. 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 ### 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_REF` (default `v7.0.1`, cloned or updated from
`$DSH_SUPERPOWERS_REPO`; offline it is reused only when already on the ref) → `$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 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 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 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 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=""
SUPERPOWERS_FROM_SET=0 SUPERPOWERS_FROM_SET=0
CLONE_HINT="git clone --depth 1 --branch $DSH_SUPERPOWERS_REF $DSH_SUPERPOWERS_REPO $DSH_HOME/superpowers" 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. # Synced staging trees, per source kind: $DSH_HOME/superpowers is reserved for
SUPERPOWERS_OUT="$DSH_HOME/superpowers/skills.synced" # 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() { usage() {
cat <<EOF cat <<EOF
@ -132,21 +134,22 @@ checkout_root() {
} }
# Resolve the superpowers install source without touching the network. # Resolve the superpowers install source without touching the network.
# Prints "<kind>\t<checkout-root-or--->\t<skills-dir>". Returns 2 when an # Prints "<kind>\t<checkout-root>\t<skills-dir>". Returns 2 when an explicitly
# explicitly configured source is invalid (never fall back silently), 1 when # configured source is invalid or is not a checkout layout (never fall back
# none is found. # silently; --check and --skills must agree on the verdict), 1 when none is
# found.
superpowers_source() { superpowers_source() {
local skills="" checkout="" legacy="" local skills="" checkout="" legacy=""
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2 skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
checkout="$(checkout_root "$skills" 2>/dev/null || true)" checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills" printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0 return 0
fi fi
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2 skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
checkout="$(checkout_root "$skills" 2>/dev/null || true)" checkout="$(checkout_root "$skills")" || return 2
printf 'explicit\t%s\t%s\n' "${checkout:--}" "$skills" printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
return 0 return 0
fi fi
if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then 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. # 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() { report_checkout_ref() {
local checkout="${1:-}" meta="$SUPERPOWERS_OUT/.sync-meta.json" ref="" version="" tag="" sha="" local checkout="${1:-}" meta="${2:-}" ref="" version="" tag="" sha=""
if [ -f "$meta" ]; then if [ -n "$meta" ] && [ -f "$meta" ]; then
ref="$(sed -n 's/^[[:space:]]*"ref":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)" 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)" version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
if [ -n "$version" ]; then if [ -n "$version" ]; then
@ -210,6 +215,15 @@ source_ref() {
printf 'unknown\n' 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. # Human-readable name of the source that made superpowers_source() return 2.
explicit_source_name() { explicit_source_name() {
if [ -n "$SUPERPOWERS_FROM" ]; then if [ -n "$SUPERPOWERS_FROM" ]; then
@ -219,7 +233,18 @@ explicit_source_name() {
fi 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() { 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 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'}" checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}"
if [ "$kind" = legacy ]; then if [ "$kind" = legacy ]; then
echo "WARN: legacy superpowers source ($skills_dir); prefer --superpowers-from or $DSH_HOME/superpowers" >&2 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 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
fi fi
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then 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 echo "FAIL: move it away and run: $CLONE_HINT" >&2
return 2 return 2
fi fi
# Online: refresh the pin. Fetching the tag ref itself and checking out # Online: refresh the pin. Fetching from $DSH_SUPERPOWERS_REPO (not from
# FETCH_HEAD handles a moved tag, which `git checkout <ref>` (resolving the # the clone's origin) makes a repo change take effect on the next run
# stale local ref) would miss. # (T3.3); fetching the tag ref itself and checking out FETCH_HEAD handles
if git -C "$dir" fetch -q --depth 1 origin "refs/tags/$ref" 2>/dev/null || # a moved tag, which `git checkout <ref>` (resolving the stale local ref)
git -C "$dir" fetch -q --depth 1 origin "$ref" 2>/dev/null; then # 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 if ! git -C "$dir" checkout -q FETCH_HEAD; then
echo "FAIL: checkout of the fetched ref failed in $dir" >&2 echo "FAIL: checkout of the fetched ref failed in $dir" >&2
return 2 return 2
@ -417,15 +446,15 @@ ensure_pinned_clone() {
return 0 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. # copied) on any problem; the synced tree is the caller's copy source.
sync_skills() { sync_skills() {
local checkout="$1" ref="$2" verified="" verified_n="" verify_rc=0 local checkout="$1" ref="$2" out="$3" verified="" verified_n="" verify_rc=0
if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$SUPERPOWERS_OUT" --ref "$ref"; then if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$out" --ref "$ref"; then
echo "FAIL: superpowers sync failed ($checkout)" >&2 echo "FAIL: superpowers sync failed ($checkout)" >&2
return 1 return 1
fi 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" printf '%s\n' "$verified"
if [ "$verify_rc" -ne 0 ]; then if [ "$verify_rc" -ne 0 ]; then
echo "FAIL: superpowers verify failed; nothing copied" >&2 echo "FAIL: superpowers verify failed; nothing copied" >&2
@ -475,13 +504,14 @@ install_skills() {
return 1 return 1
} }
checkout="$(checkout_root "$skills_dir")" || { 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 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 return 1
fi fi
copy_skills_from "$SUPERPOWERS_OUT" "$checkout" copy_skills_from "$EXPLICIT_OUT" "$checkout"
return 0 return 0
fi fi
@ -489,10 +519,10 @@ install_skills() {
local ensure_rc=0 local ensure_rc=0
ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$? ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$?
if [ "$ensure_rc" -eq 0 ]; then 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 return 1
fi fi
copy_skills_from "$SUPERPOWERS_OUT" "$DSH_HOME/superpowers" copy_skills_from "$PINNED_OUT" "$DSH_HOME/superpowers"
return 0 return 0
fi fi
if [ "$ensure_rc" -ge 2 ]; then 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. */ /** A local git repo carrying the reduced upstream checkout on tag v7.0.1. */
function makePinnedRepo(work) { function makePinnedRepo(work, marker = null, name = 'upstream-repo') {
const repo = join(work, 'upstream-repo') const repo = join(work, name)
cpSync(fixture, repo, { recursive: true }) 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 { try {
git(repo, ['init', '-b', 'main']) git(repo, ['init', '-b', 'main'])
} catch { } 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)', () => { test('--skills removes a half-cloned dir after a failed fresh clone (RF5)', () => {
const home = tempDir() const home = tempDir()
try { 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', () => { test('--check is offline and reports the configured ref', () => {
const home = tempDir() const home = tempDir()
try { try {