* fix(#997): ensure canonical ~/.claude/gsd-core path for plugin installs via SessionStart hook Claude Code marketplace plugin installs unpack the package into the version-pinned plugin cache and never run bin/install.js, so ~/.claude/gsd-core/ is never created. Agents, commands, and templates markdown-@-include the canonical ~/.claude/gsd-core/... path (which expands ~ but NOT ${CLAUDE_PLUGIN_ROOT}), so every include resolved to nothing and agents (e.g. the executor) failed. Add a SessionStart hook (hooks/gsd-ensure-canonical-path.js) that, on a plugin install, symlinks the canonical path's immutable subdirs (bin, contexts, references, templates, workflows) to the plugin's bundled gsd-core/ tree. It changes zero @-references, is a no-op in classic installs, preserves user-generated files (USER-PROFILE.md, STATE.md), prunes stale links so it self-heals after `claude plugin update`, uses Windows junctions, and rejects bundled/canonical paths that escape the resolved plugin root (no traversal, no clobber). Registered in HOOKS_TO_COPY (build-hooks), MANAGED_HOOKS, hooks.json SessionStart (runs first, timeout 5), and BUNDLED_GSD_HOOK_FILES. Behavioral regression tests folded into issue-766-plugin-manifest.test.cjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#997): backfill changeset PR number to #1207 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/997-plugin-canonical-path-hook.md
Normal file
5
.changeset/997-plugin-canonical-path-hook.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1207
|
||||
---
|
||||
**Claude Code plugin installs no longer fail with empty `@~/.claude/gsd-core/...` includes** — agents, commands, and templates `@`-include the canonical `~/.claude/gsd-core/` path, but a marketplace plugin install (`claude plugin install`) never creates that directory, so every include resolved to nothing and agents (e.g. the executor) failed. A new `SessionStart` hook (`gsd-ensure-canonical-path.js`) symlinks the canonical path's immutable subdirs (`bin`, `contexts`, `references`, `templates`, `workflows`) to the plugin's bundled tree. It is a no-op in classic `bin/install.js` installs, preserves user-generated files (e.g. `USER-PROFILE.md`), prunes stale links so it self-heals after `claude plugin update`, and uses Windows junctions. (#1207)
|
||||
@@ -290,6 +290,7 @@ Runtime hooks that integrate with the host AI agent:
|
||||
| `gsd-statusline.js` | `statusLine` | Displays model, task, directory, and context usage bar |
|
||||
| `gsd-context-monitor.js` | `PostToolUse` / `AfterTool` | Injects agent-facing context warnings at 35%/25% remaining |
|
||||
| `gsd-check-update.js` | `SessionStart` | Foreground trigger for the background update check |
|
||||
| `gsd-ensure-canonical-path.js` | `SessionStart` | For Claude Code plugin installs, symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve; runs first in `SessionStart`, no-op in classic installs, self-heals after `claude plugin update` (#997) |
|
||||
| `gsd-check-update-worker.js` | (helper) | Background worker spawned by `gsd-check-update.js`; no direct event registration |
|
||||
| `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt injection patterns (advisory) |
|
||||
| `gsd-read-injection-scanner.js` | `PostToolUse` | Scans Read tool output for injected instructions in untrusted content |
|
||||
@@ -735,9 +736,14 @@ Runtime Engine (Claude Code / Gemini CLI)
|
||||
│ Reads: stdin (tool event JSON), /tmp/claude-ctx-{session}.json (bridge)
|
||||
│ Writes: stdout (hookSpecificOutput with additionalContext warning)
|
||||
│
|
||||
└── SessionStart event ──► gsd-check-update.js
|
||||
Reads: VERSION file
|
||||
Writes: ~/.claude/cache/gsd-update-check.json (spawns background process)
|
||||
└── SessionStart event
|
||||
├──► gsd-ensure-canonical-path.js (runs first)
|
||||
│ Reads: ${CLAUDE_PLUGIN_ROOT}/gsd-core/ (plugin installs only)
|
||||
│ Writes: ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} symlinks
|
||||
│ (no-op in classic installs; preserves user files; self-heals)
|
||||
└──► gsd-check-update.js
|
||||
Reads: VERSION file
|
||||
Writes: ~/.claude/cache/gsd-update-check.json (spawns background process)
|
||||
```
|
||||
|
||||
### Context Monitor Thresholds
|
||||
|
||||
@@ -392,6 +392,7 @@
|
||||
"gsd-context-monitor.js",
|
||||
"gsd-cursor-post-tool.js",
|
||||
"gsd-cursor-session-start.js",
|
||||
"gsd-ensure-canonical-path.js",
|
||||
"gsd-graphify-update.sh",
|
||||
"gsd-phase-boundary.sh",
|
||||
"gsd-prompt-guard.js",
|
||||
|
||||
@@ -520,6 +520,7 @@ Full listing: `hooks/`.
|
||||
| `gsd-read-injection-scanner.js` | `PostToolUse` | Scans tool Read results for prompt-injection patterns (v1.36+, PR #2201) |
|
||||
| `gsd-worktree-path-guard.js` | `PreToolUse` | Hard-blocks Edit/Write/MultiEdit with absolute paths outside the worktree root (PR #579, #260) |
|
||||
| `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) |
|
||||
| `gsd-ensure-canonical-path.js` | `SessionStart` | Symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve in marketplace plugin installs; no-op in classic installs, self-heals after `claude plugin update` (#997) |
|
||||
| `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes |
|
||||
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
|
||||
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions |
|
||||
|
||||
305
hooks/gsd-ensure-canonical-path.js
Normal file
305
hooks/gsd-ensure-canonical-path.js
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env node
|
||||
// gsd-hook-version: {{GSD_VERSION}}
|
||||
//
|
||||
// gsd-ensure-canonical-path — SessionStart hook (#997)
|
||||
//
|
||||
// PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
|
||||
// hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
|
||||
// templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
|
||||
// environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
|
||||
// In a classic `bin/install.js` install the canonical path is a real directory
|
||||
// holding the bundled tree, so the includes resolve. In a Claude Code
|
||||
// *marketplace plugin* install the plugin manager only unpacks the package
|
||||
// into the version-pinned plugin cache and never runs `bin/install.js`, so
|
||||
// `~/.claude/gsd-core/` is never created and every @-include resolves to
|
||||
// nothing — every agent that depends on one fails (e.g. the executor).
|
||||
//
|
||||
// FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
|
||||
// set and a bundled `gsd-core/` tree found beneath it), ensure
|
||||
// `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
|
||||
// references, templates, workflows) are symlinked to the plugin's bundled tree.
|
||||
// This changes ZERO @-references, is a no-op in classic installs (where each
|
||||
// subdir is already a real directory), preserves user-generated files
|
||||
// (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
|
||||
// after `claude plugin update` rotates the version dir, and uses Windows
|
||||
// junctions for symlinks on win32.
|
||||
//
|
||||
// SECURITY: the resolved bundled-tree path and every per-subdir link target are
|
||||
// kept strictly inside the resolved plugin root (realpath-normalised, prefix-
|
||||
// checked). A real (non-symlink) file or directory already sitting at a managed
|
||||
// link target is NEVER clobbered.
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Immutable, bundled subdirectories that the canonical path must expose. These
|
||||
// are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
|
||||
// User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
|
||||
// NOT in this list and are never created, moved, or deleted by this hook.
|
||||
const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
|
||||
|
||||
/**
|
||||
* Resolve the canonical runtime config dir for the active runtime.
|
||||
*
|
||||
* Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
|
||||
* gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
|
||||
* canonical GSD tree always lives at `<configDir>/gsd-core`.
|
||||
*/
|
||||
function resolveConfigDir(homeDir, env) {
|
||||
const envDir = env.CLAUDE_CONFIG_DIR;
|
||||
if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
|
||||
return envDir;
|
||||
}
|
||||
return path.join(homeDir, '.claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the bundled `gsd-core/` tree beneath a plugin root.
|
||||
*
|
||||
* Claude Code unpacks the package so the bundled tree sits at
|
||||
* `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
|
||||
* that directory, or null if it is absent / not a directory. Resolving with
|
||||
* realpath collapses symlinks/.. so the subsequent containment check is sound.
|
||||
*/
|
||||
function resolveBundledTree(pluginRoot) {
|
||||
if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
let root;
|
||||
try {
|
||||
root = fs.realpathSync(pluginRoot);
|
||||
} catch (_) {
|
||||
return null; // plugin root does not exist
|
||||
}
|
||||
const bundled = path.join(root, 'gsd-core');
|
||||
let bundledReal;
|
||||
try {
|
||||
// The bundled tree must be a real directory (or a symlink to one) that
|
||||
// resolves to a path inside the plugin root. realpathSync throws ENOENT/
|
||||
// ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
|
||||
// check is needed. Reject anything that does not resolve to a directory.
|
||||
bundledReal = fs.realpathSync(bundled);
|
||||
if (!fs.statSync(bundledReal).isDirectory()) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
// SECURITY: the resolved bundled tree must stay inside the resolved plugin
|
||||
// root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
|
||||
// is rejected — we never link the canonical path at content we do not own.
|
||||
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
||||
if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
|
||||
return null;
|
||||
}
|
||||
return bundledReal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fs.symlinkSync `type` to use for a directory link on a given platform.
|
||||
*
|
||||
* On Windows, unprivileged users cannot create symlinks but CAN create
|
||||
* junctions; 'junction' requires an absolute target (we always pass one). On
|
||||
* POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
|
||||
* without a Windows host.
|
||||
*/
|
||||
function dirLinkType(platform) {
|
||||
return platform === 'win32' ? 'junction' : 'dir';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory symlink (junction on win32) from linkPath -> target.
|
||||
* Throws on real failure so the caller records it.
|
||||
*/
|
||||
function createDirLink(target, linkPath, platform) {
|
||||
fs.symlinkSync(target, linkPath, dirLinkType(platform));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `linkPath` already correctly point at `expectedTarget`?
|
||||
* Used to make the hook idempotent — a correct link is left untouched.
|
||||
*/
|
||||
function linkPointsAt(linkPath, expectedTarget) {
|
||||
try {
|
||||
if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
|
||||
const resolved = fs.realpathSync(linkPath);
|
||||
return resolved === fs.realpathSync(expectedTarget);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
|
||||
*
|
||||
* Pure, dependency-injected core so tests drive it with a fake home, fake
|
||||
* plugin root, and explicit platform. Returns a structured result describing
|
||||
* exactly what happened (never throws for ordinary conditions — only truly
|
||||
* unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
|
||||
* a hook failure never blocks a session).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.homeDir] home directory (default os.homedir())
|
||||
* @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env)
|
||||
* @param {string} [opts.platform] process.platform override (tests)
|
||||
* @param {object} [opts.env] environment (default process.env)
|
||||
* @returns {{status:string, canonicalDir?:string, bundledTree?:string,
|
||||
* linked?:string[], prunedStale?:string[], preserved?:string[],
|
||||
* skipped?:string[], reason?:string}}
|
||||
*/
|
||||
function ensureCanonicalPath(opts = {}) {
|
||||
const env = opts.env || process.env;
|
||||
const homeDir = opts.homeDir || os.homedir();
|
||||
const platform = opts.platform || process.platform;
|
||||
const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT;
|
||||
|
||||
// Uniform result contract: every return carries the four action arrays so
|
||||
// callers can read result.linked/etc without first switching on status.
|
||||
const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] };
|
||||
|
||||
// No plugin context → classic/npm install or non-plugin runtime. No-op.
|
||||
const bundledTree = resolveBundledTree(pluginRoot);
|
||||
if (!bundledTree) {
|
||||
return { status: 'noop', reason: 'no-plugin-bundle', ...empty };
|
||||
}
|
||||
|
||||
const configDir = resolveConfigDir(homeDir, env);
|
||||
const canonicalDir = path.join(configDir, 'gsd-core');
|
||||
|
||||
// Inspect the canonical path itself exactly once.
|
||||
// - If it is a SYMLINK, the user (or another tool) deliberately pointed the
|
||||
// canonical path elsewhere. We must NOT write managed links *through* that
|
||||
// symlink into a directory we do not own — bail as a no-op.
|
||||
// - If it is a REAL directory with at least one REAL (non-link) managed
|
||||
// subdir, this is a classic `bin/install.js` install — leave it alone.
|
||||
let canonicalStat = null;
|
||||
try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; }
|
||||
|
||||
if (canonicalStat && canonicalStat.isSymbolicLink()) {
|
||||
return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty };
|
||||
}
|
||||
|
||||
if (canonicalStat && canonicalStat.isDirectory()) {
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
try {
|
||||
const subSt = fs.lstatSync(path.join(canonicalDir, sub));
|
||||
if (subSt.isDirectory() && !subSt.isSymbolicLink()) {
|
||||
return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty };
|
||||
}
|
||||
} catch (_) { /* subdir absent — keep checking */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the canonical directory exists (as a real directory). We never
|
||||
// replace an existing real directory; recursive mkdir is a no-op if present.
|
||||
try {
|
||||
fs.mkdirSync(canonicalDir, { recursive: true });
|
||||
} catch (e) {
|
||||
return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty };
|
||||
}
|
||||
|
||||
const linked = [];
|
||||
const prunedStale = [];
|
||||
const preserved = [];
|
||||
const skipped = [];
|
||||
|
||||
// SECURITY: prefix used to confirm every per-subdir link target resolves
|
||||
// strictly inside the bundled tree. Defence-in-depth against a tampered
|
||||
// bundle that ships an internally-escaping symlink at <bundledTree>/<sub>.
|
||||
const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep;
|
||||
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
const target = path.join(bundledTree, sub);
|
||||
// Only expose subdirs the bundle actually ships, AND only when the target
|
||||
// resolves to a real directory that stays inside the bundled tree. A
|
||||
// subdir whose realpath escapes the bundle (e.g. a planted symlink) is
|
||||
// skipped — we never point the canonical path at content outside the
|
||||
// validated plugin bundle.
|
||||
let targetIsDir = false;
|
||||
try {
|
||||
const targetReal = fs.realpathSync(target);
|
||||
// A NAMED subdir must resolve strictly BELOW the bundled tree root. We do
|
||||
// NOT accept targetReal === bundledTree here: a subdir that self-links to
|
||||
// the tree root would otherwise be exposed at the wrong level (e.g.
|
||||
// `workflows` -> the whole tree), making `@.../workflows/foo` resolve to
|
||||
// `<tree>/foo` instead of `<tree>/workflows/foo`.
|
||||
targetIsDir = fs.statSync(targetReal).isDirectory()
|
||||
&& targetReal.startsWith(bundledWithSep);
|
||||
} catch (_) { targetIsDir = false; }
|
||||
if (!targetIsDir) {
|
||||
skipped.push(sub);
|
||||
continue;
|
||||
}
|
||||
|
||||
const linkPath = path.join(canonicalDir, sub);
|
||||
|
||||
// Already a correct link → idempotent no-op.
|
||||
if (linkPointsAt(linkPath, target)) {
|
||||
linked.push(sub);
|
||||
continue;
|
||||
}
|
||||
|
||||
let existing = null;
|
||||
try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; }
|
||||
|
||||
if (existing) {
|
||||
// lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows
|
||||
// junctions, so this single predicate identifies every GSD-managed link.
|
||||
if (existing.isSymbolicLink()) {
|
||||
// A GSD-managed link that is stale or points elsewhere (e.g. previous
|
||||
// plugin version after `claude plugin update`). Prune and recreate.
|
||||
try {
|
||||
fs.unlinkSync(linkPath);
|
||||
prunedStale.push(sub);
|
||||
} catch (e) {
|
||||
skipped.push(sub);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// A REAL file or directory the user (or a classic install) owns. NEVER
|
||||
// clobber it — preserve it untouched. This is the USER-PROFILE.md /
|
||||
// partially-real-canonical-dir safety case.
|
||||
preserved.push(sub);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
createDirLink(target, linkPath, platform);
|
||||
linked.push(sub);
|
||||
} catch (e) {
|
||||
skipped.push(sub);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ensured',
|
||||
canonicalDir,
|
||||
bundledTree,
|
||||
linked,
|
||||
prunedStale,
|
||||
preserved,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureCanonicalPath,
|
||||
resolveBundledTree,
|
||||
resolveConfigDir,
|
||||
dirLinkType,
|
||||
MANAGED_SUBDIRS,
|
||||
};
|
||||
|
||||
// CLI entry: run on SessionStart. Never block the session — any unexpected
|
||||
// failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep
|
||||
// the hook silent in normal operation.
|
||||
if (require.main === module) {
|
||||
try {
|
||||
ensureCanonicalPath();
|
||||
} catch (_) {
|
||||
// Best-effort: a canonical-path failure must never abort a session.
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js\"", "timeout": 5 },
|
||||
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const MANAGED_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-cursor-post-tool.js',
|
||||
'gsd-cursor-session-start.js',
|
||||
'gsd-ensure-canonical-path.js',
|
||||
'gsd-graphify-update.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-prompt-guard.js',
|
||||
|
||||
@@ -26,6 +26,13 @@ const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
|
||||
const HOOKS_TO_COPY = [
|
||||
'gsd-check-update-worker.js',
|
||||
'gsd-check-update.js',
|
||||
// SessionStart canonical-path bootstrap (#997). In a Claude Code marketplace
|
||||
// plugin install, ~/.claude/gsd-core is never created, so every
|
||||
// `@~/.claude/gsd-core/...` include in agents/commands/templates resolves to
|
||||
// nothing. This hook symlinks the canonical path's immutable subdirs to the
|
||||
// plugin's bundled gsd-core/ tree; no-op in classic installs. Must ship to
|
||||
// dist so the installer copies it into the target hooks/ dir.
|
||||
'gsd-ensure-canonical-path.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',
|
||||
|
||||
@@ -33,6 +33,7 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
|
||||
'hooks/gsd-context-monitor.js',
|
||||
'hooks/gsd-cursor-post-tool.js',
|
||||
'hooks/gsd-cursor-session-start.js',
|
||||
'hooks/gsd-ensure-canonical-path.js',
|
||||
'hooks/gsd-graphify-update.sh',
|
||||
'hooks/gsd-phase-boundary.sh',
|
||||
'hooks/gsd-prompt-guard.js',
|
||||
|
||||
@@ -28,6 +28,8 @@ const EXPECTED_ALL_HOOKS = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js',
|
||||
'gsd-context-monitor.js',
|
||||
// #997: SessionStart canonical-path bootstrap for plugin installs.
|
||||
'gsd-ensure-canonical-path.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
* without requiring ajv in devDependencies.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
@@ -416,19 +417,33 @@ describe('D: always-on hook contract drift guard', () => {
|
||||
return map;
|
||||
}
|
||||
|
||||
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
|
||||
test('SessionStart: one no-matcher group with gsd-ensure-canonical-path.js then gsd-check-update.js', () => {
|
||||
// #997: gsd-ensure-canonical-path.js is wired alongside gsd-check-update.js
|
||||
// in the single SessionStart no-matcher group. It must run FIRST so the
|
||||
// canonical ~/.claude/gsd-core path (and its @-include targets) exist before
|
||||
// any other SessionStart logic that may read the bundled tree.
|
||||
const map = buildHookMap();
|
||||
const groups = map['SessionStart'];
|
||||
assert.ok(groups, 'SessionStart must be present in hooks.json');
|
||||
// There must be exactly one entry group (key '' = no matcher)
|
||||
const noMatcherHooks = groups[''];
|
||||
assert.ok(
|
||||
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
|
||||
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
|
||||
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 2,
|
||||
`SessionStart no-matcher group must contain exactly two hooks; got: ${JSON.stringify(noMatcherHooks)}`
|
||||
);
|
||||
const h = noMatcherHooks[0];
|
||||
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
|
||||
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
|
||||
assert.equal(
|
||||
noMatcherHooks[0].script, 'gsd-ensure-canonical-path.js',
|
||||
'gsd-ensure-canonical-path.js must be the FIRST SessionStart hook (#997)'
|
||||
);
|
||||
assert.equal(
|
||||
noMatcherHooks[0].timeout, 5,
|
||||
'gsd-ensure-canonical-path.js must have a small timeout (5s) — symlink setup is fast'
|
||||
);
|
||||
assert.equal(
|
||||
noMatcherHooks[1].script, 'gsd-check-update.js',
|
||||
'gsd-check-update.js must remain a SessionStart hook'
|
||||
);
|
||||
assert.equal(noMatcherHooks[1].timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
|
||||
});
|
||||
|
||||
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
|
||||
@@ -512,3 +527,356 @@ describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section F: #997 canonical-path hook registration ────────────────────────
|
||||
//
|
||||
// gsd-ensure-canonical-path.js must be shipped + wired so plugin installs get a
|
||||
// real ~/.claude/gsd-core directory (with the immutable bundled subdirs
|
||||
// symlinked) — otherwise every `@~/.claude/gsd-core/...` include in agents /
|
||||
// commands / templates resolves to nothing and agents fail (#997).
|
||||
describe('F: #997 gsd-ensure-canonical-path.js is shipped and wired', () => {
|
||||
const HOOK_BASENAME = 'gsd-ensure-canonical-path.js';
|
||||
|
||||
test('hook source file exists in hooks/', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(ROOT, 'hooks', HOOK_BASENAME)),
|
||||
`hooks/${HOOK_BASENAME} must exist on disk`
|
||||
);
|
||||
});
|
||||
|
||||
test('hook is listed in HOOKS_TO_COPY (build-hooks.js) so it ships to dist', () => {
|
||||
const { HOOKS_TO_COPY } = require(path.join(ROOT, 'scripts', 'build-hooks.js'));
|
||||
assert.ok(
|
||||
HOOKS_TO_COPY.includes(HOOK_BASENAME),
|
||||
`${HOOK_BASENAME} must be in HOOKS_TO_COPY or it never ships to hooks/dist`
|
||||
);
|
||||
});
|
||||
|
||||
test('hook is listed in MANAGED_HOOKS (staleness detection)', () => {
|
||||
assert.ok(
|
||||
MANAGED_HOOKS.includes(HOOK_BASENAME),
|
||||
`${HOOK_BASENAME} must be in MANAGED_HOOKS so it is checked for staleness after update`
|
||||
);
|
||||
});
|
||||
|
||||
test('hook is wired in hooks.json SessionStart with ${CLAUDE_PLUGIN_ROOT}', () => {
|
||||
const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON_PATH, 'utf-8'));
|
||||
const sessionStart = hooksConfig.hooks.SessionStart || [];
|
||||
let wired = false;
|
||||
for (const entry of sessionStart) {
|
||||
for (const hook of entry.hooks || []) {
|
||||
if (hook.command && hook.command.includes(HOOK_BASENAME)) {
|
||||
wired = true;
|
||||
assert.ok(
|
||||
hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
'canonical-path hook command must use ${CLAUDE_PLUGIN_ROOT}'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(wired, `${HOOK_BASENAME} must be wired under SessionStart in hooks.json`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section G: #997 ensureCanonicalPath() behavioral regression ─────────────
|
||||
//
|
||||
// Drives the hook's exported pure core with fake home / fake plugin-root layouts
|
||||
// to prove the actual canonical-path bootstrap behaviour: creates symlinks for a
|
||||
// plugin layout, no-ops for classic installs, preserves user files, prunes stale
|
||||
// links (self-heal after `claude plugin update`), and handles boundary cases
|
||||
// (missing bundled dir, pre-existing real dir, pre-existing user file at a link
|
||||
// target). Behavioral — calls the exported function and asserts the resulting
|
||||
// filesystem state, not source text.
|
||||
describe('G: #997 ensureCanonicalPath() behavioural regression', () => {
|
||||
const { ensureCanonicalPath, dirLinkType, MANAGED_SUBDIRS } =
|
||||
require(path.join(ROOT, 'hooks', 'gsd-ensure-canonical-path.js'));
|
||||
|
||||
test('win32 uses a junction; other platforms use a dir symlink', () => {
|
||||
// Junction correctness is an explicit requirement but real junctions can
|
||||
// only be created on Windows. Assert the platform→fs.symlinkSync type
|
||||
// mapping directly so the win32 branch is covered on any host.
|
||||
assert.equal(dirLinkType('win32'), 'junction', 'win32 must use a junction');
|
||||
assert.equal(dirLinkType('linux'), 'dir', 'POSIX must use a dir symlink');
|
||||
assert.equal(dirLinkType('darwin'), 'dir', 'POSIX must use a dir symlink');
|
||||
});
|
||||
|
||||
let tmp;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-997-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- per-test temp cleanup, swallows ENOENT
|
||||
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
// Build a fake plugin layout: <tmp>/plugin/gsd-core/<subdir>/marker.md and a
|
||||
// separate fake home <tmp>/home with an (initially absent) .claude dir.
|
||||
function makePluginLayout(subdirs = MANAGED_SUBDIRS) {
|
||||
const pluginRoot = path.join(tmp, 'plugin');
|
||||
const bundled = path.join(pluginRoot, 'gsd-core');
|
||||
for (const sub of subdirs) {
|
||||
fs.mkdirSync(path.join(bundled, sub), { recursive: true });
|
||||
fs.writeFileSync(path.join(bundled, sub, 'marker.md'), `bundled ${sub}`);
|
||||
}
|
||||
const homeDir = path.join(tmp, 'home');
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
return { pluginRoot, homeDir, bundled };
|
||||
}
|
||||
|
||||
test('plugin layout: creates ~/.claude/gsd-core with all subdirs symlinked to the bundle', () => {
|
||||
const { pluginRoot, homeDir, bundled } = makePluginLayout();
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
|
||||
assert.equal(result.status, 'ensured', `expected ensured; got ${JSON.stringify(result)}`);
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
assert.ok(fs.existsSync(canonical), 'canonical dir must exist');
|
||||
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
const linkPath = path.join(canonical, sub);
|
||||
const st = fs.lstatSync(linkPath);
|
||||
assert.ok(st.isSymbolicLink(), `${sub} must be a symlink`);
|
||||
assert.equal(
|
||||
fs.realpathSync(linkPath),
|
||||
fs.realpathSync(path.join(bundled, sub)),
|
||||
`${sub} link must resolve to the bundled subdir`
|
||||
);
|
||||
// The @-include target now resolves to real bundled content.
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(linkPath, 'marker.md'), 'utf-8'),
|
||||
`bundled ${sub}`,
|
||||
`@-include into ${sub} must resolve to bundled content (this is the #997 fix)`
|
||||
);
|
||||
}
|
||||
assert.deepEqual(result.linked.sort(), [...MANAGED_SUBDIRS].sort());
|
||||
});
|
||||
|
||||
test('idempotent: a second run with the same layout re-affirms links and changes nothing', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
const second = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(second.status, 'ensured');
|
||||
assert.deepEqual(second.linked.sort(), [...MANAGED_SUBDIRS].sort());
|
||||
assert.deepEqual(second.prunedStale, []);
|
||||
assert.deepEqual(second.preserved, []);
|
||||
});
|
||||
|
||||
test('classic install: real bundled subdirs at canonical path → no-op (never touched)', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
// Simulate a classic bin/install.js layout: canonical dir is a REAL dir with
|
||||
// REAL subdirs (not symlinks).
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
fs.mkdirSync(path.join(canonical, sub), { recursive: true });
|
||||
fs.writeFileSync(path.join(canonical, sub, 'real.md'), `classic ${sub}`);
|
||||
}
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'noop');
|
||||
assert.equal(result.reason, 'classic-install');
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
const st = fs.lstatSync(path.join(canonical, sub));
|
||||
assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${sub} must stay a real dir`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no plugin context: CLAUDE_PLUGIN_ROOT unset → no-op (classic/npm install path)', () => {
|
||||
const { homeDir } = makePluginLayout();
|
||||
const result = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'noop');
|
||||
assert.equal(result.reason, 'no-plugin-bundle');
|
||||
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core')), 'must not create canonical dir');
|
||||
});
|
||||
|
||||
test('boundary: bundled gsd-core dir missing under plugin root → no-op', () => {
|
||||
const pluginRoot = path.join(tmp, 'plugin-empty');
|
||||
fs.mkdirSync(pluginRoot, { recursive: true }); // no gsd-core/ inside
|
||||
const homeDir = path.join(tmp, 'home');
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'noop');
|
||||
assert.equal(result.reason, 'no-plugin-bundle');
|
||||
});
|
||||
|
||||
test('preserve: a real user file at a managed link target is never clobbered', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
fs.mkdirSync(canonical, { recursive: true });
|
||||
// User (or partial state) put a REAL directory at 'references' with content.
|
||||
fs.mkdirSync(path.join(canonical, 'references'), { recursive: true });
|
||||
fs.writeFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'precious');
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
// 'references' is a real dir → classic detection kicks in and the whole op
|
||||
// is a no-op, preserving everything. Either way, the user file survives.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(canonical, 'references', 'USER-NOTES.md')),
|
||||
'user file under a managed target must survive'
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'utf-8'),
|
||||
'precious'
|
||||
);
|
||||
void result;
|
||||
});
|
||||
|
||||
test('preserve user-generated top-level file (USER-PROFILE.md) while linking subdirs', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
fs.mkdirSync(canonical, { recursive: true });
|
||||
// A user-generated file at the TOP of the canonical dir (not a managed
|
||||
// subdir) — must never be removed. No managed subdir is real yet, so the
|
||||
// hook proceeds to link them.
|
||||
fs.writeFileSync(path.join(canonical, 'USER-PROFILE.md'), 'my profile');
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(canonical, 'USER-PROFILE.md')),
|
||||
'USER-PROFILE.md must survive canonical-path setup'
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(canonical, 'USER-PROFILE.md'), 'utf-8'), 'my profile');
|
||||
// And subdirs are still linked.
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
assert.ok(fs.lstatSync(path.join(canonical, sub)).isSymbolicLink(), `${sub} linked`);
|
||||
}
|
||||
});
|
||||
|
||||
test('self-heal: a stale symlink (pointing at a removed prior plugin version) is pruned and recreated', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
fs.mkdirSync(canonical, { recursive: true });
|
||||
// Simulate a stale link left by a previous plugin version that has since
|
||||
// been removed (claude plugin update rotated the version dir).
|
||||
const stalePrior = path.join(tmp, 'plugin-OLD', 'gsd-core', 'references');
|
||||
fs.mkdirSync(stalePrior, { recursive: true });
|
||||
const linkPath = path.join(canonical, 'references');
|
||||
fs.symlinkSync(stalePrior, linkPath, 'dir');
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- simulate removed prior version
|
||||
fs.rmSync(path.join(tmp, 'plugin-OLD'), { recursive: true, force: true });
|
||||
assert.ok(!fs.existsSync(linkPath), 'precondition: link now dangles (target removed)');
|
||||
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.ok(result.prunedStale.includes('references'), 'stale references link must be pruned');
|
||||
// Now resolves to the CURRENT bundle.
|
||||
assert.equal(
|
||||
fs.realpathSync(linkPath),
|
||||
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'references')),
|
||||
'references must now point at the current bundled tree'
|
||||
);
|
||||
});
|
||||
|
||||
test('self-heal: a managed symlink pointing at the wrong (but existing) target is repointed', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
fs.mkdirSync(canonical, { recursive: true });
|
||||
// A link to some OTHER real directory (e.g. a different plugin version still
|
||||
// on disk). It is a valid link but points at the wrong place.
|
||||
const otherDir = path.join(tmp, 'plugin-OTHER', 'gsd-core', 'workflows');
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
fs.symlinkSync(otherDir, path.join(canonical, 'workflows'), 'dir');
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.equal(
|
||||
fs.realpathSync(path.join(canonical, 'workflows')),
|
||||
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'workflows')),
|
||||
'workflows must be repointed to the current bundle'
|
||||
);
|
||||
});
|
||||
|
||||
test('security: bundled gsd-core that symlinks OUTSIDE the plugin root is rejected', () => {
|
||||
const pluginRoot = path.join(tmp, 'plugin-evil');
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
// Attacker places a symlink at <pluginRoot>/gsd-core pointing outside root.
|
||||
const outside = path.join(tmp, 'OUTSIDE');
|
||||
fs.mkdirSync(path.join(outside, 'references'), { recursive: true });
|
||||
fs.symlinkSync(outside, path.join(pluginRoot, 'gsd-core'), 'dir');
|
||||
const homeDir = path.join(tmp, 'home');
|
||||
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'noop', 'a bundled tree resolving outside the plugin root must be rejected');
|
||||
assert.equal(result.reason, 'no-plugin-bundle');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
|
||||
'must NOT link the canonical path at content outside the plugin root'
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR honoured: canonical path is created under the custom config dir', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const customCfg = path.join(tmp, 'custom-cfg');
|
||||
fs.mkdirSync(customCfg, { recursive: true });
|
||||
const result = ensureCanonicalPath({
|
||||
pluginRoot, homeDir, platform: 'linux',
|
||||
env: { CLAUDE_CONFIG_DIR: customCfg },
|
||||
});
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.equal(result.canonicalDir, path.join(customCfg, 'gsd-core'));
|
||||
assert.ok(fs.lstatSync(path.join(customCfg, 'gsd-core', 'references')).isSymbolicLink());
|
||||
});
|
||||
|
||||
test('canonical path is itself a symlink → no-op (never writes links through a user-pointed symlink)', () => {
|
||||
// A user pointed ~/.claude/gsd-core at some other directory via a symlink.
|
||||
// The hook must NOT create managed links through it into a dir it does not
|
||||
// own — it bails as a no-op.
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const userTarget = path.join(tmp, 'user-gsd');
|
||||
fs.mkdirSync(userTarget, { recursive: true });
|
||||
const canonical = path.join(homeDir, '.claude', 'gsd-core');
|
||||
fs.symlinkSync(userTarget, canonical, 'dir');
|
||||
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'noop');
|
||||
assert.equal(result.reason, 'canonical-is-symlink');
|
||||
// No managed links were written into the user's target directory.
|
||||
for (const sub of MANAGED_SUBDIRS) {
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(userTarget, sub)),
|
||||
`must not write ${sub} link through the user symlink`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('uniform result contract: every status carries the four action arrays', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout();
|
||||
const noop = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
|
||||
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
|
||||
assert.ok(Array.isArray(noop[k]), `noop result.${k} must be an array, not undefined`);
|
||||
}
|
||||
const ensured = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
|
||||
assert.ok(Array.isArray(ensured[k]), `ensured result.${k} must be an array`);
|
||||
}
|
||||
});
|
||||
|
||||
test('security: a bundled subdir that symlinks OUTSIDE the bundle is skipped, not linked', () => {
|
||||
// Defence-in-depth: even within a (validated) plugin root, a tampered
|
||||
// bundle that ships <bundle>/references as a symlink escaping the bundle
|
||||
// must NOT be exposed at the canonical path.
|
||||
const { pluginRoot, homeDir, bundled } = makePluginLayout(['workflows']);
|
||||
// Plant an escaping symlink at <bundle>/references → outside the bundle.
|
||||
const outside = path.join(tmp, 'OUTSIDE-references');
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
fs.writeFileSync(path.join(outside, 'evil.md'), 'evil');
|
||||
fs.symlinkSync(outside, path.join(bundled, 'references'), 'dir');
|
||||
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.ok(result.linked.includes('workflows'), 'legit subdir still linked');
|
||||
assert.ok(result.skipped.includes('references'), 'escaping subdir must be skipped');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
|
||||
'canonical path must NOT expose the escaping subdir'
|
||||
);
|
||||
});
|
||||
|
||||
test('partial bundle: only ships some subdirs → links those, skips absent ones', () => {
|
||||
const { pluginRoot, homeDir } = makePluginLayout(['references', 'workflows']);
|
||||
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
|
||||
assert.equal(result.status, 'ensured');
|
||||
assert.deepEqual(result.linked.sort(), ['references', 'workflows']);
|
||||
assert.deepEqual(
|
||||
result.skipped.sort(),
|
||||
['bin', 'contexts', 'templates'].sort(),
|
||||
'subdirs not present in the bundle must be skipped, not errored'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user