fix(dsh): explicit-стейджинг вне клона, вердикты --check/--skills совпадают, fetch от DSH_SUPERPOWERS_REPO (T3.1-T3.3, #69)
This commit is contained in:
parent
335e966cfc
commit
914a54c3e4
15
dsh/PORT.md
15
dsh/PORT.md
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user