mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-26 01:35:29 +02:00
Compare commits
4 Commits
fix/2192-c
...
fix/2240-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b5278040 | ||
|
|
50f61bfd9a | ||
|
|
201b8f1a05 | ||
|
|
73c7281a36 |
80
CHANGELOG.md
80
CHANGELOG.md
@@ -6,18 +6,80 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **`@gsd-build/sdk` — Phase 1 typed query foundation** — Registry-based `gsd-sdk query` command, classified errors (`GSDQueryError`), and unit-tested handlers under `sdk/src/query/` (state, roadmap, phase lifecycle, init, config, validation, and related domains). Implements incremental SDK-first migration scope approved in #2083; builds on validated work from #2007 / `feat/sdk-foundation` without migrating workflows or removing `gsd-tools.cjs` in this phase.
|
|
||||||
- **Flow diagram directive for phase researcher** — `gsd-phase-researcher` now enforces data-flow architecture diagrams instead of file-listing diagrams. Language-agnostic directive added to agent prompt and research template. (#2139)
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Shell hooks falsely flagged as stale on every session** — `gsd-phase-boundary.sh`, `gsd-session-state.sh`, and `gsd-validate-commit.sh` now ship with a `# gsd-hook-version: {{GSD_VERSION}}` header; the installer substitutes `{{GSD_VERSION}}` in `.sh` hooks the same way it does for `.js` hooks; and the stale-hook detector in `gsd-check-update.js` now matches bash `#` comment syntax in addition to JS `//` syntax. All three changes are required together — neither the regex fix alone nor the install fix alone is sufficient to resolve the false positive (#2136, #2206, #2209, #2210, #2212)
|
||||||
|
|
||||||
- **SDK query layer (PR review hardening)** — `commit-to-subrepo` uses realpath-aware path containment and sanitized commit messages; `state.planned-phase` uses the STATE.md lockfile; `verifyKeyLinks` mitigates ReDoS on frontmatter patterns; frontmatter handlers resolve paths under the real project root; phase directory names reject `..` and separators; `gsd-sdk` restores strict CLI parsing by stripping `--pick` before `parseArgs`; `QueryRegistry.commands()` for enumeration; `todoComplete` uses static error imports.
|
## [1.36.0] - 2026-04-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`/gsd-graphify` integration** — Knowledge graph for planning agents, enabling richer context connections between project artifacts (#2164)
|
||||||
|
- **`gsd-pattern-mapper` agent** — Codebase pattern analysis agent for identifying recurring patterns and conventions (#1861)
|
||||||
|
- **`@gsd-build/sdk` — Phase 1 typed query foundation** — Registry-based `gsd-sdk query` command with classified errors and unit-tested handlers for state, roadmap, phase lifecycle, init, config, and validation (#2118)
|
||||||
|
- **Opt-in TDD pipeline mode** — `tdd_mode` exposed in init JSON with `--tdd` flag override for test-driven development workflows (#2119, #2124)
|
||||||
|
- **Stale/orphan worktree detection (W017)** — `validate-health` now detects stale and orphan worktrees (#2175)
|
||||||
|
- **Seed scanning in new-milestone** — Planted seeds are scanned during milestone step 2.5 for automatic surfacing (#2177)
|
||||||
|
- **Artifact audit gate** — Open artifact auditing for milestone close and phase verify (#2157, #2158, #2160)
|
||||||
|
- **`/gsd-quick` and `/gsd-thread` subcommands** — Added list/status/resume/close subcommands (#2159)
|
||||||
|
- **Debug skill dispatch and session manager** — Sub-orchestrator for `/gsd-debug` sessions (#2154)
|
||||||
|
- **Project skills awareness** — 9 GSD agents now discover and use project-scoped skills (#2152)
|
||||||
|
- **`/gsd-debug` session management** — TDD gate, reasoning checkpoint, and security hardening (#2146)
|
||||||
|
- **Context-window-aware prompt thinning** — Automatic prompt size reduction for sub-200K models (#1978)
|
||||||
|
- **SDK `--ws` flag** — Workstream-aware execution support (#1884)
|
||||||
|
- **`/gsd-extract-learnings` command** — Phase knowledge capture workflow (#1873)
|
||||||
|
- **Cross-AI execution hook** — Step 2.5 in execute-phase for external AI integration (#1875)
|
||||||
|
- **Ship workflow external review hook** — External code review command hook in ship workflow
|
||||||
|
- **Plan bounce hook** — Optional external refinement step (12.5) in plan-phase workflow
|
||||||
|
- **Cursor CLI self-detection** — Cursor detection and REVIEWS.md template for `/gsd-review` (#1960)
|
||||||
|
- **Architectural Responsibility Mapping** — Added to phase-researcher pipeline (#1988, #2103)
|
||||||
|
- **Configurable `claude_md_path`** — Custom CLAUDE.md path setting (#2010, #2102)
|
||||||
|
- **`/gsd-skill-manifest` command** — Pre-compute skill discovery for faster session starts (#2101)
|
||||||
|
- **`--dry-run` mode and resolved blocker pruning** — State management improvements (#1970)
|
||||||
|
- **State prune command** — Prune unbounded section growth in STATE.md (#1970)
|
||||||
|
- **Global skills support** — Support `~/.claude/skills/` in `agent_skills` config (#1992)
|
||||||
|
- **Context exhaustion auto-recording** — Hooks auto-record session state on context exhaustion (#1974)
|
||||||
|
- **Metrics table pruning** — Auto-prune on phase complete for STATE.md metrics (#2087, #2120)
|
||||||
|
- **Flow diagram directive for phase researcher** — Data-flow architecture diagrams enforced (#2139, #2147)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Planner context-cost sizing** — Replaced time-based reasoning with context-cost sizing and multi-source coverage audit (#2091, #2092, #2114)
|
||||||
|
- **`/gsd-next` prior-phase completeness scan** — Replaced consecutive-call counter with completeness scan (#2097)
|
||||||
|
- **Inline execution for small plans** — Default to inline execution, skip subagent overhead for small plans (#1979)
|
||||||
|
- **Prior-phase context optimization** — Limited to 3 most recent phases and includes `Depends on` phases (#1969)
|
||||||
|
- **Non-technical owner adaptation** — `discuss-phase` adapts gray area language for non-technical owners via USER-PROFILE.md (#2125, #2173)
|
||||||
|
- **Agent specs standardization** — Standardized `required_reading` patterns across agent specs (#2176)
|
||||||
|
- **CI upgrades** — GitHub Actions upgraded to Node 22+ runtimes; release pipeline fixes (#2128, #1956)
|
||||||
|
- **Branch cleanup workflow** — Auto-delete on merge + weekly sweep (#2051)
|
||||||
|
- **SDK query follow-up** — Expanded mutation commands, PID-liveness lock cleanup, depth-bounded JSON search, and comprehensive unit tests
|
||||||
|
|
||||||
- **SDK query follow-up (tests, docs, registry)** — Expanded `QUERY_MUTATION_COMMANDS` for event emission; stale lock cleanup uses PID liveness (`process.kill(pid, 0)`) when a lock file exists; `searchJsonEntries` is depth-bounded (`MAX_JSON_SEARCH_DEPTH`); removed unnecessary `readdirSync`/`Dirent` casts across query handlers; added `sdk/src/query/QUERY-HANDLERS.md` (error vs `{ data.error }`, mutations, locks, intel limits); unit tests for intel, profile, uat, skills, summary, websearch, workstream, registry vs `QUERY_MUTATION_COMMANDS`, and frontmatter extract/splice round-trip.
|
### Fixed
|
||||||
|
- **Init ignores archived phases** — Archived phases from prior milestones sharing a phase number no longer interfere (#2186)
|
||||||
|
- **UAT file listing** — Removed `head -5` truncation from verify-work (#2172)
|
||||||
|
- **Intel status relative time** — Display relative time correctly (#2132)
|
||||||
|
- **Codex hook install** — Copy hook files to Codex install target (#2153, #2166)
|
||||||
|
- **Phase add-batch duplicate prevention** — Prevents duplicate phase numbers on parallel invocations (#2165, #2170)
|
||||||
|
- **Stale hooks warning** — Show contextual warning for dev installs with stale hooks (#2162)
|
||||||
|
- **Worktree submodule skip** — Skip worktree isolation when `.gitmodules` detected (#2144)
|
||||||
|
- **Worktree STATE.md backup** — Use `cp` instead of `git-show` (#2143)
|
||||||
|
- **Bash hooks staleness check** — Add missing bash hooks to `MANAGED_HOOKS` (#2141)
|
||||||
|
- **Code-review parser fix** — Fix SUMMARY.md parser section-reset for top-level keys (#2142)
|
||||||
|
- **Backlog phase exclusion** — Exclude 999.x backlog phases from next-phase and all_complete (#2135)
|
||||||
|
- **Frontmatter regex anchor** — Anchor `extractFrontmatter` regex to file start (#2133)
|
||||||
|
- **Qwen Code install paths** — Eliminate Claude reference leaks (#2112)
|
||||||
|
- **Plan bounce default** — Correct `plan_bounce_passes` default from 1 to 2
|
||||||
|
- **GSD temp directory** — Use dedicated temp subdirectory for GSD temp files (#1975, #2100)
|
||||||
|
- **Workspace path quoting** — Quote path variables in workspace next-step examples (#2096)
|
||||||
|
- **Answer validation loop** — Carve out Other+empty exception from retry loop (#2093)
|
||||||
|
- **Test race condition** — Add `before()` hook to bug-1736 test (#2099)
|
||||||
|
- **Qwen Code path replacement** — Dedicated path replacement branches and finishInstall labels (#2082)
|
||||||
|
- **Global skill symlink guard** — Tests and empty-name handling for config (#1992)
|
||||||
|
- **Context exhaustion hook defects** — Three blocking defects fixed (#1974)
|
||||||
|
- **State disk scan cache** — Invalidate disk scan cache in writeStateMd (#1967)
|
||||||
|
- **State frontmatter caching** — Cache buildStateFrontmatter disk scan per process (#1967)
|
||||||
|
- **Grep anchor and threshold guard** — Correct grep anchor and add threshold=0 guard (#1979)
|
||||||
|
- **Atomic write coverage** — Extend atomicWriteFileSync to milestone, phase, and frontmatter (#1972)
|
||||||
|
- **Health check optimization** — Merge four readdirSync passes into one (#1973)
|
||||||
|
- **SDK query layer hardening** — Realpath-aware path containment, ReDoS mitigation, strict CLI parsing, phase directory sanitization (#2118)
|
||||||
|
- **Prompt injection scan** — Allowlist plan-phase.md
|
||||||
|
|
||||||
## [1.35.0] - 2026-04-10
|
## [1.35.0] - 2026-04-10
|
||||||
|
|
||||||
@@ -1907,7 +1969,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- YOLO mode for autonomous execution
|
- YOLO mode for autonomous execution
|
||||||
- Interactive mode with checkpoints
|
- Interactive mode with checkpoints
|
||||||
|
|
||||||
[Unreleased]: https://github.com/gsd-build/get-shit-done/compare/v1.34.2...HEAD
|
[Unreleased]: https://github.com/gsd-build/get-shit-done/compare/v1.36.0...HEAD
|
||||||
|
[1.36.0]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.36.0
|
||||||
|
[1.35.0]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.35.0
|
||||||
[1.34.2]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.2
|
[1.34.2]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.2
|
||||||
[1.34.1]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.1
|
[1.34.1]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.1
|
||||||
[1.34.0]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.0
|
[1.34.0]: https://github.com/gsd-build/get-shit-done/releases/tag/v1.34.0
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -89,13 +89,14 @@ People who want to describe what they want and have it built correctly — witho
|
|||||||
|
|
||||||
Built-in quality gates catch real problems: schema drift detection flags ORM changes missing migrations, security enforcement anchors verification to threat models, and scope reduction detection prevents the planner from silently dropping your requirements.
|
Built-in quality gates catch real problems: schema drift detection flags ORM changes missing migrations, security enforcement anchors verification to threat models, and scope reduction detection prevents the planner from silently dropping your requirements.
|
||||||
|
|
||||||
### v1.34.0 Highlights
|
### v1.36.0 Highlights
|
||||||
|
|
||||||
- **Gates taxonomy** — 4 canonical gate types (pre-flight, revision, escalation, abort) wired into plan-checker and verifier agents
|
- **Knowledge graph integration** — `/gsd-graphify` brings knowledge graphs to planning agents for richer context connections
|
||||||
- **Shell hooks fix** — `hooks/*.sh` files are now correctly included in the npm package, eliminating startup hook errors on fresh installs
|
- **SDK typed query foundation** — Registry-based `gsd-sdk query` command with classified errors and handlers for state, roadmap, phase lifecycle, and config
|
||||||
- **Post-merge hunk verification** — `reapply-patches` detects silently dropped hunks after three-way merge
|
- **TDD pipeline mode** — Opt-in test-driven development workflow with `--tdd` flag
|
||||||
- **detectConfigDir fix** — Claude Code users no longer see false "update available" warnings when multiple runtimes are installed
|
- **Context-window-aware prompt thinning** — Automatic prompt size reduction for sub-200K models
|
||||||
- **3 bug fixes** — Milestone backlog preservation, detectConfigDir priority, and npm package manifest
|
- **Project skills awareness** — 9 GSD agents now discover and use project-scoped skills
|
||||||
|
- **30+ bug fixes** — Worktree safety, state management, installer paths, and health check optimizations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5761,10 +5761,15 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
// Ensure hook files are executable (fixes #1162 — missing +x permission)
|
// Ensure hook files are executable (fixes #1162 — missing +x permission)
|
||||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
|
||||||
} else {
|
} else {
|
||||||
fs.copyFileSync(srcFile, destFile);
|
// .sh hooks carry a gsd-hook-version header so gsd-check-update.js can
|
||||||
// Ensure .sh hook files are executable (mirrors chmod in build-hooks.js)
|
// detect staleness after updates — stamp the version just like .js hooks.
|
||||||
if (entry.endsWith('.sh')) {
|
if (entry.endsWith('.sh')) {
|
||||||
|
let content = fs.readFileSync(srcFile, 'utf8');
|
||||||
|
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||||
|
fs.writeFileSync(destFile, content);
|
||||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows doesn't support chmod */ }
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(srcFile, destFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5876,9 +5881,13 @@ function install(isGlobal, runtime = 'claude') {
|
|||||||
fs.writeFileSync(destFile, content);
|
fs.writeFileSync(destFile, content);
|
||||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||||||
} else {
|
} else {
|
||||||
fs.copyFileSync(srcFile, destFile);
|
|
||||||
if (entry.endsWith('.sh')) {
|
if (entry.endsWith('.sh')) {
|
||||||
|
let content = fs.readFileSync(srcFile, 'utf8');
|
||||||
|
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||||
|
fs.writeFileSync(destFile, content);
|
||||||
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(srcFile, destFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ async function main() {
|
|||||||
// filesystem traversal on every invocation.
|
// filesystem traversal on every invocation.
|
||||||
const SKIP_ROOT_RESOLUTION = new Set([
|
const SKIP_ROOT_RESOLUTION = new Set([
|
||||||
'generate-slug', 'current-timestamp', 'verify-path-exists',
|
'generate-slug', 'current-timestamp', 'verify-path-exists',
|
||||||
'verify-summary', 'template', 'frontmatter',
|
'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
|
||||||
]);
|
]);
|
||||||
if (!SKIP_ROOT_RESOLUTION.has(command)) {
|
if (!SKIP_ROOT_RESOLUTION.has(command)) {
|
||||||
cwd = findProjectRoot(cwd);
|
cwd = findProjectRoot(cwd);
|
||||||
@@ -1142,6 +1142,98 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── detect-custom-files ───────────────────────────────────────────────
|
||||||
|
// Detect user-added files inside GSD-managed directories that are not
|
||||||
|
// tracked in gsd-file-manifest.json. Used by the update workflow to back
|
||||||
|
// up custom files before the installer wipes those directories.
|
||||||
|
//
|
||||||
|
// This replaces the fragile bash pattern:
|
||||||
|
// MANIFEST_FILES=$(node -e "require('$RUNTIME_DIR/...')" 2>/dev/null)
|
||||||
|
// ${filepath#$RUNTIME_DIR/} # unreliable path stripping
|
||||||
|
// which silently returns CUSTOM_COUNT=0 when $RUNTIME_DIR is unset or
|
||||||
|
// when the stripped path does not match the manifest key format (#1997).
|
||||||
|
|
||||||
|
case 'detect-custom-files': {
|
||||||
|
const configDirIdx = args.indexOf('--config-dir');
|
||||||
|
const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
|
||||||
|
if (!configDir) {
|
||||||
|
error('Usage: gsd-tools detect-custom-files --config-dir <path>');
|
||||||
|
}
|
||||||
|
const resolvedConfigDir = path.resolve(configDir);
|
||||||
|
if (!fs.existsSync(resolvedConfigDir)) {
|
||||||
|
error(`Config directory not found: ${resolvedConfigDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
// No manifest — cannot determine what is custom. Return empty list
|
||||||
|
// (same behaviour as saveLocalPatches in install.js when no manifest).
|
||||||
|
const out = { custom_files: [], custom_count: 0, manifest_found: false };
|
||||||
|
process.stdout.write(JSON.stringify(out, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
const out = { custom_files: [], custom_count: 0, manifest_found: false, error: 'manifest parse error' };
|
||||||
|
process.stdout.write(JSON.stringify(out, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestKeys = new Set(Object.keys(manifest.files || {}));
|
||||||
|
|
||||||
|
// GSD-managed directories to scan for user-added files.
|
||||||
|
// These are the directories the installer wipes on update.
|
||||||
|
const GSD_MANAGED_DIRS = [
|
||||||
|
'get-shit-done',
|
||||||
|
'agents',
|
||||||
|
path.join('commands', 'gsd'),
|
||||||
|
'hooks',
|
||||||
|
// OpenCode/Kilo flat command dir
|
||||||
|
'command',
|
||||||
|
// Codex/Copilot skills dir
|
||||||
|
'skills',
|
||||||
|
];
|
||||||
|
|
||||||
|
function walkDir(dir, baseDir) {
|
||||||
|
const results = [];
|
||||||
|
if (!fs.existsSync(dir)) return results;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...walkDir(fullPath, baseDir));
|
||||||
|
} else {
|
||||||
|
// Use forward slashes for cross-platform manifest key compatibility
|
||||||
|
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
||||||
|
results.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customFiles = [];
|
||||||
|
for (const managedDir of GSD_MANAGED_DIRS) {
|
||||||
|
const absDir = path.join(resolvedConfigDir, managedDir);
|
||||||
|
if (!fs.existsSync(absDir)) continue;
|
||||||
|
for (const relPath of walkDir(absDir, resolvedConfigDir)) {
|
||||||
|
if (!manifestKeys.has(relPath)) {
|
||||||
|
customFiles.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
custom_files: customFiles,
|
||||||
|
custom_count: customFiles.length,
|
||||||
|
manifest_found: true,
|
||||||
|
manifest_version: manifest.version || null,
|
||||||
|
};
|
||||||
|
process.stdout.write(JSON.stringify(out, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GSD-2 Reverse Migration ───────────────────────────────────────────
|
// ─── GSD-2 Reverse Migration ───────────────────────────────────────────
|
||||||
|
|
||||||
case 'from-gsd2': {
|
case 'from-gsd2': {
|
||||||
|
|||||||
@@ -361,6 +361,88 @@ Use AskUserQuestion:
|
|||||||
**If user cancels:** Exit.
|
**If user cancels:** Exit.
|
||||||
</step>
|
</step>
|
||||||
|
|
||||||
|
<step name="backup_custom_files">
|
||||||
|
Before running the installer, detect and back up any user-added files inside
|
||||||
|
GSD-managed directories. These are files that exist on disk but are NOT listed
|
||||||
|
in `gsd-file-manifest.json` — i.e., files the user added themselves that the
|
||||||
|
installer does not know about and will delete during the wipe.
|
||||||
|
|
||||||
|
**Do not use bash path-stripping (`${filepath#$RUNTIME_DIR/}`) or `node -e require()`
|
||||||
|
inline** — those patterns fail when `$RUNTIME_DIR` is unset and the stripped
|
||||||
|
relative path may not match manifest key format, which causes CUSTOM_COUNT=0
|
||||||
|
even when custom files exist (bug #1997). Use `gsd-tools detect-custom-files`
|
||||||
|
instead, which resolves paths reliably with Node.js `path.relative()`.
|
||||||
|
|
||||||
|
First, resolve the config directory (`RUNTIME_DIR`) from the install scope
|
||||||
|
detected in `get_installed_version`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# RUNTIME_DIR is the resolved config directory (e.g. ~/.claude, ~/.config/opencode)
|
||||||
|
# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR.
|
||||||
|
# Use the appropriate variable based on INSTALL_SCOPE.
|
||||||
|
if [ "$INSTALL_SCOPE" = "LOCAL" ]; then
|
||||||
|
RUNTIME_DIR="$LOCAL_DIR"
|
||||||
|
elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then
|
||||||
|
RUNTIME_DIR="$GLOBAL_DIR"
|
||||||
|
else
|
||||||
|
RUNTIME_DIR=""
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
If `RUNTIME_DIR` is empty or does not exist, skip this step (no config dir to
|
||||||
|
inspect).
|
||||||
|
|
||||||
|
Otherwise, resolve the path to `gsd-tools.cjs` and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GSD_TOOLS="$RUNTIME_DIR/get-shit-done/bin/gsd-tools.cjs"
|
||||||
|
if [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then
|
||||||
|
CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
|
||||||
|
CUSTOM_COUNT=$(echo "$CUSTOM_JSON" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).custom_count);}catch{console.log(0);}})" 2>/dev/null || echo "0")
|
||||||
|
else
|
||||||
|
CUSTOM_COUNT=0
|
||||||
|
CUSTOM_JSON='{"custom_files":[],"custom_count":0}'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
**If `CUSTOM_COUNT` > 0:**
|
||||||
|
|
||||||
|
Back up each custom file to `$RUNTIME_DIR/gsd-user-files-backup/` before the
|
||||||
|
installer wipes the directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKUP_DIR="$RUNTIME_DIR/gsd-user-files-backup"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Parse custom_files array from CUSTOM_JSON and copy each file
|
||||||
|
node - "$RUNTIME_DIR" "$BACKUP_DIR" "$CUSTOM_JSON" <<'JSEOF'
|
||||||
|
const [,, runtimeDir, backupDir, customJson] = process.argv;
|
||||||
|
const { custom_files } = JSON.parse(customJson);
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
for (const relPath of custom_files) {
|
||||||
|
const src = path.join(runtimeDir, relPath);
|
||||||
|
const dst = path.join(backupDir, relPath);
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||||
|
fs.copyFileSync(src, dst);
|
||||||
|
console.log(' Backed up: ' + relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSEOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inform the user:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ Found N custom file(s) inside GSD-managed directories.
|
||||||
|
These have been backed up to gsd-user-files-backup/ before the update.
|
||||||
|
Restore them after the update if needed.
|
||||||
|
```
|
||||||
|
|
||||||
|
**If `CUSTOM_COUNT` == 0:** No user-added files detected. Continue to install.
|
||||||
|
</step>
|
||||||
|
|
||||||
<step name="run_update">
|
<step name="run_update">
|
||||||
Run the update using the install type detected in step 1:
|
Run the update using the install type detected in step 1:
|
||||||
|
|
||||||
|
|||||||
107
hooks/gsd-check-update-worker.js
Normal file
107
hooks/gsd-check-update-worker.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// gsd-hook-version: {{GSD_VERSION}}
|
||||||
|
// Background worker spawned by gsd-check-update.js (SessionStart hook).
|
||||||
|
// Checks for GSD updates and stale hooks, writes result to cache file.
|
||||||
|
// Receives paths via environment variables set by the parent hook.
|
||||||
|
//
|
||||||
|
// Using a separate file (rather than node -e '<inline code>') avoids the
|
||||||
|
// template-literal regex-escaping problem: regex source is plain JS here.
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const cacheFile = process.env.GSD_CACHE_FILE;
|
||||||
|
const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE;
|
||||||
|
const globalVersionFile = process.env.GSD_GLOBAL_VERSION_FILE;
|
||||||
|
|
||||||
|
// Compare semver: true if a > b (a is strictly newer than b)
|
||||||
|
// Strips pre-release suffixes (e.g. '3-beta.1' → '3') to avoid NaN from Number()
|
||||||
|
function isNewer(a, b) {
|
||||||
|
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||||
|
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (pa[i] > pb[i]) return true;
|
||||||
|
if (pa[i] < pb[i]) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check project directory first (local install), then global
|
||||||
|
let installed = '0.0.0';
|
||||||
|
let configDir = '';
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(projectVersionFile)) {
|
||||||
|
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
||||||
|
configDir = path.dirname(path.dirname(projectVersionFile));
|
||||||
|
} else if (fs.existsSync(globalVersionFile)) {
|
||||||
|
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
||||||
|
configDir = path.dirname(path.dirname(globalVersionFile));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Check for stale hooks — compare hook version headers against installed VERSION
|
||||||
|
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/) (#1421)
|
||||||
|
// Only check hooks that GSD currently ships — orphaned files from removed features
|
||||||
|
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
||||||
|
const MANAGED_HOOKS = [
|
||||||
|
'gsd-check-update-worker.js',
|
||||||
|
'gsd-check-update.js',
|
||||||
|
'gsd-context-monitor.js',
|
||||||
|
'gsd-phase-boundary.sh',
|
||||||
|
'gsd-prompt-guard.js',
|
||||||
|
'gsd-read-guard.js',
|
||||||
|
'gsd-session-state.sh',
|
||||||
|
'gsd-statusline.js',
|
||||||
|
'gsd-validate-commit.sh',
|
||||||
|
'gsd-workflow-guard.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
let staleHooks = [];
|
||||||
|
if (configDir) {
|
||||||
|
const hooksDir = path.join(configDir, 'hooks');
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(hooksDir)) {
|
||||||
|
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
||||||
|
for (const hookFile of hookFiles) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
||||||
|
// Match both JS (//) and bash (#) comment styles
|
||||||
|
const versionMatch = content.match(/(?:\/\/|#) gsd-hook-version:\s*(.+)/);
|
||||||
|
if (versionMatch) {
|
||||||
|
const hookVersion = versionMatch[1].trim();
|
||||||
|
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||||
|
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No version header at all — definitely stale (pre-version-tracking)
|
||||||
|
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let latest = null;
|
||||||
|
try {
|
||||||
|
latest = execFileSync('npm', ['view', 'get-shit-done-cc', 'version'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10000,
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
update_available: latest && isNewer(latest, installed),
|
||||||
|
installed,
|
||||||
|
latest: latest || 'unknown',
|
||||||
|
checked: Math.floor(Date.now() / 1000),
|
||||||
|
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cacheFile) {
|
||||||
|
try { fs.writeFileSync(cacheFile, JSON.stringify(result)); } catch (e) {}
|
||||||
|
}
|
||||||
@@ -44,99 +44,21 @@ if (!fs.existsSync(cacheDir)) {
|
|||||||
fs.mkdirSync(cacheDir, { recursive: true });
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run check in background (spawn background process, windowsHide prevents console flash)
|
// Run check in background via a dedicated worker script.
|
||||||
const child = spawn(process.execPath, ['-e', `
|
// Spawning a file (rather than node -e '<inline code>') keeps the worker logic
|
||||||
const fs = require('fs');
|
// in plain JS with no template-literal regex-escaping concerns, and makes the
|
||||||
const path = require('path');
|
// worker independently testable.
|
||||||
const { execSync } = require('child_process');
|
const workerPath = path.join(__dirname, 'gsd-check-update-worker.js');
|
||||||
|
const child = spawn(process.execPath, [workerPath], {
|
||||||
// Compare semver: true if a > b (a is strictly newer than b)
|
|
||||||
// Strips pre-release suffixes (e.g. '3-beta.1' → '3') to avoid NaN from Number()
|
|
||||||
function isNewer(a, b) {
|
|
||||||
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
|
||||||
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
if (pa[i] > pb[i]) return true;
|
|
||||||
if (pa[i] < pb[i]) return false;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheFile = ${JSON.stringify(cacheFile)};
|
|
||||||
const projectVersionFile = ${JSON.stringify(projectVersionFile)};
|
|
||||||
const globalVersionFile = ${JSON.stringify(globalVersionFile)};
|
|
||||||
|
|
||||||
// Check project directory first (local install), then global
|
|
||||||
let installed = '0.0.0';
|
|
||||||
let configDir = '';
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(projectVersionFile)) {
|
|
||||||
installed = fs.readFileSync(projectVersionFile, 'utf8').trim();
|
|
||||||
configDir = path.dirname(path.dirname(projectVersionFile));
|
|
||||||
} else if (fs.existsSync(globalVersionFile)) {
|
|
||||||
installed = fs.readFileSync(globalVersionFile, 'utf8').trim();
|
|
||||||
configDir = path.dirname(path.dirname(globalVersionFile));
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
// Check for stale hooks — compare hook version headers against installed VERSION
|
|
||||||
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/) (#1421)
|
|
||||||
// Only check hooks that GSD currently ships — orphaned files from removed features
|
|
||||||
// (e.g., gsd-intel-*.js) must be ignored to avoid permanent stale warnings (#1750)
|
|
||||||
const MANAGED_HOOKS = [
|
|
||||||
'gsd-check-update.js',
|
|
||||||
'gsd-context-monitor.js',
|
|
||||||
'gsd-phase-boundary.sh',
|
|
||||||
'gsd-prompt-guard.js',
|
|
||||||
'gsd-read-guard.js',
|
|
||||||
'gsd-session-state.sh',
|
|
||||||
'gsd-statusline.js',
|
|
||||||
'gsd-validate-commit.sh',
|
|
||||||
'gsd-workflow-guard.js',
|
|
||||||
];
|
|
||||||
let staleHooks = [];
|
|
||||||
if (configDir) {
|
|
||||||
const hooksDir = path.join(configDir, 'hooks');
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(hooksDir)) {
|
|
||||||
const hookFiles = fs.readdirSync(hooksDir).filter(f => MANAGED_HOOKS.includes(f));
|
|
||||||
for (const hookFile of hookFiles) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(path.join(hooksDir, hookFile), 'utf8');
|
|
||||||
const versionMatch = content.match(/\\/\\/ gsd-hook-version:\\s*(.+)/);
|
|
||||||
if (versionMatch) {
|
|
||||||
const hookVersion = versionMatch[1].trim();
|
|
||||||
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
|
||||||
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No version header at all — definitely stale (pre-version-tracking)
|
|
||||||
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let latest = null;
|
|
||||||
try {
|
|
||||||
latest = execSync('npm view get-shit-done-cc version', { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim();
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
update_available: latest && isNewer(latest, installed),
|
|
||||||
installed,
|
|
||||||
latest: latest || 'unknown',
|
|
||||||
checked: Math.floor(Date.now() / 1000),
|
|
||||||
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(cacheFile, JSON.stringify(result));
|
|
||||||
`], {
|
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
detached: true // Required on Windows for proper process detachment
|
detached: true, // Required on Windows for proper process detachment
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GSD_CACHE_FILE: cacheFile,
|
||||||
|
GSD_PROJECT_VERSION_FILE: projectVersionFile,
|
||||||
|
GSD_GLOBAL_VERSION_FILE: globalVersionFile,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# gsd-hook-version: {{GSD_VERSION}}
|
||||||
# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes
|
# gsd-phase-boundary.sh — PostToolUse hook: detect .planning/ file writes
|
||||||
# Outputs a reminder when planning files are modified outside normal workflow.
|
# Outputs a reminder when planning files are modified outside normal workflow.
|
||||||
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
|
# Uses Node.js for JSON parsing (always available in GSD projects, no jq dependency).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# gsd-hook-version: {{GSD_VERSION}}
|
||||||
# gsd-session-state.sh — SessionStart hook: inject project state reminder
|
# gsd-session-state.sh — SessionStart hook: inject project state reminder
|
||||||
# Outputs STATE.md head on every session start for orientation.
|
# Outputs STATE.md head on every session start for orientation.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# gsd-hook-version: {{GSD_VERSION}}
|
||||||
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
|
# gsd-validate-commit.sh — PreToolUse hook: enforce Conventional Commits format
|
||||||
# Blocks git commit commands with non-conforming messages (exit 2).
|
# Blocks git commit commands with non-conforming messages (exit 2).
|
||||||
# Allows conforming messages and all non-commit commands (exit 0).
|
# Allows conforming messages and all non-commit commands (exit 0).
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "get-shit-done-cc",
|
"name": "get-shit-done-cc",
|
||||||
"version": "1.35.0",
|
"version": "1.36.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "get-shit-done-cc",
|
"name": "get-shit-done-cc",
|
||||||
"version": "1.35.0",
|
"version": "1.36.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"get-shit-done-cc": "bin/install.js"
|
"get-shit-done-cc": "bin/install.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "get-shit-done-cc",
|
"name": "get-shit-done-cc",
|
||||||
"version": "1.35.0",
|
"version": "1.36.0",
|
||||||
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.",
|
"description": "A meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini and Codex by TÂCHES.",
|
||||||
"bin": {
|
"bin": {
|
||||||
"get-shit-done-cc": "bin/install.js"
|
"get-shit-done-cc": "bin/install.js"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const DIST_DIR = path.join(HOOKS_DIR, 'dist');
|
|||||||
|
|
||||||
// Hooks to copy (pure Node.js, no bundling needed)
|
// Hooks to copy (pure Node.js, no bundling needed)
|
||||||
const HOOKS_TO_COPY = [
|
const HOOKS_TO_COPY = [
|
||||||
|
'gsd-check-update-worker.js',
|
||||||
'gsd-check-update.js',
|
'gsd-check-update.js',
|
||||||
'gsd-context-monitor.js',
|
'gsd-context-monitor.js',
|
||||||
'gsd-prompt-guard.js',
|
'gsd-prompt-guard.js',
|
||||||
|
|||||||
359
tests/bug-2136-sh-hook-version.test.cjs
Normal file
359
tests/bug-2136-sh-hook-version.test.cjs
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for bug #2136 / #2206
|
||||||
|
*
|
||||||
|
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
|
||||||
|
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
|
||||||
|
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
|
||||||
|
* syntax (//) — not bash comment syntax (#).
|
||||||
|
*
|
||||||
|
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
|
||||||
|
* after a fresh install, because the detector saw hookVersion: 'unknown' for
|
||||||
|
* all three bash hooks.
|
||||||
|
*
|
||||||
|
* This fix requires THREE parts working in concert:
|
||||||
|
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
|
||||||
|
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
|
||||||
|
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
|
||||||
|
*
|
||||||
|
* Neither fix alone is sufficient:
|
||||||
|
* - Headers + regex fix only (no install.js fix): installed hooks contain
|
||||||
|
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
|
||||||
|
* bash hook staleness permanently undetectable after future updates.
|
||||||
|
* - Headers + install.js fix only (no regex fix): installed hooks are
|
||||||
|
* stamped correctly but the detector still can't read bash "#" comments,
|
||||||
|
* so they still land in the "unknown / stale" branch on every session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
|
||||||
|
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
|
||||||
|
|
||||||
|
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
|
||||||
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||||
|
const CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
||||||
|
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
||||||
|
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||||
|
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||||
|
|
||||||
|
const SH_HOOKS = [
|
||||||
|
'gsd-phase-boundary.sh',
|
||||||
|
'gsd-session-state.sh',
|
||||||
|
'gsd-validate-commit.sh',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createTempDir(prefix) {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runInstaller(configDir) {
|
||||||
|
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
||||||
|
});
|
||||||
|
return path.join(configDir, 'hooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Part 1: Bash hook sources carry the version header placeholder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
|
||||||
|
for (const sh of SH_HOOKS) {
|
||||||
|
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
|
||||||
|
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
|
||||||
|
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
|
||||||
|
`installer can stamp it and gsd-check-update.js can detect staleness`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('version header is on line 2 (immediately after shebang)', () => {
|
||||||
|
// Placing the header immediately after #!/bin/bash ensures it is always
|
||||||
|
// found regardless of how much of the file is read.
|
||||||
|
for (const sh of SH_HOOKS) {
|
||||||
|
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split('\n');
|
||||||
|
assert.strictEqual(lines[0], '#!/bin/bash', `${sh} line 1 must be #!/bin/bash`);
|
||||||
|
assert.ok(
|
||||||
|
lines[1].startsWith('# gsd-hook-version:'),
|
||||||
|
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
|
||||||
|
// (Logic moved from inline -e template literal to dedicated worker file)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
|
||||||
|
let src;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
src = fs.readFileSync(WORKER_FILE, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
|
||||||
|
// The regex string in the source must contain the alternation for "#".
|
||||||
|
// The worker uses plain JS (no template-literal escaping), so the form is
|
||||||
|
// "(?:\/\/|#)" directly in source.
|
||||||
|
const hasBashAlternative =
|
||||||
|
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
|
||||||
|
src.includes('(?:\/\/|#)'); // direct form in plain JS worker
|
||||||
|
assert.ok(
|
||||||
|
hasBashAlternative,
|
||||||
|
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
|
||||||
|
'Expected to find (?:\\/\\/|#) or (?:\/\/|#) in the source. ' +
|
||||||
|
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version regex does not use the old JS-only form as the sole pattern', () => {
|
||||||
|
// The old regex inside the template literal was the string:
|
||||||
|
// /\\/\\/ gsd-hook-version:\\s*(.+)/
|
||||||
|
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
|
||||||
|
// That only matched JS "//" comments — never bash "#".
|
||||||
|
// We verify that the old exact string no longer appears.
|
||||||
|
assert.ok(
|
||||||
|
!src.includes('\\/\\/ gsd-hook-version'),
|
||||||
|
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
|
||||||
|
'escape form as the sole version matcher — it cannot match bash "#" comments'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version regex correctly matches both bash and JS hook version headers', () => {
|
||||||
|
// Verify that the versionMatch line in the source uses a regex that matches
|
||||||
|
// both bash "#" and JS "//" comment styles. We check the source contains the
|
||||||
|
// expected alternation, then directly test the known required pattern.
|
||||||
|
//
|
||||||
|
// We do NOT try to extract and evaluate the regex from source (it contains ")"
|
||||||
|
// which breaks simple extraction), so instead we confirm the source matches
|
||||||
|
// our expectation and run the regex itself.
|
||||||
|
assert.ok(
|
||||||
|
src.includes('gsd-hook-version'),
|
||||||
|
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
|
||||||
|
);
|
||||||
|
|
||||||
|
// The fixed regex that must be present: matches both comment styles
|
||||||
|
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
fixedRegex.test('# gsd-hook-version: 1.36.0'),
|
||||||
|
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
fixedRegex.test('// gsd-hook-version: 1.36.0'),
|
||||||
|
'JS-style "// gsd-hook-version: X" must still match (no regression)'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!fixedRegex.test('gsd-hook-version: 1.36.0'),
|
||||||
|
'line without a comment prefix must not match (prevents false positives)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #2136 part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks', () => {
|
||||||
|
let src;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
src = fs.readFileSync(INSTALL_SCRIPT, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.sh branch in bundled hook copy loop reads file and substitutes GSD_VERSION', () => {
|
||||||
|
// Anchor on configDirReplacement — unique to the bundled-hooks path.
|
||||||
|
const anchorIdx = src.indexOf('configDirReplacement');
|
||||||
|
assert.ok(anchorIdx !== -1, 'bundled hook copy loop anchor (configDirReplacement) not found');
|
||||||
|
|
||||||
|
// Window large enough for the if/else block
|
||||||
|
const region = src.slice(anchorIdx, anchorIdx + 2000);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
region.includes("entry.endsWith('.sh')"),
|
||||||
|
"bundled hook copy loop must check entry.endsWith('.sh')"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
region.includes('GSD_VERSION'),
|
||||||
|
'bundled .sh branch must reference GSD_VERSION substitution. Without this, ' +
|
||||||
|
'installed .sh hooks contain the literal "{{GSD_VERSION}}" placeholder and ' +
|
||||||
|
'bash hook staleness becomes permanently undetectable after future updates'
|
||||||
|
);
|
||||||
|
// copyFileSync on a .sh file would skip substitution — ensure we read+write instead
|
||||||
|
const shBranchIdx = region.indexOf("entry.endsWith('.sh')");
|
||||||
|
const shBranchRegion = region.slice(shBranchIdx, shBranchIdx + 400);
|
||||||
|
assert.ok(
|
||||||
|
shBranchRegion.includes('readFileSync') || shBranchRegion.includes('writeFileSync'),
|
||||||
|
'bundled .sh branch must read the file (readFileSync) to perform substitution, ' +
|
||||||
|
'not copyFileSync directly (which skips template expansion)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Part 3b: install.js Codex path also substitutes {{GSD_VERSION}} in .sh hooks
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #2136 part 3b: install.js Codex path substitutes {{GSD_VERSION}} in .sh hooks', () => {
|
||||||
|
let src;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
src = fs.readFileSync(INSTALL_SCRIPT, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('.sh branch in Codex hook copy block substitutes GSD_VERSION', () => {
|
||||||
|
// Anchor on codexHooksSrc — unique to the Codex path.
|
||||||
|
const anchorIdx = src.indexOf('codexHooksSrc');
|
||||||
|
assert.ok(anchorIdx !== -1, 'Codex hook copy block anchor (codexHooksSrc) not found');
|
||||||
|
|
||||||
|
const region = src.slice(anchorIdx, anchorIdx + 2000);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
region.includes("entry.endsWith('.sh')"),
|
||||||
|
"Codex hook copy block must check entry.endsWith('.sh')"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
region.includes('GSD_VERSION'),
|
||||||
|
'Codex .sh branch must substitute {{GSD_VERSION}}. The bundled path was fixed ' +
|
||||||
|
'but Codex installs a separate copy of the hooks from hooks/dist that also needs stamping'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir('gsd-2136-install-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
|
||||||
|
const hooksDir = runInstaller(tmpDir);
|
||||||
|
|
||||||
|
for (const sh of SH_HOOKS) {
|
||||||
|
const hookPath = path.join(hooksDir, sh);
|
||||||
|
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(hookPath, 'utf8');
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
content.includes('# gsd-hook-version:'),
|
||||||
|
`installed ${sh} must contain a "# gsd-hook-version:" header`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!content.includes('{{GSD_VERSION}}'),
|
||||||
|
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
|
||||||
|
`install.js must substitute it with the concrete package version`
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
|
||||||
|
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
|
||||||
|
assert.match(
|
||||||
|
versionMatch[1],
|
||||||
|
/^\d+\.\d+\.\d+/,
|
||||||
|
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
|
||||||
|
// This is the definitive end-to-end proof: after install, run the actual
|
||||||
|
// version-check logic (extracted from gsd-check-update.js) against the
|
||||||
|
// installed hooks and verify none are flagged stale.
|
||||||
|
const hooksDir = runInstaller(tmpDir);
|
||||||
|
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
||||||
|
const installedVersion = pkg.version;
|
||||||
|
|
||||||
|
// Build a subprocess that runs the staleness check logic in isolation.
|
||||||
|
// We pass the installed version, hooks dir, and hook filenames as JSON
|
||||||
|
// to avoid any injection risk.
|
||||||
|
const checkScript = `
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function isNewer(a, b) {
|
||||||
|
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||||
|
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (pa[i] > pb[i]) return true;
|
||||||
|
if (pa[i] < pb[i]) return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooksDir = ${JSON.stringify(hooksDir)};
|
||||||
|
const installed = ${JSON.stringify(installedVersion)};
|
||||||
|
const shHooks = ${JSON.stringify(SH_HOOKS)};
|
||||||
|
// Use the same regex that the fixed gsd-check-update.js uses
|
||||||
|
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
|
||||||
|
|
||||||
|
const staleHooks = [];
|
||||||
|
for (const hookFile of shHooks) {
|
||||||
|
const hookPath = path.join(hooksDir, hookFile);
|
||||||
|
if (!fs.existsSync(hookPath)) {
|
||||||
|
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(hookPath, 'utf8');
|
||||||
|
const m = content.match(versionRe);
|
||||||
|
if (m) {
|
||||||
|
const hookVersion = m[1].trim();
|
||||||
|
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
||||||
|
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify(staleHooks));
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
|
||||||
|
const staleHooks = JSON.parse(result);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
staleHooks,
|
||||||
|
[],
|
||||||
|
`Fresh install must produce zero stale bash hooks.\n` +
|
||||||
|
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
|
||||||
|
`This indicates either the version header was not stamped by install.js, ` +
|
||||||
|
`or the detector regex cannot match bash "#" comment syntax.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1071,8 +1071,10 @@ describe('stale hook filter', () => {
|
|||||||
|
|
||||||
describe('stale hook path', () => {
|
describe('stale hook path', () => {
|
||||||
test('gsd-check-update.js checks configDir/hooks/ where hooks are actually installed (#1421)', () => {
|
test('gsd-check-update.js checks configDir/hooks/ where hooks are actually installed (#1421)', () => {
|
||||||
|
// The stale-hook scan logic lives in the worker (moved from inline -e template literal).
|
||||||
|
// The worker receives configDir via env and constructs the hooksDir path.
|
||||||
const content = fs.readFileSync(
|
const content = fs.readFileSync(
|
||||||
path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
|
path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'), 'utf-8'
|
||||||
);
|
);
|
||||||
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/),
|
// Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/),
|
||||||
// not configDir/get-shit-done/hooks/ which doesn't exist (#1421)
|
// not configDir/get-shit-done/hooks/ which doesn't exist (#1421)
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
||||||
const CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
// MANAGED_HOOKS now lives in the worker script (extracted from inline -e code
|
||||||
|
// to avoid template-literal regex-escaping concerns). The test reads the worker.
|
||||||
|
const MANAGED_HOOKS_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
||||||
|
|
||||||
describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
||||||
let src;
|
let src;
|
||||||
@@ -26,12 +28,12 @@ describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
|||||||
let shippedHooks;
|
let shippedHooks;
|
||||||
|
|
||||||
// Read once — all tests share the same source snapshot
|
// Read once — all tests share the same source snapshot
|
||||||
src = fs.readFileSync(CHECK_UPDATE_FILE, 'utf-8');
|
src = fs.readFileSync(MANAGED_HOOKS_FILE, 'utf-8');
|
||||||
|
|
||||||
// Extract the MANAGED_HOOKS array entries from the source
|
// Extract the MANAGED_HOOKS array entries from the source
|
||||||
// The array is defined as a multi-line array literal of quoted strings
|
// The array is defined as a multi-line array literal of quoted strings
|
||||||
const match = src.match(/const MANAGED_HOOKS\s*=\s*\[([\s\S]*?)\]/);
|
const match = src.match(/const MANAGED_HOOKS\s*=\s*\[([\s\S]*?)\]/);
|
||||||
assert.ok(match, 'MANAGED_HOOKS array not found in gsd-check-update.js');
|
assert.ok(match, 'MANAGED_HOOKS array not found in gsd-check-update-worker.js');
|
||||||
|
|
||||||
managedHooks = match[1]
|
managedHooks = match[1]
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -47,7 +49,7 @@ describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
|||||||
for (const hookFile of jsHooks) {
|
for (const hookFile of jsHooks) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
managedHooks.includes(hookFile),
|
managedHooks.includes(hookFile),
|
||||||
`${hookFile} is shipped in hooks/ but missing from MANAGED_HOOKS in gsd-check-update.js`
|
`${hookFile} is shipped in hooks/ but missing from MANAGED_HOOKS in gsd-check-update-worker.js`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -57,7 +59,7 @@ describe('bug #2136: MANAGED_HOOKS must include all shipped hook files', () => {
|
|||||||
for (const hookFile of shHooks) {
|
for (const hookFile of shHooks) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
managedHooks.includes(hookFile),
|
managedHooks.includes(hookFile),
|
||||||
`${hookFile} is shipped in hooks/ but missing from MANAGED_HOOKS in gsd-check-update.js`
|
`${hookFile} is shipped in hooks/ but missing from MANAGED_HOOKS in gsd-check-update-worker.js`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,31 +11,41 @@ const assert = require('node:assert/strict');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
// MANAGED_HOOKS lives in the worker file (extracted from inline -e code to eliminate
|
||||||
|
// template-literal regex-escaping concerns). Tests read the worker directly.
|
||||||
const CHECK_UPDATE_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update.js');
|
const CHECK_UPDATE_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update.js');
|
||||||
|
const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js');
|
||||||
const BUILD_HOOKS_PATH = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
const BUILD_HOOKS_PATH = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||||
|
|
||||||
describe('orphaned hooks stale detection (#1750)', () => {
|
describe('orphaned hooks stale detection (#1750)', () => {
|
||||||
test('stale hook scanner uses an allowlist of managed hooks, not a wildcard', () => {
|
test('stale hook scanner uses an allowlist of managed hooks, not a wildcard', () => {
|
||||||
const content = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
const content = fs.readFileSync(WORKER_PATH, 'utf8');
|
||||||
|
|
||||||
// The scanner MUST NOT use a broad `startsWith('gsd-')` filter that catches
|
// The scanner MUST NOT use a broad `startsWith('gsd-')` filter that catches
|
||||||
// orphaned files from removed features (gsd-intel-index.js, gsd-intel-prune.js, etc.)
|
// orphaned files from removed features (gsd-intel-index.js, gsd-intel-prune.js, etc.)
|
||||||
// Instead, it should reference a known set of managed hook filenames.
|
// Instead, it should reference a known set of managed hook filenames.
|
||||||
|
const hasBroadFilter = /readdirSync\([^)]+\)\.filter\([^)]*startsWith\('gsd-'\)\s*&&[^)]*endsWith\('\.js'\)/s.test(content);
|
||||||
// Extract the spawned child script (everything between the template literal backticks)
|
|
||||||
const childScriptMatch = content.match(/spawn\(process\.execPath,\s*\['-e',\s*`([\s\S]*?)`\]/);
|
|
||||||
assert.ok(childScriptMatch, 'should find the spawned child script');
|
|
||||||
const childScript = childScriptMatch[1];
|
|
||||||
|
|
||||||
// The child script must NOT have a broad gsd-*.js wildcard filter
|
|
||||||
const hasBroadFilter = /readdirSync\([^)]+\)\.filter\([^)]*startsWith\('gsd-'\)\s*&&[^)]*endsWith\('\.js'\)/s.test(childScript);
|
|
||||||
assert.ok(!hasBroadFilter,
|
assert.ok(!hasBroadFilter,
|
||||||
'scanner must NOT use broad startsWith("gsd-") && endsWith(".js") filter — ' +
|
'scanner must NOT use broad startsWith("gsd-") && endsWith(".js") filter — ' +
|
||||||
'this catches orphaned hooks from removed features (e.g., gsd-intel-index.js). ' +
|
'this catches orphaned hooks from removed features (e.g., gsd-intel-index.js). ' +
|
||||||
'Use a MANAGED_HOOKS allowlist instead.');
|
'Use a MANAGED_HOOKS allowlist instead.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('managed hooks list in check-update matches build-hooks HOOKS_TO_COPY JS entries', () => {
|
test('gsd-check-update.js spawns the worker by file path (not inline -e code)', () => {
|
||||||
|
// After the worker extraction, the main hook must spawn the worker file
|
||||||
|
// rather than embedding all logic in a template literal.
|
||||||
|
const content = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
content.includes('gsd-check-update-worker.js'),
|
||||||
|
'gsd-check-update.js must reference gsd-check-update-worker.js as the spawn target'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!content.includes("'-e'"),
|
||||||
|
'gsd-check-update.js must not use node -e inline code (logic moved to worker file)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('managed hooks list in worker matches build-hooks HOOKS_TO_COPY JS entries', () => {
|
||||||
// Extract JS hooks from build-hooks.js HOOKS_TO_COPY
|
// Extract JS hooks from build-hooks.js HOOKS_TO_COPY
|
||||||
const buildContent = fs.readFileSync(BUILD_HOOKS_PATH, 'utf8');
|
const buildContent = fs.readFileSync(BUILD_HOOKS_PATH, 'utf8');
|
||||||
const hooksArrayMatch = buildContent.match(/HOOKS_TO_COPY\s*=\s*\[([\s\S]*?)\]/);
|
const hooksArrayMatch = buildContent.match(/HOOKS_TO_COPY\s*=\s*\[([\s\S]*?)\]/);
|
||||||
@@ -48,25 +58,18 @@ describe('orphaned hooks stale detection (#1750)', () => {
|
|||||||
}
|
}
|
||||||
assert.ok(jsHooks.length >= 5, `expected at least 5 JS hooks in HOOKS_TO_COPY, got ${jsHooks.length}`);
|
assert.ok(jsHooks.length >= 5, `expected at least 5 JS hooks in HOOKS_TO_COPY, got ${jsHooks.length}`);
|
||||||
|
|
||||||
// The check-update hook should define its own managed hooks list
|
// MANAGED_HOOKS in the worker must include each JS hook from HOOKS_TO_COPY
|
||||||
// that matches the JS entries from HOOKS_TO_COPY
|
const workerContent = fs.readFileSync(WORKER_PATH, 'utf8');
|
||||||
const checkContent = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
|
||||||
const childScriptMatch = checkContent.match(/spawn\(process\.execPath,\s*\['-e',\s*`([\s\S]*?)`\]/);
|
|
||||||
const childScript = childScriptMatch[1];
|
|
||||||
|
|
||||||
// Verify each JS hook from HOOKS_TO_COPY is referenced in the managed list
|
|
||||||
for (const hook of jsHooks) {
|
for (const hook of jsHooks) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
childScript.includes(hook),
|
workerContent.includes(hook),
|
||||||
`managed hooks in check-update should include '${hook}' from HOOKS_TO_COPY`
|
`MANAGED_HOOKS in worker should include '${hook}' from HOOKS_TO_COPY`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('orphaned hook filenames would NOT match the managed hooks list', () => {
|
test('orphaned hook filenames are NOT in the MANAGED_HOOKS list', () => {
|
||||||
const checkContent = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
const workerContent = fs.readFileSync(WORKER_PATH, 'utf8');
|
||||||
const childScriptMatch = checkContent.match(/spawn\(process\.execPath,\s*\['-e',\s*`([\s\S]*?)`\]/);
|
|
||||||
const childScript = childScriptMatch[1];
|
|
||||||
|
|
||||||
// These are real orphaned hooks from the removed intel feature
|
// These are real orphaned hooks from the removed intel feature
|
||||||
const orphanedHooks = [
|
const orphanedHooks = [
|
||||||
@@ -77,8 +80,8 @@ describe('orphaned hooks stale detection (#1750)', () => {
|
|||||||
|
|
||||||
for (const orphan of orphanedHooks) {
|
for (const orphan of orphanedHooks) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!childScript.includes(orphan),
|
!workerContent.includes(orphan),
|
||||||
`orphaned hook '${orphan}' must NOT be in the managed hooks list`
|
`orphaned hook '${orphan}' must NOT be in the MANAGED_HOOKS list`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
228
tests/update-custom-backup.test.cjs
Normal file
228
tests/update-custom-backup.test.cjs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* GSD Tools Tests — update workflow custom file backup detection (#1997)
|
||||||
|
*
|
||||||
|
* The update workflow must detect user-added files inside GSD-managed
|
||||||
|
* directories (get-shit-done/, agents/, commands/gsd/, hooks/) before the
|
||||||
|
* installer wipes those directories.
|
||||||
|
*
|
||||||
|
* This tests the `detect-custom-files` subcommand of gsd-tools.cjs, which is
|
||||||
|
* the correct fix for the bash path-stripping failure described in #1997.
|
||||||
|
*
|
||||||
|
* The bash pattern `${filepath#$RUNTIME_DIR/}` is unreliable because
|
||||||
|
* $RUNTIME_DIR may not be set and the stripped relative path may not match
|
||||||
|
* manifest key format. Moving the logic into gsd-tools.cjs eliminates the
|
||||||
|
* shell variable expansion failure entirely.
|
||||||
|
*
|
||||||
|
* Closes: #1997
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
|
||||||
|
|
||||||
|
function sha256(content) {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
|
||||||
|
*/
|
||||||
|
function writeManifest(configDir, files) {
|
||||||
|
const manifest = {
|
||||||
|
version: '1.32.0',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
files: {}
|
||||||
|
};
|
||||||
|
for (const [relPath, content] of Object.entries(files)) {
|
||||||
|
const fullPath = path.join(configDir, relPath);
|
||||||
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||||
|
fs.writeFileSync(fullPath, content);
|
||||||
|
manifest.files[relPath] = sha256(content);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'gsd-file-manifest.json'),
|
||||||
|
JSON.stringify(manifest, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detect-custom-files — update workflow backup detection (#1997)', () => {
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempDir('gsd-custom-detect-');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects a custom file added inside get-shit-done/workflows/', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
|
||||||
|
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a custom file NOT in the manifest
|
||||||
|
const customFile = path.join(tmpDir, 'get-shit-done/workflows/my-custom-workflow.md');
|
||||||
|
fs.writeFileSync(customFile, '# My Custom Workflow\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
|
||||||
|
assert.ok(json.custom_files.length > 0, 'should detect at least one custom file');
|
||||||
|
assert.ok(
|
||||||
|
json.custom_files.includes('get-shit-done/workflows/my-custom-workflow.md'),
|
||||||
|
`custom file should be listed; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects custom files added inside agents/', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'agents/gsd-executor.md': '# GSD Executor\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a user's custom agent (not prefixed with gsd-)
|
||||||
|
const customAgent = path.join(tmpDir, 'agents/my-custom-agent.md');
|
||||||
|
fs.mkdirSync(path.dirname(customAgent), { recursive: true });
|
||||||
|
fs.writeFileSync(customAgent, '# My Custom Agent\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(json.custom_files.includes('agents/my-custom-agent.md'),
|
||||||
|
`custom agent should be detected; got: ${JSON.stringify(json.custom_files)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports zero custom files when all files are in manifest', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
|
||||||
|
'get-shit-done/references/gates.md': '# Gates\n',
|
||||||
|
'agents/gsd-executor.md': '# Executor\n',
|
||||||
|
});
|
||||||
|
// No extra files added
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
|
||||||
|
assert.strictEqual(json.custom_files.length, 0, 'no custom files should be detected');
|
||||||
|
assert.strictEqual(json.custom_count, 0, 'custom_count should be 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns custom_count equal to custom_files length', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add two custom files
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'get-shit-done/workflows/custom-a.md'),
|
||||||
|
'# Custom A\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'get-shit-done/workflows/custom-b.md'),
|
||||||
|
'# Custom B\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.strictEqual(json.custom_count, json.custom_files.length,
|
||||||
|
'custom_count should equal custom_files.length');
|
||||||
|
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom files');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not flag manifest files as custom even if content was modified', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\nOriginal\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modify the content of an existing manifest file
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(tmpDir, 'get-shit-done/workflows/execute-phase.md'),
|
||||||
|
'# Execute Phase\nModified by user\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
// Modified manifest files are handled by saveLocalPatches (in install.js).
|
||||||
|
// detect-custom-files only finds files NOT in the manifest at all.
|
||||||
|
assert.ok(
|
||||||
|
!json.custom_files.includes('get-shit-done/workflows/execute-phase.md'),
|
||||||
|
'modified manifest files should NOT be listed as custom (that is saveLocalPatches territory)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing manifest gracefully — treats all GSD-dir files as custom', () => {
|
||||||
|
// No manifest. Add a file in a GSD-managed dir.
|
||||||
|
const workflowDir = path.join(tmpDir, 'get-shit-done/workflows');
|
||||||
|
fs.mkdirSync(workflowDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workflowDir, 'my-workflow.md'), '# My Workflow\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
// Without a manifest, we cannot determine what is custom vs GSD-owned.
|
||||||
|
// The command should return an empty list (no manifest = skip detection,
|
||||||
|
// which is safe since saveLocalPatches also does nothing without a manifest).
|
||||||
|
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
|
||||||
|
assert.ok(typeof json.custom_count === 'number', 'should return numeric custom_count');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects custom files inside get-shit-done/references/', () => {
|
||||||
|
writeManifest(tmpDir, {
|
||||||
|
'get-shit-done/references/gates.md': '# Gates\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
const customRef = path.join(tmpDir, 'get-shit-done/references/my-domain-probes.md');
|
||||||
|
fs.writeFileSync(customRef, '# My Domain Probes\n');
|
||||||
|
|
||||||
|
const result = runGsdTools(
|
||||||
|
['detect-custom-files', '--config-dir', tmpDir],
|
||||||
|
tmpDir
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
|
|
||||||
|
const json = JSON.parse(result.output);
|
||||||
|
assert.ok(
|
||||||
|
json.custom_files.includes('get-shit-done/references/my-domain-probes.md'),
|
||||||
|
`should detect custom reference; got: ${JSON.stringify(json.custom_files)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user