mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 10:18:32 +01:00
fix(harness): keep edits beside themselves, never follow symlinks, cut only gstack's own context block
Review blockers on #3526: uninstall deleted the edit backups under the state root; unregister followed a symlinked folder below the destination; a lone or edited context marker made the cut eat user lines; a re-register adding --context left an unrecorded block. Also: ledger written before the files, --refresh-registered no longer re-runs harness hosts through setup (gstack-harness refresh keeps their mode), bash goes through runBashSync, and setup tests follow openclaw/hermes becoming installs. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
808dead70e
commit
dd2f3c13ec
10 files changed
+200
-71
No files matched your search
@@ -3689,11 +3689,11 @@ describe('setup script validation', () => {
|
||||
expect(claudeSection).toContain('link_claude_root_skill_alias "$SOURCE_GSTACK_DIR" "$INSTALL_SKILLS_DIR"');
|
||||
});
|
||||
|
||||
test('setup supports --host auto|claude|codex|kiro|opencode|cursor; slate is informational', () => {
|
||||
test('setup supports --host auto|claude|codex|kiro|opencode|cursor|openclaw|hermes; slate is informational', () => {
|
||||
expect(setupContent).toContain('--host');
|
||||
// #2361: slate moved OUT of the install accept-list (it was accepted but
|
||||
// never dispatched — a silent exit-0 no-op) into an informational arm.
|
||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|auto');
|
||||
expect(setupContent).toContain('claude|codex|kiro|factory|opencode|cursor|copilot|agy|openclaw|hermes|auto');
|
||||
expect(setupContent).toMatch(/^ {2}slate\)/m);
|
||||
});
|
||||
|
||||
|
||||
@@ -152,9 +152,67 @@ describe('gstack-harness register and unregister (skill folders)', () => {
|
||||
unregister(path.join(d, 'state'), ROOT, 'generic');
|
||||
expect(fs.readFileSync(ctx, 'utf8')).toBe('# my rules\nbe kind\n\n');
|
||||
expect(fs.existsSync(r.router!)).toBe(false);
|
||||
const kept = files(path.join(d, 'state', 'backups', 'harness'));
|
||||
expect(kept.length).toBe(1);
|
||||
expect(kept[0].endsWith('gstack/SKILL.md')).toBe(true);
|
||||
const kept = files(path.dirname(r.router!));
|
||||
expect(kept).toHaveLength(1);
|
||||
expect(kept[0]).toMatch(/^SKILL\.md\.edited-/);
|
||||
expect(fs.readFileSync(path.join(path.dirname(r.router!), kept[0]), 'utf8')).toEndWith('\nmy note\n');
|
||||
}, 60_000);
|
||||
|
||||
test('generic: a lone, repeated or edited marker block is never cut or rewritten; prose that names a marker is left alone', () => {
|
||||
const d = tmp();
|
||||
const ctx = path.join(d, 'AGENTS.md');
|
||||
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
|
||||
const lone = '# mine\n' + BLOCK_BEGIN + '\n## Important\nkeep me\n';
|
||||
fs.writeFileSync(ctx, lone);
|
||||
expect(code(() => register(opts(d, 'generic', { context: ctx })))).toBe('HARNESS_UNOWNED_ENTRY');
|
||||
expect(fs.readFileSync(ctx, 'utf8')).toBe(lone);
|
||||
const prose = '# mine\nthe markers look like ' + BLOCK_BEGIN + ' and ' + BLOCK_END + '\n';
|
||||
fs.writeFileSync(ctx, prose);
|
||||
register(opts(d, 'generic', { context: ctx }));
|
||||
unregister(path.join(d, 'state'), ROOT, 'generic');
|
||||
expect(fs.readFileSync(ctx, 'utf8')).toBe(prose + '\n');
|
||||
for (const edit of [(s: string) => s.replace(BLOCK_END + '\n', ''), (s: string) => s.replace('## gstack skills', '## gstack skills\nmy own line')]) {
|
||||
fs.writeFileSync(ctx, '# mine\n');
|
||||
register(opts(d, 'generic', { context: ctx }));
|
||||
const edited = edit(fs.readFileSync(ctx, 'utf8')) + '## after\nkeep me too\n';
|
||||
fs.writeFileSync(ctx, edited);
|
||||
const un = spawnSync('bash', ['-c', '. "$0" && gstack_harness_unregister_all', path.join(ROOT, 'bin', 'gstack-harness-ledger.sh')], { env: { PATH: '/usr/bin:/bin', GSTACK_STATE_ROOT: path.join(d, 'state') }, encoding: 'utf8', timeout: 30_000 });
|
||||
expect(un.status, un.stderr).toBe(0);
|
||||
expect(un.stdout).toContain('left ' + ctx + ' as it is');
|
||||
expect(fs.readFileSync(ctx, 'utf8')).toBe(edited);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test('generic: adding or moving --context is recorded, and the old block is cut', () => {
|
||||
const d = tmp();
|
||||
const [a, b] = [path.join(d, 'a.md'), path.join(d, 'b.md')];
|
||||
fs.writeFileSync(a, '# a\n');
|
||||
fs.writeFileSync(b, '# b\n');
|
||||
const skills = path.join(d, 'skills');
|
||||
register(opts(d, 'dots', { skillsDir: skills }));
|
||||
expect(register(opts(d, 'dots', { skillsDir: skills, context: a })).status).toBe('updated');
|
||||
expect(fs.readFileSync(a, 'utf8')).toContain(BLOCK_BEGIN);
|
||||
const r = register(opts(d, 'dots', { skillsDir: skills, context: b }));
|
||||
expect(fs.readFileSync(a, 'utf8')).toBe('# a\n\n');
|
||||
expect(readLedger(r.ledger)!.blocks.map(x => x.path)).toEqual([b]);
|
||||
unregister(path.join(d, 'state'), ROOT, r.id);
|
||||
expect(fs.readFileSync(b, 'utf8')).toBe('# b\n\n');
|
||||
}, 60_000);
|
||||
|
||||
test('a folder swapped for a symlink below the destination is never followed', () => {
|
||||
const d = tmp();
|
||||
const skills = path.join(d, 'skills');
|
||||
const r = register(opts(d, 'openclaw', { skillsDir: skills }));
|
||||
const review = path.join(skills, 'gstack', 'skills', fs.readdirSync(path.join(skills, 'gstack', 'skills')).sort()[0]);
|
||||
const outside = path.join(d, 'outside');
|
||||
fs.cpSync(review, outside, { recursive: true });
|
||||
fs.rmSync(review, { recursive: true });
|
||||
fs.symlinkSync(outside, review);
|
||||
const code = (fn: () => unknown) => { try { fn(); return 'ok'; } catch (e) { return e instanceof HarnessError ? e.code : String(e); } };
|
||||
expect(code(() => register(opts(d, 'openclaw', { skillsDir: skills })))).toBe('HARNESS_UNSAFE_DESTINATION');
|
||||
unregister(path.join(d, 'state'), ROOT, r.id);
|
||||
expect(fs.existsSync(path.join(outside, 'SKILL.md'))).toBe(true);
|
||||
expect(fs.lstatSync(review).isSymbolicLink()).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -254,9 +312,13 @@ describe.skipIf(process.platform === 'win32')('setup and uninstall with harnesse
|
||||
const status = runSetup(f, path.join(src, 'setup'), ['--status']);
|
||||
expect(status.stdout).toMatch(new RegExp('\\b' + host + '\\s+experimental\\s+global\\s+current'));
|
||||
expect(status.stdout).not.toContain('move it first');
|
||||
fs.appendFileSync(path.join(f.home, dest, probe), '\nmy note\n');
|
||||
const un = spawnSync('bash', [path.join(src, 'bin/gstack-uninstall'), '--force'], { cwd: f.home, env: { ...f.env, PATH: '/usr/bin:/bin' }, encoding: 'utf8', timeout: 60_000 });
|
||||
expect(un.status, un.stdout + un.stderr).toBe(0);
|
||||
expect(files(path.join(f.home, dest)).filter(p => !p.startsWith('.'))).toEqual([]);
|
||||
const left = files(path.join(f.home, dest)).filter(p => !p.startsWith('.'));
|
||||
expect(left).toHaveLength(1);
|
||||
expect(left[0]).toStartWith(probe + '.edited-');
|
||||
expect(fs.readFileSync(path.join(f.home, dest, left[0]), 'utf8')).toEndWith('\nmy note\n');
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('setup: host accept-list ↔ hosts/index.ts registry cross-check (#2361
|
||||
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
|
||||
});
|
||||
|
||||
test.each(['slate', 'openclaw', 'hermes', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
|
||||
test.each(['slate', 'gbrain'])('%s finishes before installation preflight commands', (host) => {
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-info-preflight-'));
|
||||
const startup = path.join(temporary, 'startup.bash');
|
||||
fs.writeFileSync(startup, `command() { echo 'unexpected installation preflight' >&2; return 97; }
|
||||
@@ -134,9 +134,6 @@ date() { echo 'unexpected backup timestamp' >&2; return 97; }
|
||||
expect(res.status).toBe(0);
|
||||
expect(res.stderr).toBe('');
|
||||
expect(res.stdout).not.toMatch(/Installing|bun install|Building/);
|
||||
if (host === 'openclaw' || host === 'hermes') {
|
||||
expect(res.stdout.replaceAll('\\', '/')).toContain('/agents-digest/gstack-AGENTS.md');
|
||||
}
|
||||
} finally { fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user