162 lines
6.7 KiB
JavaScript
162 lines
6.7 KiB
JavaScript
'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 crypto = require('node:crypto');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
|
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
|
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
|
|
function withCodexHome(codexHome, fn) {
|
|
const prev = process.env.CODEX_HOME;
|
|
process.env.CODEX_HOME = codexHome;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (prev == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = prev;
|
|
}
|
|
}
|
|
|
|
function extractSessionStartCommandsFromHooksJson(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
|
const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks))
|
|
? value.hooks
|
|
: value;
|
|
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
|
return sessionStart.flatMap((entry) => {
|
|
const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string');
|
|
});
|
|
}
|
|
|
|
describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
|
}
|
|
tmpRoot = createTempDir('gsd-3427-3433-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => {
|
|
// Stale legacy body — fresh install must overwrite this so Codex sees the
|
|
// current SKILL.md, not whatever was last on disk.
|
|
const legacySkillBody = '# old managed\n';
|
|
fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody);
|
|
const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex');
|
|
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({
|
|
version: 1,
|
|
files: {
|
|
'skills/gsd-help/SKILL.md': legacyHash,
|
|
},
|
|
}, null, 2));
|
|
|
|
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
|
|
fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n');
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
const skillsDir = path.join(codexHome, 'skills');
|
|
const entries = fs.existsSync(skillsDir)
|
|
? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
|
: [];
|
|
|
|
// #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md
|
|
// exists. The installer must regenerate (not remove) the managed gsd-*
|
|
// directories.
|
|
assert.equal(entries.includes('gsd-help'), true);
|
|
const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
|
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
|
|
const frontmatter = parseFrontmatter(refreshedBody);
|
|
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
|
|
|
|
// Unrelated user skills are preserved — the regen scope is `gsd-*` only.
|
|
assert.equal(entries.includes('custom-user-skill'), true);
|
|
});
|
|
|
|
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
|
|
const configToml = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'[[hooks.SessionStart]]',
|
|
'[[hooks.SessionStart.hooks]]',
|
|
'type = "command"',
|
|
'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml);
|
|
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
|
],
|
|
},
|
|
],
|
|
}, null, 2));
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
|
const tomlSessionStart = parsedToml.hooks?.SessionStart ?? [];
|
|
const tomlCommands = tomlSessionStart.flatMap((entry) =>
|
|
(Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string')
|
|
);
|
|
assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false);
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
|
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update.js'));
|
|
|
|
assert.equal(gsdCommands.length, 1);
|
|
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
});
|
|
|
|
test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => {
|
|
const hooksDir = path.join(codexHome, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n');
|
|
const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
|
SessionStart: [
|
|
{
|
|
hooks: [
|
|
{ type: 'command', command: `node "${managedHookPath}"` },
|
|
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
|
],
|
|
},
|
|
],
|
|
}, null, 2));
|
|
|
|
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
|
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update.js'));
|
|
|
|
assert.equal(gsdCommands.length, 0);
|
|
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
});
|
|
});
|