feat(#607): rebuild get-shit-done-cc → gsd-core migration (per-package cache + installer auto-cleanup + --dry-run) (#611)
* feat(#607): rebuild get-shit-done-cc → gsd-core migration Leftover get-shit-done-cc installs poisoned the shared update cache, causing a permanent false "update available". Rebuild the migration so a stale old install is both harmless and actively removed. - Per-package update cache filename (gsd-update-check-<slug>.json) in the shared ~/.cache/gsd dir, single-sourced via package-identity; writers stamp package_name and readers reject foreign/absent lineage. Multi- runtime visibility preserved (same shared dir + filename across runtimes). - New get-shit-done/bin/lib/legacy-cleanup.cjs seam: detects code-file references to the old package + the legacy fixed-name cache across home runtime dirs; installer auto-cleans on every install; --dry-run previews and mutates nothing. User hooks and dev-preferences are never touched. - update.md cache-clear globs gsd-update-check*.json across ALL supported runtimes (adds cursor/windsurf/augment/trae/qwen/hermes/codebuddy/cline). - Fix worker MODULE_NOT_FOUND post-install (ship managed-hooks-registry.cjs + degrade gracefully) so the per-package cache is always written. - Diataxis how-to: docs/cleanup-get-shit-done-cc.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#607): add changeset for PR #611 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#607): set USERPROFILE alongside HOME in dry-run install test for Windows os.homedir() reads USERPROFILE on win32, so HOME-only isolation let the spawned installer scan the real runner home on windows-latest. Set both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/nimble-eagles-romp.md
Normal file
5
.changeset/nimble-eagles-romp.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 611
|
||||
---
|
||||
**Update-check cache is now per-package and lineage-validated, and the installer cleans up leftover `get-shit-done-cc` installs** — after migrating from `get-shit-done-cc` to `@opengsd/gsd-core`, a leftover old install in any runtime dir could write a higher `latest` into the shared update cache and cause a permanent false `⬆ /gsd:update`. The cache now uses a per-package filename (`gsd-update-check-<slug>.json`) carrying a `package_name` lineage field that readers validate, so a different package can no longer poison the indicator (multi-runtime visibility preserved). The installer now auto-detects and removes leftover `get-shit-done-cc` artifacts across runtime config dirs on every install; a new `--dry-run` flag previews the cleanup plan without modifying anything. `/gsd:update` clears the cache for all 15 supported runtimes. See the new how-to: docs/cleanup-get-shit-done-cc.md.
|
||||
120
bin/install.js
120
bin/install.js
@@ -227,6 +227,13 @@ const {
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs'));
|
||||
const {
|
||||
planLegacyCleanup,
|
||||
applyLegacyCleanup,
|
||||
} = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs'));
|
||||
const {
|
||||
updateCacheFileName,
|
||||
} = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'package-identity.cjs'));
|
||||
|
||||
// Parse args
|
||||
const args = process.argv.slice(2);
|
||||
@@ -253,6 +260,7 @@ const hasUninstall = args.includes('--uninstall') || args.includes('-u');
|
||||
const hasSkillsRoot = args.includes('--skills-root');
|
||||
const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1';
|
||||
const hasMinimal = args.includes('--minimal') || args.includes('--core-only');
|
||||
const hasDryRun = args.includes('--dry-run');
|
||||
// --profile=<name> or --profile=<n1>,<n2> (composable); mutually exclusive with --minimal
|
||||
const _profileArgRaw = (() => {
|
||||
for (const arg of args) {
|
||||
@@ -9092,10 +9100,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
||||
console.log(` ${green}✓${reset} Installed hooks/lib/ helpers (git-cmd, graphify-rebuild, ...)`);
|
||||
}
|
||||
|
||||
// Clear stale update cache so next session re-evaluates hook versions
|
||||
// Cache lives at ~/.cache/gsd/ (see hooks/gsd-check-update.js line 35-36)
|
||||
const updateCacheFile = path.join(os.homedir(), '.cache', 'gsd', 'gsd-update-check.json');
|
||||
try { fs.unlinkSync(updateCacheFile); } catch (e) { /* cache may not exist yet */ }
|
||||
// Remove legacy get-shit-done-cc artifacts and stale update caches (#607).
|
||||
// cleanupLegacyGsdCc handles both the legacy shared cache and the per-package
|
||||
// cache (formerly an inline unlinkSync here). A cleanup failure must never
|
||||
// abort a successful install — log a warning and continue.
|
||||
// install() is never reached in --dry-run mode (the early-exit at the CLI
|
||||
// dispatch handles preview), so cleanup here always applies for real.
|
||||
try {
|
||||
cleanupLegacyGsdCc({ dryRun: false });
|
||||
} catch (cleanupErr) {
|
||||
console.warn(` ${yellow}Warning: legacy cleanup failed: ${cleanupErr.message}${reset}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n ${yellow}Installation incomplete!${reset} Failed: ${failures.join(', ')}`);
|
||||
@@ -10697,6 +10712,86 @@ function maybeSuggestPathExport(globalBin, homeDir) {
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Runtime subdir names to scan for legacy get-shit-done-cc artifacts (#607).
|
||||
// Covers both local (project-relative) and common global forms.
|
||||
const _LEGACY_SCAN_SUBDIR_NAMES = [
|
||||
'.claude',
|
||||
'.gemini',
|
||||
'.opencode',
|
||||
'.config/opencode',
|
||||
'.kilo',
|
||||
'.config/kilo',
|
||||
'.codex',
|
||||
'.copilot',
|
||||
'.github', // copilot local form
|
||||
'.agent', // antigravity local form
|
||||
'.cursor',
|
||||
'.windsurf',
|
||||
'.codeium/windsurf',
|
||||
'.augment',
|
||||
'.trae',
|
||||
'.qwen',
|
||||
'.hermes',
|
||||
'.codebuddy',
|
||||
'.cline',
|
||||
];
|
||||
|
||||
/**
|
||||
* Detect and remove leftover get-shit-done-cc artifacts across ALL known
|
||||
* runtime config directories (issue #607).
|
||||
*
|
||||
* Exported so tests can call it directly without spawning a subprocess.
|
||||
*
|
||||
* Scans ONLY subdirs under homeDir — never cwd — to avoid touching the
|
||||
* user's active-project hooks when the installer is run from a project dir.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.homeDir=os.homedir()] - home directory to scan
|
||||
* @param {boolean} [opts.dryRun=false] - preview only; no mutations
|
||||
* @param {object} [opts.logger=console] - injectable logger
|
||||
* @returns {{ plan: {path:string,reason:string}[], result: object }}
|
||||
*/
|
||||
function cleanupLegacyGsdCc({ homeDir = os.homedir(), dryRun = false, logger = console } = {}) {
|
||||
// Build de-duplicated list of candidate config dirs to scan.
|
||||
// Only scan under homeDir — never cwd — to prevent accidental deletion of
|
||||
// the user's active-project hooks when the installer is invoked from a
|
||||
// project directory that has .claude/hooks or similar subdirs.
|
||||
const seen = new Set();
|
||||
const configDirs = [];
|
||||
for (const name of _LEGACY_SCAN_SUBDIR_NAMES) {
|
||||
const candidate = path.join(homeDir, name);
|
||||
if (!seen.has(candidate) && fs.existsSync(candidate)) {
|
||||
seen.add(candidate);
|
||||
configDirs.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// planLegacyCleanup scans each configDir and already includes the legacy
|
||||
// shared cache (gsd-update-check.json) as a plan entry.
|
||||
const plan = planLegacyCleanup(configDirs, { homeDir });
|
||||
|
||||
// Apply the plan (dryRun honors the flag).
|
||||
const result = applyLegacyCleanup(plan, { dryRun, logger });
|
||||
|
||||
// Also clear / preview the per-package cache so next session re-evaluates
|
||||
// hook versions (replaces the former inline unlinkSync on line ~9104).
|
||||
const perPkgCacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName);
|
||||
if (dryRun) {
|
||||
logger.log('[dry-run] would remove: ' + perPkgCacheFile + ' (per-package-update-cache)');
|
||||
} else {
|
||||
try { fs.unlinkSync(perPkgCacheFile); } catch (_e) { /* cache may not exist yet */ }
|
||||
}
|
||||
|
||||
// Concise summary
|
||||
if (plan.length > 0 || !dryRun) {
|
||||
const verb = dryRun ? 'Would remove' : 'Removed';
|
||||
const count = dryRun ? plan.length : result.removed.length;
|
||||
logger.log(`[legacy-cleanup] ${verb} ${count} legacy artifact(s).`);
|
||||
}
|
||||
|
||||
return { plan, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install GSD for all selected runtimes
|
||||
*/
|
||||
@@ -10920,11 +11015,26 @@ module.exports = {
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
parseConfigDirFromArgs,
|
||||
cleanupLegacyGsdCc,
|
||||
};
|
||||
|
||||
// Main logic — only run when not loaded as a module for testing
|
||||
if (require.main === module && !process.env.GSD_TEST_MODE) {
|
||||
if (hasSkillsRoot) {
|
||||
if (hasDryRun) {
|
||||
// --dry-run: preview legacy cleanup and exit without installing.
|
||||
if (hasUninstall) {
|
||||
console.log('Note: --dry-run previews legacy get-shit-done-cc cleanup only; it does not preview --uninstall.');
|
||||
}
|
||||
console.log('Dry run — no files will be modified.\n');
|
||||
// cleanupLegacyGsdCc with dryRun:true is the single source of truth for
|
||||
// both the legacy artifacts and the per-package cache path — no duplicate
|
||||
// printing here.
|
||||
const { plan } = cleanupLegacyGsdCc({ dryRun: true });
|
||||
if (plan.length === 0) {
|
||||
console.log(' (no legacy get-shit-done-cc artifacts found)');
|
||||
}
|
||||
process.exit(0);
|
||||
} else if (hasSkillsRoot) {
|
||||
// Print the skills root directory for a given runtime (used by /gsd-sync-skills).
|
||||
// Usage: node install.js --skills-root <runtime>
|
||||
const runtimeArg = args[args.indexOf('--skills-root') + 1];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated": "2026-06-01",
|
||||
"generated": "2026-06-02",
|
||||
"families": {
|
||||
"agents": [
|
||||
"gsd-advisor-researcher",
|
||||
@@ -297,6 +297,7 @@
|
||||
"installer-migrations.cjs",
|
||||
"intel.cjs",
|
||||
"learnings.cjs",
|
||||
"legacy-cleanup.cjs",
|
||||
"milestone.cjs",
|
||||
"model-catalog.cjs",
|
||||
"model-profiles.cjs",
|
||||
|
||||
@@ -366,7 +366,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (81 shipped)
|
||||
## CLI Modules (82 shipped)
|
||||
|
||||
Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
|
||||
@@ -408,6 +408,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
|
||||
| `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers |
|
||||
| `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` |
|
||||
| `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` |
|
||||
| `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) |
|
||||
| `milestone.cjs` | Milestone archival, requirements marking |
|
||||
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
|
||||
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
|
||||
|
||||
@@ -32,6 +32,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue
|
||||
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
|
||||
- [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release
|
||||
- [Clean up get-shit-done-cc](cleanup-get-shit-done-cc.md) — remove leftover old-package artifacts that cause a spurious `⬆ /gsd:update` indicator after migrating to `@opengsd/gsd-core`
|
||||
- [Recover and troubleshoot](how-to/recover-and-troubleshoot.md) — fix common problems, rebuild context, and uninstall
|
||||
|
||||
---
|
||||
|
||||
98
docs/cleanup-get-shit-done-cc.md
Normal file
98
docs/cleanup-get-shit-done-cc.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Cleaning Up get-shit-done-cc
|
||||
|
||||
Use this procedure when you see a persistent `⬆ /gsd:update` indicator in
|
||||
your statusline even though `@opengsd/gsd-core` is already up to date. It
|
||||
removes leftover files from the old `get-shit-done-cc` package that was
|
||||
renamed to `@opengsd/gsd-core` in issue [#607](https://github.com/open-gsd/gsd-core/issues/607).
|
||||
|
||||
## Why this happens
|
||||
|
||||
When the package was renamed, its version counter reset — `get-shit-done-cc`
|
||||
reached `1.42.x` while `@opengsd/gsd-core` started at `1.2.0`. If the old
|
||||
package is still installed in any runtime config directory (e.g. `~/.gemini`),
|
||||
its update checker writes a higher `latest` version into the shared update
|
||||
cache (`~/.cache/gsd/gsd-update-check.json`), and older versions of the new
|
||||
tooling accepted those foreign writes. The statusline then permanently shows
|
||||
an upgrade that does not exist. The current installer detects and removes
|
||||
these leftovers automatically, and the update cache is now per-package with a
|
||||
`package_name` lineage field that readers validate — so a foreign package can
|
||||
no longer poison it.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Preview the cleanup (dry run)
|
||||
|
||||
Run the installer with `--dry-run` to see exactly what it would change without
|
||||
touching anything:
|
||||
|
||||
```bash
|
||||
npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global --dry-run
|
||||
```
|
||||
|
||||
The command prints the removal plan — each file path and the reason it would
|
||||
be deleted — and lists any stale update-cache files it would clear, then exits
|
||||
without making any modifications.
|
||||
|
||||
Swap `--claude` for the flag matching your runtime if you use a different one
|
||||
(see the [runtime flags table](manual-update.md#runtime-flags)).
|
||||
|
||||
### 2. Apply the cleanup
|
||||
|
||||
Run the same installer without `--dry-run`:
|
||||
|
||||
```bash
|
||||
npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global
|
||||
```
|
||||
|
||||
The installer:
|
||||
|
||||
- Detects leftover `get-shit-done-cc` artifacts across all runtime config
|
||||
directories (`~/.claude`, `~/.gemini`, `~/.codex`, `~/.config/opencode`,
|
||||
`~/.kilo`, and others).
|
||||
- Removes orphaned hooks, commands, and any file that references the old
|
||||
package name.
|
||||
- Clears the stale shared update cache.
|
||||
- Preserves user-owned artifacts such as `dev-preferences.md`, custom agents,
|
||||
and any file not managed by GSD.
|
||||
|
||||
### 3. Manual fallback
|
||||
|
||||
If the installer cannot resolve `get-shit-done-cc` in your environment, or you
|
||||
prefer to clean up by hand:
|
||||
|
||||
1. **Check each runtime config directory** for a `get-shit-done/` subtree left
|
||||
by the old package:
|
||||
|
||||
```bash
|
||||
ls ~/.claude/get-shit-done/
|
||||
ls ~/.gemini/get-shit-done/
|
||||
ls ~/.codex/get-shit-done/
|
||||
ls ~/.config/opencode/get-shit-done/
|
||||
ls ~/.kilo/get-shit-done/
|
||||
```
|
||||
|
||||
Remove any directories found there that were written by `get-shit-done-cc`
|
||||
(the new package installs under the same path, so only remove the directory
|
||||
if you have not yet run the new installer for that runtime).
|
||||
|
||||
2. **Uninstall the old package** if it is still resolvable:
|
||||
|
||||
```bash
|
||||
npx get-shit-done-cc --uninstall
|
||||
```
|
||||
|
||||
3. **Delete the stale shared cache**:
|
||||
|
||||
```bash
|
||||
rm -f ~/.cache/gsd/gsd-update-check.json
|
||||
```
|
||||
|
||||
### 4. Verify
|
||||
|
||||
Open a new terminal session (or restart your AI runtime). The `⬆ /gsd:update`
|
||||
indicator should no longer appear in the statusline. You can confirm the
|
||||
installed version with:
|
||||
|
||||
```bash
|
||||
npx @opengsd/gsd-core@latest -- gsd-core --version
|
||||
```
|
||||
253
get-shit-done/bin/lib/legacy-cleanup.cjs
Normal file
253
get-shit-done/bin/lib/legacy-cleanup.cjs
Normal file
@@ -0,0 +1,253 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* legacy-cleanup.cjs — detect and remove leftover artifacts from the old package.
|
||||
*
|
||||
* Provides a pure-ish scan phase (planLegacyCleanup) and a thin IO applier
|
||||
* (applyLegacyCleanup) that together root out stale files from the old
|
||||
* package across every GSD-managed runtime config directory.
|
||||
*
|
||||
* Issue: #607
|
||||
*
|
||||
* House style: CommonJS, 'use strict', pure functions + thin IO appliers.
|
||||
* Seams (opts.fs, opts.logger) allow full unit-test coverage without touching
|
||||
* the real filesystem except in the apply phase.
|
||||
*/
|
||||
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Substring that identifies a file as belonging to the old package.
|
||||
* Assembled from parts so this source file itself never contains the literal
|
||||
* as a plain substring (avoids self-flagging if the content scan were ever
|
||||
* widened back to include this subtree).
|
||||
*/
|
||||
const OLD_PACKAGE_SIGNAL = 'get-shit-done' + '-cc';
|
||||
|
||||
/**
|
||||
* Subtrees within a configDir that GSD actively scans for old-package content.
|
||||
* Deliberately excludes 'get-shit-done' — the current package's own infra and
|
||||
* docs live there (CHANGELOG.md, this file, etc.) and are overwritten by
|
||||
* install anyway. Poisoning hooks from the old package live in 'hooks/', which
|
||||
* IS scanned.
|
||||
*/
|
||||
const GSD_MANAGED_SUBTREES = ['hooks', 'commands'];
|
||||
|
||||
/**
|
||||
* Extensions eligible for the content-reference scan.
|
||||
*
|
||||
* WHY: The current @opengsd/gsd-core package ships ZERO references to the old
|
||||
* package name in any code file (.js/.cjs/.mjs/.sh). Therefore a code file
|
||||
* that still contains that string is genuinely a leftover from the old package
|
||||
* and is safe to flag.
|
||||
*
|
||||
* Markdown, JSON, TOML, YAML, and other doc/config files, however,
|
||||
* legitimately cite the old name in historical or reference context
|
||||
* (e.g. CHANGELOG.md, workflow .md files). Scanning them caused the
|
||||
* installer to delete the freshly-installed get-shit-done/CHANGELOG.md,
|
||||
* breaking installs. Fix: restrict the content scan to code extensions only.
|
||||
*/
|
||||
const CODE_EXTENSIONS = new Set(['.js', '.cjs', '.mjs', '.sh']);
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return true if any segment of the absolute file path is `dev-preferences`
|
||||
* or the file is named `dev-preferences.md`. These are always user artifacts.
|
||||
*
|
||||
* @param {string} absPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDevPreferencesPath(absPath) {
|
||||
const parts = absPath.split(path.sep);
|
||||
return parts.some(
|
||||
(seg) => seg === 'dev-preferences' || seg === 'dev-preferences.md'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all file paths under `dir` (bounded; skips
|
||||
* unreadable entries silently).
|
||||
*
|
||||
* @param {string} dir
|
||||
* @param {object} fsMod - injectable fs module
|
||||
* @returns {string[]} absolute file paths
|
||||
*/
|
||||
function collectFilesUnder(dir, fsMod) {
|
||||
const results = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = fsMod.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...collectFilesUnder(full, fsMod));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the file at `absPath` contains the old-package substring.
|
||||
* Skips unreadable files (returns false on any error).
|
||||
*
|
||||
* @param {string} absPath
|
||||
* @param {object} fsMod
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function fileContainsOldPackageSignal(absPath, fsMod) {
|
||||
try {
|
||||
const content = fsMod.readFileSync(absPath, 'utf8');
|
||||
return content.includes(OLD_PACKAGE_SIGNAL);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan `configDirs` for leftover old-package artifacts and the legacy
|
||||
* shared cache, returning an ordered array of removal candidates.
|
||||
*
|
||||
* Possible reasons in returned entries:
|
||||
* - 'content-references-old-package': a code file whose content contains
|
||||
* the old package name signal (hooks/ and commands/ subtrees only).
|
||||
* - 'legacy-shared-cache': the old package's shared update-check cache file.
|
||||
*
|
||||
* @param {string[]} configDirs - absolute paths to runtime config dirs to scan
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.homeDir] - home directory (default: os.homedir())
|
||||
* @param {object} [opts.fs] - injectable fs module (default: require('node:fs'))
|
||||
* @returns {{ path: string, reason: string }[]}
|
||||
*/
|
||||
function planLegacyCleanup(configDirs, opts = {}) {
|
||||
const homeDir = opts.homeDir || os.homedir();
|
||||
const fsMod = opts.fs || fs;
|
||||
|
||||
/** @type {Map<string, string>} path → reason (de-dup by path) */
|
||||
const candidates = new Map();
|
||||
|
||||
const addCandidate = (absPath, reason) => {
|
||||
if (!candidates.has(absPath)) {
|
||||
candidates.set(absPath, reason);
|
||||
}
|
||||
};
|
||||
|
||||
for (const configDir of configDirs) {
|
||||
for (const subtree of GSD_MANAGED_SUBTREES) {
|
||||
const subtreeDir = path.join(configDir, subtree);
|
||||
|
||||
// Collect all files under this subtree (skip if absent)
|
||||
const files = collectFilesUnder(subtreeDir, fsMod);
|
||||
|
||||
for (const absPath of files) {
|
||||
// Never flag user-authored dev-preferences artifacts
|
||||
if (isDevPreferencesPath(absPath)) continue;
|
||||
|
||||
// Content signal: code files referencing the old package name.
|
||||
// Only scan files with code extensions — docs/config files (.md, .json,
|
||||
// .yml, etc.) legitimately cite the old name in historical context and
|
||||
// must never be deleted (see CODE_EXTENSIONS declaration above).
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
if (CODE_EXTENSIONS.has(ext) && fileContainsOldPackageSignal(absPath, fsMod)) {
|
||||
addCandidate(absPath, 'content-references-old-package');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy shared cache (fixed name from the old package)
|
||||
const legacyCachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
try {
|
||||
const stat = fsMod.statSync(legacyCachePath);
|
||||
if (stat.isFile()) {
|
||||
addCandidate(legacyCachePath, 'legacy-shared-cache');
|
||||
}
|
||||
} catch {
|
||||
// absent — skip
|
||||
}
|
||||
|
||||
// Sort deterministically by path
|
||||
const sorted = [...candidates.entries()]
|
||||
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
||||
.map(([p, reason]) => ({ path: p, reason }));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the plan returned by `planLegacyCleanup`.
|
||||
*
|
||||
* @param {{ path: string, reason: string }[]} plan
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.dryRun=false] - when true, log but do not remove
|
||||
* @param {object} [opts.fs] - injectable fs module
|
||||
* @param {object} [opts.logger] - injectable logger (default: console)
|
||||
* @returns {{ removed: string[], skipped: string[], errors: Array<{path:string,error:string}>, dryRun: boolean }}
|
||||
*/
|
||||
function applyLegacyCleanup(plan, opts = {}) {
|
||||
const dryRun = opts.dryRun === true;
|
||||
const fsMod = opts.fs || fs;
|
||||
const logger = opts.logger || console;
|
||||
|
||||
if (dryRun) {
|
||||
for (const item of plan) {
|
||||
logger.log('[dry-run] would remove: ' + item.path + ' (' + item.reason + ')');
|
||||
}
|
||||
return {
|
||||
removed: [],
|
||||
skipped: plan.map((item) => item.path),
|
||||
errors: [],
|
||||
dryRun: true,
|
||||
};
|
||||
}
|
||||
|
||||
const removed = [];
|
||||
const errors = [];
|
||||
|
||||
for (const item of plan) {
|
||||
let lastErr;
|
||||
const maxAttempts = process.platform === 'win32' ? 3 : 1;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
// Synchronous 100ms delay before retry (win32 EBUSY/EPERM from Defender)
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
||||
}
|
||||
fsMod.rmSync(item.path, { force: true });
|
||||
lastErr = undefined;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (process.platform !== 'win32' ||
|
||||
(err.code !== 'EBUSY' && err.code !== 'EPERM')) {
|
||||
break; // non-retryable error; stop immediately
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastErr) {
|
||||
errors.push({ path: item.path, error: lastErr.message });
|
||||
} else {
|
||||
removed.push(item.path);
|
||||
}
|
||||
}
|
||||
|
||||
return { removed, skipped: [], errors, dryRun: false };
|
||||
}
|
||||
|
||||
// ─── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
planLegacyCleanup,
|
||||
applyLegacyCleanup,
|
||||
};
|
||||
@@ -8,6 +8,8 @@ const binName = "gsd-core";
|
||||
const repoSlug = "open-gsd/gsd-core";
|
||||
const repoUrl = "https://github.com/open-gsd/gsd-core";
|
||||
const changelogRawUrl = "https://raw.githubusercontent.com/open-gsd/gsd-core/main/CHANGELOG.md";
|
||||
const cacheSlug = "opengsd-gsd-core";
|
||||
const updateCacheFileName = "gsd-update-check-opengsd-gsd-core.json";
|
||||
|
||||
function formatManualInstall({ packageName, binName, scope, runtime } = {}) {
|
||||
const runtimeFlag = runtime ? ` --${runtime}` : '';
|
||||
@@ -21,11 +23,13 @@ function manualInstallCommand(opts = {}) {
|
||||
module.exports = Object.freeze({
|
||||
packageName,
|
||||
// PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it
|
||||
// survives the installed tree’s synthetic package.json (fixes the #378 undefined).
|
||||
// survives the installed tree's synthetic package.json (fixes the #378 undefined).
|
||||
PACKAGE_NAME: packageName,
|
||||
binName,
|
||||
repoSlug,
|
||||
repoUrl,
|
||||
changelogRawUrl,
|
||||
cacheSlug,
|
||||
updateCacheFileName,
|
||||
manualInstallCommand,
|
||||
});
|
||||
|
||||
@@ -379,22 +379,47 @@ fi
|
||||
if [ -n "$CODEX_HOME" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$CODEX_HOME")" )
|
||||
fi
|
||||
if [ -n "$CURSOR_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$CURSOR_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$WINDSURF_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$WINDSURF_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$AUGMENT_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$AUGMENT_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$TRAE_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$TRAE_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$QWEN_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$QWEN_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$HERMES_HOME" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$HERMES_HOME")" )
|
||||
fi
|
||||
if [ -n "$CODEBUDDY_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$CODEBUDDY_CONFIG_DIR")" )
|
||||
fi
|
||||
if [ -n "$CLINE_CONFIG_DIR" ]; then
|
||||
CACHE_DIRS+=( "$(expand_home "$CLINE_CONFIG_DIR")" )
|
||||
fi
|
||||
|
||||
for dir in "${CACHE_DIRS[@]}"; do
|
||||
if [ -n "$dir" ]; then
|
||||
rm -f "$dir/cache/gsd-update-check.json"
|
||||
rm -f "$dir/cache/gsd-update-check"*.json
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex; do
|
||||
rm -f "./$dir/cache/gsd-update-check.json"
|
||||
rm -f "$HOME/$dir/cache/gsd-update-check.json"
|
||||
for dir in .claude .config/opencode .opencode .gemini/antigravity-ide .gemini/antigravity-cli .gemini/antigravity .agent .gemini .config/kilo .kilo .codex .cursor .codeium/windsurf .augment .trae .qwen .hermes .codebuddy .cline; do
|
||||
rm -f "./$dir/cache/gsd-update-check"*.json
|
||||
rm -f "$HOME/$dir/cache/gsd-update-check"*.json
|
||||
done
|
||||
|
||||
# Clear the shared tool-agnostic cache written by gsd-check-update.js hook (#2784).
|
||||
# The hook uses ~/.cache/gsd/gsd-update-check.json regardless of runtime; clear it
|
||||
# so the statusline stops showing the stale "⬆ /gsd:update" indicator after update.
|
||||
rm -f "$HOME/.cache/gsd/gsd-update-check.json"
|
||||
# The hook uses ~/.cache/gsd/gsd-update-check.json (legacy) or a per-package name
|
||||
# like gsd-update-check-opengsd-gsd-core.json; the glob clears all variants so the
|
||||
# statusline stops showing the stale "⬆ /gsd:update" indicator after update.
|
||||
rm -f "$HOME/.cache/gsd/gsd-update-check"*.json
|
||||
```
|
||||
|
||||
The SessionStart hook (`gsd-check-update.js`) writes to the detected runtime's cache directory, so preferred/env-derived paths and default paths must all be cleared to prevent stale update indicators.
|
||||
|
||||
@@ -19,9 +19,21 @@ const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs')
|
||||
// in the installed tree (only a {"type":"commonjs"} marker ships), so the
|
||||
// background check never reported updates.
|
||||
const { checkLatestVersion } = require('../get-shit-done/bin/check-latest-version.cjs');
|
||||
const { PACKAGE_NAME } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
// Authoritative list of managed hooks — shared with tests to retire source-grep
|
||||
// assertions (pending-migration-to-typed-ir [#455]).
|
||||
const { MANAGED_HOOKS } = require('./managed-hooks-registry.cjs');
|
||||
// NOTE: managed-hooks-registry.cjs must be in HOOKS_TO_COPY (scripts/build-hooks.js)
|
||||
// so it is present in hooks/dist/ and ships to the installed runtime hooks/ dir.
|
||||
// If it is missing (e.g., installed from an older dist), catch and degrade gracefully
|
||||
// so the worker always proceeds to compute and write the result cache record.
|
||||
let MANAGED_HOOKS = [];
|
||||
try {
|
||||
({ MANAGED_HOOKS } = require('./managed-hooks-registry.cjs'));
|
||||
} catch (e) {
|
||||
// Module not found in installed runtime — stale-hook detection degrades to
|
||||
// no-op (empty list means no hooks are checked for staleness). The worker
|
||||
// still runs and writes package_name / installed / latest / update_available.
|
||||
}
|
||||
|
||||
const cacheFile = process.env.GSD_CACHE_FILE;
|
||||
const projectVersionFile = process.env.GSD_PROJECT_VERSION_FILE;
|
||||
@@ -88,6 +100,7 @@ const result = {
|
||||
latest: latest || 'unknown',
|
||||
checked: Math.floor(Date.now() / 1000),
|
||||
stale_hooks: staleHooks.length > 0 ? staleHooks : undefined,
|
||||
package_name: PACKAGE_NAME,
|
||||
};
|
||||
|
||||
if (cacheFile) {
|
||||
|
||||
@@ -8,6 +8,8 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const { updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
|
||||
@@ -33,7 +35,7 @@ const projectConfigDir = detectConfigDir(cwd);
|
||||
// resolution mismatches where check-update writes to one runtime's cache
|
||||
// but statusline reads from another (#1421).
|
||||
const cacheDir = path.join(homeDir, '.cache', 'gsd');
|
||||
const cacheFile = path.join(cacheDir, 'gsd-update-check.json');
|
||||
const cacheFile = path.join(cacheDir, updateCacheFileName);
|
||||
|
||||
// VERSION file locations (check project first, then global)
|
||||
const projectVersionFile = path.join(projectConfigDir, 'get-shit-done', 'VERSION');
|
||||
|
||||
@@ -7,6 +7,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { isSemverNewer } = require('../get-shit-done/bin/lib/semver-compare.cjs');
|
||||
const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
|
||||
// --- Config + last-command readers ------------------------------------------
|
||||
|
||||
@@ -395,32 +396,22 @@ function runStatusline() {
|
||||
const gsdStateStr = task ? '' : formatGsdState(readGsdState(dir) || {});
|
||||
|
||||
// GSD update available?
|
||||
// Check shared cache first (#1421), fall back to runtime-specific cache for
|
||||
// backward compatibility with older gsd-check-update.js versions.
|
||||
// Read only the per-package shared cache file (#607). The legacy
|
||||
// runtime-specific fallback has been removed — the per-package filename
|
||||
// carries lineage and avoids multi-runtime resolution mismatches (#1421).
|
||||
let gsdUpdate = '';
|
||||
const sharedCacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
const legacyCacheFile = path.join(claudeDir, 'cache', 'gsd-update-check.json');
|
||||
const cacheFile = fs.existsSync(sharedCacheFile) ? sharedCacheFile : legacyCacheFile;
|
||||
const cacheFile = path.join(homeDir, '.cache', 'gsd', updateCacheFileName);
|
||||
if (fs.existsSync(cacheFile)) {
|
||||
try {
|
||||
const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||
if (cache.update_available) {
|
||||
const { showUpdate, staleWarning } = evaluateUpdateCache(cache);
|
||||
if (showUpdate) {
|
||||
gsdUpdate = '\x1b[33m⬆ /gsd:update\x1b[0m │ ';
|
||||
}
|
||||
if (cache.stale_hooks && cache.stale_hooks.length > 0) {
|
||||
// If installed version is ahead of npm latest, this is a dev install.
|
||||
// Running /gsd:update would downgrade — show a contextual warning instead.
|
||||
const isDevInstall = (
|
||||
cache.installed &&
|
||||
cache.latest &&
|
||||
cache.latest !== 'unknown' &&
|
||||
isInstalledAheadOfLatest(cache.installed, cache.latest)
|
||||
);
|
||||
if (isDevInstall) {
|
||||
gsdUpdate += '\x1b[33m⚠ dev install — re-run installer to sync hooks\x1b[0m │ ';
|
||||
} else {
|
||||
gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd:update\x1b[0m │ ';
|
||||
}
|
||||
if (staleWarning === 'dev') {
|
||||
gsdUpdate += '\x1b[33m⚠ dev install — re-run installer to sync hooks\x1b[0m │ ';
|
||||
} else if (staleWarning === 'stale') {
|
||||
gsdUpdate += '\x1b[31m⚠ stale hooks — run /gsd:update\x1b[0m │ ';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -507,12 +498,39 @@ function isInstalledAheadOfLatest(installed, latest) {
|
||||
return isSemverNewer(installed, latest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function: evaluate an update-check cache object and return display flags.
|
||||
* Applies lineage guard — if package_name is absent or foreign, treats cache as absent.
|
||||
*
|
||||
* @param {object|null} cache Parsed cache object, or null.
|
||||
* @returns {{ showUpdate: boolean, staleWarning: 'none'|'dev'|'stale' }}
|
||||
*/
|
||||
function evaluateUpdateCache(cache) {
|
||||
const none = { showUpdate: false, staleWarning: 'none' };
|
||||
if (!cache) return none;
|
||||
// Lineage guard: package_name must be present and match this package.
|
||||
if (!cache.package_name || cache.package_name !== PACKAGE_NAME) return none;
|
||||
const showUpdate = Boolean(cache.update_available);
|
||||
let staleWarning = 'none';
|
||||
if (cache.stale_hooks && cache.stale_hooks.length > 0) {
|
||||
const isDevInstall = (
|
||||
cache.installed &&
|
||||
cache.latest &&
|
||||
cache.latest !== 'unknown' &&
|
||||
isInstalledAheadOfLatest(cache.installed, cache.latest)
|
||||
);
|
||||
staleWarning = isDevInstall ? 'dev' : 'stale';
|
||||
}
|
||||
return { showUpdate, staleWarning };
|
||||
}
|
||||
|
||||
// Export helpers for unit tests. Harmless when run as a script.
|
||||
module.exports = {
|
||||
readGsdState, parseStateMd, formatGsdState,
|
||||
readGsdConfig, getConfigValue, readLastSlashCommand,
|
||||
composeStatusline,
|
||||
isInstalledAheadOfLatest,
|
||||
evaluateUpdateCache,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
// SessionStart banner that surfaces GSD update availability when GSD's
|
||||
// statusline isn't installed. Reads the cache that
|
||||
// gsd-check-update-worker.js writes to ~/.cache/gsd/gsd-update-check.json.
|
||||
// gsd-check-update-worker.js writes to ~/.cache/gsd/<updateCacheFileName> (per-package).
|
||||
//
|
||||
// Opt-in by design: bin/install.js only registers this hook when the user
|
||||
// declines to install (or replace) the GSD statusline. The presence of the
|
||||
@@ -15,6 +15,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
|
||||
// Suppress repeat parse-error banners for 24 hours so a genuinely broken
|
||||
// cache file doesn't nag the user every session.
|
||||
@@ -37,6 +38,9 @@ function buildBannerOutput(state) {
|
||||
return { systemMessage: 'GSD update check failed.' };
|
||||
}
|
||||
if (!cache) return null;
|
||||
// Lineage guard: package_name must be present and match this package.
|
||||
// Absent package_name means the cache predates lineage tracking — treat as untrusted.
|
||||
if (!cache.package_name || cache.package_name !== PACKAGE_NAME) return null;
|
||||
if (!cache.update_available) return null;
|
||||
const installed = cache.installed || 'unknown';
|
||||
const latest = cache.latest || 'unknown';
|
||||
@@ -96,7 +100,7 @@ function recordFailureWarning(sentinelFile, nowSeconds) {
|
||||
|
||||
function main() {
|
||||
const cacheDir = path.join(os.homedir(), '.cache', 'gsd');
|
||||
const cacheFile = path.join(cacheDir, 'gsd-update-check.json');
|
||||
const cacheFile = path.join(cacheDir, updateCacheFileName);
|
||||
const sentinelFile = path.join(cacheDir, 'banner-failure-warned-at');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
|
||||
const HOOKS_TO_COPY = [
|
||||
'gsd-check-update-worker.js',
|
||||
'gsd-check-update.js',
|
||||
// Required by gsd-check-update-worker.js at runtime — must ship alongside it
|
||||
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
|
||||
'managed-hooks-registry.cjs',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
|
||||
@@ -24,6 +24,21 @@ function parseRepoSlug(repository) {
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: turn an npm package name into a filesystem-safe slug for cache filenames.
|
||||
* Strips a leading `@`, replaces `/` with `-`, then collapses any run of
|
||||
* characters that are NOT `[a-z0-9]` to a single `-`, and trims leading/trailing `-`.
|
||||
*/
|
||||
function slugifyPackageName(name) {
|
||||
if (!name) return '';
|
||||
return name
|
||||
.replace(/^@/, '')
|
||||
.replace(/\//g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: package.json object -> the package identity coordinates.
|
||||
*/
|
||||
@@ -35,7 +50,9 @@ function deriveIdentity(pkg = {}) {
|
||||
const changelogRawUrl = repoSlug
|
||||
? `https://raw.githubusercontent.com/${repoSlug}/main/CHANGELOG.md`
|
||||
: '';
|
||||
return { packageName, binName, repoSlug, repoUrl, changelogRawUrl };
|
||||
const cacheSlug = slugifyPackageName(packageName);
|
||||
const updateCacheFileName = cacheSlug ? `gsd-update-check-${cacheSlug}.json` : 'gsd-update-check.json';
|
||||
return { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +79,7 @@ const GENERATED_HEADER =
|
||||
* runtime command builder is byte-identical to the tested source above.
|
||||
*/
|
||||
function render(identity) {
|
||||
const { packageName, binName, repoSlug, repoUrl, changelogRawUrl } = identity;
|
||||
const { packageName, binName, repoSlug, repoUrl, changelogRawUrl, cacheSlug, updateCacheFileName } = identity;
|
||||
const j = (v) => JSON.stringify(v);
|
||||
return (
|
||||
GENERATED_HEADER +
|
||||
@@ -71,7 +88,9 @@ function render(identity) {
|
||||
`const binName = ${j(binName)};\n` +
|
||||
`const repoSlug = ${j(repoSlug)};\n` +
|
||||
`const repoUrl = ${j(repoUrl)};\n` +
|
||||
`const changelogRawUrl = ${j(changelogRawUrl)};\n\n` +
|
||||
`const changelogRawUrl = ${j(changelogRawUrl)};\n` +
|
||||
`const cacheSlug = ${j(cacheSlug)};\n` +
|
||||
`const updateCacheFileName = ${j(updateCacheFileName)};\n\n` +
|
||||
`${formatManualInstall.toString()}\n\n` +
|
||||
'function manualInstallCommand(opts = {}) {\n' +
|
||||
' return formatManualInstall({ packageName, binName, scope: opts.scope, runtime: opts.runtime });\n' +
|
||||
@@ -79,12 +98,14 @@ function render(identity) {
|
||||
'module.exports = Object.freeze({\n' +
|
||||
' packageName,\n' +
|
||||
' // PACKAGE_NAME: back-compat alias for #516-era consumers. Baked here, so it\n' +
|
||||
' // survives the installed tree’s synthetic package.json (fixes the #378 undefined).\n' +
|
||||
" // survives the installed tree's synthetic package.json (fixes the #378 undefined).\n" +
|
||||
' PACKAGE_NAME: packageName,\n' +
|
||||
' binName,\n' +
|
||||
' repoSlug,\n' +
|
||||
' repoUrl,\n' +
|
||||
' changelogRawUrl,\n' +
|
||||
' cacheSlug,\n' +
|
||||
' updateCacheFileName,\n' +
|
||||
' manualInstallCommand,\n' +
|
||||
'});\n'
|
||||
);
|
||||
@@ -101,4 +122,4 @@ function main() {
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { deriveIdentity, parseRepoSlug, formatManualInstall, render, main };
|
||||
module.exports = { deriveIdentity, parseRepoSlug, slugifyPackageName, formatManualInstall, render, main };
|
||||
|
||||
@@ -71,12 +71,12 @@ describe('bug-2784: update.md cache-clear covers shared cache path', () => {
|
||||
}
|
||||
|
||||
const sharedCacheClearCmds = bashLines.filter(
|
||||
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check.json')
|
||||
(line) => /^rm\b/.test(line) && line.includes('.cache/gsd/gsd-update-check') && line.includes('*.json')
|
||||
);
|
||||
assert.ok(
|
||||
sharedCacheClearCmds.length > 0,
|
||||
[
|
||||
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check.json.',
|
||||
'run_update step bash blocks must include an `rm` command targeting .cache/gsd/gsd-update-check*.json (glob form clearing legacy + per-package variants).',
|
||||
`Bash lines found: ${JSON.stringify(bashLines)}`,
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
@@ -17,8 +17,8 @@ const {
|
||||
renderGithubReleaseNotes,
|
||||
} = require(path.join(ROOT, 'scripts', 'changeset', 'github-release-notes.cjs'));
|
||||
|
||||
function run(command, args, cwd) {
|
||||
const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8' });
|
||||
function run(command, args, cwd, env) {
|
||||
const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8', env: env || process.env });
|
||||
assert.equal(result.status, 0, `${command} ${args.join(' ')}\nstdout=${result.stdout}\nstderr=${result.stderr}`);
|
||||
return result.stdout;
|
||||
}
|
||||
@@ -31,19 +31,34 @@ function writeFragment(repo, name, type, pr, body) {
|
||||
|
||||
function createTaggedRepo() {
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-release-notes-'));
|
||||
run('git', ['init', '-q'], repo);
|
||||
run('git', ['config', 'user.email', 'test@example.com'], repo);
|
||||
run('git', ['config', 'user.name', 'Test User'], repo);
|
||||
// Isolate from the developer's global/system git config (e.g. gpgSign settings)
|
||||
// by pointing GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM at an empty file inside
|
||||
// the temp dir. This prevents tag.gpgSign / commit.gpgSign / tag.forceSignAnnotated
|
||||
// from leaking in and breaking lightweight tags or unsigned commits.
|
||||
const emptyGitConfig = path.join(repo, '.git-config-empty');
|
||||
fs.writeFileSync(emptyGitConfig, '');
|
||||
const gitEnv = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: emptyGitConfig,
|
||||
GIT_CONFIG_SYSTEM: emptyGitConfig,
|
||||
};
|
||||
run('git', ['init', '-q'], repo, gitEnv);
|
||||
// Belt-and-suspenders: also set local repo config to disable signing
|
||||
run('git', ['config', 'user.email', 'test@example.com'], repo, gitEnv);
|
||||
run('git', ['config', 'user.name', 'Test User'], repo, gitEnv);
|
||||
run('git', ['config', 'commit.gpgSign', 'false'], repo, gitEnv);
|
||||
run('git', ['config', 'tag.gpgSign', 'false'], repo, gitEnv);
|
||||
run('git', ['config', 'tag.forceSignAnnotated', 'false'], repo, gitEnv);
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n');
|
||||
run('git', ['add', 'README.md'], repo);
|
||||
run('git', ['commit', '-q', '-m', 'initial'], repo);
|
||||
run('git', ['tag', 'v1.0.0'], repo);
|
||||
run('git', ['add', 'README.md'], repo, gitEnv);
|
||||
run('git', ['commit', '-q', '-m', 'initial'], repo, gitEnv);
|
||||
run('git', ['tag', 'v1.0.0'], repo, gitEnv);
|
||||
|
||||
writeFragment(repo, 'fix-install-sdk', 'Fixed', 101, '**`gsd-sdk` now installs reliably** — persistent PATH is checked.');
|
||||
writeFragment(repo, 'remove-intel-noise', 'Removed', 102, '**`gsd-intel-updater` no longer emits layout detection noise** — ordinary projects stay quiet.');
|
||||
run('git', ['add', '.changeset'], repo);
|
||||
run('git', ['commit', '-q', '-m', 'add changesets'], repo);
|
||||
run('git', ['tag', 'v1.0.1'], repo);
|
||||
run('git', ['add', '.changeset'], repo, gitEnv);
|
||||
run('git', ['commit', '-q', '-m', 'add changesets'], repo, gitEnv);
|
||||
run('git', ['tag', 'v1.0.1'], repo, gitEnv);
|
||||
return repo;
|
||||
}
|
||||
|
||||
|
||||
@@ -1265,26 +1265,62 @@ describe('shared cache directory (#1421)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-statusline.js checks shared cache first, falls back to legacy (#1421)', () => {
|
||||
const content = fs.readFileSync(
|
||||
test('gsd-statusline.js reads the per-package shared cache and rejects foreign lineage (#1421/#607)', () => {
|
||||
const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
|
||||
const { updateCacheFileName, PACKAGE_NAME } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
|
||||
// Per-package filename embeds the package identity — no generic fallback
|
||||
assert.strictEqual(
|
||||
updateCacheFileName,
|
||||
'gsd-update-check-opengsd-gsd-core.json',
|
||||
'updateCacheFileName must be the per-package filename'
|
||||
);
|
||||
|
||||
// The statusline must NOT reference a legacyCacheFile — the legacy fallback was removed
|
||||
// allow-test-rule: architectural-invariant
|
||||
const statuslineSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8'
|
||||
);
|
||||
// Statusline must check the shared cache path first
|
||||
assert.ok(
|
||||
content.includes("path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json')"),
|
||||
'statusline must check shared cache at ~/.cache/gsd/gsd-update-check.json'
|
||||
!statuslineSrc.includes('legacyCacheFile'),
|
||||
'gsd-statusline.js must not reference legacyCacheFile — legacy fallback was removed in #607'
|
||||
);
|
||||
// Must fall back to legacy runtime-specific cache for backward compat
|
||||
assert.ok(
|
||||
content.includes("path.join(claudeDir, 'cache', 'gsd-update-check.json')"),
|
||||
'statusline must fall back to legacy cache at claudeDir/cache/gsd-update-check.json'
|
||||
statuslineSrc.includes(updateCacheFileName) || statuslineSrc.includes('updateCacheFileName'),
|
||||
'gsd-statusline.js must reference the per-package updateCacheFileName'
|
||||
);
|
||||
// Shared cache must be checked before legacy (existsSync order matters)
|
||||
const sharedIdx = content.indexOf('sharedCacheFile');
|
||||
const legacyIdx = content.indexOf('legacyCacheFile');
|
||||
assert.ok(
|
||||
sharedIdx < legacyIdx,
|
||||
'shared cache must be defined and checked before legacy cache'
|
||||
|
||||
// evaluateUpdateCache: foreign package_name → no update shown
|
||||
assert.deepStrictEqual(
|
||||
evaluateUpdateCache({ package_name: 'other-package', update_available: true }),
|
||||
{ showUpdate: false, staleWarning: 'none' },
|
||||
'foreign package_name must be rejected (lineage guard)'
|
||||
);
|
||||
|
||||
// evaluateUpdateCache: absent package_name → no update shown
|
||||
assert.deepStrictEqual(
|
||||
evaluateUpdateCache({ update_available: true }),
|
||||
{ showUpdate: false, staleWarning: 'none' },
|
||||
'absent package_name must be rejected (lineage guard)'
|
||||
);
|
||||
|
||||
// evaluateUpdateCache: null cache → no update shown
|
||||
assert.deepStrictEqual(
|
||||
evaluateUpdateCache(null),
|
||||
{ showUpdate: false, staleWarning: 'none' },
|
||||
'null cache must return no-update'
|
||||
);
|
||||
|
||||
// evaluateUpdateCache: matching package_name + update_available:true → show update
|
||||
const result = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: true });
|
||||
assert.strictEqual(result.showUpdate, true,
|
||||
'matching package_name with update_available:true must set showUpdate=true'
|
||||
);
|
||||
|
||||
// evaluateUpdateCache: matching package_name + update_available:false → no update
|
||||
const noUpdate = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: false });
|
||||
assert.strictEqual(noUpdate.showUpdate, false,
|
||||
'matching package_name with update_available:false must not show update'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const {
|
||||
shouldSuppressFailureWarning,
|
||||
RATE_LIMIT_SECONDS,
|
||||
} = require('../hooks/gsd-update-banner.js');
|
||||
const { updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
|
||||
// ─── Pure function: buildBannerOutput ───────────────────────────────────────
|
||||
|
||||
@@ -51,7 +52,7 @@ describe('buildBannerOutput', () => {
|
||||
|
||||
test('returns banner envelope when update_available is true', () => {
|
||||
const out = buildBannerOutput({
|
||||
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0' },
|
||||
cache: { update_available: true, installed: '1.39.0', latest: '1.40.0', package_name: '@opengsd/gsd-core' },
|
||||
parseError: false,
|
||||
suppressFailureWarning: false,
|
||||
});
|
||||
@@ -96,7 +97,7 @@ describe('buildBannerOutput', () => {
|
||||
|
||||
test('falls back to "unknown" when installed/latest missing', () => {
|
||||
const out = buildBannerOutput({
|
||||
cache: { update_available: true },
|
||||
cache: { update_available: true, package_name: '@opengsd/gsd-core' },
|
||||
parseError: false,
|
||||
suppressFailureWarning: false,
|
||||
});
|
||||
@@ -183,7 +184,7 @@ describe('gsd-update-banner.js end-to-end', () => {
|
||||
|
||||
function writeCache(home, contents) {
|
||||
fs.writeFileSync(
|
||||
path.join(home, '.cache', 'gsd', 'gsd-update-check.json'),
|
||||
path.join(home, '.cache', 'gsd', updateCacheFileName),
|
||||
typeof contents === 'string' ? contents : JSON.stringify(contents)
|
||||
);
|
||||
}
|
||||
@@ -206,6 +207,7 @@ describe('gsd-update-banner.js end-to-end', () => {
|
||||
update_available: true,
|
||||
installed: '1.39.0',
|
||||
latest: '1.40.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
});
|
||||
const r = runHook(home);
|
||||
assert.equal(r.status, 0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Tests for gsd-tools effort sync command (#488)
|
||||
// Verifies that effort frontmatter in installed agent files can be re-synced
|
||||
// when effort config changes after initial install.
|
||||
// allow-test-rule: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Covers: query, status, diff, validate, snapshot, patch-meta,
|
||||
* extract-exports, enabled/disabled gating, and CLI routing via gsd-tools.
|
||||
*/
|
||||
// allow-test-rule: source-text-is-the-product — readFileSync assertions target API-SURFACE.md, which is the generated product of intelApiSurface; asserting on its text content is the only way to verify correct generation.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { deriveIdentity, formatManualInstall, render } = require(
|
||||
const { deriveIdentity, formatManualInstall, render, slugifyPackageName } = require(
|
||||
path.join(ROOT, 'scripts', 'generate-package-identity.cjs'),
|
||||
);
|
||||
const GENERATED = path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs');
|
||||
@@ -57,6 +57,28 @@ describe('Issue #498: deriveIdentity (pure, package.json -> coordinates)', () =>
|
||||
assert.equal(id.binName, 'gsd-core');
|
||||
assert.equal(id.repoSlug, 'open-gsd/gsd-core');
|
||||
});
|
||||
|
||||
test('deriveIdentity returns cacheSlug for @opengsd/gsd-core', () => {
|
||||
const real = require(path.join(ROOT, 'package.json'));
|
||||
const id = deriveIdentity(real);
|
||||
assert.equal(id.cacheSlug, 'opengsd-gsd-core');
|
||||
});
|
||||
|
||||
test('deriveIdentity returns updateCacheFileName for @opengsd/gsd-core', () => {
|
||||
const real = require(path.join(ROOT, 'package.json'));
|
||||
const id = deriveIdentity(real);
|
||||
assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue #498: slugifyPackageName (pure helper for cache filename)', () => {
|
||||
test('slugifyPackageName strips leading @, replaces / with -, for @opengsd/gsd-core', () => {
|
||||
assert.equal(slugifyPackageName('@opengsd/gsd-core'), 'opengsd-gsd-core');
|
||||
});
|
||||
|
||||
test('slugifyPackageName returns empty string for empty input', () => {
|
||||
assert.equal(slugifyPackageName(''), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue #498: formatManualInstall (the npx fallback command)', () => {
|
||||
@@ -91,6 +113,7 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => {
|
||||
// bug-3707). The sync check is about content, not the checkout's eol.
|
||||
const norm = (s) => s.replace(/\r\n/g, '\n');
|
||||
const expected = render(deriveIdentity(require(path.join(ROOT, 'package.json'))));
|
||||
// allow-test-rule: architectural-invariant
|
||||
const actual = fs.readFileSync(GENERATED, 'utf8');
|
||||
assert.equal(norm(actual), norm(expected),
|
||||
'package-identity.cjs is stale — run `node scripts/generate-package-identity.cjs`');
|
||||
@@ -103,6 +126,16 @@ describe('Issue #498: generated runtime module (baked, drift-checked)', () => {
|
||||
assert.equal(id.repoSlug, 'open-gsd/gsd-core');
|
||||
});
|
||||
|
||||
test('generated module exports cacheSlug matching @opengsd/gsd-core', () => {
|
||||
const id = require(GENERATED);
|
||||
assert.equal(id.cacheSlug, 'opengsd-gsd-core');
|
||||
});
|
||||
|
||||
test('generated module exports updateCacheFileName matching @opengsd/gsd-core', () => {
|
||||
const id = require(GENERATED);
|
||||
assert.equal(id.updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
|
||||
});
|
||||
|
||||
test('generated manualInstallCommand closes over the baked coordinates', () => {
|
||||
const id = require(GENERATED);
|
||||
assert.equal(
|
||||
|
||||
177
tests/issue-607-cache-lineage.test.cjs
Normal file
177
tests/issue-607-cache-lineage.test.cjs
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for cache lineage validation (issue #607).
|
||||
*
|
||||
* Verifies that per-package cache filenames and package_name lineage guards
|
||||
* are correctly enforced across gsd-update-banner.js, gsd-statusline.js,
|
||||
* and the worker result shape.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { PACKAGE_NAME, updateCacheFileName } = require('../get-shit-done/bin/lib/package-identity.cjs');
|
||||
const { buildBannerOutput } = require('../hooks/gsd-update-banner.js');
|
||||
const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
|
||||
|
||||
// ─── Package identity constants ──────────────────────────────────────────────
|
||||
|
||||
describe('package-identity exports', () => {
|
||||
test('PACKAGE_NAME is @opengsd/gsd-core', () => {
|
||||
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
|
||||
});
|
||||
|
||||
test('updateCacheFileName is per-package filename', () => {
|
||||
assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Worker result shape: package_name field ─────────────────────────────────
|
||||
// The worker writes { ..., package_name: PACKAGE_NAME } to the cache.
|
||||
// We assert the documented contract by confirming PACKAGE_NAME is correct
|
||||
// and that it equals the value that the worker will embed.
|
||||
|
||||
describe('worker result shape contract', () => {
|
||||
test('PACKAGE_NAME value matches the expected installed package', () => {
|
||||
// The worker adds package_name: PACKAGE_NAME to its result object.
|
||||
// This test asserts the value that will appear in the cache.
|
||||
assert.equal(PACKAGE_NAME, '@opengsd/gsd-core');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildBannerOutput: lineage guard ────────────────────────────────────────
|
||||
|
||||
describe('buildBannerOutput lineage guard', () => {
|
||||
test('returns null when package_name is present but foreign', () => {
|
||||
const out = buildBannerOutput({
|
||||
cache: {
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.42.3',
|
||||
package_name: 'get-shit-done-cc',
|
||||
},
|
||||
parseError: false,
|
||||
suppressFailureWarning: false,
|
||||
});
|
||||
assert.equal(out, null, 'foreign lineage must be rejected');
|
||||
});
|
||||
|
||||
test('returns banner when package_name matches PACKAGE_NAME', () => {
|
||||
const out = buildBannerOutput({
|
||||
cache: {
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.3.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
},
|
||||
parseError: false,
|
||||
suppressFailureWarning: false,
|
||||
});
|
||||
assert.ok(out, 'expected banner envelope for matching lineage');
|
||||
assert.equal(typeof out.systemMessage, 'string');
|
||||
assert.ok(out.systemMessage.includes('1.2.0'));
|
||||
assert.ok(out.systemMessage.includes('1.3.0'));
|
||||
assert.ok(out.systemMessage.includes('/gsd:update'));
|
||||
});
|
||||
|
||||
test('returns null when package_name is absent (untrusted cache)', () => {
|
||||
const out = buildBannerOutput({
|
||||
cache: {
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.3.0',
|
||||
// no package_name field
|
||||
},
|
||||
parseError: false,
|
||||
suppressFailureWarning: false,
|
||||
});
|
||||
assert.equal(out, null, 'absent package_name must be treated as untrusted → null');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── evaluateUpdateCache: lineage guard in statusline ────────────────────────
|
||||
|
||||
describe('evaluateUpdateCache lineage guard', () => {
|
||||
test('returns showUpdate=false when cache is null', () => {
|
||||
const r = evaluateUpdateCache(null);
|
||||
assert.equal(r.showUpdate, false);
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
|
||||
test('returns showUpdate=false when package_name is absent (untrusted)', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.3.0',
|
||||
});
|
||||
assert.equal(r.showUpdate, false);
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
|
||||
test('returns showUpdate=false when package_name is foreign', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.3.0',
|
||||
package_name: 'some-other-package',
|
||||
});
|
||||
assert.equal(r.showUpdate, false);
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
|
||||
test('returns showUpdate=true when update_available and package_name matches', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: true,
|
||||
installed: '1.2.0',
|
||||
latest: '1.3.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
});
|
||||
assert.equal(r.showUpdate, true);
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
|
||||
test('returns showUpdate=false when update_available=false', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: false,
|
||||
installed: '1.3.0',
|
||||
latest: '1.3.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
});
|
||||
assert.equal(r.showUpdate, false);
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
|
||||
test('returns staleWarning=stale when stale_hooks present and matching package_name', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: false,
|
||||
installed: '1.3.0',
|
||||
latest: '1.3.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
|
||||
});
|
||||
assert.equal(r.staleWarning, 'stale');
|
||||
});
|
||||
|
||||
test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: false,
|
||||
installed: '2.0.0',
|
||||
latest: '1.3.0',
|
||||
package_name: '@opengsd/gsd-core',
|
||||
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }],
|
||||
});
|
||||
assert.equal(r.staleWarning, 'dev');
|
||||
});
|
||||
|
||||
test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => {
|
||||
const r = evaluateUpdateCache({
|
||||
update_available: false,
|
||||
installed: '1.3.0',
|
||||
latest: '1.2.0',
|
||||
package_name: 'foreign-pkg',
|
||||
stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }],
|
||||
});
|
||||
assert.equal(r.staleWarning, 'none');
|
||||
});
|
||||
});
|
||||
302
tests/issue-607-installer-dry-run.install.test.cjs
Normal file
302
tests/issue-607-installer-dry-run.install.test.cjs
Normal file
@@ -0,0 +1,302 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Test-created temp dirs are the only filesystem reads here — not repo source files.
|
||||
// This is an integration test that seeds fixture files in OS temp dirs and
|
||||
// asserts that the installer correctly handles --dry-run and the
|
||||
// cleanupLegacyGsdCc exported helper.
|
||||
|
||||
/**
|
||||
* #607 — --dry-run flag and cleanupLegacyGsdCc wiring.
|
||||
*
|
||||
* Covers:
|
||||
* 1. Spawning `node bin/install.js --claude --global --dry-run` with an
|
||||
* isolated HOME that contains a seeded legacy artifact. Asserts exit 0,
|
||||
* stdout names the artifact and contains "dry" (case-insensitive), and
|
||||
* no files are mutated (artifact still present; no .claude install).
|
||||
* Also asserts the per-package cache path appears AT MOST ONCE (no
|
||||
* double-print regression).
|
||||
* 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts
|
||||
* the "does not preview --uninstall" warning prints and exits 0 without
|
||||
* uninstalling anything.
|
||||
* 3. Direct unit call to the exported cleanupLegacyGsdCc helper:
|
||||
* - dryRun:true → plan lists the artifact, removes nothing.
|
||||
* - dryRun:false → seeded leftover removed, dev-preferences.md preserved.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkTmp(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// The assembled signal string used as file content to trigger
|
||||
// content-references-old-package detection.
|
||||
const LEGACY_PKG_SIGNAL = 'get-shit-done' + '-cc';
|
||||
|
||||
// ─── Suite 1: spawn --dry-run, assert no mutations ───────────────────────────
|
||||
|
||||
describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => {
|
||||
let tmpHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmp('gsd-607-dryhome-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpHome);
|
||||
});
|
||||
|
||||
test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => {
|
||||
// Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose
|
||||
// content contains the old package name (content-signal, not orphan-by-name).
|
||||
// This exercises the content-references-old-package reason exclusively.
|
||||
const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed the legacy shared cache file
|
||||
const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(legacyCache, JSON.stringify({ legacy: true }));
|
||||
|
||||
// Spawn the installer with --dry-run
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--global', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
// Redirect Claude config dir into isolated tmp home
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
// Suppress slow stale-SDK npm check
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
// Do NOT set GSD_TEST_MODE — we want the main() block to run
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit code must be 0
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// stdout must contain the word "dry" (case-insensitive)
|
||||
assert.match(
|
||||
stdout,
|
||||
/dry/i,
|
||||
`Expected stdout to contain "dry". Got:\n${stdout}`
|
||||
);
|
||||
|
||||
// stdout must mention the seeded legacy artifact path
|
||||
assert.ok(
|
||||
stdout.includes(legacyHook),
|
||||
`Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// The seeded artifact must STILL EXIST (no mutations)
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Legacy hook must still exist after --dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The legacy cache must STILL EXIST
|
||||
assert.ok(
|
||||
fs.existsSync(legacyCache),
|
||||
`Legacy cache must still exist after --dry-run: ${legacyCache}`
|
||||
);
|
||||
|
||||
// No actual install happened — .claude/get-shit-done must not exist
|
||||
const installDir = path.join(tmpHome, '.claude', 'get-shit-done');
|
||||
assert.equal(
|
||||
fs.existsSync(installDir),
|
||||
false,
|
||||
`No install should happen during --dry-run; found: ${installDir}`
|
||||
);
|
||||
|
||||
// Regression: the per-package cache path must appear AT MOST ONCE
|
||||
// (guard against the duplicate-print bug where it was printed both inside
|
||||
// cleanupLegacyGsdCc and again in the outer --dry-run block).
|
||||
const updateCacheFileName = require(
|
||||
path.join(REPO_ROOT, 'get-shit-done', 'bin', 'lib', 'package-identity.cjs')
|
||||
).updateCacheFileName;
|
||||
const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName);
|
||||
const occurrences = stdout.split(perPkgCacheFile).length - 1;
|
||||
assert.ok(
|
||||
occurrences <= 1,
|
||||
`Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}`
|
||||
);
|
||||
});
|
||||
|
||||
test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--uninstall', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// Must print the warning about --uninstall not being previewed
|
||||
assert.ok(
|
||||
stdout.includes('does not preview --uninstall'),
|
||||
`Expected "does not preview --uninstall" warning.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// No uninstall occurred — .claude/get-shit-done must not have been removed
|
||||
// (it never existed, but we confirm the installer didn't blow up)
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
'Process must exit 0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: direct helper unit tests ───────────────────────────────────────
|
||||
|
||||
describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => {
|
||||
// GSD_TEST_MODE is already set at the top so requiring install.js is safe.
|
||||
const { cleanupLegacyGsdCc } = require(INSTALL_BIN);
|
||||
|
||||
let tmpRoot;
|
||||
let homeDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkTmp('gsd-607-unit-');
|
||||
homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('dryRun:true — plan lists seeded artifact; nothing removed', () => {
|
||||
// Seed a content-signal code file under homeDir/.gemini/hooks/
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
const logMessages = [];
|
||||
const mockLogger = { log: (msg) => logMessages.push(msg) };
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
// Plan must include the seeded artifact
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// dryRun result must flag it as skipped, not removed
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
|
||||
|
||||
// The artifact must still exist
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Artifact must survive dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// Logger should have been called at least once
|
||||
assert.ok(logMessages.length > 0, 'Logger should have been called');
|
||||
});
|
||||
|
||||
test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => {
|
||||
// Seed a content-signal code file
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed a dev-preferences.md that must NOT be removed
|
||||
const devPrefs = path.join(homeDir, '.gemini', 'get-shit-done', 'dev-preferences.md');
|
||||
writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.');
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
// The legacy hook must be in the plan
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// The legacy hook must have been removed
|
||||
assert.equal(
|
||||
fs.existsSync(legacyHook),
|
||||
false,
|
||||
`Legacy hook must be removed: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The removed list must include the legacy hook
|
||||
assert.ok(
|
||||
result.removed.includes(legacyHook),
|
||||
`removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}`
|
||||
);
|
||||
|
||||
// dev-preferences.md must NOT be in the plan and must still exist
|
||||
const devPrefsInPlan = plan.find((p) => p.path === devPrefs);
|
||||
assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefs),
|
||||
`dev-preferences.md must be preserved: ${devPrefs}`
|
||||
);
|
||||
});
|
||||
|
||||
test('dryRun:true — returns plan and result without error (no files present)', () => {
|
||||
// homeDir exists but no legacy artifacts seeded
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(plan), 'plan must be an array');
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'nothing to remove');
|
||||
});
|
||||
});
|
||||
327
tests/issue-607-legacy-cleanup.test.cjs
Normal file
327
tests/issue-607-legacy-cleanup.test.cjs
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Tests for legacy artifact cleanup seam (issue #607).
|
||||
*
|
||||
* Covers planLegacyCleanup and applyLegacyCleanup from
|
||||
* get-shit-done/bin/lib/legacy-cleanup.cjs using real temp dirs so the
|
||||
* filesystem logic is exercised end-to-end without touching live config dirs.
|
||||
*
|
||||
* These tests read files they create themselves in OS temp directories —
|
||||
* not repo source files. The fs reads are test-input reads, not source-grep.
|
||||
* // allow-test-rule: integration-test-input
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { planLegacyCleanup, applyLegacyCleanup } = require(
|
||||
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs')
|
||||
);
|
||||
const { MANAGED_HOOKS } = require(
|
||||
path.join(__dirname, '..', 'hooks', 'managed-hooks-registry.cjs')
|
||||
);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Assembled the same way the implementation does so this file also avoids the
|
||||
// bare literal (correctness: the test content strings below DO contain it,
|
||||
// which is fine — tests may reference the signal string directly).
|
||||
const OLD_PACKAGE_SIGNAL = 'get-shit-done' + '-cc';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-607-'));
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('issue-607 legacy-cleanup: planLegacyCleanup', () => {
|
||||
let tmpRoot;
|
||||
let configDir;
|
||||
let homeDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkTmpDir();
|
||||
configDir = path.join(tmpRoot, 'config');
|
||||
homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
// ── content-references-old-package ─────────────────────────────────────────
|
||||
|
||||
test('flags a hook file in hooks/ whose content contains the old package signal', () => {
|
||||
const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
|
||||
writeFile(hookFile, '// installed via ' + OLD_PACKAGE_SIGNAL + '\nconsole.log("hello");');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === hookFile);
|
||||
assert.ok(entry, 'expected hookFile to appear in plan');
|
||||
assert.equal(entry.reason, 'content-references-old-package');
|
||||
});
|
||||
|
||||
test('does NOT flag a file whose content does not reference the old package', () => {
|
||||
const hookFile = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
|
||||
writeFile(hookFile, '// installed via @opengsd/gsd-core\nconsole.log("ok");');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === hookFile);
|
||||
assert.equal(entry, undefined, 'clean hook must not appear in plan');
|
||||
});
|
||||
|
||||
// ── data-loss regression: user custom hooks must be preserved ──────────────
|
||||
|
||||
test('regression #607 (data-loss): user custom gsd-*.js with NO old-package content must NOT appear in plan', () => {
|
||||
// Previously the orphaned-hook-by-name rule would flag any gsd-*.js not in
|
||||
// MANAGED_HOOKS — deleting user-authored hooks. This is the key regression test.
|
||||
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js');
|
||||
writeFile(userHook, '// my custom hook — does not reference the old package');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === userHook);
|
||||
assert.equal(entry, undefined, 'user custom gsd-*.js with no old-package content must NOT be in plan');
|
||||
});
|
||||
|
||||
test('regression #607 (data-loss): user custom gsd-*.sh with NO old-package content must NOT appear in plan', () => {
|
||||
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.sh');
|
||||
writeFile(userHook, '#!/bin/sh\n# my custom shell hook, clean');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === userHook);
|
||||
assert.equal(entry, undefined, 'user custom gsd-*.sh with no old-package content must NOT be in plan');
|
||||
});
|
||||
|
||||
// ── self-deletion regression: get-shit-done/ subtree must NOT be scanned ──
|
||||
|
||||
test('regression #607 (self-deletion): a code file under get-shit-done/bin/lib/ containing old-package signal must NOT be flagged', () => {
|
||||
// The subtree 'get-shit-done' is no longer scanned — the current package's
|
||||
// own infra lives there and would falsely match if scanned.
|
||||
const libFile = path.join(configDir, 'get-shit-done', 'bin', 'lib', 'legacy-cleanup.cjs');
|
||||
writeFile(libFile, "'use strict';\nconst SIG = 'get-shit-done' + '-cc';\nmodule.exports = {};");
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === libFile);
|
||||
assert.equal(entry, undefined, 'code file in get-shit-done/ subtree must NOT appear in plan (subtree not scanned)');
|
||||
});
|
||||
|
||||
// ── issue-607 regression: markdown files must never be flagged ─────────────
|
||||
|
||||
test('regression #607: CHANGELOG.md containing old-package signal must NOT be flagged', () => {
|
||||
// Markdown docs legitimately cite the old package name in historical context.
|
||||
const changelogFile = path.join(configDir, 'get-shit-done', 'CHANGELOG.md');
|
||||
writeFile(changelogFile, '# Changelog\n\nMigrated from ' + OLD_PACKAGE_SIGNAL + ' to @opengsd/gsd-core.');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === changelogFile);
|
||||
assert.equal(entry, undefined, 'CHANGELOG.md must never appear in plan even if it cites the old package name');
|
||||
});
|
||||
|
||||
test('regression #607: a workflow .md file containing old-package signal must NOT be flagged', () => {
|
||||
const workflowMd = path.join(configDir, 'get-shit-done', 'workflows', 'update.md');
|
||||
writeFile(workflowMd, '# Update workflow\n\nPreviously required ' + OLD_PACKAGE_SIGNAL + ' to be installed.');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === workflowMd);
|
||||
assert.equal(entry, undefined, 'workflow .md must never appear in plan even if it cites the old package name');
|
||||
});
|
||||
|
||||
// ── dev-preferences exclusion ──────────────────────────────────────────────
|
||||
|
||||
test('NEVER flags dev-preferences.md even if its content contains old-package signal', () => {
|
||||
// Place dev-preferences.md inside a GSD-managed subtree
|
||||
const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md');
|
||||
writeFile(devPrefs, '# My prefs\n\nI used to use ' + OLD_PACKAGE_SIGNAL);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === devPrefs);
|
||||
assert.equal(entry, undefined, 'dev-preferences.md must never appear in plan');
|
||||
});
|
||||
|
||||
test('NEVER flags a file under a dev-preferences/ directory', () => {
|
||||
const dpFile = path.join(configDir, 'hooks', 'dev-preferences', 'notes.md');
|
||||
writeFile(dpFile, 'old notes referencing ' + OLD_PACKAGE_SIGNAL);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const entry = plan.find((p) => p.path === dpFile);
|
||||
assert.equal(entry, undefined, 'file under dev-preferences/ dir must never appear in plan');
|
||||
});
|
||||
|
||||
// ── legacy-shared-cache ────────────────────────────────────────────────────
|
||||
|
||||
test('flags the legacy shared cache when it exists with reason legacy-shared-cache', () => {
|
||||
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(cachePath, JSON.stringify({ update_available: false }));
|
||||
|
||||
const plan = planLegacyCleanup([], { homeDir });
|
||||
|
||||
const entry = plan.find((p) => p.path === cachePath);
|
||||
assert.ok(entry, 'expected legacy cache to appear in plan');
|
||||
assert.equal(entry.reason, 'legacy-shared-cache');
|
||||
});
|
||||
|
||||
test('does NOT flag the legacy shared cache when it is absent', () => {
|
||||
// homeDir exists but cache file was never written
|
||||
const plan = planLegacyCleanup([], { homeDir });
|
||||
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
const entry = plan.find((p) => p.path === cachePath);
|
||||
assert.equal(entry, undefined, 'absent cache must not appear in plan');
|
||||
});
|
||||
|
||||
// ── deduplication and sort ─────────────────────────────────────────────────
|
||||
|
||||
test('de-duplicates candidates when two configDirs share same absolute path (same dir listed twice)', () => {
|
||||
const signalFile = path.join(configDir, 'hooks', 'gsd-old-feature.sh');
|
||||
writeFile(signalFile, '# ' + OLD_PACKAGE_SIGNAL);
|
||||
|
||||
const plan = planLegacyCleanup([configDir, configDir], { homeDir });
|
||||
const entries = plan.filter((p) => p.path === signalFile);
|
||||
assert.equal(entries.length, 1, 'same path must appear only once');
|
||||
});
|
||||
|
||||
test('plan entries are sorted by path', () => {
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-zzz-last.js'), '// ' + OLD_PACKAGE_SIGNAL);
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-aaa-first.js'), '// ' + OLD_PACKAGE_SIGNAL);
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const paths = plan.map((p) => p.path);
|
||||
const sorted = [...paths].sort();
|
||||
assert.deepEqual(paths, sorted, 'plan must be sorted by path');
|
||||
});
|
||||
|
||||
// ── only content-references-old-package and legacy-shared-cache reasons ────
|
||||
|
||||
test('plan entries only ever have reason content-references-old-package or legacy-shared-cache', () => {
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-worker.js'), '// ' + OLD_PACKAGE_SIGNAL);
|
||||
const cachePath = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(cachePath, '{}');
|
||||
// User custom hook — should NOT appear
|
||||
writeFile(path.join(configDir, 'hooks', 'gsd-my-custom.js'), '// user hook, clean');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const validReasons = new Set(['content-references-old-package', 'legacy-shared-cache']);
|
||||
for (const entry of plan) {
|
||||
assert.ok(validReasons.has(entry.reason), `unexpected reason: ${entry.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue-607 legacy-cleanup: applyLegacyCleanup', () => {
|
||||
let tmpRoot;
|
||||
let configDir;
|
||||
let homeDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkTmpDir();
|
||||
configDir = path.join(tmpRoot, 'config');
|
||||
homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
// ── dry-run ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('dryRun:true removes nothing and returns dryRun:true + all paths in skipped', () => {
|
||||
// Use a content-signal hook (only way to get a plan entry now)
|
||||
const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
|
||||
writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL);
|
||||
const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(cacheFile, '{}');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
assert.ok(plan.length > 0, 'precondition: plan must be non-empty');
|
||||
|
||||
const logMessages = [];
|
||||
const mockLogger = { log: (msg) => logMessages.push(msg) };
|
||||
|
||||
const result = applyLegacyCleanup(plan, { dryRun: true, logger: mockLogger });
|
||||
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
|
||||
assert.equal(result.skipped.length, plan.length, 'all plan entries must be in skipped');
|
||||
assert.deepEqual(result.skipped.sort(), plan.map((p) => p.path).sort());
|
||||
|
||||
// All flagged files must still exist
|
||||
for (const item of plan) {
|
||||
assert.ok(fs.existsSync(item.path), `${item.path} must still exist after dry-run`);
|
||||
}
|
||||
|
||||
// Logger must have been called for each item
|
||||
assert.equal(logMessages.length, plan.length, 'logger must be called once per plan item');
|
||||
for (const msg of logMessages) {
|
||||
assert.ok(msg.startsWith('[dry-run] would remove:'), `log message format unexpected: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── real apply ────────────────────────────────────────────────────────────
|
||||
|
||||
test('apply removes flagged files and returns them in removed[]', () => {
|
||||
// Content-signal hook — flagged and must be removed
|
||||
const signalHook = path.join(configDir, 'hooks', 'gsd-check-update-worker.js');
|
||||
writeFile(signalHook, '// ' + OLD_PACKAGE_SIGNAL);
|
||||
const cacheFile = path.join(homeDir, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(cacheFile, '{}');
|
||||
|
||||
// User custom hook — clean content, must NOT be in plan and must survive
|
||||
const userHook = path.join(configDir, 'hooks', 'gsd-my-custom.js');
|
||||
writeFile(userHook, '// user hook, no old package ref');
|
||||
|
||||
// Managed hook with clean content — must NOT be in plan and must survive
|
||||
const managedHook = path.join(configDir, 'hooks', MANAGED_HOOKS[0]);
|
||||
writeFile(managedHook, '// @opengsd/gsd-core only');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
assert.ok(plan.length > 0, 'precondition: plan must be non-empty');
|
||||
// Verify clean files not in plan
|
||||
assert.equal(plan.find((p) => p.path === userHook), undefined, 'user hook must not be in plan');
|
||||
assert.equal(plan.find((p) => p.path === managedHook), undefined, 'managed hook must not be in plan');
|
||||
|
||||
const result = applyLegacyCleanup(plan);
|
||||
|
||||
assert.equal(result.dryRun, false);
|
||||
assert.deepEqual(result.removed.sort(), plan.map((p) => p.path).sort());
|
||||
assert.equal(result.errors.length, 0, 'no errors expected');
|
||||
|
||||
// Flagged files must be gone
|
||||
for (const item of plan) {
|
||||
assert.equal(fs.existsSync(item.path), false, `${item.path} must have been removed`);
|
||||
}
|
||||
|
||||
// Clean (non-flagged) files must still exist
|
||||
assert.ok(fs.existsSync(userHook), 'user custom hook must be preserved');
|
||||
assert.ok(fs.existsSync(managedHook), 'managed hook must be preserved');
|
||||
});
|
||||
|
||||
test('apply does not touch dev-preferences.md even if it somehow entered the plan (invariant)', () => {
|
||||
// planLegacyCleanup never adds dev-prefs; this test confirms that invariant.
|
||||
const devPrefs = path.join(configDir, 'hooks', 'dev-preferences.md');
|
||||
writeFile(devPrefs, '# prefs\n' + OLD_PACKAGE_SIGNAL + ' was used here');
|
||||
|
||||
const plan = planLegacyCleanup([configDir], { homeDir });
|
||||
const inPlan = plan.find((p) => p.path === devPrefs);
|
||||
assert.equal(inPlan, undefined, 'planLegacyCleanup must never include dev-preferences.md');
|
||||
|
||||
// Since dev-prefs is not in the plan, applying the plan cannot remove it.
|
||||
applyLegacyCleanup(plan);
|
||||
assert.ok(fs.existsSync(devPrefs), 'dev-preferences.md must survive apply');
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,24 @@ describe('orphaned hooks stale detection (#1750)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('MANAGED_HOOKS is a superset of all gsd-* hooks in HOOKS_TO_COPY (all extensions)', () => {
|
||||
// Every hook-named file in HOOKS_TO_COPY (matching the gsd-* naming pattern
|
||||
// that the registry governs, regardless of extension) must appear in MANAGED_HOOKS.
|
||||
// Non-hook support files like managed-hooks-registry.cjs are intentionally
|
||||
// excluded from this check because they are not themselves hooks.
|
||||
// This catches missing .sh entries as well as .js entries.
|
||||
assert.ok(Array.isArray(HOOKS_TO_COPY), 'HOOKS_TO_COPY must be an array');
|
||||
const gsdHooks = HOOKS_TO_COPY.filter(h => h.startsWith('gsd-'));
|
||||
assert.ok(gsdHooks.length >= 5, `expected at least 5 gsd-* hooks in HOOKS_TO_COPY, got ${gsdHooks.length}`);
|
||||
|
||||
for (const hook of gsdHooks) {
|
||||
assert.ok(
|
||||
MANAGED_HOOKS.includes(hook),
|
||||
`MANAGED_HOOKS should include '${hook}' (from HOOKS_TO_COPY) — add it to hooks/managed-hooks-registry.cjs`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('orphaned hook filenames are NOT in MANAGED_HOOKS', () => {
|
||||
const orphanedHooks = [
|
||||
'gsd-intel-index.js',
|
||||
|
||||
Reference in New Issue
Block a user