edsger 0.36.0 → 0.36.1

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.
@@ -0,0 +1,6 @@
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
+ export {};
@@ -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 Xcode projects found', async () => {
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
- // -- Happy paths --
154
- it('runs full archive + export pipeline', async () => {
155
- const config = makeAppleConfig({
156
- build_config: { team_id: 'TEAM1', export_method: 'app-store' },
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
- // Should have called xcodebuild twice: archive + exportArchive
161
- const xcodeCalls = calls.filter((c) => c.cmd === 'xcodebuild');
162
- assert.strictEqual(xcodeCalls.length, 2);
163
- assert.ok(xcodeCalls[0].args.includes('archive'));
164
- assert.ok(xcodeCalls[1].args.includes('-exportArchive'));
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 scheme from options over saved config', async () => {
167
- const config = makeAppleConfig({
168
- build_config: { scheme: 'SavedScheme', project_path: 'A.xcworkspace' },
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({ tmpDir, configs: [config] });
171
- await runBuild(makeOptions({ scheme: 'OverrideScheme' }), deps);
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], 'OverrideScheme');
283
+ assert.strictEqual(archiveCall.args[schemeIdx + 1], 'ManualScheme');
176
284
  });
177
- it('uses project path from saved build_config', async () => {
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({ tmpDir, configs: [config] });
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('persists discovered build config to DB', async () => {
188
- const { deps, savedConfigs } = createMockDeps({ tmpDir });
189
- await runBuild(makeOptions(), deps);
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, 'MyApp.xcworkspace');
192
- assert.strictEqual(savedConfigs[0].scheme, 'MyApp');
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 { deps, calls } = createMockDeps({ tmpDir });
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 { deps, calls } = createMockDeps({ tmpDir });
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 { dirname, join, relative } from 'path';
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 Xcode project path
293
- let projectPath = options.project || buildConfig.project_path;
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
- if (!projectPath || shouldReselect) {
296
- const projects = deps.findProjects(repoPath);
297
- if (projects.length === 0) {
298
- throw new Error(`No Xcode projects found in ${repoPath}.\n` +
299
- 'Ensure the repository contains a .xcworkspace or .xcodeproj file.');
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
- if (projects.length === 1) {
302
- projectPath = projects[0].path;
303
- projectType = projects[0].type;
304
- logInfo(`Found Xcode project: ${projectPath}`);
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
- else {
307
- logInfo(`Found ${projects.length} Xcode projects:`);
308
- for (let i = 0; i < projects.length; i++) {
309
- logInfo(` [${i + 1}] ${projects[i].path} (${projects[i].type})`);
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
- // 5. Resolve scheme
322
- let scheme = options.scheme || buildConfig.scheme;
323
- if (!scheme || shouldReselect) {
324
- const schemes = deps.findSchemes(repoPath, projectPath, projectType);
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
- // 6. Build settings
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
- // 7. Persist discovered build config
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
- // 9. Archive (async, cancellable)
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
- // 10. Export IPA / .app
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.length > 100
70
- ? listing.keywords.slice(0, 100).replace(/,[^,]*$/, '')
71
- : listing.keywords
75
+ ? truncateKeywords(listing.keywords)
72
76
  : undefined,
73
77
  };
74
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.36.0",
3
+ "version": "0.36.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"