edsger 0.36.0 → 0.36.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/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
- package/dist/commands/build/__tests__/detect-project.test.js +160 -0
- package/dist/commands/build/__tests__/run-build.test.js +192 -62
- package/dist/commands/build/detect-project.d.ts +52 -0
- package/dist/commands/build/detect-project.js +277 -0
- package/dist/commands/build/index.d.ts +7 -0
- package/dist/commands/build/index.js +83 -62
- package/dist/phases/app-store-generation/index.js +7 -3
- package/package.json +1 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the AI build plan module.
|
|
3
|
+
* Tests the directory tree builder, response parser, and command whitelist
|
|
4
|
+
* without real AI calls.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
11
|
+
import { buildDirectoryTree, parseBuildPlan, validateInstallSteps, } from '../detect-project.js';
|
|
12
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
13
|
+
function createTmpDir() {
|
|
14
|
+
const dir = join(tmpdir(), `edsger-detect-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
// ── buildDirectoryTree ─────────────────────────────────────────────
|
|
19
|
+
describe('buildDirectoryTree', () => {
|
|
20
|
+
let tmpDir;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
tmpDir = createTmpDir();
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
it('builds a tree for a React Native structure', () => {
|
|
28
|
+
mkdirSync(join(tmpDir, 'ios', 'MyApp.xcworkspace'), { recursive: true });
|
|
29
|
+
mkdirSync(join(tmpDir, 'ios', 'MyApp.xcodeproj'), { recursive: true });
|
|
30
|
+
mkdirSync(join(tmpDir, 'android'), { recursive: true });
|
|
31
|
+
mkdirSync(join(tmpDir, 'src'), { recursive: true });
|
|
32
|
+
writeFileSync(join(tmpDir, 'package.json'), '{}');
|
|
33
|
+
writeFileSync(join(tmpDir, 'ios', 'Podfile'), '');
|
|
34
|
+
const tree = buildDirectoryTree(tmpDir, 2);
|
|
35
|
+
assert.ok(tree.includes('ios/'));
|
|
36
|
+
assert.ok(tree.includes('MyApp.xcworkspace/'));
|
|
37
|
+
assert.ok(tree.includes('package.json'));
|
|
38
|
+
});
|
|
39
|
+
it('excludes node_modules and Pods', () => {
|
|
40
|
+
mkdirSync(join(tmpDir, 'node_modules', 'pkg'), { recursive: true });
|
|
41
|
+
mkdirSync(join(tmpDir, 'ios', 'Pods'), { recursive: true });
|
|
42
|
+
mkdirSync(join(tmpDir, 'src'), { recursive: true });
|
|
43
|
+
const tree = buildDirectoryTree(tmpDir);
|
|
44
|
+
assert.ok(!tree.includes('node_modules'));
|
|
45
|
+
assert.ok(!tree.includes('Pods'));
|
|
46
|
+
assert.ok(tree.includes('src/'));
|
|
47
|
+
});
|
|
48
|
+
it('respects max depth', () => {
|
|
49
|
+
mkdirSync(join(tmpDir, 'a', 'b', 'c', 'd', 'e'), { recursive: true });
|
|
50
|
+
writeFileSync(join(tmpDir, 'a', 'b', 'c', 'd', 'e', 'deep.txt'), '');
|
|
51
|
+
const tree = buildDirectoryTree(tmpDir, 2);
|
|
52
|
+
assert.ok(!tree.includes('deep.txt'));
|
|
53
|
+
});
|
|
54
|
+
it('returns empty string for empty directory', () => {
|
|
55
|
+
assert.strictEqual(buildDirectoryTree(tmpDir), '');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
// ── parseBuildPlan ─────────────────────────────────────────────────
|
|
59
|
+
describe('parseBuildPlan', () => {
|
|
60
|
+
it('parses a valid JSON response with code block', () => {
|
|
61
|
+
const response = `\`\`\`json
|
|
62
|
+
{
|
|
63
|
+
"build_plan": {
|
|
64
|
+
"project_type": "react-native",
|
|
65
|
+
"install_steps": [
|
|
66
|
+
{ "cmd": "npm", "args": ["ci"], "cwd": "" },
|
|
67
|
+
{ "cmd": "pod", "args": ["install"], "cwd": "ios" }
|
|
68
|
+
],
|
|
69
|
+
"project_path": "ios/MyApp.xcworkspace",
|
|
70
|
+
"scheme_hint": "MyApp",
|
|
71
|
+
"reasoning": "React Native project with CocoaPods"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
\`\`\``;
|
|
75
|
+
const plan = parseBuildPlan(response);
|
|
76
|
+
assert.ok(plan);
|
|
77
|
+
assert.strictEqual(plan.projectType, 'react-native');
|
|
78
|
+
assert.strictEqual(plan.projectPath, 'ios/MyApp.xcworkspace');
|
|
79
|
+
assert.strictEqual(plan.schemeHint, 'MyApp');
|
|
80
|
+
assert.strictEqual(plan.installSteps.length, 2);
|
|
81
|
+
assert.strictEqual(plan.installSteps[0].cmd, 'npm');
|
|
82
|
+
assert.strictEqual(plan.installSteps[1].cmd, 'pod');
|
|
83
|
+
assert.strictEqual(plan.installSteps[1].cwd, 'ios');
|
|
84
|
+
});
|
|
85
|
+
it('parses raw JSON without code block', () => {
|
|
86
|
+
const response = JSON.stringify({
|
|
87
|
+
build_plan: {
|
|
88
|
+
project_type: 'native',
|
|
89
|
+
install_steps: [],
|
|
90
|
+
project_path: 'App.xcodeproj',
|
|
91
|
+
scheme_hint: 'App',
|
|
92
|
+
reasoning: 'Simple native project',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const plan = parseBuildPlan(response);
|
|
96
|
+
assert.ok(plan);
|
|
97
|
+
assert.strictEqual(plan.projectType, 'native');
|
|
98
|
+
assert.strictEqual(plan.installSteps.length, 0);
|
|
99
|
+
});
|
|
100
|
+
it('returns null for empty project_path', () => {
|
|
101
|
+
const response = JSON.stringify({
|
|
102
|
+
build_plan: {
|
|
103
|
+
project_type: 'native',
|
|
104
|
+
install_steps: [],
|
|
105
|
+
project_path: '',
|
|
106
|
+
scheme_hint: '',
|
|
107
|
+
reasoning: '',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
assert.strictEqual(parseBuildPlan(response), null);
|
|
111
|
+
});
|
|
112
|
+
it('returns null for garbage input', () => {
|
|
113
|
+
assert.strictEqual(parseBuildPlan('not json at all'), null);
|
|
114
|
+
});
|
|
115
|
+
it('handles missing optional fields gracefully', () => {
|
|
116
|
+
const response = JSON.stringify({
|
|
117
|
+
build_plan: {
|
|
118
|
+
project_type: 'flutter',
|
|
119
|
+
project_path: 'ios/Runner.xcworkspace',
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const plan = parseBuildPlan(response);
|
|
123
|
+
assert.ok(plan);
|
|
124
|
+
assert.strictEqual(plan.schemeHint, '');
|
|
125
|
+
assert.strictEqual(plan.installSteps.length, 0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
// ── validateInstallSteps ───────────────────────────────────────────
|
|
129
|
+
describe('validateInstallSteps', () => {
|
|
130
|
+
it('allows known safe commands', () => {
|
|
131
|
+
const steps = [
|
|
132
|
+
{ cmd: 'npm', args: ['ci'], cwd: '' },
|
|
133
|
+
{ cmd: 'yarn', args: ['install'], cwd: '' },
|
|
134
|
+
{ cmd: 'pod', args: ['install'], cwd: 'ios' },
|
|
135
|
+
{ cmd: 'flutter', args: ['pub', 'get'], cwd: '' },
|
|
136
|
+
{ cmd: 'xcodegen', args: ['generate'], cwd: '' },
|
|
137
|
+
{ cmd: 'tuist', args: ['generate'], cwd: '' },
|
|
138
|
+
{ cmd: 'bundle', args: ['install'], cwd: '' },
|
|
139
|
+
];
|
|
140
|
+
const valid = validateInstallSteps(steps);
|
|
141
|
+
assert.strictEqual(valid.length, 7);
|
|
142
|
+
});
|
|
143
|
+
it('filters out disallowed commands', () => {
|
|
144
|
+
const steps = [
|
|
145
|
+
{ cmd: 'npm', args: ['ci'], cwd: '' },
|
|
146
|
+
{ cmd: 'rm', args: ['-rf', '/'], cwd: '' },
|
|
147
|
+
{ cmd: 'curl', args: ['http://evil.com'], cwd: '' },
|
|
148
|
+
{ cmd: 'pod', args: ['install'], cwd: 'ios' },
|
|
149
|
+
];
|
|
150
|
+
const valid = validateInstallSteps(steps);
|
|
151
|
+
assert.strictEqual(valid.length, 2);
|
|
152
|
+
assert.strictEqual(valid[0].cmd, 'npm');
|
|
153
|
+
assert.strictEqual(valid[1].cmd, 'pod');
|
|
154
|
+
});
|
|
155
|
+
it('returns empty array for all-disallowed steps', () => {
|
|
156
|
+
const steps = [{ cmd: 'bash', args: ['-c', 'echo pwned'], cwd: '' }];
|
|
157
|
+
const valid = validateInstallSteps(steps);
|
|
158
|
+
assert.strictEqual(valid.length, 0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -48,6 +48,22 @@ function makeOptions(overrides = {}) {
|
|
|
48
48
|
...overrides,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
const DEFAULT_PRODUCT = {
|
|
52
|
+
id: 'prod-1',
|
|
53
|
+
name: 'My App',
|
|
54
|
+
description: 'A test application',
|
|
55
|
+
};
|
|
56
|
+
/** Default mock build plan for a React Native project */
|
|
57
|
+
const DEFAULT_PLAN = {
|
|
58
|
+
projectType: 'react-native',
|
|
59
|
+
installSteps: [
|
|
60
|
+
{ cmd: 'npm', args: ['ci'], cwd: '' },
|
|
61
|
+
{ cmd: 'pod', args: ['install'], cwd: 'ios' },
|
|
62
|
+
],
|
|
63
|
+
projectPath: 'ios/MyApp.xcworkspace',
|
|
64
|
+
schemeHint: 'MyApp',
|
|
65
|
+
reasoning: 'Mock plan',
|
|
66
|
+
};
|
|
51
67
|
function createMockDeps(overrides = {}) {
|
|
52
68
|
const calls = [];
|
|
53
69
|
const savedConfigs = [];
|
|
@@ -56,9 +72,13 @@ function createMockDeps(overrides = {}) {
|
|
|
56
72
|
const exportDir = join(tmpDir, 'build', 'export');
|
|
57
73
|
mkdirSync(exportDir, { recursive: true });
|
|
58
74
|
writeFileSync(join(exportDir, 'MyApp.ipa'), 'fake-ipa-data');
|
|
75
|
+
// Create the AI-suggested project path so existsSync passes
|
|
76
|
+
mkdirSync(join(tmpDir, 'ios', 'MyApp.xcworkspace'), { recursive: true });
|
|
59
77
|
const deps = {
|
|
60
78
|
fetchConfigs: overrides.fetchConfigs ??
|
|
61
79
|
(async () => overrides.configs ?? [makeAppleConfig()]),
|
|
80
|
+
fetchProduct: overrides.fetchProduct ??
|
|
81
|
+
(async () => overrides.product ?? DEFAULT_PRODUCT),
|
|
62
82
|
fetchGitHub: overrides.fetchGitHub ??
|
|
63
83
|
(async () => ({
|
|
64
84
|
configured: true,
|
|
@@ -74,9 +94,10 @@ function createMockDeps(overrides = {}) {
|
|
|
74
94
|
return null;
|
|
75
95
|
}),
|
|
76
96
|
checkoutDefaultBranch: overrides.checkoutDefaultBranch ?? (() => { }),
|
|
97
|
+
analyzeBuildPlan: overrides.analyzeBuildPlan ?? (async () => DEFAULT_PLAN),
|
|
77
98
|
findProjects: overrides.findProjects ??
|
|
78
99
|
(() => overrides.projects ?? [
|
|
79
|
-
{ path: 'MyApp.xcworkspace', type: 'workspace' },
|
|
100
|
+
{ path: 'ios/MyApp.xcworkspace', type: 'workspace' },
|
|
80
101
|
]),
|
|
81
102
|
findSchemes: overrides.findSchemes ?? (() => overrides.schemes ?? ['MyApp']),
|
|
82
103
|
runCommand: overrides.runCommand ??
|
|
@@ -130,9 +151,10 @@ describe('runBuild orchestration', () => {
|
|
|
130
151
|
return true;
|
|
131
152
|
});
|
|
132
153
|
});
|
|
133
|
-
it('throws when no
|
|
154
|
+
it('throws when AI plan fails and no projects found in fallback scan', async () => {
|
|
134
155
|
const { deps } = createMockDeps({
|
|
135
156
|
tmpDir,
|
|
157
|
+
analyzeBuildPlan: async () => null,
|
|
136
158
|
projects: [],
|
|
137
159
|
});
|
|
138
160
|
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
@@ -143,6 +165,10 @@ describe('runBuild orchestration', () => {
|
|
|
143
165
|
it('throws when no schemes found', async () => {
|
|
144
166
|
const { deps } = createMockDeps({
|
|
145
167
|
tmpDir,
|
|
168
|
+
analyzeBuildPlan: async () => ({
|
|
169
|
+
...DEFAULT_PLAN,
|
|
170
|
+
schemeHint: '', // no hint → must discover
|
|
171
|
+
}),
|
|
146
172
|
schemes: [],
|
|
147
173
|
});
|
|
148
174
|
await assert.rejects(() => runBuild(makeOptions(), deps), (err) => {
|
|
@@ -150,52 +176,166 @@ describe('runBuild orchestration', () => {
|
|
|
150
176
|
return true;
|
|
151
177
|
});
|
|
152
178
|
});
|
|
153
|
-
// --
|
|
154
|
-
it('
|
|
155
|
-
const
|
|
156
|
-
|
|
179
|
+
// -- AI plan execution --
|
|
180
|
+
it('executes install steps from AI plan in order', async () => {
|
|
181
|
+
const { deps, calls } = createMockDeps({
|
|
182
|
+
tmpDir,
|
|
183
|
+
analyzeBuildPlan: async () => ({
|
|
184
|
+
projectType: 'react-native',
|
|
185
|
+
installSteps: [
|
|
186
|
+
{ cmd: 'npm', args: ['ci'], cwd: '' },
|
|
187
|
+
{ cmd: 'pod', args: ['install'], cwd: 'ios' },
|
|
188
|
+
],
|
|
189
|
+
projectPath: 'ios/MyApp.xcworkspace',
|
|
190
|
+
schemeHint: 'MyApp',
|
|
191
|
+
reasoning: 'React Native project',
|
|
192
|
+
}),
|
|
157
193
|
});
|
|
158
|
-
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
159
194
|
await runBuild(makeOptions(), deps);
|
|
160
|
-
//
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
assert.ok(
|
|
195
|
+
// Install steps should run before xcodebuild
|
|
196
|
+
const npmIdx = calls.findIndex((c) => c.cmd === 'npm');
|
|
197
|
+
const podIdx = calls.findIndex((c) => c.cmd === 'pod');
|
|
198
|
+
const xcodeIdx = calls.findIndex((c) => c.cmd === 'xcodebuild');
|
|
199
|
+
assert.ok(npmIdx >= 0, 'Expected npm call');
|
|
200
|
+
assert.ok(podIdx >= 0, 'Expected pod call');
|
|
201
|
+
assert.ok(xcodeIdx >= 0, 'Expected xcodebuild call');
|
|
202
|
+
assert.ok(npmIdx < podIdx, 'npm should run before pod');
|
|
203
|
+
assert.ok(podIdx < xcodeIdx, 'pod should run before xcodebuild');
|
|
204
|
+
// Verify cwd
|
|
205
|
+
assert.strictEqual(calls[npmIdx].opts?.cwd, tmpDir);
|
|
206
|
+
assert.strictEqual(calls[podIdx].opts?.cwd, join(tmpDir, 'ios'));
|
|
165
207
|
});
|
|
166
|
-
it('uses
|
|
167
|
-
|
|
168
|
-
|
|
208
|
+
it('uses AI-suggested project path and scheme', async () => {
|
|
209
|
+
mkdirSync(join(tmpDir, 'apps', 'mobile', 'ios', 'Mobile.xcworkspace'), {
|
|
210
|
+
recursive: true,
|
|
169
211
|
});
|
|
170
|
-
const { deps, calls } = createMockDeps({
|
|
171
|
-
|
|
212
|
+
const { deps, calls, savedConfigs } = createMockDeps({
|
|
213
|
+
tmpDir,
|
|
214
|
+
analyzeBuildPlan: async () => ({
|
|
215
|
+
projectType: 'react-native',
|
|
216
|
+
installSteps: [],
|
|
217
|
+
projectPath: 'apps/mobile/ios/Mobile.xcworkspace',
|
|
218
|
+
schemeHint: 'MobileApp',
|
|
219
|
+
reasoning: 'Monorepo — mobile app matches product',
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
await runBuild(makeOptions(), deps);
|
|
223
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
224
|
+
assert.ok(archiveCall);
|
|
225
|
+
assert.ok(archiveCall.args.some((a) => a.includes('apps/mobile/ios/Mobile.xcworkspace')));
|
|
226
|
+
const schemeIdx = archiveCall.args.indexOf('-scheme');
|
|
227
|
+
assert.strictEqual(archiveCall.args[schemeIdx + 1], 'MobileApp');
|
|
228
|
+
// Verify persisted to DB
|
|
229
|
+
assert.strictEqual(savedConfigs.length, 1);
|
|
230
|
+
assert.strictEqual(savedConfigs[0].project_path, 'apps/mobile/ios/Mobile.xcworkspace');
|
|
231
|
+
assert.strictEqual(savedConfigs[0].scheme, 'MobileApp');
|
|
232
|
+
});
|
|
233
|
+
it('falls back to scan when AI-suggested project path does not exist', async () => {
|
|
234
|
+
const { deps, calls } = createMockDeps({
|
|
235
|
+
tmpDir,
|
|
236
|
+
analyzeBuildPlan: async () => ({
|
|
237
|
+
...DEFAULT_PLAN,
|
|
238
|
+
projectPath: 'nonexistent/App.xcworkspace', // does not exist on disk
|
|
239
|
+
}),
|
|
240
|
+
projects: [{ path: 'ios/MyApp.xcworkspace', type: 'workspace' }],
|
|
241
|
+
});
|
|
242
|
+
await runBuild(makeOptions(), deps);
|
|
243
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
244
|
+
assert.ok(archiveCall);
|
|
245
|
+
// Should have fallen back to scanned project
|
|
246
|
+
assert.ok(archiveCall.args.some((a) => a.includes('ios/MyApp.xcworkspace')));
|
|
247
|
+
});
|
|
248
|
+
it('discovers scheme via xcodebuild -list when AI has no hint', async () => {
|
|
249
|
+
const { deps, calls } = createMockDeps({
|
|
250
|
+
tmpDir,
|
|
251
|
+
analyzeBuildPlan: async () => ({
|
|
252
|
+
...DEFAULT_PLAN,
|
|
253
|
+
schemeHint: '', // empty → discover
|
|
254
|
+
}),
|
|
255
|
+
schemes: ['DiscoveredScheme'],
|
|
256
|
+
});
|
|
257
|
+
await runBuild(makeOptions(), deps);
|
|
258
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
259
|
+
assert.ok(archiveCall);
|
|
260
|
+
const schemeIdx = archiveCall.args.indexOf('-scheme');
|
|
261
|
+
assert.strictEqual(archiveCall.args[schemeIdx + 1], 'DiscoveredScheme');
|
|
262
|
+
});
|
|
263
|
+
it('falls back to scan when AI returns null (no plan)', async () => {
|
|
264
|
+
const { deps, calls } = createMockDeps({
|
|
265
|
+
tmpDir,
|
|
266
|
+
analyzeBuildPlan: async () => null,
|
|
267
|
+
projects: [{ path: 'FallbackApp.xcworkspace', type: 'workspace' }],
|
|
268
|
+
schemes: ['FallbackScheme'],
|
|
269
|
+
});
|
|
270
|
+
await runBuild(makeOptions(), deps);
|
|
271
|
+
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
272
|
+
assert.ok(archiveCall);
|
|
273
|
+
assert.ok(archiveCall.args.some((a) => a.includes('FallbackApp.xcworkspace')));
|
|
274
|
+
const schemeIdx = archiveCall.args.indexOf('-scheme');
|
|
275
|
+
assert.strictEqual(archiveCall.args[schemeIdx + 1], 'FallbackScheme');
|
|
276
|
+
});
|
|
277
|
+
it('CLI --scheme overrides AI scheme hint', async () => {
|
|
278
|
+
const { deps, calls } = createMockDeps({ tmpDir });
|
|
279
|
+
await runBuild(makeOptions({ scheme: 'ManualScheme' }), deps);
|
|
172
280
|
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
173
281
|
assert.ok(archiveCall);
|
|
174
282
|
const schemeIdx = archiveCall.args.indexOf('-scheme');
|
|
175
|
-
assert.strictEqual(archiveCall.args[schemeIdx + 1], '
|
|
283
|
+
assert.strictEqual(archiveCall.args[schemeIdx + 1], 'ManualScheme');
|
|
176
284
|
});
|
|
177
|
-
|
|
285
|
+
// -- Saved config paths (skip AI) --
|
|
286
|
+
it('skips AI when project_path and scheme are saved', async () => {
|
|
287
|
+
let aiCalled = false;
|
|
178
288
|
const config = makeAppleConfig({
|
|
179
289
|
build_config: { project_path: 'ios/App.xcworkspace', scheme: 'App' },
|
|
180
290
|
});
|
|
181
|
-
const { deps, calls } = createMockDeps({
|
|
291
|
+
const { deps, calls } = createMockDeps({
|
|
292
|
+
tmpDir,
|
|
293
|
+
configs: [config],
|
|
294
|
+
analyzeBuildPlan: async () => {
|
|
295
|
+
aiCalled = true;
|
|
296
|
+
return null;
|
|
297
|
+
},
|
|
298
|
+
});
|
|
182
299
|
await runBuild(makeOptions(), deps);
|
|
300
|
+
assert.ok(!aiCalled, 'AI should NOT be called when config is saved');
|
|
183
301
|
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
184
302
|
assert.ok(archiveCall);
|
|
185
303
|
assert.ok(archiveCall.args.some((a) => a.includes('ios/App.xcworkspace')));
|
|
186
304
|
});
|
|
187
|
-
it('
|
|
188
|
-
|
|
189
|
-
|
|
305
|
+
it('re-runs AI on --reselect even with saved config', async () => {
|
|
306
|
+
let aiCalled = false;
|
|
307
|
+
const config = makeAppleConfig({
|
|
308
|
+
build_config: {
|
|
309
|
+
project_path: 'old/Old.xcworkspace',
|
|
310
|
+
scheme: 'OldScheme',
|
|
311
|
+
configuration: 'Release',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
mkdirSync(join(tmpDir, 'new', 'New.xcworkspace'), { recursive: true });
|
|
315
|
+
const { deps, savedConfigs } = createMockDeps({
|
|
316
|
+
tmpDir,
|
|
317
|
+
configs: [config],
|
|
318
|
+
analyzeBuildPlan: async () => {
|
|
319
|
+
aiCalled = true;
|
|
320
|
+
return {
|
|
321
|
+
projectType: 'native',
|
|
322
|
+
installSteps: [],
|
|
323
|
+
projectPath: 'new/New.xcworkspace',
|
|
324
|
+
schemeHint: 'NewScheme',
|
|
325
|
+
reasoning: 'Redetected',
|
|
326
|
+
};
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
await runBuild(makeOptions({ reselect: true }), deps);
|
|
330
|
+
assert.ok(aiCalled, 'AI should be called on --reselect');
|
|
190
331
|
assert.strictEqual(savedConfigs.length, 1);
|
|
191
|
-
assert.strictEqual(savedConfigs[0].project_path, '
|
|
192
|
-
assert.strictEqual(savedConfigs[0].scheme, '
|
|
193
|
-
assert.strictEqual(savedConfigs[0].configuration, 'Release');
|
|
332
|
+
assert.strictEqual(savedConfigs[0].project_path, 'new/New.xcworkspace');
|
|
333
|
+
assert.strictEqual(savedConfigs[0].scheme, 'NewScheme');
|
|
194
334
|
});
|
|
195
335
|
it('does not re-save config when nothing changed', async () => {
|
|
196
336
|
const config = makeAppleConfig({
|
|
197
337
|
build_config: {
|
|
198
|
-
project_path: 'MyApp.xcworkspace',
|
|
338
|
+
project_path: 'ios/MyApp.xcworkspace',
|
|
199
339
|
scheme: 'MyApp',
|
|
200
340
|
configuration: 'Release',
|
|
201
341
|
},
|
|
@@ -207,6 +347,23 @@ describe('runBuild orchestration', () => {
|
|
|
207
347
|
await runBuild(makeOptions(), deps);
|
|
208
348
|
assert.strictEqual(savedConfigs.length, 0);
|
|
209
349
|
});
|
|
350
|
+
// -- Archive / export / upload --
|
|
351
|
+
it('runs full archive + export pipeline', async () => {
|
|
352
|
+
const config = makeAppleConfig({
|
|
353
|
+
build_config: {
|
|
354
|
+
project_path: 'A.xcworkspace',
|
|
355
|
+
scheme: 'A',
|
|
356
|
+
team_id: 'TEAM1',
|
|
357
|
+
export_method: 'app-store',
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
361
|
+
await runBuild(makeOptions(), deps);
|
|
362
|
+
const xcodeCalls = calls.filter((c) => c.cmd === 'xcodebuild');
|
|
363
|
+
assert.strictEqual(xcodeCalls.length, 2);
|
|
364
|
+
assert.ok(xcodeCalls[0].args.includes('archive'));
|
|
365
|
+
assert.ok(xcodeCalls[1].args.includes('-exportArchive'));
|
|
366
|
+
});
|
|
210
367
|
it('passes --upload to trigger upload after build', async () => {
|
|
211
368
|
const config = makeAppleConfig({
|
|
212
369
|
build_config: {
|
|
@@ -219,7 +376,6 @@ describe('runBuild orchestration', () => {
|
|
|
219
376
|
});
|
|
220
377
|
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
221
378
|
await runBuild(makeOptions({ upload: true }), deps);
|
|
222
|
-
// Should have xcrun altool call for upload
|
|
223
379
|
const uploadCall = calls.find((c) => c.cmd === 'xcrun' && c.args.includes('altool'));
|
|
224
380
|
assert.ok(uploadCall, 'Expected xcrun altool upload call');
|
|
225
381
|
assert.ok(uploadCall.args.includes('--upload-app'));
|
|
@@ -240,28 +396,21 @@ describe('runBuild orchestration', () => {
|
|
|
240
396
|
assert.ok(uploadCall, 'Expected xcrun notarytool call for macOS');
|
|
241
397
|
});
|
|
242
398
|
it('passes platform destination to xcodebuild', async () => {
|
|
243
|
-
const
|
|
399
|
+
const config = makeAppleConfig({
|
|
400
|
+
build_config: { project_path: 'A.xcworkspace', scheme: 'A' },
|
|
401
|
+
});
|
|
402
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
244
403
|
await runBuild(makeOptions({ platform: 'macos' }), deps);
|
|
245
404
|
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
246
405
|
assert.ok(archiveCall);
|
|
247
406
|
const destIdx = archiveCall.args.indexOf('-destination');
|
|
248
407
|
assert.strictEqual(archiveCall.args[destIdx + 1], 'generic/platform=macOS');
|
|
249
408
|
});
|
|
250
|
-
it('auto-selects first workspace when multiple projects exist', async () => {
|
|
251
|
-
const { deps, calls } = createMockDeps({
|
|
252
|
-
tmpDir,
|
|
253
|
-
projects: [
|
|
254
|
-
{ path: 'ios/App.xcworkspace', type: 'workspace' },
|
|
255
|
-
{ path: 'App.xcodeproj', type: 'project' },
|
|
256
|
-
],
|
|
257
|
-
});
|
|
258
|
-
await runBuild(makeOptions(), deps);
|
|
259
|
-
const archiveCall = calls.find((c) => c.cmd === 'xcodebuild' && c.args.includes('archive'));
|
|
260
|
-
assert.ok(archiveCall);
|
|
261
|
-
assert.ok(archiveCall.args.some((a) => a.includes('ios/App.xcworkspace')));
|
|
262
|
-
});
|
|
263
409
|
it('skips archive when --skipArchive is set', async () => {
|
|
264
|
-
const
|
|
410
|
+
const config = makeAppleConfig({
|
|
411
|
+
build_config: { project_path: 'A.xcworkspace', scheme: 'A' },
|
|
412
|
+
});
|
|
413
|
+
const { deps, calls } = createMockDeps({ tmpDir, configs: [config] });
|
|
265
414
|
await runBuild(makeOptions({ skipArchive: true }), deps);
|
|
266
415
|
const xcodeCalls = calls.filter((c) => c.cmd === 'xcodebuild');
|
|
267
416
|
assert.strictEqual(xcodeCalls.length, 0);
|
|
@@ -281,23 +430,4 @@ describe('runBuild orchestration', () => {
|
|
|
281
430
|
assert.ok(archiveCall);
|
|
282
431
|
assert.ok(archiveCall.args.some((a) => a === 'DEVELOPMENT_TEAM=TEAM123'));
|
|
283
432
|
});
|
|
284
|
-
it('re-discovers project on --reselect even with saved config', async () => {
|
|
285
|
-
const config = makeAppleConfig({
|
|
286
|
-
build_config: {
|
|
287
|
-
project_path: 'old/Old.xcworkspace',
|
|
288
|
-
scheme: 'OldScheme',
|
|
289
|
-
configuration: 'Release',
|
|
290
|
-
},
|
|
291
|
-
});
|
|
292
|
-
const { deps, savedConfigs } = createMockDeps({
|
|
293
|
-
tmpDir,
|
|
294
|
-
configs: [config],
|
|
295
|
-
projects: [{ path: 'new/New.xcworkspace', type: 'workspace' }],
|
|
296
|
-
schemes: ['NewScheme'],
|
|
297
|
-
});
|
|
298
|
-
await runBuild(makeOptions({ reselect: true }), deps);
|
|
299
|
-
assert.strictEqual(savedConfigs.length, 1);
|
|
300
|
-
assert.strictEqual(savedConfigs[0].project_path, 'new/New.xcworkspace');
|
|
301
|
-
assert.strictEqual(savedConfigs[0].scheme, 'NewScheme');
|
|
302
|
-
});
|
|
303
433
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-powered build plan generation.
|
|
3
|
+
*
|
|
4
|
+
* Separation of concerns:
|
|
5
|
+
* AI → analyzes repo structure + product context → outputs a BuildPlan (JSON)
|
|
6
|
+
* Code → executes the plan deterministically (install deps, find scheme)
|
|
7
|
+
*
|
|
8
|
+
* The AI never executes commands. It only reads the directory tree we give it.
|
|
9
|
+
*/
|
|
10
|
+
/** A shell command the build runner should execute. */
|
|
11
|
+
export interface InstallStep {
|
|
12
|
+
cmd: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Working directory relative to repo root. Empty string = repo root. */
|
|
15
|
+
cwd: string;
|
|
16
|
+
}
|
|
17
|
+
/** The AI's analysis of how to build this repo. */
|
|
18
|
+
export interface BuildPlan {
|
|
19
|
+
/** Detected project framework */
|
|
20
|
+
projectType: 'react-native' | 'flutter' | 'expo' | 'native' | 'xcodegen' | 'tuist' | 'spm' | 'other';
|
|
21
|
+
/** Ordered list of commands to install dependencies / generate projects */
|
|
22
|
+
installSteps: InstallStep[];
|
|
23
|
+
/** Expected Xcode project path (relative to repo root) after deps are installed */
|
|
24
|
+
projectPath: string;
|
|
25
|
+
/** Hint for the scheme name (may be empty — code will discover via xcodebuild -list) */
|
|
26
|
+
schemeHint: string;
|
|
27
|
+
/** Why the AI made these decisions */
|
|
28
|
+
reasoning: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AnalyzeParams {
|
|
31
|
+
/** Absolute path to the cloned repository */
|
|
32
|
+
repoPath: string;
|
|
33
|
+
/** Product name from the database */
|
|
34
|
+
productName: string;
|
|
35
|
+
/** Product description from the database */
|
|
36
|
+
productDescription?: string;
|
|
37
|
+
}
|
|
38
|
+
/** Injectable for testing */
|
|
39
|
+
export type AnalyzeBuildPlanFn = (params: AnalyzeParams) => Promise<BuildPlan | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Build a concise directory tree string for the repository.
|
|
42
|
+
* Only goes `maxDepth` levels deep and skips vendored directories.
|
|
43
|
+
*/
|
|
44
|
+
export declare function buildDirectoryTree(rootDir: string, maxDepth?: number): string;
|
|
45
|
+
/** Validate that the AI only proposed safe, expected commands. */
|
|
46
|
+
export declare function validateInstallSteps(steps: InstallStep[]): InstallStep[];
|
|
47
|
+
export declare function parseBuildPlan(response: string): BuildPlan | null;
|
|
48
|
+
/**
|
|
49
|
+
* Ask AI to analyze the repo and produce a build plan.
|
|
50
|
+
* The AI sees only the directory tree — it cannot execute commands.
|
|
51
|
+
*/
|
|
52
|
+
export declare function analyzeBuildPlan(params: AnalyzeParams): Promise<BuildPlan | null>;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-powered build plan generation.
|
|
3
|
+
*
|
|
4
|
+
* Separation of concerns:
|
|
5
|
+
* AI → analyzes repo structure + product context → outputs a BuildPlan (JSON)
|
|
6
|
+
* Code → executes the plan deterministically (install deps, find scheme)
|
|
7
|
+
*
|
|
8
|
+
* The AI never executes commands. It only reads the directory tree we give it.
|
|
9
|
+
*/
|
|
10
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
11
|
+
import { readdirSync, statSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
14
|
+
import { logInfo, logWarning } from '../../utils/logger.js';
|
|
15
|
+
// ── Directory tree helper ──────────────────────────────────────────
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
'node_modules',
|
|
18
|
+
'.git',
|
|
19
|
+
'Pods',
|
|
20
|
+
'DerivedData',
|
|
21
|
+
'build',
|
|
22
|
+
'.build',
|
|
23
|
+
'Carthage',
|
|
24
|
+
'.next',
|
|
25
|
+
'.expo',
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Build a concise directory tree string for the repository.
|
|
29
|
+
* Only goes `maxDepth` levels deep and skips vendored directories.
|
|
30
|
+
*/
|
|
31
|
+
export function buildDirectoryTree(rootDir, maxDepth = 3) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
function walk(dir, prefix, depth) {
|
|
34
|
+
if (depth > maxDepth)
|
|
35
|
+
return;
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(dir).sort();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const filtered = entries.filter((e) => {
|
|
44
|
+
if (SKIP_DIRS.has(e))
|
|
45
|
+
return false;
|
|
46
|
+
if (depth > 0 &&
|
|
47
|
+
e.startsWith('.') &&
|
|
48
|
+
!e.endsWith('.xcworkspace') &&
|
|
49
|
+
!e.endsWith('.xcodeproj'))
|
|
50
|
+
return false;
|
|
51
|
+
return true;
|
|
52
|
+
});
|
|
53
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
54
|
+
const entry = filtered[i];
|
|
55
|
+
const fullPath = join(dir, entry);
|
|
56
|
+
const isLast = i === filtered.length - 1;
|
|
57
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
58
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
59
|
+
let isDir = false;
|
|
60
|
+
try {
|
|
61
|
+
isDir = statSync(fullPath).isDirectory();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
lines.push(`${prefix}${connector}${entry}${isDir ? '/' : ''}`);
|
|
67
|
+
if (isDir) {
|
|
68
|
+
walk(fullPath, prefix + childPrefix, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
walk(rootDir, '', 0);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
// ── Allowed commands whitelist ─────────────────────────────────────
|
|
76
|
+
const ALLOWED_COMMANDS = new Set([
|
|
77
|
+
'npm',
|
|
78
|
+
'yarn',
|
|
79
|
+
'pnpm',
|
|
80
|
+
'bun',
|
|
81
|
+
'pod',
|
|
82
|
+
'flutter',
|
|
83
|
+
'xcodegen',
|
|
84
|
+
'tuist',
|
|
85
|
+
'swift',
|
|
86
|
+
'bundle', // ruby bundler (for `bundle exec pod install`)
|
|
87
|
+
]);
|
|
88
|
+
/** Validate that the AI only proposed safe, expected commands. */
|
|
89
|
+
export function validateInstallSteps(steps) {
|
|
90
|
+
return steps.filter((step) => {
|
|
91
|
+
if (!ALLOWED_COMMANDS.has(step.cmd)) {
|
|
92
|
+
logWarning(`Skipping disallowed command: ${step.cmd}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// ── Response parser ────────────────────────────────────────────────
|
|
99
|
+
function extractTextFromContent(
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
101
|
+
content) {
|
|
102
|
+
let text = '';
|
|
103
|
+
for (const item of content) {
|
|
104
|
+
if (item.type === 'text') {
|
|
105
|
+
text += item.text;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return text;
|
|
109
|
+
}
|
|
110
|
+
export function parseBuildPlan(response) {
|
|
111
|
+
try {
|
|
112
|
+
const jsonMatch = response.match(/```json\s*\n([\s\S]*?)\n\s*```/);
|
|
113
|
+
const jsonStr = jsonMatch ? jsonMatch[1] : response;
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
let parsed;
|
|
116
|
+
try {
|
|
117
|
+
parsed = JSON.parse(jsonStr);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
const objMatch = jsonStr.match(/\{[\s\S]*"project_path"[\s\S]*"project_type"[\s\S]*\}/);
|
|
121
|
+
if (objMatch) {
|
|
122
|
+
parsed = JSON.parse(objMatch[0]);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
throw new Error('No valid JSON found');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const plan = parsed.build_plan || parsed;
|
|
129
|
+
const projectPath = String(plan.project_path || '');
|
|
130
|
+
if (!projectPath)
|
|
131
|
+
return null;
|
|
132
|
+
const installSteps = Array.isArray(plan.install_steps)
|
|
133
|
+
? plan.install_steps.map(
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
(s) => ({
|
|
136
|
+
cmd: String(s.cmd || ''),
|
|
137
|
+
args: Array.isArray(s.args) ? s.args.map(String) : [],
|
|
138
|
+
cwd: String(s.cwd || ''),
|
|
139
|
+
}))
|
|
140
|
+
: [];
|
|
141
|
+
return {
|
|
142
|
+
projectType: plan.project_type || 'other',
|
|
143
|
+
installSteps: validateInstallSteps(installSteps),
|
|
144
|
+
projectPath,
|
|
145
|
+
schemeHint: String(plan.scheme_hint || ''),
|
|
146
|
+
reasoning: String(plan.reasoning || ''),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// ── System prompt ──────────────────────────────────────────────────
|
|
154
|
+
const SYSTEM_PROMPT = `You are an expert iOS/macOS build engineer. You are given a repository directory tree and product context. Your ONLY job is to analyze the structure and output a build plan as JSON.
|
|
155
|
+
|
|
156
|
+
You must NOT execute any commands. You only analyze the directory tree provided and return a plan.
|
|
157
|
+
|
|
158
|
+
Determine:
|
|
159
|
+
1. **project_type**: What kind of project is this?
|
|
160
|
+
- "react-native": has package.json with react-native dependency, ios/ directory
|
|
161
|
+
- "expo": has package.json with expo, app.json/app.config.js
|
|
162
|
+
- "flutter": has pubspec.yaml, ios/ directory
|
|
163
|
+
- "xcodegen": has project.yml (XcodeGen config)
|
|
164
|
+
- "tuist": has Project.swift or Tuist/ directory
|
|
165
|
+
- "spm": has Package.swift, no .xcodeproj
|
|
166
|
+
- "native": has .xcodeproj or .xcworkspace already committed
|
|
167
|
+
- "other": none of the above
|
|
168
|
+
|
|
169
|
+
2. **install_steps**: Ordered commands needed before building. Each step has:
|
|
170
|
+
- "cmd": the executable (npm, yarn, pnpm, bun, pod, flutter, xcodegen, tuist, swift, bundle)
|
|
171
|
+
- "args": array of arguments
|
|
172
|
+
- "cwd": working directory relative to repo root ("" for repo root, "ios" for ios/ subdirectory)
|
|
173
|
+
|
|
174
|
+
Common patterns:
|
|
175
|
+
- React Native: [npm ci] then [pod install in ios/]
|
|
176
|
+
- React Native with yarn.lock: [yarn install --frozen-lockfile] then [pod install in ios/]
|
|
177
|
+
- Flutter: [flutter pub get] then [pod install in ios/]
|
|
178
|
+
- xcodegen: [xcodegen generate]
|
|
179
|
+
- tuist: [tuist generate]
|
|
180
|
+
- Native with Podfile: [pod install in directory containing Podfile]
|
|
181
|
+
- Native with Gemfile: [bundle install] then [bundle exec pod install]
|
|
182
|
+
- Native without deps: [] (empty)
|
|
183
|
+
|
|
184
|
+
3. **project_path**: The Xcode project to build (relative to repo root).
|
|
185
|
+
- After pod install, prefer .xcworkspace over .xcodeproj
|
|
186
|
+
- For React Native: typically "ios/<AppName>.xcworkspace"
|
|
187
|
+
- For native: wherever the .xcworkspace/.xcodeproj is
|
|
188
|
+
|
|
189
|
+
4. **scheme_hint**: Best guess for the scheme name. Often matches the app name.
|
|
190
|
+
Leave empty "" if unsure — the build system will discover it via xcodebuild -list.
|
|
191
|
+
|
|
192
|
+
5. **reasoning**: Brief explanation of your analysis.
|
|
193
|
+
|
|
194
|
+
In monorepos with multiple apps, use the product name/description to pick the right one.
|
|
195
|
+
|
|
196
|
+
Respond with ONLY a JSON block, no other text:`;
|
|
197
|
+
// ── Main AI call ───────────────────────────────────────────────────
|
|
198
|
+
/**
|
|
199
|
+
* Ask AI to analyze the repo and produce a build plan.
|
|
200
|
+
* The AI sees only the directory tree — it cannot execute commands.
|
|
201
|
+
*/
|
|
202
|
+
export async function analyzeBuildPlan(params) {
|
|
203
|
+
const { repoPath, productName, productDescription } = params;
|
|
204
|
+
const tree = buildDirectoryTree(repoPath);
|
|
205
|
+
const prompt = `Analyze this repository and produce a build plan.
|
|
206
|
+
|
|
207
|
+
**Product name:** ${productName}
|
|
208
|
+
**Product description:** ${productDescription || 'N/A'}
|
|
209
|
+
|
|
210
|
+
**Repository structure:**
|
|
211
|
+
\`\`\`
|
|
212
|
+
${tree}
|
|
213
|
+
\`\`\`
|
|
214
|
+
|
|
215
|
+
\`\`\`json
|
|
216
|
+
{
|
|
217
|
+
"build_plan": {
|
|
218
|
+
"project_type": "<react-native|flutter|expo|native|xcodegen|tuist|spm|other>",
|
|
219
|
+
"install_steps": [
|
|
220
|
+
{ "cmd": "<executable>", "args": ["<arg1>", "<arg2>"], "cwd": "<relative dir or empty>" }
|
|
221
|
+
],
|
|
222
|
+
"project_path": "<relative path to .xcworkspace or .xcodeproj>",
|
|
223
|
+
"scheme_hint": "<scheme name or empty>",
|
|
224
|
+
"reasoning": "<brief explanation>"
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
\`\`\``;
|
|
228
|
+
logInfo('Analyzing repository with AI...');
|
|
229
|
+
let resultText = '';
|
|
230
|
+
try {
|
|
231
|
+
for await (const message of query({
|
|
232
|
+
prompt,
|
|
233
|
+
options: {
|
|
234
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
235
|
+
model: DEFAULT_MODEL,
|
|
236
|
+
maxTurns: 1,
|
|
237
|
+
},
|
|
238
|
+
})) {
|
|
239
|
+
if (message.type === 'assistant') {
|
|
240
|
+
resultText += extractTextFromContent(message.message?.content ?? []);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (message.type === 'result') {
|
|
244
|
+
if (message.subtype === 'success' && message.result) {
|
|
245
|
+
resultText = message.result;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const plan = parseBuildPlan(resultText);
|
|
250
|
+
if (plan) {
|
|
251
|
+
logInfo(`AI analysis complete:`);
|
|
252
|
+
logInfo(` Type: ${plan.projectType}`);
|
|
253
|
+
logInfo(` Project: ${plan.projectPath}`);
|
|
254
|
+
if (plan.schemeHint) {
|
|
255
|
+
logInfo(` Scheme: ${plan.schemeHint}`);
|
|
256
|
+
}
|
|
257
|
+
if (plan.installSteps.length > 0) {
|
|
258
|
+
logInfo(` Install steps:`);
|
|
259
|
+
for (const step of plan.installSteps) {
|
|
260
|
+
const cwdSuffix = step.cwd ? ` (in ${step.cwd}/)` : '';
|
|
261
|
+
logInfo(` - ${step.cmd} ${step.args.join(' ')}${cwdSuffix}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (plan.reasoning) {
|
|
265
|
+
logInfo(` Reasoning: ${plan.reasoning}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
logWarning('AI did not return a valid build plan');
|
|
270
|
+
}
|
|
271
|
+
return plan;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
logWarning(`AI analysis failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type AppStoreConfig, type BuildConfig } from '../../api/app-store.js';
|
|
2
|
+
import { type ProductInfo } from '../../api/products.js';
|
|
2
3
|
import { type CliOptions } from '../../types/index.js';
|
|
4
|
+
import { type AnalyzeBuildPlanFn } from './detect-project.js';
|
|
3
5
|
export interface BuildOptions extends CliOptions {
|
|
4
6
|
buildProductId: string;
|
|
5
7
|
scheme?: string;
|
|
@@ -22,6 +24,7 @@ export type Platform = 'ios' | 'macos';
|
|
|
22
24
|
*/
|
|
23
25
|
export interface BuildDeps {
|
|
24
26
|
fetchConfigs: (productId: string, verbose?: boolean) => Promise<AppStoreConfig[]>;
|
|
27
|
+
fetchProduct: (productId: string, verbose?: boolean) => Promise<ProductInfo>;
|
|
25
28
|
fetchGitHub: (productId: string, verbose?: boolean) => Promise<{
|
|
26
29
|
configured: boolean;
|
|
27
30
|
token?: string;
|
|
@@ -35,7 +38,11 @@ export interface BuildDeps {
|
|
|
35
38
|
getWorkspaceRoot: () => string;
|
|
36
39
|
saveBuildConfig: (configId: string, buildConfig: BuildConfig, verbose?: boolean) => Promise<AppStoreConfig | null>;
|
|
37
40
|
checkoutDefaultBranch: (repoPath: string) => void;
|
|
41
|
+
/** AI analysis: read repo structure → output a BuildPlan (no execution) */
|
|
42
|
+
analyzeBuildPlan: AnalyzeBuildPlanFn;
|
|
43
|
+
/** Scan for .xcworkspace / .xcodeproj files */
|
|
38
44
|
findProjects: (rootDir: string) => XcodeProject[];
|
|
45
|
+
/** Manual fallback: run xcodebuild -list to discover schemes */
|
|
39
46
|
findSchemes: (repoPath: string, projectPath: string, projectType: 'workspace' | 'project') => string[];
|
|
40
47
|
runCommand: (cmd: string, args: string[], opts?: {
|
|
41
48
|
cwd?: string;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { execFileSync, execSync, spawn } from 'child_process';
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'fs';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
-
import {
|
|
4
|
+
import { join, relative } from 'path';
|
|
5
5
|
import { getAppStoreConfigs, updateBuildConfig, } from '../../api/app-store.js';
|
|
6
6
|
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
7
|
+
import { getProduct } from '../../api/products.js';
|
|
7
8
|
import { logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
8
9
|
import { cloneFeatureRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
|
|
10
|
+
import { analyzeBuildPlan, } from './detect-project.js';
|
|
9
11
|
/** Default (real) implementations of all dependencies. */
|
|
10
12
|
export function createDefaultDeps() {
|
|
11
13
|
return {
|
|
12
14
|
fetchConfigs: getAppStoreConfigs,
|
|
15
|
+
fetchProduct: getProduct,
|
|
13
16
|
fetchGitHub: getGitHubConfigByProduct,
|
|
14
17
|
cloneRepo: (workspaceRoot, dirName, owner, repo, token) => cloneFeatureRepo(workspaceRoot, dirName, owner, repo, token),
|
|
15
18
|
getWorkspaceRoot: ensureWorkspaceDir,
|
|
16
19
|
saveBuildConfig: updateBuildConfig,
|
|
17
20
|
checkoutDefaultBranch: checkoutDefaultBranchImpl,
|
|
21
|
+
analyzeBuildPlan: analyzeBuildPlan,
|
|
18
22
|
findProjects: findXcodeProjects,
|
|
19
23
|
findSchemes: discoverSchemes,
|
|
20
24
|
runCommand: spawnAsync,
|
|
@@ -289,28 +293,80 @@ export async function runBuild(options, deps = createDefaultDeps()) {
|
|
|
289
293
|
const workspaceRoot = deps.getWorkspaceRoot();
|
|
290
294
|
const { repoPath } = deps.cloneRepo(workspaceRoot, `build-${productId}`, github.owner, github.repo, github.token);
|
|
291
295
|
deps.checkoutDefaultBranch(repoPath);
|
|
292
|
-
// 4. Resolve
|
|
293
|
-
|
|
296
|
+
// 4. Resolve project path, scheme, and install dependencies.
|
|
297
|
+
// If we already have a saved build_config (and not --reselect),
|
|
298
|
+
// skip the AI and use the saved values directly.
|
|
299
|
+
// Otherwise: AI analyzes → code executes install steps → discover project/scheme.
|
|
300
|
+
let projectPath = options.project || (shouldReselect ? undefined : buildConfig.project_path);
|
|
294
301
|
let projectType = 'workspace';
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
302
|
+
let scheme = options.scheme || (shouldReselect ? undefined : buildConfig.scheme);
|
|
303
|
+
const needsDiscovery = !projectPath || !scheme || shouldReselect;
|
|
304
|
+
if (needsDiscovery) {
|
|
305
|
+
// 4a. Ask AI to analyze the repo and produce a build plan
|
|
306
|
+
const product = await deps.fetchProduct(productId, verbose);
|
|
307
|
+
const plan = await deps.analyzeBuildPlan({
|
|
308
|
+
repoPath,
|
|
309
|
+
productName: product.name,
|
|
310
|
+
productDescription: product.description,
|
|
311
|
+
});
|
|
312
|
+
// 4b. Execute the plan's install steps (deterministic)
|
|
313
|
+
if (plan) {
|
|
314
|
+
for (const step of plan.installSteps) {
|
|
315
|
+
const cwd = step.cwd ? join(repoPath, step.cwd) : repoPath;
|
|
316
|
+
logInfo(`Running: ${step.cmd} ${step.args.join(' ')}${step.cwd ? ` (in ${step.cwd}/)` : ''}`);
|
|
317
|
+
try {
|
|
318
|
+
await deps.runCommand(step.cmd, step.args, {
|
|
319
|
+
cwd,
|
|
320
|
+
timeout: 300_000,
|
|
321
|
+
});
|
|
322
|
+
logSuccess(`${step.cmd} completed`);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
logWarning(`${step.cmd} ${step.args[0] || ''} failed — continuing`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
300
328
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
329
|
+
// 4c. Resolve project path — prefer AI hint, fallback to scan
|
|
330
|
+
if (!projectPath) {
|
|
331
|
+
if (plan?.projectPath) {
|
|
332
|
+
const fullPath = join(repoPath, plan.projectPath);
|
|
333
|
+
if (existsSync(fullPath)) {
|
|
334
|
+
projectPath = plan.projectPath;
|
|
335
|
+
logInfo(`Using AI-suggested project: ${projectPath}`);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
logWarning(`AI-suggested project not found: ${plan.projectPath}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Fallback: scan filesystem
|
|
342
|
+
if (!projectPath) {
|
|
343
|
+
const projects = deps.findProjects(repoPath);
|
|
344
|
+
if (projects.length === 0) {
|
|
345
|
+
throw new Error(`No Xcode projects found in ${repoPath}.\n` +
|
|
346
|
+
'Ensure the repository contains a .xcworkspace or .xcodeproj file.');
|
|
347
|
+
}
|
|
348
|
+
projectPath = projects[0].path;
|
|
349
|
+
projectType = projects[0].type;
|
|
350
|
+
logInfo(`Found project: ${projectPath}`);
|
|
351
|
+
}
|
|
305
352
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
353
|
+
projectType = projectPath.endsWith('.xcworkspace')
|
|
354
|
+
? 'workspace'
|
|
355
|
+
: 'project';
|
|
356
|
+
// 4d. Resolve scheme — prefer CLI flag, then AI hint, then xcodebuild -list
|
|
357
|
+
if (!scheme) {
|
|
358
|
+
if (plan?.schemeHint) {
|
|
359
|
+
scheme = plan.schemeHint;
|
|
360
|
+
logInfo(`Using AI-suggested scheme: ${scheme}`);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const schemes = deps.findSchemes(repoPath, projectPath, projectType);
|
|
364
|
+
if (schemes.length === 0) {
|
|
365
|
+
throw new Error(`No schemes found in ${projectPath}. Specify one with --scheme.`);
|
|
366
|
+
}
|
|
367
|
+
scheme = schemes[0];
|
|
368
|
+
logInfo(`Discovered scheme: ${scheme}`);
|
|
310
369
|
}
|
|
311
|
-
projectPath = projects[0].path;
|
|
312
|
-
projectType = projects[0].type;
|
|
313
|
-
logInfo(`Auto-selected: ${projectPath}`);
|
|
314
370
|
}
|
|
315
371
|
}
|
|
316
372
|
else {
|
|
@@ -318,29 +374,17 @@ export async function runBuild(options, deps = createDefaultDeps()) {
|
|
|
318
374
|
? 'workspace'
|
|
319
375
|
: 'project';
|
|
320
376
|
}
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (schemes.length === 0) {
|
|
326
|
-
throw new Error(`No schemes found in ${projectPath}. Specify one with --scheme.`);
|
|
327
|
-
}
|
|
328
|
-
if (schemes.length === 1) {
|
|
329
|
-
scheme = schemes[0];
|
|
330
|
-
logInfo(`Using scheme: ${scheme}`);
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
logInfo(`Found schemes: ${schemes.join(', ')}`);
|
|
334
|
-
scheme = schemes[0];
|
|
335
|
-
logInfo(`Auto-selected scheme: ${scheme}`);
|
|
336
|
-
}
|
|
377
|
+
// At this point projectPath and scheme must be resolved
|
|
378
|
+
if (!projectPath || !scheme) {
|
|
379
|
+
throw new Error('Could not determine Xcode project path and scheme.\n' +
|
|
380
|
+
'Specify them manually with --project and --scheme, or check the repository structure.');
|
|
337
381
|
}
|
|
338
|
-
//
|
|
382
|
+
// 5. Build settings
|
|
339
383
|
const configuration = options.configuration || buildConfig.configuration || 'Release';
|
|
340
384
|
const teamId = buildConfig.team_id;
|
|
341
385
|
const exportMethod = buildConfig.export_method || 'app-store';
|
|
342
386
|
const platform = options.platform || 'ios';
|
|
343
|
-
//
|
|
387
|
+
// 6. Persist discovered build config to DB
|
|
344
388
|
const newBuildConfig = {
|
|
345
389
|
project_path: projectPath,
|
|
346
390
|
scheme,
|
|
@@ -354,31 +398,8 @@ export async function runBuild(options, deps = createDefaultDeps()) {
|
|
|
354
398
|
logInfo('Saving build configuration...');
|
|
355
399
|
await deps.saveBuildConfig(appleConfig.id, newBuildConfig, verbose);
|
|
356
400
|
}
|
|
357
|
-
// 8. Install dependencies (CocoaPods)
|
|
358
|
-
const projectDir = projectPath.includes('/')
|
|
359
|
-
? join(repoPath, dirname(projectPath))
|
|
360
|
-
: repoPath;
|
|
361
|
-
for (const podfile of [
|
|
362
|
-
join(projectDir, 'Podfile'),
|
|
363
|
-
join(repoPath, 'Podfile'),
|
|
364
|
-
]) {
|
|
365
|
-
if (existsSync(podfile)) {
|
|
366
|
-
logInfo('Installing CocoaPods dependencies...');
|
|
367
|
-
try {
|
|
368
|
-
await deps.runCommand('pod', ['install'], {
|
|
369
|
-
cwd: dirname(podfile),
|
|
370
|
-
timeout: 300_000,
|
|
371
|
-
});
|
|
372
|
-
logSuccess('CocoaPods installed');
|
|
373
|
-
}
|
|
374
|
-
catch {
|
|
375
|
-
logWarning('pod install failed — continuing anyway');
|
|
376
|
-
}
|
|
377
|
-
break;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
401
|
if (!options.skipArchive && !options.ipa) {
|
|
381
|
-
//
|
|
402
|
+
// 7. Archive (async, cancellable)
|
|
382
403
|
const archivePath = join(repoPath, 'build', `${scheme}.xcarchive`);
|
|
383
404
|
const projectFlag = projectType === 'workspace' ? '-workspace' : '-project';
|
|
384
405
|
const fullProjectPath = join(repoPath, projectPath);
|
|
@@ -405,7 +426,7 @@ export async function runBuild(options, deps = createDefaultDeps()) {
|
|
|
405
426
|
timeout: 1_200_000,
|
|
406
427
|
});
|
|
407
428
|
logSuccess('Archive created successfully');
|
|
408
|
-
//
|
|
429
|
+
// 8. Export IPA / .app
|
|
409
430
|
if (!teamId) {
|
|
410
431
|
logWarning('team_id not configured — skipping export. Set it in build settings.');
|
|
411
432
|
return;
|
|
@@ -7,6 +7,12 @@ import { prepareAppStoreContext } from './context.js';
|
|
|
7
7
|
import { createAppStoreSystemPrompt } from './prompts.js';
|
|
8
8
|
import { generateStoreScreenshots, } from './screenshot-composer.js';
|
|
9
9
|
import { uploadAppStoreScreenshots } from './uploader.js';
|
|
10
|
+
function truncateKeywords(keywords) {
|
|
11
|
+
if (keywords.length <= 100) {
|
|
12
|
+
return keywords;
|
|
13
|
+
}
|
|
14
|
+
return keywords.slice(0, 100).replace(/,[^,]*$/, '');
|
|
15
|
+
}
|
|
10
16
|
export const generateAppStoreAssets = async (options, config
|
|
11
17
|
// eslint-disable-next-line complexity -- orchestration function with sequential asset generation steps
|
|
12
18
|
) => {
|
|
@@ -66,9 +72,7 @@ export const generateAppStoreAssets = async (options, config
|
|
|
66
72
|
short_description: listing.short_description?.slice(0, 80),
|
|
67
73
|
description: listing.description?.slice(0, 4000) ?? '',
|
|
68
74
|
keywords: listing.keywords
|
|
69
|
-
? listing.keywords
|
|
70
|
-
? listing.keywords.slice(0, 100).replace(/,[^,]*$/, '')
|
|
71
|
-
: listing.keywords
|
|
75
|
+
? truncateKeywords(listing.keywords)
|
|
72
76
|
: undefined,
|
|
73
77
|
};
|
|
74
78
|
}
|