@automagik/genie 4.260409.1 → 4.260409.2
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/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +6 -6
- package/knip.json +2 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/__tests__/discovery.test.ts +19 -9
- package/src/__tests__/migrate.test.ts +498 -0
- package/src/lib/discovery.ts +23 -8
- package/src/lib/migrate.ts +488 -0
- package/src/lib/omni-approval-handler.ts +1 -1
- package/src/term-commands/approval.ts +4 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync,
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { discoverExternalAgents, importAgents } from '../lib/discovery.js';
|
|
@@ -94,9 +94,12 @@ describe('discoverExternalAgents()', () => {
|
|
|
94
94
|
// ─── importAgents ────────────────────────────────────────────────────────────
|
|
95
95
|
|
|
96
96
|
describe('importAgents()', () => {
|
|
97
|
-
test('
|
|
97
|
+
test('moves agent into agents/ directory (physical, not symlink)', () => {
|
|
98
98
|
mkWorkspace(testDir);
|
|
99
99
|
mkAgent(testDir, 'services/auth');
|
|
100
|
+
// Add a hidden folder to verify dotfiles are preserved
|
|
101
|
+
mkdirSync(join(testDir, 'services', 'auth', '.claude'), { recursive: true });
|
|
102
|
+
writeFileSync(join(testDir, 'services', 'auth', '.claude', 'settings.json'), '{}');
|
|
100
103
|
|
|
101
104
|
const agents = [
|
|
102
105
|
{
|
|
@@ -113,10 +116,16 @@ describe('importAgents()', () => {
|
|
|
113
116
|
expect(result.skipped).toEqual([]);
|
|
114
117
|
expect(result.errors).toEqual([]);
|
|
115
118
|
|
|
116
|
-
const
|
|
117
|
-
expect(existsSync(
|
|
118
|
-
expect(lstatSync(
|
|
119
|
-
expect(
|
|
119
|
+
const destPath = join(testDir, 'agents', 'auth');
|
|
120
|
+
expect(existsSync(destPath)).toBe(true);
|
|
121
|
+
expect(lstatSync(destPath).isDirectory()).toBe(true);
|
|
122
|
+
expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
|
|
123
|
+
// Files were moved
|
|
124
|
+
expect(readFileSync(join(destPath, 'AGENTS.md'), 'utf-8')).toContain('# Agent');
|
|
125
|
+
// Hidden folders preserved
|
|
126
|
+
expect(existsSync(join(destPath, '.claude', 'settings.json'))).toBe(true);
|
|
127
|
+
// Source removed
|
|
128
|
+
expect(existsSync(join(testDir, 'services', 'auth'))).toBe(false);
|
|
120
129
|
});
|
|
121
130
|
|
|
122
131
|
test('resolves name collisions with numeric suffix', () => {
|
|
@@ -139,9 +148,10 @@ describe('importAgents()', () => {
|
|
|
139
148
|
expect(result.imported).toEqual(['auth-2']);
|
|
140
149
|
expect(result.errors).toEqual([]);
|
|
141
150
|
|
|
142
|
-
const
|
|
143
|
-
expect(existsSync(
|
|
144
|
-
expect(lstatSync(
|
|
151
|
+
const destPath = join(testDir, 'agents', 'auth-2');
|
|
152
|
+
expect(existsSync(destPath)).toBe(true);
|
|
153
|
+
expect(lstatSync(destPath).isDirectory()).toBe(true);
|
|
154
|
+
expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
|
|
145
155
|
});
|
|
146
156
|
|
|
147
157
|
test('creates agents/ directory if it does not exist', () => {
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readlinkSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
type MigrationJournalEntry,
|
|
17
|
+
executeMigration,
|
|
18
|
+
hasDirtyWorkingTree,
|
|
19
|
+
isInsideSeparateGitRepo,
|
|
20
|
+
planMigration,
|
|
21
|
+
recalculateInternalSymlinks,
|
|
22
|
+
rollbackMigration,
|
|
23
|
+
} from '../lib/migrate.js';
|
|
24
|
+
|
|
25
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
let testDir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
testDir = join(tmpdir(), `genie-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
31
|
+
mkdirSync(testDir, { recursive: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Create a minimal workspace with .genie/ and agents/ dirs. */
|
|
39
|
+
function mkWorkspace(root: string): void {
|
|
40
|
+
mkdirSync(join(root, '.genie'), { recursive: true });
|
|
41
|
+
mkdirSync(join(root, 'agents'), { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Create an agent directory with AGENTS.md. */
|
|
45
|
+
function mkAgent(dir: string): void {
|
|
46
|
+
mkdirSync(dir, { recursive: true });
|
|
47
|
+
writeFileSync(join(dir, 'AGENTS.md'), '---\nname: test\n---\n# Agent\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create a symlink in agents/ pointing to an external directory (relative). */
|
|
51
|
+
function mkSymlink(workspaceRoot: string, agentName: string, targetPath: string): string {
|
|
52
|
+
const linkPath = join(workspaceRoot, 'agents', agentName);
|
|
53
|
+
const relTarget = relative(join(workspaceRoot, 'agents'), targetPath);
|
|
54
|
+
symlinkSync(relTarget, linkPath);
|
|
55
|
+
return linkPath;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read the migration journal from a workspace. */
|
|
59
|
+
function readJournal(workspaceRoot: string): MigrationJournalEntry[] {
|
|
60
|
+
const path = join(workspaceRoot, '.genie', 'migration-journal.json');
|
|
61
|
+
if (!existsSync(path)) return [];
|
|
62
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── planMigration ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe('planMigration()', () => {
|
|
68
|
+
test('finds symlinked agents and returns plans', () => {
|
|
69
|
+
mkWorkspace(testDir);
|
|
70
|
+
const extDir = join(testDir, 'services', 'auth');
|
|
71
|
+
mkAgent(extDir);
|
|
72
|
+
mkSymlink(testDir, 'auth', extDir);
|
|
73
|
+
|
|
74
|
+
const plans = planMigration(testDir);
|
|
75
|
+
|
|
76
|
+
expect(plans.length).toBe(1);
|
|
77
|
+
expect(plans[0].agent).toBe('auth');
|
|
78
|
+
expect(plans[0].from).toBe(extDir);
|
|
79
|
+
expect(plans[0].to).toBe(join(testDir, 'agents', 'auth'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('skips physical directories (not symlinks)', () => {
|
|
83
|
+
mkWorkspace(testDir);
|
|
84
|
+
// Create a physical agent dir directly in agents/
|
|
85
|
+
mkAgent(join(testDir, 'agents', 'physical'));
|
|
86
|
+
|
|
87
|
+
const plans = planMigration(testDir);
|
|
88
|
+
|
|
89
|
+
expect(plans).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('returns empty array when no symlinks exist', () => {
|
|
93
|
+
mkWorkspace(testDir);
|
|
94
|
+
|
|
95
|
+
const plans = planMigration(testDir);
|
|
96
|
+
|
|
97
|
+
expect(plans).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('returns empty array when agents/ does not exist', () => {
|
|
101
|
+
mkdirSync(join(testDir, '.genie'), { recursive: true });
|
|
102
|
+
// No agents/ dir
|
|
103
|
+
|
|
104
|
+
const plans = planMigration(testDir);
|
|
105
|
+
|
|
106
|
+
expect(plans).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('detects cross-repo risk', () => {
|
|
110
|
+
mkWorkspace(testDir);
|
|
111
|
+
// Create a fake separate git repo
|
|
112
|
+
const externalRepo = join(testDir, 'external-repo');
|
|
113
|
+
mkAgent(join(externalRepo, 'agent-x'));
|
|
114
|
+
mkdirSync(join(externalRepo, '.git'), { recursive: true });
|
|
115
|
+
// Create a .git in the workspace root too
|
|
116
|
+
mkdirSync(join(testDir, '.git'), { recursive: true });
|
|
117
|
+
|
|
118
|
+
mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
|
|
119
|
+
|
|
120
|
+
const plans = planMigration(testDir);
|
|
121
|
+
|
|
122
|
+
expect(plans.length).toBe(1);
|
|
123
|
+
expect(plans[0].risks).toContain('Cross-repo agent');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ─── executeMigration ───────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
describe('executeMigration()', () => {
|
|
130
|
+
test('converts symlink to physical directory via copy', () => {
|
|
131
|
+
mkWorkspace(testDir);
|
|
132
|
+
const extDir = join(testDir, 'services', 'billing');
|
|
133
|
+
mkAgent(extDir);
|
|
134
|
+
writeFileSync(join(extDir, 'config.json'), '{"key":"value"}');
|
|
135
|
+
mkSymlink(testDir, 'billing', extDir);
|
|
136
|
+
|
|
137
|
+
const plans = planMigration(testDir);
|
|
138
|
+
// Force copy method for this test
|
|
139
|
+
const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
|
|
140
|
+
const result = executeMigration(testDir, plan, { noGit: true });
|
|
141
|
+
|
|
142
|
+
expect(result.migrated).toEqual(['billing']);
|
|
143
|
+
expect(result.skipped).toEqual([]);
|
|
144
|
+
expect(result.errors).toEqual([]);
|
|
145
|
+
|
|
146
|
+
// Verify the destination is now a physical directory (not a symlink)
|
|
147
|
+
const destPath = join(testDir, 'agents', 'billing');
|
|
148
|
+
expect(existsSync(destPath)).toBe(true);
|
|
149
|
+
expect(lstatSync(destPath).isSymbolicLink()).toBe(false);
|
|
150
|
+
expect(lstatSync(destPath).isDirectory()).toBe(true);
|
|
151
|
+
|
|
152
|
+
// Verify files were copied
|
|
153
|
+
expect(existsSync(join(destPath, 'AGENTS.md'))).toBe(true);
|
|
154
|
+
expect(existsSync(join(destPath, 'config.json'))).toBe(true);
|
|
155
|
+
expect(readFileSync(join(destPath, 'config.json'), 'utf-8')).toBe('{"key":"value"}');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('writes journal entries', () => {
|
|
159
|
+
mkWorkspace(testDir);
|
|
160
|
+
const extDir = join(testDir, 'services', 'auth');
|
|
161
|
+
mkAgent(extDir);
|
|
162
|
+
mkSymlink(testDir, 'auth', extDir);
|
|
163
|
+
|
|
164
|
+
const plans = planMigration(testDir);
|
|
165
|
+
const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
|
|
166
|
+
const result = executeMigration(testDir, plan, { noGit: true });
|
|
167
|
+
|
|
168
|
+
expect(result.migrated).toEqual(['auth']);
|
|
169
|
+
|
|
170
|
+
const journal = readJournal(testDir);
|
|
171
|
+
expect(journal.length).toBe(1);
|
|
172
|
+
expect(journal[0].agent).toBe('auth');
|
|
173
|
+
expect(journal[0].batchId).toBe(result.batchId);
|
|
174
|
+
expect(journal[0].method).toBe('copy');
|
|
175
|
+
expect(journal[0].from).toBe(extDir);
|
|
176
|
+
expect(journal[0].to).toBe(join(testDir, 'agents', 'auth'));
|
|
177
|
+
// Timestamp is ISO 8601
|
|
178
|
+
expect(new Date(journal[0].timestamp).toISOString()).toBe(journal[0].timestamp);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('skips cross-repo agents without force', () => {
|
|
182
|
+
mkWorkspace(testDir);
|
|
183
|
+
const externalRepo = join(testDir, 'external-repo');
|
|
184
|
+
mkAgent(join(externalRepo, 'agent-x'));
|
|
185
|
+
mkdirSync(join(externalRepo, '.git'), { recursive: true });
|
|
186
|
+
mkdirSync(join(testDir, '.git'), { recursive: true });
|
|
187
|
+
mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
|
|
188
|
+
|
|
189
|
+
const plans = planMigration(testDir);
|
|
190
|
+
expect(plans[0].risks).toContain('Cross-repo agent');
|
|
191
|
+
|
|
192
|
+
const result = executeMigration(testDir, plans, { noGit: true });
|
|
193
|
+
|
|
194
|
+
expect(result.skipped).toEqual(['agent-x']);
|
|
195
|
+
expect(result.migrated).toEqual([]);
|
|
196
|
+
// Symlink should still exist
|
|
197
|
+
expect(lstatSync(join(testDir, 'agents', 'agent-x')).isSymbolicLink()).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('migrates cross-repo agents with force', () => {
|
|
201
|
+
mkWorkspace(testDir);
|
|
202
|
+
const externalRepo = join(testDir, 'external-repo');
|
|
203
|
+
mkAgent(join(externalRepo, 'agent-x'));
|
|
204
|
+
mkdirSync(join(externalRepo, '.git'), { recursive: true });
|
|
205
|
+
mkdirSync(join(testDir, '.git'), { recursive: true });
|
|
206
|
+
mkSymlink(testDir, 'agent-x', join(externalRepo, 'agent-x'));
|
|
207
|
+
|
|
208
|
+
const plans = planMigration(testDir);
|
|
209
|
+
const result = executeMigration(testDir, plans, { force: true, noGit: true });
|
|
210
|
+
|
|
211
|
+
expect(result.migrated).toEqual(['agent-x']);
|
|
212
|
+
expect(result.skipped).toEqual([]);
|
|
213
|
+
expect(lstatSync(join(testDir, 'agents', 'agent-x')).isDirectory()).toBe(true);
|
|
214
|
+
expect(lstatSync(join(testDir, 'agents', 'agent-x')).isSymbolicLink()).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('aborts on dirty source repos', () => {
|
|
218
|
+
mkWorkspace(testDir);
|
|
219
|
+
const extDir = join(testDir, 'services', 'dirty');
|
|
220
|
+
mkAgent(extDir);
|
|
221
|
+
mkSymlink(testDir, 'dirty', extDir);
|
|
222
|
+
|
|
223
|
+
// Create plan with "Uncommitted changes" risk manually
|
|
224
|
+
const plan = [
|
|
225
|
+
{
|
|
226
|
+
agent: 'dirty',
|
|
227
|
+
from: extDir,
|
|
228
|
+
to: join(testDir, 'agents', 'dirty'),
|
|
229
|
+
method: 'copy' as const,
|
|
230
|
+
risks: ['Uncommitted changes'],
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const result = executeMigration(testDir, plan, { noGit: true });
|
|
235
|
+
|
|
236
|
+
expect(result.errors.length).toBe(1);
|
|
237
|
+
expect(result.errors[0].agent).toBe('dirty');
|
|
238
|
+
expect(result.errors[0].error).toContain('Uncommitted changes');
|
|
239
|
+
expect(result.migrated).toEqual([]);
|
|
240
|
+
// Symlink should still exist
|
|
241
|
+
expect(lstatSync(join(testDir, 'agents', 'dirty')).isSymbolicLink()).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('recalculates internal relative symlinks', () => {
|
|
245
|
+
mkWorkspace(testDir);
|
|
246
|
+
// Create an agent with an internal relative symlink
|
|
247
|
+
const extDir = join(testDir, 'services', 'linked');
|
|
248
|
+
mkAgent(extDir);
|
|
249
|
+
|
|
250
|
+
// Create a shared dir and a symlink from the agent to it
|
|
251
|
+
const sharedDir = join(testDir, 'services', 'shared');
|
|
252
|
+
mkdirSync(sharedDir, { recursive: true });
|
|
253
|
+
writeFileSync(join(sharedDir, 'utils.ts'), 'export const x = 1;');
|
|
254
|
+
// Relative symlink: services/linked/shared-link -> ../shared
|
|
255
|
+
symlinkSync('../shared', join(extDir, 'shared-link'));
|
|
256
|
+
|
|
257
|
+
mkSymlink(testDir, 'linked', extDir);
|
|
258
|
+
|
|
259
|
+
const plans = planMigration(testDir);
|
|
260
|
+
const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
|
|
261
|
+
const result = executeMigration(testDir, plan, { noGit: true });
|
|
262
|
+
|
|
263
|
+
expect(result.migrated).toEqual(['linked']);
|
|
264
|
+
|
|
265
|
+
// The internal symlink should be recalculated
|
|
266
|
+
const newSharedLink = join(testDir, 'agents', 'linked', 'shared-link');
|
|
267
|
+
expect(existsSync(newSharedLink)).toBe(true);
|
|
268
|
+
expect(lstatSync(newSharedLink).isSymbolicLink()).toBe(true);
|
|
269
|
+
// The target should resolve to the same shared dir
|
|
270
|
+
const linkTarget = readlinkSync(newSharedLink);
|
|
271
|
+
// From agents/linked/shared-link, the relative path to services/shared is ../../services/shared
|
|
272
|
+
expect(linkTarget).toBe('../../services/shared');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ─── rollbackMigration ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
describe('rollbackMigration()', () => {
|
|
279
|
+
test('reverses moves from journal', () => {
|
|
280
|
+
mkWorkspace(testDir);
|
|
281
|
+
const extDir = join(testDir, 'services', 'auth');
|
|
282
|
+
mkAgent(extDir);
|
|
283
|
+
mkSymlink(testDir, 'auth', extDir);
|
|
284
|
+
|
|
285
|
+
// Execute migration first
|
|
286
|
+
const plans = planMigration(testDir);
|
|
287
|
+
const plan = plans.map((p) => ({ ...p, method: 'copy' as const, risks: [] }));
|
|
288
|
+
const migrateResult = executeMigration(testDir, plan, { noGit: true });
|
|
289
|
+
expect(migrateResult.migrated).toEqual(['auth']);
|
|
290
|
+
|
|
291
|
+
// Verify it's a physical dir now
|
|
292
|
+
expect(lstatSync(join(testDir, 'agents', 'auth')).isSymbolicLink()).toBe(false);
|
|
293
|
+
|
|
294
|
+
// Source was removed during migration
|
|
295
|
+
expect(existsSync(extDir)).toBe(false);
|
|
296
|
+
|
|
297
|
+
// Rollback
|
|
298
|
+
const rollbackResult = rollbackMigration(testDir);
|
|
299
|
+
|
|
300
|
+
expect(rollbackResult.rolledBack).toEqual(['auth']);
|
|
301
|
+
expect(rollbackResult.errors).toEqual([]);
|
|
302
|
+
|
|
303
|
+
// Source should be restored
|
|
304
|
+
expect(existsSync(extDir)).toBe(true);
|
|
305
|
+
expect(existsSync(join(extDir, 'AGENTS.md'))).toBe(true);
|
|
306
|
+
|
|
307
|
+
// agents/auth should be a symlink again
|
|
308
|
+
expect(lstatSync(join(testDir, 'agents', 'auth')).isSymbolicLink()).toBe(true);
|
|
309
|
+
// Relative symlink
|
|
310
|
+
const linkTarget = readlinkSync(join(testDir, 'agents', 'auth'));
|
|
311
|
+
expect(linkTarget).toBe('../services/auth');
|
|
312
|
+
|
|
313
|
+
// Journal should be empty after rollback
|
|
314
|
+
const journal = readJournal(testDir);
|
|
315
|
+
expect(journal).toEqual([]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('returns empty result when journal is empty', () => {
|
|
319
|
+
mkWorkspace(testDir);
|
|
320
|
+
|
|
321
|
+
const result = rollbackMigration(testDir);
|
|
322
|
+
|
|
323
|
+
expect(result.rolledBack).toEqual([]);
|
|
324
|
+
expect(result.errors).toEqual([]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('only rolls back the most recent batch', () => {
|
|
328
|
+
mkWorkspace(testDir);
|
|
329
|
+
|
|
330
|
+
// Write a fake journal with two batches
|
|
331
|
+
const journalEntries: MigrationJournalEntry[] = [
|
|
332
|
+
{
|
|
333
|
+
agent: 'old-agent',
|
|
334
|
+
from: '/tmp/old',
|
|
335
|
+
to: '/tmp/old-dest',
|
|
336
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
337
|
+
method: 'copy',
|
|
338
|
+
batchId: 'batch-old',
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
agent: 'new-agent',
|
|
342
|
+
from: join(testDir, 'services', 'new'),
|
|
343
|
+
to: join(testDir, 'agents', 'new'),
|
|
344
|
+
timestamp: '2025-06-01T00:00:00.000Z',
|
|
345
|
+
method: 'copy',
|
|
346
|
+
batchId: 'batch-new',
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
writeFileSync(join(testDir, '.genie', 'migration-journal.json'), JSON.stringify(journalEntries));
|
|
350
|
+
|
|
351
|
+
// Create the physical dir that would be rolled back
|
|
352
|
+
mkAgent(join(testDir, 'agents', 'new'));
|
|
353
|
+
|
|
354
|
+
const result = rollbackMigration(testDir);
|
|
355
|
+
|
|
356
|
+
expect(result.rolledBack).toEqual(['new-agent']);
|
|
357
|
+
|
|
358
|
+
// Old batch should remain in journal
|
|
359
|
+
const remaining = readJournal(testDir);
|
|
360
|
+
expect(remaining.length).toBe(1);
|
|
361
|
+
expect(remaining[0].batchId).toBe('batch-old');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// ─── recalculateInternalSymlinks ────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe('recalculateInternalSymlinks()', () => {
|
|
368
|
+
test('fixes relative symlinks when directory moves', () => {
|
|
369
|
+
// Setup: dir at "old/" contains a symlink "link" -> "../target"
|
|
370
|
+
const oldDir = join(testDir, 'old');
|
|
371
|
+
const newDir = join(testDir, 'new', 'nested');
|
|
372
|
+
const targetDir = join(testDir, 'target');
|
|
373
|
+
|
|
374
|
+
mkdirSync(oldDir, { recursive: true });
|
|
375
|
+
mkdirSync(targetDir, { recursive: true });
|
|
376
|
+
writeFileSync(join(targetDir, 'file.txt'), 'content');
|
|
377
|
+
|
|
378
|
+
// Create a relative symlink in oldDir
|
|
379
|
+
symlinkSync('../target', join(oldDir, 'link'));
|
|
380
|
+
|
|
381
|
+
// Verify the old symlink works
|
|
382
|
+
expect(existsSync(join(oldDir, 'link', 'file.txt'))).toBe(true);
|
|
383
|
+
|
|
384
|
+
// "Move" oldDir to newDir by manual copy that preserves symlinks
|
|
385
|
+
mkdirSync(newDir, { recursive: true });
|
|
386
|
+
// Manually copy: file + symlink
|
|
387
|
+
writeFileSync(join(newDir, 'placeholder'), ''); // ensure dir exists
|
|
388
|
+
rmSync(join(newDir, 'placeholder'));
|
|
389
|
+
for (const entry of readdirSync(oldDir)) {
|
|
390
|
+
const srcPath = join(oldDir, entry);
|
|
391
|
+
const destPath = join(newDir, entry);
|
|
392
|
+
const stat = lstatSync(srcPath);
|
|
393
|
+
if (stat.isSymbolicLink()) {
|
|
394
|
+
symlinkSync(readlinkSync(srcPath), destPath);
|
|
395
|
+
} else {
|
|
396
|
+
writeFileSync(destPath, readFileSync(srcPath));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The symlink in newDir is now broken (../target doesn't exist from new/nested/)
|
|
401
|
+
// Recalculate
|
|
402
|
+
recalculateInternalSymlinks(newDir, oldDir, newDir);
|
|
403
|
+
|
|
404
|
+
// The symlink should now point to ../../target (from new/nested/ to target/)
|
|
405
|
+
const newLinkTarget = readlinkSync(join(newDir, 'link'));
|
|
406
|
+
expect(newLinkTarget).toBe('../../target');
|
|
407
|
+
expect(existsSync(join(newDir, 'link', 'file.txt'))).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('leaves absolute symlinks unchanged', () => {
|
|
411
|
+
const dir = join(testDir, 'mydir');
|
|
412
|
+
const absTarget = join(testDir, 'abs-target');
|
|
413
|
+
mkdirSync(dir, { recursive: true });
|
|
414
|
+
mkdirSync(absTarget, { recursive: true });
|
|
415
|
+
|
|
416
|
+
symlinkSync(absTarget, join(dir, 'abs-link'));
|
|
417
|
+
|
|
418
|
+
recalculateInternalSymlinks(dir, dir, dir);
|
|
419
|
+
|
|
420
|
+
expect(readlinkSync(join(dir, 'abs-link'))).toBe(absTarget);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ─── isInsideSeparateGitRepo ────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
describe('isInsideSeparateGitRepo()', () => {
|
|
427
|
+
test('returns true when path is in a different git repo', () => {
|
|
428
|
+
// Workspace git root
|
|
429
|
+
mkdirSync(join(testDir, '.git'), { recursive: true });
|
|
430
|
+
// External git root
|
|
431
|
+
const externalRoot = join(testDir, 'external');
|
|
432
|
+
mkdirSync(join(externalRoot, '.git'), { recursive: true });
|
|
433
|
+
const agentDir = join(externalRoot, 'agents', 'bot');
|
|
434
|
+
mkdirSync(agentDir, { recursive: true });
|
|
435
|
+
|
|
436
|
+
expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('returns false when path is in the same git repo', () => {
|
|
440
|
+
mkdirSync(join(testDir, '.git'), { recursive: true });
|
|
441
|
+
const agentDir = join(testDir, 'services', 'auth');
|
|
442
|
+
mkdirSync(agentDir, { recursive: true });
|
|
443
|
+
|
|
444
|
+
expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('returns false when no .git directory exists', () => {
|
|
448
|
+
const agentDir = join(testDir, 'services', 'auth');
|
|
449
|
+
mkdirSync(agentDir, { recursive: true });
|
|
450
|
+
|
|
451
|
+
expect(isInsideSeparateGitRepo(agentDir, testDir)).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ─── hasDirtyWorkingTree ────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
describe('hasDirtyWorkingTree()', () => {
|
|
458
|
+
test('returns false for non-git directory', () => {
|
|
459
|
+
const dir = join(testDir, 'not-a-repo');
|
|
460
|
+
mkdirSync(dir, { recursive: true });
|
|
461
|
+
|
|
462
|
+
expect(hasDirtyWorkingTree(dir)).toBe(false);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('returns false for clean git repo', () => {
|
|
466
|
+
const dir = join(testDir, 'clean-repo');
|
|
467
|
+
mkdirSync(dir, { recursive: true });
|
|
468
|
+
|
|
469
|
+
// Initialize a real git repo
|
|
470
|
+
const { execSync } = require('node:child_process');
|
|
471
|
+
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
472
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
473
|
+
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
474
|
+
writeFileSync(join(dir, 'file.txt'), 'content');
|
|
475
|
+
execSync('git add .', { cwd: dir, stdio: 'pipe' });
|
|
476
|
+
execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
|
|
477
|
+
|
|
478
|
+
expect(hasDirtyWorkingTree(dir)).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('returns true for dirty git repo', () => {
|
|
482
|
+
const dir = join(testDir, 'dirty-repo');
|
|
483
|
+
mkdirSync(dir, { recursive: true });
|
|
484
|
+
|
|
485
|
+
const { execSync } = require('node:child_process');
|
|
486
|
+
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
|
487
|
+
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
|
488
|
+
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
|
489
|
+
writeFileSync(join(dir, 'file.txt'), 'content');
|
|
490
|
+
execSync('git add .', { cwd: dir, stdio: 'pipe' });
|
|
491
|
+
execSync('git commit -m "init"', { cwd: dir, stdio: 'pipe' });
|
|
492
|
+
|
|
493
|
+
// Make it dirty
|
|
494
|
+
writeFileSync(join(dir, 'dirty.txt'), 'uncommitted');
|
|
495
|
+
|
|
496
|
+
expect(hasDirtyWorkingTree(dir)).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
});
|
package/src/lib/discovery.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - Pending queue (feeds discovered agents into the pending list)
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync,
|
|
13
|
+
import { cpSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
14
14
|
import { join, relative } from 'node:path';
|
|
15
15
|
import { scanForAgentsAll } from './tree-scanner.js';
|
|
16
16
|
import { scanAgents } from './workspace.js';
|
|
@@ -81,9 +81,10 @@ export async function discoverExternalAgents(workspaceRoot: string): Promise<Dis
|
|
|
81
81
|
// ============================================================================
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Import discovered agents into the canonical agents/ directory
|
|
84
|
+
* Import discovered agents into the canonical agents/ directory by moving them.
|
|
85
85
|
*
|
|
86
|
-
* For each agent,
|
|
86
|
+
* For each agent, physically moves the directory: {agent.path} -> {root}/agents/{name}
|
|
87
|
+
* Preserves all contents including hidden folders (.claude, .genie, .git).
|
|
87
88
|
* If the name collides, appends a suffix derived from the relative path.
|
|
88
89
|
*/
|
|
89
90
|
export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]): ImportResult {
|
|
@@ -93,17 +94,17 @@ export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]):
|
|
|
93
94
|
const result: ImportResult = { imported: [], skipped: [], errors: [] };
|
|
94
95
|
|
|
95
96
|
for (const agent of agents) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
97
|
+
const destName = resolveUniqueName(agentsDir, agent.name);
|
|
98
|
+
const destPath = join(agentsDir, destName);
|
|
98
99
|
|
|
99
|
-
if (existsSync(
|
|
100
|
+
if (existsSync(destPath)) {
|
|
100
101
|
result.skipped.push(agent.name);
|
|
101
102
|
continue;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
try {
|
|
105
|
-
|
|
106
|
-
result.imported.push(
|
|
106
|
+
moveDirectory(agent.path, destPath);
|
|
107
|
+
result.imported.push(destName);
|
|
107
108
|
} catch (err) {
|
|
108
109
|
result.errors.push({
|
|
109
110
|
name: agent.name,
|
|
@@ -115,6 +116,20 @@ export function importAgents(workspaceRoot: string, agents: DiscoveredAgent[]):
|
|
|
115
116
|
return result;
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Move a directory, trying atomic rename first, falling back to copy+remove
|
|
121
|
+
* for cross-filesystem moves. Preserves all contents including dotfiles.
|
|
122
|
+
*/
|
|
123
|
+
function moveDirectory(src: string, dest: string): void {
|
|
124
|
+
try {
|
|
125
|
+
renameSync(src, dest);
|
|
126
|
+
} catch {
|
|
127
|
+
// Cross-filesystem — copy then remove
|
|
128
|
+
cpSync(src, dest, { recursive: true });
|
|
129
|
+
rmSync(src, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
/**
|
|
119
134
|
* Resolve a unique agent name within the agents directory.
|
|
120
135
|
* If `name` already exists, appends `-2`, `-3`, etc.
|