xtrm-cli 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { deepMergeWithProtection } from '../src/utils/atomic-config.js';
|
|
3
|
+
|
|
4
|
+
describe('deepMergeWithProtection (hooks merge behavior)', () => {
|
|
5
|
+
it('upgrades matcher tokens for same hook command without duplicating hook', () => {
|
|
6
|
+
const local = {
|
|
7
|
+
hooks: {
|
|
8
|
+
PostToolUse: [{
|
|
9
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
10
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
|
|
11
|
+
}],
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const incoming = {
|
|
16
|
+
hooks: {
|
|
17
|
+
PostToolUse: [{
|
|
18
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
19
|
+
hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
|
|
20
|
+
}],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
25
|
+
const wrappers = merged.hooks.PostToolUse;
|
|
26
|
+
expect(wrappers).toHaveLength(1);
|
|
27
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__insert_after_symbol');
|
|
28
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__insert_before_symbol');
|
|
29
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__rename_symbol');
|
|
30
|
+
expect(wrappers[0].matcher).toContain('mcp__serena__replace_symbol_body');
|
|
31
|
+
expect(wrappers[0].hooks).toHaveLength(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('preserves local custom hooks and still installs incoming wrappers', () => {
|
|
35
|
+
const local = {
|
|
36
|
+
hooks: {
|
|
37
|
+
PreToolUse: [{
|
|
38
|
+
matcher: 'Write',
|
|
39
|
+
hooks: [{ command: 'node /custom/local-hook.mjs' }],
|
|
40
|
+
}],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const incoming = {
|
|
45
|
+
hooks: {
|
|
46
|
+
PreToolUse: [{
|
|
47
|
+
matcher: 'Edit',
|
|
48
|
+
hooks: [{ command: 'node /repo/hooks/main-guard.mjs' }],
|
|
49
|
+
}],
|
|
50
|
+
PostToolUse: [{
|
|
51
|
+
matcher: 'Edit',
|
|
52
|
+
hooks: [{ command: 'node /repo/hooks/main-guard-post-push.mjs' }],
|
|
53
|
+
}],
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
58
|
+
expect(merged.hooks.PreToolUse).toHaveLength(2);
|
|
59
|
+
expect(merged.hooks.PreToolUse[0].hooks[0].command).toBe('node /custom/local-hook.mjs');
|
|
60
|
+
expect(merged.hooks.PreToolUse[1].hooks[0].command).toBe('node /repo/hooks/main-guard.mjs');
|
|
61
|
+
expect(merged.hooks.PostToolUse).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('keeps protected non-hook keys unchanged', () => {
|
|
65
|
+
const local = {
|
|
66
|
+
model: 'claude-sonnet',
|
|
67
|
+
hooks: {
|
|
68
|
+
SessionStart: [{ hooks: [{ command: 'echo start-local' }] }],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const incoming = {
|
|
73
|
+
model: 'claude-opus',
|
|
74
|
+
hooks: {
|
|
75
|
+
SessionStart: [{ hooks: [{ command: 'echo start-repo' }] }],
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
80
|
+
expect(merged.model).toBe('claude-sonnet');
|
|
81
|
+
expect(merged.hooks.SessionStart).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
describe('deepMergeWithProtection (hooks merge behavior) — matcher dedup', () => {
|
|
87
|
+
it('keeps two same-script entries separate when their matchers are disjoint', () => {
|
|
88
|
+
// Simulates config/hooks.json having main-guard wired for write-tools
|
|
89
|
+
// AND separately for Bash — they must not be merged into one entry
|
|
90
|
+
const local = {
|
|
91
|
+
hooks: {
|
|
92
|
+
PreToolUse: [] as any[],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const incoming = {
|
|
96
|
+
hooks: {
|
|
97
|
+
PreToolUse: [
|
|
98
|
+
{
|
|
99
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
100
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
matcher: 'Bash',
|
|
104
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
110
|
+
const wrappers = merged.hooks.PreToolUse;
|
|
111
|
+
expect(wrappers).toHaveLength(2);
|
|
112
|
+
expect(wrappers[0].matcher).toBe('Write|Edit|MultiEdit');
|
|
113
|
+
expect(wrappers[1].matcher).toBe('Bash');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still upgrades matcher when entries share at least one common token', () => {
|
|
117
|
+
const local = {
|
|
118
|
+
hooks: {
|
|
119
|
+
PreToolUse: [{
|
|
120
|
+
matcher: 'Write|Edit',
|
|
121
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
|
|
122
|
+
}],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const incoming = {
|
|
126
|
+
hooks: {
|
|
127
|
+
PreToolUse: [{
|
|
128
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
129
|
+
hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
|
|
130
|
+
}],
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const merged = deepMergeWithProtection(local, incoming);
|
|
134
|
+
const wrappers = merged.hooks.PreToolUse;
|
|
135
|
+
expect(wrappers).toHaveLength(1);
|
|
136
|
+
expect(wrappers[0].matcher).toContain('MultiEdit');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const REPO_ROOT = path.join(__dirname, '../..');
|
|
10
|
+
const CLI_ENTRY = path.join(__dirname, '../src/index.ts');
|
|
11
|
+
|
|
12
|
+
const CLI_BIN = path.join(__dirname, '../dist/index.cjs');
|
|
13
|
+
|
|
14
|
+
function runClean(
|
|
15
|
+
args: string[],
|
|
16
|
+
env: Record<string, string> = {},
|
|
17
|
+
): { stdout: string; stderr: string; status: number } {
|
|
18
|
+
const r = spawnSync('node', [CLI_BIN, 'clean', ...args], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
cwd: REPO_ROOT,
|
|
21
|
+
env: { ...process.env, ...env },
|
|
22
|
+
timeout: 30000,
|
|
23
|
+
});
|
|
24
|
+
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── canonical wiring validation ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('xtrm clean — canonical wiring validation', () => {
|
|
30
|
+
it('reports stale event: serena-workflow-reminder.py wired to PreToolUse is removed', () => {
|
|
31
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'xtrm-clean-test-'));
|
|
32
|
+
const hooksDir = path.join(tmpHome, '.claude', 'hooks');
|
|
33
|
+
mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
34
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
// Stub hook file so cleanHooks doesn't also flag it as orphaned
|
|
37
|
+
writeFileSync(path.join(hooksDir, 'serena-workflow-reminder.py'), '# stub');
|
|
38
|
+
|
|
39
|
+
writeFileSync(path.join(tmpHome, '.claude', 'settings.json'), JSON.stringify({
|
|
40
|
+
hooks: {
|
|
41
|
+
SessionStart: [
|
|
42
|
+
{ hooks: [{ type: 'command', command: `python3 "${path.join(hooksDir, 'serena-workflow-reminder.py')}"` }] },
|
|
43
|
+
],
|
|
44
|
+
// Stale: serena-workflow-reminder.py is NOT in PreToolUse in config/hooks.json
|
|
45
|
+
PreToolUse: [
|
|
46
|
+
{
|
|
47
|
+
matcher: 'Read|Edit|mcp__serena__rename_symbol',
|
|
48
|
+
hooks: [{ type: 'command', command: `python3 "${path.join(hooksDir, 'serena-workflow-reminder.py')}"` }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
}, null, 2));
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const r = runClean(['--dry-run', '--hooks-only'], { HOME: tmpHome });
|
|
56
|
+
expect(r.stdout, `stdout:\n${r.stdout}\nstderr:\n${r.stderr}`).toMatch(
|
|
57
|
+
/serena-workflow-reminder\.py.*stale wiring/i,
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('reports stale matcher: gitnexus-hook.cjs with Read|Grep|Glob prefix is removed', () => {
|
|
65
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'xtrm-clean-test-'));
|
|
66
|
+
const hooksDir = path.join(tmpHome, '.claude', 'hooks');
|
|
67
|
+
mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
68
|
+
mkdirSync(path.join(hooksDir, 'gitnexus'), { recursive: true });
|
|
69
|
+
writeFileSync(path.join(hooksDir, 'gitnexus', 'gitnexus-hook.cjs'), '// stub');
|
|
70
|
+
|
|
71
|
+
writeFileSync(path.join(tmpHome, '.claude', 'settings.json'), JSON.stringify({
|
|
72
|
+
hooks: {
|
|
73
|
+
PostToolUse: [
|
|
74
|
+
{
|
|
75
|
+
// Stale: canonical has Bash|mcp__serena__... (no Read|Grep|Glob)
|
|
76
|
+
matcher: 'Read|Grep|Glob|Bash|mcp__serena__find_symbol|mcp__serena__get_symbols_overview',
|
|
77
|
+
hooks: [{ type: 'command', command: `node "${path.join(hooksDir, 'gitnexus/gitnexus-hook.cjs')}"`, timeout: 10000 }],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
}, null, 2));
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const r = runClean(['--dry-run', '--hooks-only'], { HOME: tmpHome });
|
|
85
|
+
expect(r.stdout, `stdout:\n${r.stdout}\nstderr:\n${r.stderr}`).toMatch(
|
|
86
|
+
/gitnexus-hook\.cjs.*stale wiring/i,
|
|
87
|
+
);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps branch-state.mjs as canonical (not flagged as orphaned)', () => {
|
|
94
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'xtrm-clean-test-'));
|
|
95
|
+
const hooksDir = path.join(tmpHome, '.claude', 'hooks');
|
|
96
|
+
mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
97
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
98
|
+
writeFileSync(path.join(hooksDir, 'branch-state.mjs'), '// stub');
|
|
99
|
+
|
|
100
|
+
writeFileSync(path.join(tmpHome, '.claude', 'settings.json'), JSON.stringify({
|
|
101
|
+
hooks: {
|
|
102
|
+
UserPromptSubmit: [
|
|
103
|
+
{ hooks: [{ type: 'command', command: `node "${path.join(hooksDir, 'branch-state.mjs')}"`, timeout: 3000 }] },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
}, null, 2));
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const r = runClean(['--dry-run', '--hooks-only'], { HOME: tmpHome });
|
|
110
|
+
expect(r.stdout).toContain('No orphaned hook entries found');
|
|
111
|
+
expect(r.stdout).not.toContain('branch-state.mjs');
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('keeps canonical entries that match config/hooks.json exactly', () => {
|
|
118
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'xtrm-clean-test-'));
|
|
119
|
+
const hooksDir = path.join(tmpHome, '.claude', 'hooks');
|
|
120
|
+
mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
121
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
122
|
+
writeFileSync(path.join(hooksDir, 'main-guard.mjs'), '// stub');
|
|
123
|
+
|
|
124
|
+
writeFileSync(path.join(tmpHome, '.claude', 'settings.json'), JSON.stringify({
|
|
125
|
+
hooks: {
|
|
126
|
+
PreToolUse: [
|
|
127
|
+
{
|
|
128
|
+
matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
|
|
129
|
+
hooks: [{ type: 'command', command: `node "${path.join(hooksDir, 'main-guard.mjs')}"`, timeout: 5000 }],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
matcher: 'Bash',
|
|
133
|
+
hooks: [{ type: 'command', command: `node "${path.join(hooksDir, 'main-guard.mjs')}"`, timeout: 5000 }],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
}, null, 2));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const r = runClean(['--dry-run', '--hooks-only'], { HOME: tmpHome });
|
|
141
|
+
expect(r.stdout).toContain('No orphaned hook entries found');
|
|
142
|
+
expect(r.stdout).not.toContain('main-guard.mjs');
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('falls back to script-only check and removes non-canonical scripts regardless', () => {
|
|
149
|
+
const tmpHome = mkdtempSync(path.join(os.tmpdir(), 'xtrm-clean-test-'));
|
|
150
|
+
const hooksDir = path.join(tmpHome, '.claude', 'hooks');
|
|
151
|
+
mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
152
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
153
|
+
|
|
154
|
+
writeFileSync(path.join(tmpHome, '.claude', 'settings.json'), JSON.stringify({
|
|
155
|
+
hooks: {
|
|
156
|
+
PreToolUse: [
|
|
157
|
+
{
|
|
158
|
+
matcher: 'Bash',
|
|
159
|
+
hooks: [{ type: 'command', command: `node "${path.join(hooksDir, 'some-old-hook.mjs')}"` }],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
}, null, 2));
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const r = runClean(['--dry-run', '--hooks-only'], { HOME: tmpHome });
|
|
167
|
+
expect(r.stdout).toContain('some-old-hook.mjs');
|
|
168
|
+
} finally {
|
|
169
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ROOT = resolve(__dirname, '..', '..');
|
|
6
|
+
|
|
7
|
+
describe('config schema integrity', () => {
|
|
8
|
+
describe('cli/package.json bin field (in5e)', () => {
|
|
9
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
10
|
+
|
|
11
|
+
it('has xtrm bin entry pointing to dist/index.cjs', () => {
|
|
12
|
+
expect(pkg.bin?.xtrm).toBe('./dist/index.cjs');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('has xt bin entry pointing to dist/index.cjs', () => {
|
|
16
|
+
expect(pkg.bin?.xt).toBe('./dist/index.cjs');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('both bin entries point to the same file', () => {
|
|
20
|
+
expect(pkg.bin?.xt).toBe(pkg.bin?.xtrm);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('config/pi/install-schema.json packages (m54d, x87v)', () => {
|
|
25
|
+
const schema = JSON.parse(readFileSync(resolve(ROOT, 'config', 'pi', 'install-schema.json'), 'utf8'));
|
|
26
|
+
|
|
27
|
+
it('contains npm:@robhowley/pi-structured-return', () => {
|
|
28
|
+
expect(schema.packages).toContain('npm:@robhowley/pi-structured-return');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does NOT contain npm:@aliou/pi-guardrails', () => {
|
|
32
|
+
expect(schema.packages).not.toContain('npm:@aliou/pi-guardrails');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('contains all expected canonical packages', () => {
|
|
36
|
+
const expected = [
|
|
37
|
+
'npm:pi-gitnexus',
|
|
38
|
+
'npm:pi-serena-tools',
|
|
39
|
+
'npm:@zenobius/pi-worktrees',
|
|
40
|
+
'npm:@robhowley/pi-structured-return',
|
|
41
|
+
];
|
|
42
|
+
for (const pkg of expected) {
|
|
43
|
+
expect(schema.packages).toContain(pkg);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('packages is an array with no duplicates', () => {
|
|
48
|
+
const unique = new Set(schema.packages);
|
|
49
|
+
expect(unique.size).toBe(schema.packages.length);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getCandidatePaths, resolveTargets } from '../src/core/context.js';
|
|
3
|
+
|
|
4
|
+
describe('getCandidatePaths', () => {
|
|
5
|
+
it('includes Claude Code and skills-only targets', () => {
|
|
6
|
+
const candidates = getCandidatePaths();
|
|
7
|
+
expect(candidates.some(candidate => candidate.label === '~/.claude (hooks + skills)')).toBe(true);
|
|
8
|
+
expect(candidates.some(candidate => candidate.label === '~/.agents/skills')).toBe(true);
|
|
9
|
+
expect(candidates.length).toBe(2);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('resolveTargets', () => {
|
|
14
|
+
it('returns all candidate paths for the "*" selector', () => {
|
|
15
|
+
const candidates = getCandidatePaths();
|
|
16
|
+
expect(resolveTargets('*', candidates)).toEqual(candidates.map(candidate => candidate.path));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns all candidate paths for the "all" selector', () => {
|
|
20
|
+
const candidates = getCandidatePaths();
|
|
21
|
+
expect(resolveTargets('all', candidates)).toEqual(candidates.map(candidate => candidate.path));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns null when no selector is provided', () => {
|
|
25
|
+
expect(resolveTargets(undefined, getCandidatePaths())).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects unknown selectors', () => {
|
|
29
|
+
expect(() => resolveTargets('everything', getCandidatePaths())).toThrow(
|
|
30
|
+
"Unknown install target selector 'everything'. Use '*' or 'all'.",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const CLI_BIN = path.join(__dirname, '../dist/index.cjs');
|
|
10
|
+
|
|
11
|
+
function run(args: string[], opts: { cwd?: string } = {}): { stdout: string; stderr: string; status: number } {
|
|
12
|
+
const r = spawnSync('node', [CLI_BIN, ...args], {
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
timeout: 15000,
|
|
15
|
+
cwd: opts.cwd,
|
|
16
|
+
env: { ...process.env },
|
|
17
|
+
});
|
|
18
|
+
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(args: string[], cwd: string): string {
|
|
22
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
23
|
+
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
|
24
|
+
return (r.stdout ?? '').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let tmpBase: string;
|
|
28
|
+
let mainRepo: string;
|
|
29
|
+
let xtWorktree: string;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'xtrm-end-'));
|
|
33
|
+
mainRepo = path.join(tmpBase, 'myrepo');
|
|
34
|
+
fs.mkdirSync(mainRepo);
|
|
35
|
+
git(['init'], mainRepo);
|
|
36
|
+
git(['config', 'user.email', 'test@test.com'], mainRepo);
|
|
37
|
+
git(['config', 'user.name', 'Test'], mainRepo);
|
|
38
|
+
fs.writeFileSync(path.join(mainRepo, 'README.md'), '# test');
|
|
39
|
+
git(['add', '.'], mainRepo);
|
|
40
|
+
git(['commit', '-m', 'init'], mainRepo);
|
|
41
|
+
|
|
42
|
+
// Create an xt/* worktree
|
|
43
|
+
xtWorktree = path.join(tmpBase, 'myrepo-xt-wt');
|
|
44
|
+
git(['worktree', 'add', '-b', 'xt/test-session', xtWorktree], mainRepo);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
try { fs.rmSync(tmpBase, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── xt end ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('xt end command surface (ua3z)', () => {
|
|
54
|
+
|
|
55
|
+
it('xt end --help exits 0', () => {
|
|
56
|
+
const r = run(['end', '--help']);
|
|
57
|
+
expect(r.status).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('xt end --help describes draft, keep, and yes flags', () => {
|
|
61
|
+
const r = run(['end', '--help']);
|
|
62
|
+
expect(r.stdout).toMatch(/--draft/);
|
|
63
|
+
expect(r.stdout).toMatch(/--keep/);
|
|
64
|
+
expect(r.stdout).toMatch(/--yes|-y/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('xt end rejects when not on an xt/* branch', () => {
|
|
68
|
+
// mainRepo is on 'main' (or 'master'), not xt/*
|
|
69
|
+
const r = run(['end'], { cwd: mainRepo });
|
|
70
|
+
const combined = r.stdout + r.stderr;
|
|
71
|
+
expect(r.status).not.toBe(0);
|
|
72
|
+
expect(combined).toMatch(/not in an xt worktree|xt\/|branch/i);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('xt end rejects with uncommitted changes', () => {
|
|
76
|
+
// Create a dirty file in the xt worktree
|
|
77
|
+
fs.writeFileSync(path.join(xtWorktree, 'dirty.txt'), 'uncommitted');
|
|
78
|
+
const r = run(['end'], { cwd: xtWorktree });
|
|
79
|
+
const combined = r.stdout + r.stderr;
|
|
80
|
+
// Either gate fires (dirty tree or no origin/main), both are acceptable
|
|
81
|
+
expect(r.status).not.toBe(0);
|
|
82
|
+
// Clean up
|
|
83
|
+
fs.unlinkSync(path.join(xtWorktree, 'dirty.txt'));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('xt end rejects when origin/main is not configured (no remote)', () => {
|
|
87
|
+
// The xt worktree has no remote configured, so rebase/push will fail
|
|
88
|
+
// After cleaning dirty state, end will get past the dirty-tree gate
|
|
89
|
+
// but fail at fetch/rebase
|
|
90
|
+
const r = run(['end', '-y'], { cwd: xtWorktree });
|
|
91
|
+
const combined = r.stdout + r.stderr;
|
|
92
|
+
expect(r.status).not.toBe(0);
|
|
93
|
+
// Should mention rebase, push, or remote issues
|
|
94
|
+
expect(combined).toMatch(/rebase|push|fetch|remote|origin/i);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── xt worktree ──────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe('xt worktree command surface (c5pi)', () => {
|
|
101
|
+
|
|
102
|
+
it('xt worktree --help exits 0', () => {
|
|
103
|
+
const r = run(['worktree', '--help']);
|
|
104
|
+
expect(r.status).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('xt worktree list --help exits 0', () => {
|
|
108
|
+
const r = run(['worktree', 'list', '--help']);
|
|
109
|
+
expect(r.status).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('xt worktree clean --help exits 0', () => {
|
|
113
|
+
const r = run(['worktree', 'clean', '--help']);
|
|
114
|
+
expect(r.status).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('xt worktree remove --help exits 0', () => {
|
|
118
|
+
const r = run(['worktree', 'remove', '--help']);
|
|
119
|
+
expect(r.status).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('xt worktree list shows xt/* worktree from main repo', () => {
|
|
123
|
+
const r = run(['worktree', 'list'], { cwd: mainRepo });
|
|
124
|
+
expect(r.status).toBe(0);
|
|
125
|
+
expect(r.stdout).toMatch(/xt\/test-session/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('xt worktree list shows no worktrees message when none present', () => {
|
|
129
|
+
// Create an isolated repo with no xt worktrees
|
|
130
|
+
const isolated = path.join(tmpBase, 'isolated');
|
|
131
|
+
fs.mkdirSync(isolated);
|
|
132
|
+
git(['init'], isolated);
|
|
133
|
+
git(['config', 'user.email', 'test@test.com'], isolated);
|
|
134
|
+
git(['config', 'user.name', 'Test'], isolated);
|
|
135
|
+
fs.writeFileSync(path.join(isolated, 'file.txt'), 'x');
|
|
136
|
+
git(['add', '.'], isolated);
|
|
137
|
+
git(['commit', '-m', 'init'], isolated);
|
|
138
|
+
|
|
139
|
+
const r = run(['worktree', 'list'], { cwd: isolated });
|
|
140
|
+
expect(r.status).toBe(0);
|
|
141
|
+
expect(r.stdout).toMatch(/no xt worktrees/i);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('xt worktree clean --yes has no merged worktrees to clean', () => {
|
|
145
|
+
// The xt/test-session branch has not been merged into main
|
|
146
|
+
const r = run(['worktree', 'clean', '--yes'], { cwd: mainRepo });
|
|
147
|
+
expect(r.status).toBe(0);
|
|
148
|
+
expect(r.stdout).toMatch(/no merged xt worktrees/i);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('xt worktree remove errors on unknown branch name', () => {
|
|
152
|
+
const r = run(['worktree', 'remove', 'nonexistent-branch'], { cwd: mainRepo });
|
|
153
|
+
expect(r.status).not.toBe(0);
|
|
154
|
+
const combined = r.stdout + r.stderr;
|
|
155
|
+
expect(combined).toMatch(/no xt worktree found/i);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('xt worktree clean shows merged worktrees when branch is merged', () => {
|
|
159
|
+
// Merge the xt branch into main and verify clean would pick it up
|
|
160
|
+
git(['checkout', 'main'], mainRepo);
|
|
161
|
+
git(['merge', 'xt/test-session', '--no-ff', '-m', 'merge test'], mainRepo);
|
|
162
|
+
|
|
163
|
+
const r = run(['worktree', 'clean', '--yes'], { cwd: mainRepo });
|
|
164
|
+
expect(r.status).toBe(0);
|
|
165
|
+
// Should have removed the worktree since it's now merged
|
|
166
|
+
expect(r.stdout).toMatch(/removed|clean/i);
|
|
167
|
+
});
|
|
168
|
+
});
|