rigjs
Version:
A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.
251 lines (222 loc) • 12.6 kB
text/typescript
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import { installHandoff } from './install';
import { uninstallHandoff } from './uninstall';
import { hasInstalledHook } from './settings';
import { CODEX_HANDOFF_OWNER, hasInstalledCodexHook } from './codex-settings';
import { writeCodexLatestPointer } from './codex-pointer';
import { resolveHandoffPaths } from './paths';
describe('handoff installation', () => {
let home: string;
let env: NodeJS.ProcessEnv;
const rigRoot = path.resolve(__dirname, '../..');
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-handoff-install-'));
env = {
HOME: home,
RIG_HANDOFF_RIG_ROOT: rigRoot,
RIG_HANDOFF_CLAUDE_DIR: path.join(home, '.claude'),
RIG_HANDOFF_CODEX_HOME: path.join(home, '.codex'),
RIG_HANDOFF_BACKUP_DIR: path.join(home, '.rig', 'backups', 'handoff'),
};
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
fs.writeFileSync(path.join(home, '.claude', 'settings.json'), JSON.stringify({
hooks: {
Notification: [{ matcher: 'idle_prompt', hooks: [{ type: 'command', command: '/bin/true', args: [] }] }],
UserPromptExpansion: [{ matcher: '^other$', hooks: [{
type: 'command',
command: '/usr/local/bin/not-rig',
args: ['handoff', 'hook'],
statusMessage: 'Other tool handoff',
}] }],
},
theme: 'dark',
}, null, 2));
fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
fs.writeFileSync(path.join(home, '.codex', 'hooks.json'), JSON.stringify({
description: 'user hooks',
hooks: {
Stop: [{ hooks: [{ type: 'command', command: '/bin/true' }] }],
UserPromptSubmit: [{ hooks: [{
type: 'command',
command: "'/Applications/Bitterless/bin/session-hook'",
timeout: 2,
}] }],
},
}, null, 2));
});
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
test('installs one shared sender plus two receiver adapters across four locations and merges both hook files idempotently', () => {
const first = installHandoff({ platform: 'darwin', env });
expect(first.claudeSkillChanged).toBe(true);
expect(first.codexSkillChanged).toBe(true);
expect(first.codexHandoffSkillChanged).toBe(true);
expect(first.claudeFromCodexSkillChanged).toBe(true);
expect(first.launcherChanged).toBe(true);
expect(first.settingsChanged).toBe(true);
expect(fs.lstatSync(first.paths.claudeSkill).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(first.paths.codexSkill).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(first.paths.codexHandoffSkill).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(first.paths.claudeFromCodexSkill).isSymbolicLink()).toBe(true);
expect(fs.lstatSync(first.paths.handoffExecutable).isFile()).toBe(true);
expect(fs.readFileSync(first.paths.handoffExecutable, 'utf8')).toContain(process.execPath);
const launcher = spawnSync(first.paths.handoffExecutable, ['--version'], {
encoding: 'utf8',
env: { ...process.env, PATH: '/usr/bin:/bin' },
});
expect(launcher.status).toBe(0);
expect(launcher.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/);
expect(hasInstalledHook(first.paths.claudeSettings, 'UserPromptExpansion')).toBe(true);
expect(hasInstalledHook(first.paths.claudeSettings, 'StopFailure')).toBe(true);
expect(hasInstalledCodexHook(first.paths.codexHooks)).toBe(true);
const settings = JSON.parse(fs.readFileSync(first.paths.claudeSettings, 'utf8'));
expect(settings.theme).toBe('dark');
expect(settings.hooks.Notification).toHaveLength(1);
expect(settings.hooks.UserPromptExpansion).toHaveLength(2);
expect(settings.hooks.UserPromptExpansion[1].matcher).toBe('^handoff$');
expect(settings.hooks.UserPromptExpansion[1].hooks[0].command).toBe(first.paths.handoffExecutable);
expect(settings.skillOverrides.handoff).toBe('user-invocable-only');
expect(fs.existsSync(first.paths.claudeSkillOverrideState)).toBe(true);
expect(first.settingsBackup && fs.existsSync(first.settingsBackup)).toBe(true);
const codexHooks = JSON.parse(fs.readFileSync(first.paths.codexHooks, 'utf8'));
expect(codexHooks.description).toBe('user hooks');
expect(codexHooks.hooks.Stop).toHaveLength(1);
expect(codexHooks.hooks.UserPromptSubmit).toHaveLength(2);
expect(codexHooks.hooks.UserPromptSubmit[1].matcher).toBe(CODEX_HANDOFF_OWNER);
expect(codexHooks.hooks.UserPromptSubmit[1].hooks[0].command).toContain('handoff from-codex hook');
expect(first.codexHooksBackup && fs.existsSync(first.codexHooksBackup)).toBe(true);
const second = installHandoff({ platform: 'darwin', env });
expect(second.claudeSkillChanged).toBe(false);
expect(second.codexSkillChanged).toBe(false);
expect(second.codexHandoffSkillChanged).toBe(false);
expect(second.claudeFromCodexSkillChanged).toBe(false);
expect(second.launcherChanged).toBe(false);
expect(second.settingsChanged).toBe(false);
expect(second.codexHooksChanged).toBe(false);
});
test('uninstall removes only owned symlinks and hooks', () => {
const installed = installHandoff({
platform: 'darwin',
env,
});
writeCodexLatestPointer(installed.paths.codexLatestPointer, {
schemaVersion: 1,
sessionId: 'owned-session',
transcriptPath: path.join(home, '.codex', 'sessions', 'owned.jsonl'),
cwd: home,
updatedAt: '2026-07-20T00:00:00Z',
});
const result = uninstallHandoff({ platform: 'darwin', env });
expect(result.removed).toHaveLength(6);
expect(fs.existsSync(installed.paths.claudeSkill)).toBe(false);
expect(fs.existsSync(installed.paths.codexSkill)).toBe(false);
expect(fs.existsSync(installed.paths.codexHandoffSkill)).toBe(false);
expect(fs.existsSync(installed.paths.claudeFromCodexSkill)).toBe(false);
expect(fs.existsSync(installed.paths.handoffExecutable)).toBe(false);
expect(fs.existsSync(installed.paths.codexLatestPointer)).toBe(false);
const settings = JSON.parse(fs.readFileSync(installed.paths.claudeSettings, 'utf8'));
expect(settings.theme).toBe('dark');
expect(settings.hooks.Notification).toHaveLength(1);
expect(settings.hooks.UserPromptExpansion).toEqual([expect.objectContaining({ matcher: '^other$' })]);
expect(settings.hooks.StopFailure).toBeUndefined();
expect(settings.skillOverrides).toBeUndefined();
expect(fs.existsSync(installed.paths.claudeSkillOverrideState)).toBe(false);
const codexHooks = JSON.parse(fs.readFileSync(installed.paths.codexHooks, 'utf8'));
expect(codexHooks.description).toBe('user hooks');
expect(codexHooks.hooks.Stop).toHaveLength(1);
expect(codexHooks.hooks.UserPromptSubmit).toHaveLength(1);
expect(codexHooks.hooks.UserPromptSubmit[0].hooks[0].command).toContain('Bitterless');
});
test('can omit the emergency StopFailure hook', () => {
const result = installHandoff({
platform: 'darwin',
env,
stopFailure: false,
});
expect(hasInstalledHook(result.paths.claudeSettings, 'UserPromptExpansion')).toBe(true);
expect(hasInstalledHook(result.paths.claudeSettings, 'StopFailure')).toBe(false);
});
test('restores a pre-existing Claude handoff skill visibility on uninstall', () => {
const settingsPath = path.join(home, '.claude', 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ skillOverrides: { handoff: 'off', other: 'on' } }, null, 2));
const installed = installHandoff({ platform: 'darwin', env });
expect(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).skillOverrides.handoff)
.toBe('user-invocable-only');
uninstallHandoff({ platform: 'darwin', env });
expect(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).skillOverrides)
.toEqual({ handoff: 'off', other: 'on' });
expect(fs.existsSync(installed.paths.claudeSkillOverrideState)).toBe(false);
});
test('keeps Claude skill override restoration state separate per profile', () => {
const first = resolveHandoffPaths({ ...env, RIG_HANDOFF_CLAUDE_DIR: path.join(home, '.claude-a') });
const second = resolveHandoffPaths({ ...env, RIG_HANDOFF_CLAUDE_DIR: path.join(home, '.claude-b') });
expect(first.claudeSkillOverrideState).not.toBe(second.claudeSkillOverrideState);
});
test('preserves a symlinked Claude settings file', () => {
const dotfiles = path.join(home, 'dotfiles');
const target = path.join(dotfiles, 'claude-settings.json');
const settingsPath = path.join(home, '.claude', 'settings.json');
fs.mkdirSync(dotfiles, { recursive: true });
fs.writeFileSync(target, JSON.stringify({ theme: 'dark' }, null, 2));
fs.rmSync(settingsPath, { force: true });
fs.symlinkSync(target, settingsPath);
installHandoff({ platform: 'darwin', env });
expect(fs.lstatSync(settingsPath).isSymbolicLink()).toBe(true);
const settings = JSON.parse(fs.readFileSync(target, 'utf8'));
expect(settings.theme).toBe('dark');
expect(settings.hooks.UserPromptExpansion).toHaveLength(1);
});
test('preflights conflicts before creating any handoff links', () => {
const codexSkill = path.join(home, '.codex', 'skills', 'rig-from-claude');
fs.mkdirSync(codexSkill, { recursive: true });
fs.writeFileSync(path.join(codexSkill, 'KEEP.md'), 'user-owned');
expect(() => installHandoff({ platform: 'darwin', env })).toThrow('already exists');
expect(fs.existsSync(path.join(home, '.rig', 'bin', 'rig-handoff'))).toBe(false);
expect(fs.existsSync(path.join(home, '.claude', 'skills', 'handoff'))).toBe(false);
expect(fs.readFileSync(path.join(codexSkill, 'KEEP.md'), 'utf8')).toBe('user-owned');
});
test('migrates only the installer-owned legacy from-claude link', () => {
const legacyTarget = path.join(home, '.codex', 'skills', 'from-claude');
const legacySource = path.join(rigRoot, 'skills', 'from-claude');
fs.mkdirSync(path.dirname(legacyTarget), { recursive: true });
fs.symlinkSync(legacySource, legacyTarget, 'dir');
const result = installHandoff({ platform: 'darwin', env });
expect(result.legacyCodexSkillRemoved).toBe(true);
expect(() => fs.lstatSync(legacyTarget)).toThrow();
expect(fs.lstatSync(result.paths.codexSkill).isSymbolicLink()).toBe(true);
});
test('preserves an unrelated legacy from-claude link', () => {
const legacyTarget = path.join(home, '.codex', 'skills', 'from-claude');
const unrelatedSource = path.join(home, 'other-skill');
fs.mkdirSync(unrelatedSource, { recursive: true });
fs.mkdirSync(path.dirname(legacyTarget), { recursive: true });
fs.symlinkSync(unrelatedSource, legacyTarget, 'dir');
const result = installHandoff({ platform: 'darwin', env });
expect(result.legacyCodexSkillRemoved).toBe(false);
expect(path.resolve(path.dirname(legacyTarget), fs.readlinkSync(legacyTarget))).toBe(unrelatedSource);
});
test('rejects a dangling settings symlink before creating links', () => {
const settingsPath = path.join(home, '.claude', 'settings.json');
fs.rmSync(settingsPath, { force: true });
fs.symlinkSync(path.join(home, 'missing-settings.json'), settingsPath);
expect(() => installHandoff({ platform: 'darwin', env })).toThrow('symlink is dangling');
expect(fs.existsSync(path.join(home, '.rig', 'bin', 'rig-handoff'))).toBe(false);
expect(fs.existsSync(path.join(home, '.claude', 'skills', 'handoff'))).toBe(false);
});
test('rejects malformed Codex hooks before creating links', () => {
fs.writeFileSync(path.join(home, '.codex', 'hooks.json'), '{bad');
expect(() => installHandoff({ platform: 'darwin', env })).toThrow('cannot parse Codex hooks');
expect(fs.existsSync(path.join(home, '.rig', 'bin', 'rig-handoff'))).toBe(false);
expect(fs.existsSync(path.join(home, '.claude', 'skills', 'handoff'))).toBe(false);
});
test('rejects a parseable but invalid Claude hook shape before creating links', () => {
fs.writeFileSync(path.join(home, '.claude', 'settings.json'), JSON.stringify({
hooks: { UserPromptExpansion: { matcher: '^keep$' } },
}));
expect(() => installHandoff({ platform: 'darwin', env })).toThrow('UserPromptExpansion must contain an array');
expect(fs.existsSync(path.join(home, '.rig', 'bin', 'rig-handoff'))).toBe(false);
expect(fs.existsSync(path.join(home, '.claude', 'skills', 'handoff'))).toBe(false);
});
});