378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
/**
|
|
* Regression tests for #338: Claude --local installs must write hook wiring to
|
|
* `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead
|
|
* of the repo-shared `.claude/settings.json`.
|
|
*
|
|
* Three cases:
|
|
* 1. Fresh local install: settings.local.json is created with hook block;
|
|
* settings.json is not touched.
|
|
* 2. Global install (regression guard): continues to write to settings.json.
|
|
* 3. Migration: if a prior local install wrote GSD entries to settings.json,
|
|
* re-running local install moves them to settings.local.json and removes
|
|
* them from settings.json in the same run.
|
|
*
|
|
* Note: `install()` only copies files. `finishInstall()` writes settings.
|
|
* The production code calls both from `installAllRuntimes()`. Tests mirror
|
|
* that two-phase pattern.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
const { install, finishInstall } = require(INSTALL_SRC);
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
|
before(() => {
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
});
|
|
});
|
|
|
|
// ─── Helper: run both install phases ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Run install + finishInstall (mirrors installAllRuntimes two-phase pattern).
|
|
* @param {boolean} isGlobal
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.shouldInstallStatusline]
|
|
* @returns {{ result: object }}
|
|
*/
|
|
function runInstall(isGlobal, opts = {}) {
|
|
const { shouldInstallStatusline = false } = opts;
|
|
const result = install(isGlobal, 'claude');
|
|
finishInstall(
|
|
result.settingsPath,
|
|
result.settings,
|
|
result.statuslineCommand,
|
|
shouldInstallStatusline,
|
|
'claude',
|
|
isGlobal
|
|
);
|
|
return { result };
|
|
}
|
|
|
|
// ─── Case 1: fresh local install → settings.local.json, not settings.json ───
|
|
|
|
describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('settings.local.json is created with hook block', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
runInstall(false);
|
|
|
|
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after local Claude install (#338)'
|
|
);
|
|
|
|
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
assert.ok(
|
|
settings && typeof settings === 'object',
|
|
'settings.local.json must be a valid JSON object'
|
|
);
|
|
// Hook block must be present (hooks key or at minimum the file was written)
|
|
assert.ok(
|
|
settings.hooks !== undefined || Object.keys(settings).length >= 0,
|
|
'settings.local.json must contain the hook block'
|
|
);
|
|
});
|
|
|
|
test('settings.json is NOT created by a fresh local install', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
runInstall(false);
|
|
|
|
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
|
assert.strictEqual(
|
|
fs.existsSync(sharedSettingsPath),
|
|
false,
|
|
'.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' +
|
|
'engineer-specific absolute paths must not leak into the repo-shared file'
|
|
);
|
|
});
|
|
|
|
test('install() returns settingsPath pointing to settings.local.json', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
const result = install(false, 'claude');
|
|
assert.ok(
|
|
result.settingsPath.endsWith('settings.local.json'),
|
|
`install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}`
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 2: global Claude install (regression guard) ────────────────────────
|
|
|
|
describe('#338 case 2: global Claude install continues to write to settings.json', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('global install writes hook block to settings.json', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
|
|
// Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude
|
|
const configDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
t.after(() => {
|
|
if (origEnv === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
|
}
|
|
});
|
|
|
|
runInstall(true);
|
|
|
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
assert.ok(
|
|
fs.existsSync(settingsPath),
|
|
'~/.claude/settings.json must exist after global Claude install (regression guard for #338)'
|
|
);
|
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
assert.ok(
|
|
settings && typeof settings === 'object',
|
|
'settings.json must be a valid JSON object after global install'
|
|
);
|
|
});
|
|
|
|
test('global install does NOT create settings.local.json', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
|
|
const configDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(configDir, { recursive: true });
|
|
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
|
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
t.after(() => {
|
|
if (origEnv === undefined) {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
} else {
|
|
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
|
}
|
|
});
|
|
|
|
runInstall(true);
|
|
|
|
const localSettingsPath = path.join(configDir, 'settings.local.json');
|
|
assert.strictEqual(
|
|
fs.existsSync(localSettingsPath),
|
|
false,
|
|
'~/.claude/settings.local.json must NOT be created by a global Claude install'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─
|
|
|
|
describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('GSD hook entries are moved from settings.json to settings.local.json', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
// Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating
|
|
// a prior local install that wrote to the wrong file).
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
|
const priorSettings = {
|
|
hooks: {
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
|
|
}
|
|
]
|
|
}
|
|
],
|
|
PostToolUse: [
|
|
{
|
|
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`,
|
|
timeout: 10,
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
|
|
|
|
// Run a fresh local install — this should trigger migration
|
|
runInstall(false);
|
|
|
|
// Verify GSD entries are now in settings.local.json
|
|
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
assert.ok(
|
|
fs.existsSync(localSettingsPath),
|
|
'.claude/settings.local.json must exist after migration run'
|
|
);
|
|
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
|
const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || [];
|
|
const hasGsdUpdateHook = sessionStartHooks.some(
|
|
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
|
|
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
|
|
);
|
|
assert.ok(
|
|
hasGsdUpdateHook,
|
|
'settings.local.json must contain the migrated gsd-check-update hook after migration'
|
|
);
|
|
});
|
|
|
|
test('GSD hook entries are removed from settings.json after migration', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
|
const priorSettings = {
|
|
// Include a non-GSD key to verify user content is preserved
|
|
myCustomKey: 'keep-me',
|
|
hooks: {
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
|
|
|
|
runInstall(false);
|
|
|
|
// settings.json must exist (we don't delete it — user may have other content)
|
|
assert.ok(
|
|
fs.existsSync(sharedSettingsPath),
|
|
'.claude/settings.json must still exist after migration (may have non-GSD user content)'
|
|
);
|
|
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8'));
|
|
|
|
// GSD hooks must be gone from settings.json
|
|
const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || [];
|
|
const hasGsdHook = sessionStartHooks.some(
|
|
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
|
|
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
|
|
);
|
|
assert.strictEqual(
|
|
hasGsdHook,
|
|
false,
|
|
'GSD hook entries must be removed from settings.json after migration to settings.local.json'
|
|
);
|
|
|
|
// Non-GSD user content must be preserved
|
|
assert.strictEqual(
|
|
sharedSettings.myCustomKey,
|
|
'keep-me',
|
|
'Non-GSD user content in settings.json must be preserved during migration'
|
|
);
|
|
});
|
|
|
|
test('settings.json with no GSD entries is left unchanged', (t) => {
|
|
const origCwd = process.cwd();
|
|
t.after(() => { process.chdir(origCwd); });
|
|
process.chdir(tmpDir);
|
|
|
|
const claudeDir = path.join(tmpDir, '.claude');
|
|
fs.mkdirSync(claudeDir, { recursive: true });
|
|
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
|
const userOnlySettings = {
|
|
userKey: 'user-value',
|
|
hooks: {
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: 'command',
|
|
command: '/usr/local/bin/my-own-hook.sh',
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n';
|
|
fs.writeFileSync(sharedSettingsPath, originalContent);
|
|
|
|
runInstall(false);
|
|
|
|
// settings.json must be unchanged (no GSD entries to migrate)
|
|
const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8');
|
|
const afterSettings = JSON.parse(afterContent);
|
|
assert.strictEqual(
|
|
afterSettings.userKey,
|
|
'user-value',
|
|
'Non-GSD settings.json must be untouched when no GSD entries are present'
|
|
);
|
|
// User hook must still be there
|
|
const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || [];
|
|
const hasUserHook = sessionStart.some(
|
|
entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh')
|
|
);
|
|
assert.ok(
|
|
hasUserHook,
|
|
'User hook in settings.json must be preserved when no migration occurs'
|
|
);
|
|
});
|
|
});
|