@soleri/cli 9.7.1 → 9.8.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/dist/commands/add-domain.js +1 -0
- package/dist/commands/add-domain.js.map +1 -1
- package/dist/commands/add-pack.js +7 -147
- package/dist/commands/add-pack.js.map +1 -1
- package/dist/commands/agent.js +130 -0
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/create.js +78 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/extend.js +17 -0
- package/dist/commands/extend.js.map +1 -1
- package/dist/commands/install-knowledge.js +1 -0
- package/dist/commands/install-knowledge.js.map +1 -1
- package/dist/commands/test.js +140 -1
- package/dist/commands/test.js.map +1 -1
- package/dist/hook-packs/flock-guard/manifest.json +2 -1
- package/dist/hook-packs/installer.js +12 -5
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +26 -7
- package/dist/hook-packs/marketing-research/manifest.json +2 -1
- package/dist/hook-packs/registry.d.ts +2 -0
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +2 -0
- package/dist/prompts/create-wizard.d.ts +16 -2
- package/dist/prompts/create-wizard.js +84 -11
- package/dist/prompts/create-wizard.js.map +1 -1
- package/dist/utils/checks.d.ts +8 -5
- package/dist/utils/checks.js +105 -10
- package/dist/utils/checks.js.map +1 -1
- package/dist/utils/format-paths.d.ts +14 -0
- package/dist/utils/format-paths.js +27 -0
- package/dist/utils/format-paths.js.map +1 -0
- package/dist/utils/git.d.ts +29 -0
- package/dist/utils/git.js +88 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/logger.d.ts +1 -0
- package/dist/utils/logger.js +4 -0
- package/dist/utils/logger.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/create-wizard-git.test.ts +208 -0
- package/src/__tests__/git-utils.test.ts +268 -0
- package/src/__tests__/hook-packs.test.ts +5 -1
- package/src/__tests__/scaffold-git-e2e.test.ts +105 -0
- package/src/commands/add-domain.ts +1 -0
- package/src/commands/add-pack.ts +10 -163
- package/src/commands/agent.ts +161 -0
- package/src/commands/create.ts +89 -3
- package/src/commands/doctor.ts +1 -0
- package/src/commands/extend.ts +20 -1
- package/src/commands/install-knowledge.ts +1 -0
- package/src/commands/test.ts +141 -2
- package/src/hook-packs/flock-guard/manifest.json +2 -1
- package/src/hook-packs/installer.ts +26 -7
- package/src/hook-packs/marketing-research/manifest.json +2 -1
- package/src/hook-packs/registry.ts +2 -0
- package/src/prompts/create-wizard.ts +109 -14
- package/src/utils/checks.ts +122 -13
- package/src/utils/format-paths.ts +41 -0
- package/src/utils/git.ts +118 -0
- package/src/utils/logger.ts +5 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mock @clack/prompts — every prompt function the wizard uses.
|
|
5
|
+
*/
|
|
6
|
+
vi.mock('@clack/prompts', () => ({
|
|
7
|
+
confirm: vi.fn(),
|
|
8
|
+
select: vi.fn(),
|
|
9
|
+
text: vi.fn(),
|
|
10
|
+
intro: vi.fn(),
|
|
11
|
+
note: vi.fn(),
|
|
12
|
+
isCancel: vi.fn().mockReturnValue(false),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Mock the git utility so we control gh availability.
|
|
17
|
+
*/
|
|
18
|
+
vi.mock('../utils/git.js', () => ({
|
|
19
|
+
isGhInstalled: vi.fn().mockReturnValue(true),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import * as p from '@clack/prompts';
|
|
23
|
+
import { isGhInstalled } from '../utils/git.js';
|
|
24
|
+
import { runCreateWizard } from '../prompts/create-wizard.js';
|
|
25
|
+
import type { CreateWizardResult, WizardGitConfig } from '../prompts/create-wizard.js';
|
|
26
|
+
|
|
27
|
+
const mockText = p.text as unknown as ReturnType<typeof vi.fn>;
|
|
28
|
+
const mockSelect = p.select as unknown as ReturnType<typeof vi.fn>;
|
|
29
|
+
const mockConfirm = p.confirm as unknown as ReturnType<typeof vi.fn>;
|
|
30
|
+
const mockIsCancel = p.isCancel as unknown as ReturnType<typeof vi.fn>;
|
|
31
|
+
const mockGhInstalled = isGhInstalled as unknown as ReturnType<typeof vi.fn>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Helper: set up the standard wizard prompts that precede git questions.
|
|
35
|
+
* Returns after the "Create this agent?" confirm (step 4).
|
|
36
|
+
*
|
|
37
|
+
* Call order:
|
|
38
|
+
* 1. text — agent name
|
|
39
|
+
* 2. select — persona choice
|
|
40
|
+
* 3. note — summary (no return value needed)
|
|
41
|
+
* 4. confirm — create confirmation
|
|
42
|
+
*/
|
|
43
|
+
function mockBaseWizard() {
|
|
44
|
+
mockText.mockResolvedValueOnce('TestAgent'); // name
|
|
45
|
+
mockSelect.mockResolvedValueOnce('default'); // persona
|
|
46
|
+
mockConfirm.mockResolvedValueOnce(true); // create confirm
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('create-wizard git prompts', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
mockIsCancel.mockReturnValue(false);
|
|
53
|
+
mockGhInstalled.mockReturnValue(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── 1. Git init defaults to true ──────────────────────────────
|
|
57
|
+
it('should default git init to true when user accepts all defaults', async () => {
|
|
58
|
+
mockBaseWizard();
|
|
59
|
+
mockConfirm.mockResolvedValueOnce(true); // git init = yes
|
|
60
|
+
mockConfirm.mockResolvedValueOnce(false); // push to remote = no
|
|
61
|
+
|
|
62
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
63
|
+
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result.git.init).toBe(true);
|
|
66
|
+
expect(result.git.remote).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── 2. Git init can be declined ───────────────────────────────
|
|
70
|
+
it('should set git.init to false when user declines', async () => {
|
|
71
|
+
mockBaseWizard();
|
|
72
|
+
mockConfirm.mockResolvedValueOnce(false); // git init = no
|
|
73
|
+
|
|
74
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
75
|
+
|
|
76
|
+
expect(result).not.toBeNull();
|
|
77
|
+
expect(result.git.init).toBe(false);
|
|
78
|
+
expect(result.git.remote).toBeUndefined();
|
|
79
|
+
// No push prompt should have been called — only 2 confirms total
|
|
80
|
+
// (create + git init)
|
|
81
|
+
expect(mockConfirm).toHaveBeenCalledTimes(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── 3. Remote prompts only when init=true, no push ────────────
|
|
85
|
+
it('should leave remote undefined when init=true but push=false', async () => {
|
|
86
|
+
mockBaseWizard();
|
|
87
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
88
|
+
mockConfirm.mockResolvedValueOnce(false); // push to remote = no
|
|
89
|
+
|
|
90
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
91
|
+
|
|
92
|
+
expect(result).not.toBeNull();
|
|
93
|
+
expect(result.git.init).toBe(true);
|
|
94
|
+
expect(result.git.remote).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── 4. gh path: GitHub repo creation ──────────────────────────
|
|
98
|
+
it('should configure gh remote when user selects gh path', async () => {
|
|
99
|
+
mockBaseWizard();
|
|
100
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
101
|
+
mockConfirm.mockResolvedValueOnce(true); // push to remote
|
|
102
|
+
mockSelect.mockResolvedValueOnce('gh'); // remote choice
|
|
103
|
+
mockSelect.mockResolvedValueOnce('public'); // visibility
|
|
104
|
+
|
|
105
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
106
|
+
|
|
107
|
+
expect(result).not.toBeNull();
|
|
108
|
+
expect(result.git.remote).toEqual({
|
|
109
|
+
type: 'gh',
|
|
110
|
+
visibility: 'public',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── 5. gh path: private by default ────────────────────────────
|
|
115
|
+
it('should default visibility to private when selecting gh', async () => {
|
|
116
|
+
mockBaseWizard();
|
|
117
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
118
|
+
mockConfirm.mockResolvedValueOnce(true); // push to remote
|
|
119
|
+
mockSelect.mockResolvedValueOnce('gh'); // remote choice
|
|
120
|
+
mockSelect.mockResolvedValueOnce('private'); // visibility = private
|
|
121
|
+
|
|
122
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
123
|
+
|
|
124
|
+
expect(result).not.toBeNull();
|
|
125
|
+
expect(result.git.remote!.type).toBe('gh');
|
|
126
|
+
expect(result.git.remote!.visibility).toBe('private');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── 6. Manual path: URL input ─────────────────────────────────
|
|
130
|
+
it('should configure manual remote with URL when user selects manual', async () => {
|
|
131
|
+
mockBaseWizard();
|
|
132
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
133
|
+
mockConfirm.mockResolvedValueOnce(true); // push to remote
|
|
134
|
+
mockSelect.mockResolvedValueOnce('manual'); // remote choice
|
|
135
|
+
mockText.mockResolvedValueOnce('https://github.com/test/repo.git'); // URL
|
|
136
|
+
|
|
137
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
138
|
+
|
|
139
|
+
expect(result).not.toBeNull();
|
|
140
|
+
expect(result.git.remote).toEqual({
|
|
141
|
+
type: 'manual',
|
|
142
|
+
url: 'https://github.com/test/repo.git',
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ── 7. Manual path forced when gh not available ───────────────
|
|
147
|
+
it('should skip gh/manual select and go straight to URL when gh is not installed', async () => {
|
|
148
|
+
mockGhInstalled.mockReturnValue(false);
|
|
149
|
+
|
|
150
|
+
mockBaseWizard();
|
|
151
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
152
|
+
mockConfirm.mockResolvedValueOnce(true); // push to remote
|
|
153
|
+
// No select for gh/manual — goes straight to text for URL
|
|
154
|
+
mockText.mockResolvedValueOnce('https://gitlab.com/test/repo.git');
|
|
155
|
+
|
|
156
|
+
const result = (await runCreateWizard()) as CreateWizardResult;
|
|
157
|
+
|
|
158
|
+
expect(result).not.toBeNull();
|
|
159
|
+
expect(result.git.remote).toEqual({
|
|
160
|
+
type: 'manual',
|
|
161
|
+
url: 'https://gitlab.com/test/repo.git',
|
|
162
|
+
});
|
|
163
|
+
// select should only be called once (persona), not twice
|
|
164
|
+
expect(mockSelect).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── 8. Cancellation at git init prompt ────────────────────────
|
|
168
|
+
it('should return null when user cancels at git init prompt', async () => {
|
|
169
|
+
mockBaseWizard();
|
|
170
|
+
// The third confirm call (git init) returns a cancel symbol
|
|
171
|
+
const cancelSymbol = Symbol('cancel');
|
|
172
|
+
mockConfirm.mockResolvedValueOnce(cancelSymbol);
|
|
173
|
+
mockIsCancel.mockImplementation((val) => val === cancelSymbol);
|
|
174
|
+
|
|
175
|
+
const result = await runCreateWizard();
|
|
176
|
+
|
|
177
|
+
expect(result).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── 9. Cancellation at push prompt ────────────────────────────
|
|
181
|
+
it('should return null when user cancels at push prompt', async () => {
|
|
182
|
+
mockBaseWizard();
|
|
183
|
+
mockConfirm.mockResolvedValueOnce(true); // git init = yes
|
|
184
|
+
|
|
185
|
+
const cancelSymbol = Symbol('cancel');
|
|
186
|
+
mockConfirm.mockResolvedValueOnce(cancelSymbol); // push = cancel
|
|
187
|
+
mockIsCancel.mockImplementation((val) => val === cancelSymbol);
|
|
188
|
+
|
|
189
|
+
const result = await runCreateWizard();
|
|
190
|
+
|
|
191
|
+
expect(result).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── 10. Cancellation at remote choice ─────────────────────────
|
|
195
|
+
it('should return null when user cancels at remote choice prompt', async () => {
|
|
196
|
+
mockBaseWizard();
|
|
197
|
+
mockConfirm.mockResolvedValueOnce(true); // git init
|
|
198
|
+
mockConfirm.mockResolvedValueOnce(true); // push to remote
|
|
199
|
+
|
|
200
|
+
const cancelSymbol = Symbol('cancel');
|
|
201
|
+
mockSelect.mockResolvedValueOnce(cancelSymbol); // remote choice = cancel
|
|
202
|
+
mockIsCancel.mockImplementation((val) => val === cancelSymbol);
|
|
203
|
+
|
|
204
|
+
const result = await runCreateWizard();
|
|
205
|
+
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock node:child_process before importing the module under test
|
|
4
|
+
vi.mock('node:child_process', () => ({
|
|
5
|
+
execFile: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import {
|
|
10
|
+
isGitInstalled,
|
|
11
|
+
isGhInstalled,
|
|
12
|
+
gitInit,
|
|
13
|
+
gitInitialCommit,
|
|
14
|
+
gitAddRemote,
|
|
15
|
+
gitPush,
|
|
16
|
+
ghCreateRepo,
|
|
17
|
+
} from '../utils/git.js';
|
|
18
|
+
|
|
19
|
+
const mockExecFile = vi.mocked(execFile);
|
|
20
|
+
|
|
21
|
+
/** Helper: make execFile call its callback with success (stdout). */
|
|
22
|
+
function mockSuccess(stdout = '') {
|
|
23
|
+
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
|
24
|
+
(callback as Function)(null, stdout, '');
|
|
25
|
+
return undefined as any;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Helper: make execFile call its callback with an error. */
|
|
30
|
+
function mockFailure(message: string, stderr = '') {
|
|
31
|
+
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
|
32
|
+
const err = new Error(message);
|
|
33
|
+
(callback as Function)(err, '', stderr);
|
|
34
|
+
return undefined as any;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Helper: make execFile succeed N times then fail.
|
|
40
|
+
* Useful for testing gitInitialCommit where `git add` must succeed before `git commit` fails.
|
|
41
|
+
*/
|
|
42
|
+
function mockSequence(calls: Array<{ stdout?: string; error?: string; stderr?: string }>) {
|
|
43
|
+
let callIndex = 0;
|
|
44
|
+
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
|
45
|
+
const spec = calls[callIndex] ?? calls[calls.length - 1];
|
|
46
|
+
callIndex++;
|
|
47
|
+
if (spec.error) {
|
|
48
|
+
const err = new Error(spec.error);
|
|
49
|
+
(callback as Function)(err, '', spec.stderr ?? spec.error);
|
|
50
|
+
} else {
|
|
51
|
+
(callback as Function)(null, spec.stdout ?? '', '');
|
|
52
|
+
}
|
|
53
|
+
return undefined as any;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('git utilities', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.resetAllMocks();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── isGitInstalled ──────────────────────────────────────────────
|
|
63
|
+
describe('isGitInstalled', () => {
|
|
64
|
+
it('returns true when git binary is found', async () => {
|
|
65
|
+
mockSuccess('/usr/bin/git');
|
|
66
|
+
const result = await isGitInstalled();
|
|
67
|
+
expect(result).toBe(true);
|
|
68
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
69
|
+
'which',
|
|
70
|
+
['git'],
|
|
71
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
72
|
+
expect.any(Function),
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns false when git binary is not found', async () => {
|
|
77
|
+
mockFailure('not found');
|
|
78
|
+
const result = await isGitInstalled();
|
|
79
|
+
expect(result).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── isGhInstalled ──────────────────────────────────────────────
|
|
84
|
+
describe('isGhInstalled', () => {
|
|
85
|
+
it('returns true when gh binary is found', async () => {
|
|
86
|
+
mockSuccess('/usr/bin/gh');
|
|
87
|
+
const result = await isGhInstalled();
|
|
88
|
+
expect(result).toBe(true);
|
|
89
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
90
|
+
'which',
|
|
91
|
+
['gh'],
|
|
92
|
+
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
93
|
+
expect.any(Function),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns false when gh binary is not found', async () => {
|
|
98
|
+
mockFailure('not found');
|
|
99
|
+
const result = await isGhInstalled();
|
|
100
|
+
expect(result).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── gitInit ─────────────────────────────────────────────────────
|
|
105
|
+
describe('gitInit', () => {
|
|
106
|
+
it('returns { ok: true } on success', async () => {
|
|
107
|
+
mockSuccess('Initialized empty Git repository');
|
|
108
|
+
const result = await gitInit('/tmp/my-project');
|
|
109
|
+
expect(result).toEqual({ ok: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns { ok: false, error } on failure', async () => {
|
|
113
|
+
mockFailure('fatal: not a git repository', 'fatal: not a git repository');
|
|
114
|
+
const result = await gitInit('/tmp/my-project');
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
expect(result.error).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('passes correct args with cwd', async () => {
|
|
120
|
+
mockSuccess();
|
|
121
|
+
await gitInit('/tmp/my-project');
|
|
122
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
123
|
+
'git',
|
|
124
|
+
['init'],
|
|
125
|
+
expect.objectContaining({ cwd: '/tmp/my-project' }),
|
|
126
|
+
expect.any(Function),
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── gitInitialCommit ────────────────────────────────────────────
|
|
132
|
+
describe('gitInitialCommit', () => {
|
|
133
|
+
it('returns { ok: true } when both add and commit succeed', async () => {
|
|
134
|
+
mockSequence([{ stdout: '' }, { stdout: '' }]);
|
|
135
|
+
const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
|
|
136
|
+
expect(result).toEqual({ ok: true });
|
|
137
|
+
// Two calls: git add . and git commit -m ...
|
|
138
|
+
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('returns { ok: false } when git add fails (does not attempt commit)', async () => {
|
|
142
|
+
mockSequence([{ error: 'add failed', stderr: 'add failed' }]);
|
|
143
|
+
const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
|
|
144
|
+
expect(result.ok).toBe(false);
|
|
145
|
+
expect(result.error).toContain('add failed');
|
|
146
|
+
// Only one call — git add; commit should not be attempted
|
|
147
|
+
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns { ok: false } when add succeeds but commit fails', async () => {
|
|
151
|
+
mockSequence([{ stdout: '' }, { error: 'nothing to commit', stderr: 'nothing to commit' }]);
|
|
152
|
+
const result = await gitInitialCommit('/tmp/proj', 'Initial commit');
|
|
153
|
+
expect(result.ok).toBe(false);
|
|
154
|
+
expect(result.error).toContain('nothing to commit');
|
|
155
|
+
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('passes the correct commit message', async () => {
|
|
159
|
+
mockSequence([{ stdout: '' }, { stdout: '' }]);
|
|
160
|
+
await gitInitialCommit('/tmp/proj', 'feat: initial scaffold');
|
|
161
|
+
// Second call should be git commit -m <message>
|
|
162
|
+
const commitCall = mockExecFile.mock.calls[1];
|
|
163
|
+
expect(commitCall[0]).toBe('git');
|
|
164
|
+
expect(commitCall[1]).toEqual(['commit', '-m', 'feat: initial scaffold']);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── gitAddRemote ────────────────────────────────────────────────
|
|
169
|
+
describe('gitAddRemote', () => {
|
|
170
|
+
it('returns { ok: true } on success', async () => {
|
|
171
|
+
mockSuccess();
|
|
172
|
+
const result = await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
|
|
173
|
+
expect(result).toEqual({ ok: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('passes correct args', async () => {
|
|
177
|
+
mockSuccess();
|
|
178
|
+
await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
|
|
179
|
+
expect(mockExecFile).toHaveBeenCalledWith(
|
|
180
|
+
'git',
|
|
181
|
+
['remote', 'add', 'origin', 'https://github.com/user/repo.git'],
|
|
182
|
+
expect.objectContaining({ cwd: '/tmp/proj' }),
|
|
183
|
+
expect.any(Function),
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns { ok: false } on failure', async () => {
|
|
188
|
+
mockFailure('remote origin already exists', 'remote origin already exists');
|
|
189
|
+
const result = await gitAddRemote('/tmp/proj', 'https://github.com/user/repo.git');
|
|
190
|
+
expect(result.ok).toBe(false);
|
|
191
|
+
expect(result.error).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ── gitPush ─────────────────────────────────────────────────────
|
|
196
|
+
describe('gitPush', () => {
|
|
197
|
+
it('returns { ok: true } on success', async () => {
|
|
198
|
+
mockSuccess();
|
|
199
|
+
const result = await gitPush('/tmp/proj');
|
|
200
|
+
expect(result).toEqual({ ok: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('returns { ok: false } on network error', async () => {
|
|
204
|
+
mockFailure('Could not resolve host', 'Could not resolve host');
|
|
205
|
+
const result = await gitPush('/tmp/proj');
|
|
206
|
+
expect(result.ok).toBe(false);
|
|
207
|
+
expect(result.error).toContain('Could not resolve host');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('uses NETWORK_TIMEOUT (60s) via AbortSignal', async () => {
|
|
211
|
+
mockSuccess();
|
|
212
|
+
await gitPush('/tmp/proj');
|
|
213
|
+
const callOpts = mockExecFile.mock.calls[0][2] as { signal: AbortSignal };
|
|
214
|
+
// AbortSignal.timeout(60000) — verify it is an AbortSignal (we can't read the timeout
|
|
215
|
+
// value directly, but we can verify it's present and is an AbortSignal)
|
|
216
|
+
expect(callOpts.signal).toBeInstanceOf(AbortSignal);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── ghCreateRepo ────────────────────────────────────────────────
|
|
221
|
+
describe('ghCreateRepo', () => {
|
|
222
|
+
it('returns { ok: true, url } on success', async () => {
|
|
223
|
+
mockSuccess('https://github.com/user/my-repo\n');
|
|
224
|
+
const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
|
|
225
|
+
expect(result.ok).toBe(true);
|
|
226
|
+
expect(result.url).toBe('https://github.com/user/my-repo');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns { ok: false } on failure', async () => {
|
|
230
|
+
mockFailure('authentication required', 'authentication required');
|
|
231
|
+
const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
|
|
232
|
+
expect(result.ok).toBe(false);
|
|
233
|
+
expect(result.error).toBeDefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('passes --public flag for public visibility', async () => {
|
|
237
|
+
mockSuccess('https://github.com/user/my-repo');
|
|
238
|
+
await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
|
|
239
|
+
const args = mockExecFile.mock.calls[0][1] as string[];
|
|
240
|
+
expect(args).toContain('--public');
|
|
241
|
+
expect(args).not.toContain('--private');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('passes --private flag for private visibility', async () => {
|
|
245
|
+
mockSuccess('https://github.com/user/my-repo');
|
|
246
|
+
await ghCreateRepo('my-repo', { visibility: 'private', dir: '/tmp/proj' });
|
|
247
|
+
const args = mockExecFile.mock.calls[0][1] as string[];
|
|
248
|
+
expect(args).toContain('--private');
|
|
249
|
+
expect(args).not.toContain('--public');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('includes --source, --remote, and --push flags', async () => {
|
|
253
|
+
mockSuccess('https://github.com/user/my-repo');
|
|
254
|
+
await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
|
|
255
|
+
const args = mockExecFile.mock.calls[0][1] as string[];
|
|
256
|
+
expect(args).toContain('--source=/tmp/proj');
|
|
257
|
+
expect(args).toContain('--remote=origin');
|
|
258
|
+
expect(args).toContain('--push');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns undefined url when stdout is empty', async () => {
|
|
262
|
+
mockSuccess('');
|
|
263
|
+
const result = await ghCreateRepo('my-repo', { visibility: 'public', dir: '/tmp/proj' });
|
|
264
|
+
expect(result.ok).toBe(true);
|
|
265
|
+
expect(result.url).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -176,7 +176,11 @@ describe('hook-packs', () => {
|
|
|
176
176
|
const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
|
|
177
177
|
expect(settings.hooks).toBeDefined();
|
|
178
178
|
expect(settings.hooks.PreToolUse).toHaveLength(1);
|
|
179
|
-
expect(settings.hooks.PreToolUse[0].
|
|
179
|
+
expect(settings.hooks.PreToolUse[0].matcher).toBe('Bash');
|
|
180
|
+
expect(settings.hooks.PreToolUse[0].hooks).toHaveLength(1);
|
|
181
|
+
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(
|
|
182
|
+
'sh ~/.claude/hooks/anti-deletion.sh',
|
|
183
|
+
);
|
|
180
184
|
expect(settings.hooks.PreToolUse[0]._soleriPack).toBe('safety');
|
|
181
185
|
});
|
|
182
186
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { scaffoldFileTree } from '@soleri/forge/lib';
|
|
7
|
+
import { gitInit, gitInitialCommit } from '../utils/git.js';
|
|
8
|
+
import type { AgentYamlInput } from '@soleri/forge/lib';
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const MINIMAL_AGENT: AgentYamlInput = {
|
|
13
|
+
id: 'test-agent',
|
|
14
|
+
name: 'Test Agent',
|
|
15
|
+
role: 'Testing assistant',
|
|
16
|
+
description: 'A minimal agent used for scaffold + git E2E tests',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function gitCommand(dir: string, ...args: string[]): string {
|
|
20
|
+
return execFileSync('git', args, {
|
|
21
|
+
cwd: dir,
|
|
22
|
+
stdio: 'pipe',
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Tests ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('scaffold + git init (E2E)', () => {
|
|
30
|
+
const tempDirs: string[] = [];
|
|
31
|
+
|
|
32
|
+
function makeTempDir(): string {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), 'soleri-git-e2e-'));
|
|
34
|
+
tempDirs.push(dir);
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
for (const dir of tempDirs) {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
tempDirs.length = 0;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('scaffold with git init produces a valid git repo', async () => {
|
|
46
|
+
const outputDir = makeTempDir();
|
|
47
|
+
const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
|
|
48
|
+
expect(result.success).toBe(true);
|
|
49
|
+
|
|
50
|
+
const agentDir = result.agentDir;
|
|
51
|
+
|
|
52
|
+
// Initialize git and create initial commit
|
|
53
|
+
const initResult = await gitInit(agentDir);
|
|
54
|
+
expect(initResult.ok).toBe(true);
|
|
55
|
+
|
|
56
|
+
const commitResult = await gitInitialCommit(agentDir, 'feat: scaffold agent "test-agent"');
|
|
57
|
+
expect(commitResult.ok).toBe(true);
|
|
58
|
+
|
|
59
|
+
// .git directory exists
|
|
60
|
+
expect(existsSync(join(agentDir, '.git'))).toBe(true);
|
|
61
|
+
|
|
62
|
+
// Exactly 1 commit
|
|
63
|
+
const log = gitCommand(agentDir, 'log', '--oneline');
|
|
64
|
+
const commits = log.split('\n').filter(Boolean);
|
|
65
|
+
expect(commits).toHaveLength(1);
|
|
66
|
+
|
|
67
|
+
// Commit message contains expected text
|
|
68
|
+
expect(commits[0]).toContain('feat: scaffold agent');
|
|
69
|
+
|
|
70
|
+
// Working tree is clean — no untracked files
|
|
71
|
+
const status = gitCommand(agentDir, 'status', '--porcelain');
|
|
72
|
+
expect(status).toBe('');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('.gitignore exclusions are respected', async () => {
|
|
76
|
+
const outputDir = makeTempDir();
|
|
77
|
+
const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
|
|
80
|
+
const agentDir = result.agentDir;
|
|
81
|
+
|
|
82
|
+
await gitInit(agentDir);
|
|
83
|
+
await gitInitialCommit(agentDir, 'feat: scaffold agent "test-agent"');
|
|
84
|
+
|
|
85
|
+
const trackedFiles = gitCommand(agentDir, 'ls-files');
|
|
86
|
+
|
|
87
|
+
// Auto-generated files must NOT be tracked
|
|
88
|
+
expect(trackedFiles).not.toContain('CLAUDE.md');
|
|
89
|
+
expect(trackedFiles).not.toContain('AGENTS.md');
|
|
90
|
+
expect(trackedFiles).not.toContain('instructions/_engine.md');
|
|
91
|
+
|
|
92
|
+
// Important source-of-truth files MUST be tracked
|
|
93
|
+
expect(trackedFiles).toContain('agent.yaml');
|
|
94
|
+
expect(trackedFiles).toContain('.gitignore');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('scaffold without git does not create a .git directory', () => {
|
|
98
|
+
const outputDir = makeTempDir();
|
|
99
|
+
const result = scaffoldFileTree(MINIMAL_AGENT, outputDir);
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
|
|
102
|
+
// No git init called — .git must not exist
|
|
103
|
+
expect(existsSync(join(result.agentDir, '.git'))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|