diff --git a/dsh/PORT.md b/dsh/PORT.md index 5bd92e8..c111a13 100644 --- a/dsh/PORT.md +++ b/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 diff --git a/dsh/README.md b/dsh/README.md index fc25630..5654b9e 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -60,7 +60,11 @@ bash /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 diff --git a/dsh/install.sh b/dsh/install.sh index 33f50ff..f74cec1 100755 --- a/dsh/install.sh +++ b/dsh/install.sh @@ -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 <\t\t". Returns 2 when an -# explicitly configured source is invalid (never fall back silently), 1 when -# none is found. +# Prints "\t\t". 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 /skills/... or /...)" >&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 ` (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 ` (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 diff --git a/dsh/tests/install.test.mjs b/dsh/tests/install.test.mjs index 8c6b5ad..c3f8d02 100644 --- a/dsh/tests/install.test.mjs +++ b/dsh/tests/install.test.mjs @@ -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\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 {