edsger 0.35.2 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/app-store-iap.d.ts +60 -0
- package/dist/api/app-store-iap.js +145 -0
- package/dist/api/app-store.d.ts +12 -0
- package/dist/api/app-store.js +25 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +303 -0
- package/dist/commands/build/index.d.ts +59 -0
- package/dist/commands/build/index.js +519 -0
- package/dist/index.js +36 -0
- package/dist/phases/app-store-generation/agent.js +25 -19
- package/dist/phases/app-store-generation/index.js +18 -1
- package/package.json +1 -1
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import { execFileSync, execSync, spawn } from 'child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync, } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { dirname, join, relative } from 'path';
|
|
5
|
+
import { getAppStoreConfigs, updateBuildConfig, } from '../../api/app-store.js';
|
|
6
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
7
|
+
import { logInfo, logSuccess, logWarning } from '../../utils/logger.js';
|
|
8
|
+
import { cloneFeatureRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
|
|
9
|
+
/** Default (real) implementations of all dependencies. */
|
|
10
|
+
export function createDefaultDeps() {
|
|
11
|
+
return {
|
|
12
|
+
fetchConfigs: getAppStoreConfigs,
|
|
13
|
+
fetchGitHub: getGitHubConfigByProduct,
|
|
14
|
+
cloneRepo: (workspaceRoot, dirName, owner, repo, token) => cloneFeatureRepo(workspaceRoot, dirName, owner, repo, token),
|
|
15
|
+
getWorkspaceRoot: ensureWorkspaceDir,
|
|
16
|
+
saveBuildConfig: updateBuildConfig,
|
|
17
|
+
checkoutDefaultBranch: checkoutDefaultBranchImpl,
|
|
18
|
+
findProjects: findXcodeProjects,
|
|
19
|
+
findSchemes: discoverSchemes,
|
|
20
|
+
runCommand: spawnAsync,
|
|
21
|
+
isXcodeAvailable: isXcodebuildAvailable,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// ── Async spawn helper ─────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Active child process — killed on SIGTERM for graceful cancellation.
|
|
27
|
+
* Only one build can run at a time; concurrent calls are rejected.
|
|
28
|
+
*/
|
|
29
|
+
let activeChild = null;
|
|
30
|
+
/**
|
|
31
|
+
* Spawn a command asynchronously, stream output to stdout/stderr, and
|
|
32
|
+
* return a promise that resolves on success or rejects on failure.
|
|
33
|
+
* The spawned process is stored in `activeChild` so the desktop app's
|
|
34
|
+
* "Stop Build" button (which sends SIGTERM to the CLI) cascades to it.
|
|
35
|
+
* Rejects if another subprocess is already running (concurrency guard).
|
|
36
|
+
*/
|
|
37
|
+
function spawnAsync(cmd, args, opts = {}) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
if (activeChild && activeChild.exitCode === null) {
|
|
40
|
+
reject(new Error('A build subprocess is already running'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const child = spawn(cmd, args, {
|
|
44
|
+
cwd: opts.cwd,
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
env: opts.env ?? process.env,
|
|
47
|
+
});
|
|
48
|
+
activeChild = child;
|
|
49
|
+
child.stdout?.on('data', (chunk) => process.stdout.write(chunk));
|
|
50
|
+
child.stderr?.on('data', (chunk) => process.stderr.write(chunk));
|
|
51
|
+
let timer;
|
|
52
|
+
if (opts.timeout) {
|
|
53
|
+
timer = setTimeout(() => {
|
|
54
|
+
child.kill('SIGTERM');
|
|
55
|
+
reject(new Error(`Command timed out after ${opts.timeout / 1000}s`));
|
|
56
|
+
}, opts.timeout);
|
|
57
|
+
}
|
|
58
|
+
child.on('close', (code) => {
|
|
59
|
+
if (timer)
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
activeChild = null;
|
|
62
|
+
if (code === 0) {
|
|
63
|
+
resolve();
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
child.on('error', (err) => {
|
|
70
|
+
if (timer)
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
activeChild = null;
|
|
73
|
+
reject(err);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// ── Signal forwarding (scoped to runBuild) ─────────────────────────
|
|
78
|
+
function forwardSignal(signal) {
|
|
79
|
+
if (activeChild && activeChild.exitCode === null) {
|
|
80
|
+
activeChild.kill(signal);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function installSignalHandlers() {
|
|
84
|
+
const onTerm = () => forwardSignal('SIGTERM');
|
|
85
|
+
const onInt = () => forwardSignal('SIGINT');
|
|
86
|
+
process.on('SIGTERM', onTerm);
|
|
87
|
+
process.on('SIGINT', onInt);
|
|
88
|
+
return () => {
|
|
89
|
+
process.removeListener('SIGTERM', onTerm);
|
|
90
|
+
process.removeListener('SIGINT', onInt);
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ── Pre-flight checks ──────────────────────────────────────────────
|
|
94
|
+
function isXcodebuildAvailable() {
|
|
95
|
+
try {
|
|
96
|
+
execSync('xcodebuild -version', { stdio: 'pipe' });
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ── Git helpers ────────────────────────────────────────────────────
|
|
104
|
+
function checkoutDefaultBranchImpl(repoPath) {
|
|
105
|
+
try {
|
|
106
|
+
const defaultBranch = execFileSync('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: repoPath, stdio: 'pipe' })
|
|
107
|
+
.toString()
|
|
108
|
+
.trim()
|
|
109
|
+
.replace('refs/remotes/origin/', '');
|
|
110
|
+
execFileSync('git', ['checkout', defaultBranch], {
|
|
111
|
+
cwd: repoPath,
|
|
112
|
+
stdio: 'pipe',
|
|
113
|
+
});
|
|
114
|
+
execFileSync('git', ['pull', 'origin', defaultBranch], {
|
|
115
|
+
cwd: repoPath,
|
|
116
|
+
stdio: 'pipe',
|
|
117
|
+
});
|
|
118
|
+
logInfo(`On branch: ${defaultBranch}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
logWarning('Could not switch to default branch, using current state');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ── Xcode project discovery ────────────────────────────────────────
|
|
125
|
+
const EXCLUDED_DIRS = new Set([
|
|
126
|
+
'Pods',
|
|
127
|
+
'.build',
|
|
128
|
+
'node_modules',
|
|
129
|
+
'DerivedData',
|
|
130
|
+
'build',
|
|
131
|
+
'.git',
|
|
132
|
+
'Carthage',
|
|
133
|
+
]);
|
|
134
|
+
export function findXcodeProjects(rootDir) {
|
|
135
|
+
const results = [];
|
|
136
|
+
function walk(dir, depth) {
|
|
137
|
+
if (depth > 4)
|
|
138
|
+
return;
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(dir);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (EXCLUDED_DIRS.has(entry))
|
|
148
|
+
continue;
|
|
149
|
+
const fullPath = join(dir, entry);
|
|
150
|
+
if (entry.endsWith('.xcworkspace')) {
|
|
151
|
+
results.push({ path: relative(rootDir, fullPath), type: 'workspace' });
|
|
152
|
+
}
|
|
153
|
+
else if (entry.endsWith('.xcodeproj')) {
|
|
154
|
+
results.push({ path: relative(rootDir, fullPath), type: 'project' });
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
try {
|
|
158
|
+
if (statSync(fullPath).isDirectory()) {
|
|
159
|
+
walk(fullPath, depth + 1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// skip inaccessible
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
walk(rootDir, 0);
|
|
169
|
+
// Workspaces first, then by shallowest path
|
|
170
|
+
return results.sort((a, b) => {
|
|
171
|
+
if (a.type !== b.type)
|
|
172
|
+
return a.type === 'workspace' ? -1 : 1;
|
|
173
|
+
return a.path.length - b.path.length;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// ── Scheme discovery ───────────────────────────────────────────────
|
|
177
|
+
export function discoverSchemes(repoPath, projectPath, projectType) {
|
|
178
|
+
const flag = projectType === 'workspace' ? '-workspace' : '-project';
|
|
179
|
+
const fullProjectPath = join(repoPath, projectPath);
|
|
180
|
+
try {
|
|
181
|
+
const output = execFileSync('xcodebuild', [flag, fullProjectPath, '-list'], { stdio: 'pipe', timeout: 30_000 }).toString();
|
|
182
|
+
const schemesMatch = output.match(/Schemes:\s*\n([\s\S]*?)(?:\n\n|$)/);
|
|
183
|
+
if (!schemesMatch)
|
|
184
|
+
return [];
|
|
185
|
+
return schemesMatch[1]
|
|
186
|
+
.split('\n')
|
|
187
|
+
.map((s) => s.trim())
|
|
188
|
+
.filter((s) => s.length > 0 &&
|
|
189
|
+
!s.endsWith('Tests') &&
|
|
190
|
+
!s.endsWith('UITests') &&
|
|
191
|
+
!s.includes('Test'));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// ── ExportOptions.plist generation ─────────────────────────────────
|
|
198
|
+
/** Escape XML special characters to prevent injection in plist values. */
|
|
199
|
+
function escapeXml(str) {
|
|
200
|
+
return str
|
|
201
|
+
.replace(/&/g, '&')
|
|
202
|
+
.replace(/</g, '<')
|
|
203
|
+
.replace(/>/g, '>')
|
|
204
|
+
.replace(/"/g, '"')
|
|
205
|
+
.replace(/'/g, ''');
|
|
206
|
+
}
|
|
207
|
+
export function generateExportOptionsPlist(teamId, exportMethod) {
|
|
208
|
+
const safeTeamId = escapeXml(teamId);
|
|
209
|
+
const safeMethod = escapeXml(exportMethod);
|
|
210
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
211
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
212
|
+
<plist version="1.0">
|
|
213
|
+
<dict>
|
|
214
|
+
<key>method</key>
|
|
215
|
+
<string>${safeMethod}</string>
|
|
216
|
+
<key>teamID</key>
|
|
217
|
+
<string>${safeTeamId}</string>
|
|
218
|
+
<key>uploadSymbols</key>
|
|
219
|
+
<true/>
|
|
220
|
+
<key>signingStyle</key>
|
|
221
|
+
<string>automatic</string>
|
|
222
|
+
</dict>
|
|
223
|
+
</plist>`;
|
|
224
|
+
}
|
|
225
|
+
// ── API Key file management ────────────────────────────────────────
|
|
226
|
+
function getApiKeyDir() {
|
|
227
|
+
return join(homedir(), '.edsger', 'keys');
|
|
228
|
+
}
|
|
229
|
+
/** Validate keyId is alphanumeric to prevent path traversal. */
|
|
230
|
+
function validateKeyId(keyId) {
|
|
231
|
+
if (!/^[A-Za-z0-9_-]+$/.test(keyId)) {
|
|
232
|
+
throw new Error(`Invalid API key ID: ${keyId}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function writeApiKeyFile(keyId, privateKey) {
|
|
236
|
+
validateKeyId(keyId);
|
|
237
|
+
const keyDir = getApiKeyDir();
|
|
238
|
+
mkdirSync(keyDir, { recursive: true });
|
|
239
|
+
const keyPath = join(keyDir, `AuthKey_${keyId}.p8`);
|
|
240
|
+
writeFileSync(keyPath, privateKey, { mode: 0o600 });
|
|
241
|
+
return keyPath;
|
|
242
|
+
}
|
|
243
|
+
export function cleanupApiKeyFile(keyId) {
|
|
244
|
+
try {
|
|
245
|
+
validateKeyId(keyId);
|
|
246
|
+
rmSync(join(getApiKeyDir(), `AuthKey_${keyId}.p8`), { force: true });
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// best effort
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ── Platform → destination mapping ─────────────────────────────────
|
|
253
|
+
export function destinationForPlatform(platform) {
|
|
254
|
+
if (platform === 'macos') {
|
|
255
|
+
return 'generic/platform=macOS';
|
|
256
|
+
}
|
|
257
|
+
return 'generic/platform=iOS';
|
|
258
|
+
}
|
|
259
|
+
// ── Main build command ─────────────────────────────────────────────
|
|
260
|
+
/**
|
|
261
|
+
* Run the full build pipeline. Accepts an optional `deps` parameter
|
|
262
|
+
* for dependency injection in tests; falls back to real implementations.
|
|
263
|
+
*/
|
|
264
|
+
// eslint-disable-next-line complexity -- orchestration function, sequential steps are intentional
|
|
265
|
+
export async function runBuild(options, deps = createDefaultDeps()) {
|
|
266
|
+
const { buildProductId: productId, verbose } = options;
|
|
267
|
+
// 1. Pre-flight
|
|
268
|
+
if (!deps.isXcodeAvailable()) {
|
|
269
|
+
throw new Error('xcodebuild is not available. Please install Xcode and Xcode Command Line Tools.\n\n' +
|
|
270
|
+
' xcode-select --install\n');
|
|
271
|
+
}
|
|
272
|
+
// Install signal handlers for the duration of this build
|
|
273
|
+
const removeSignalHandlers = installSignalHandlers();
|
|
274
|
+
try {
|
|
275
|
+
logInfo(`Starting build for product: ${productId}`);
|
|
276
|
+
// 2. Fetch app store config
|
|
277
|
+
const configs = await deps.fetchConfigs(productId, verbose);
|
|
278
|
+
const appleConfig = configs.find((c) => c.store_type === 'apple_app_store');
|
|
279
|
+
if (!appleConfig) {
|
|
280
|
+
throw new Error('No Apple App Store configuration found. Add one in the App Store tab first.');
|
|
281
|
+
}
|
|
282
|
+
const buildConfig = appleConfig.build_config || {};
|
|
283
|
+
const shouldReselect = options.reselect || false;
|
|
284
|
+
// 3. Clone / update repo
|
|
285
|
+
const github = await deps.fetchGitHub(productId, verbose);
|
|
286
|
+
if (!github.configured || !github.token || !github.owner || !github.repo) {
|
|
287
|
+
throw new Error(`GitHub not configured: ${github.message || 'Connect a repository to this product.'}`);
|
|
288
|
+
}
|
|
289
|
+
const workspaceRoot = deps.getWorkspaceRoot();
|
|
290
|
+
const { repoPath } = deps.cloneRepo(workspaceRoot, `build-${productId}`, github.owner, github.repo, github.token);
|
|
291
|
+
deps.checkoutDefaultBranch(repoPath);
|
|
292
|
+
// 4. Resolve Xcode project path
|
|
293
|
+
let projectPath = options.project || buildConfig.project_path;
|
|
294
|
+
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.');
|
|
300
|
+
}
|
|
301
|
+
if (projects.length === 1) {
|
|
302
|
+
projectPath = projects[0].path;
|
|
303
|
+
projectType = projects[0].type;
|
|
304
|
+
logInfo(`Found Xcode project: ${projectPath}`);
|
|
305
|
+
}
|
|
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})`);
|
|
310
|
+
}
|
|
311
|
+
projectPath = projects[0].path;
|
|
312
|
+
projectType = projects[0].type;
|
|
313
|
+
logInfo(`Auto-selected: ${projectPath}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
projectType = projectPath.endsWith('.xcworkspace')
|
|
318
|
+
? 'workspace'
|
|
319
|
+
: 'project';
|
|
320
|
+
}
|
|
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
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// 6. Build settings
|
|
339
|
+
const configuration = options.configuration || buildConfig.configuration || 'Release';
|
|
340
|
+
const teamId = buildConfig.team_id;
|
|
341
|
+
const exportMethod = buildConfig.export_method || 'app-store';
|
|
342
|
+
const platform = options.platform || 'ios';
|
|
343
|
+
// 7. Persist discovered build config
|
|
344
|
+
const newBuildConfig = {
|
|
345
|
+
project_path: projectPath,
|
|
346
|
+
scheme,
|
|
347
|
+
configuration,
|
|
348
|
+
team_id: teamId,
|
|
349
|
+
export_method: exportMethod,
|
|
350
|
+
};
|
|
351
|
+
if (newBuildConfig.project_path !== buildConfig.project_path ||
|
|
352
|
+
newBuildConfig.scheme !== buildConfig.scheme ||
|
|
353
|
+
newBuildConfig.configuration !== buildConfig.configuration) {
|
|
354
|
+
logInfo('Saving build configuration...');
|
|
355
|
+
await deps.saveBuildConfig(appleConfig.id, newBuildConfig, verbose);
|
|
356
|
+
}
|
|
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
|
+
if (!options.skipArchive && !options.ipa) {
|
|
381
|
+
// 9. Archive (async, cancellable)
|
|
382
|
+
const archivePath = join(repoPath, 'build', `${scheme}.xcarchive`);
|
|
383
|
+
const projectFlag = projectType === 'workspace' ? '-workspace' : '-project';
|
|
384
|
+
const fullProjectPath = join(repoPath, projectPath);
|
|
385
|
+
logInfo(`Archiving ${scheme} (${configuration}, ${platform})...`);
|
|
386
|
+
const archiveArgs = [
|
|
387
|
+
projectFlag,
|
|
388
|
+
fullProjectPath,
|
|
389
|
+
'-scheme',
|
|
390
|
+
scheme,
|
|
391
|
+
'-configuration',
|
|
392
|
+
configuration,
|
|
393
|
+
'-destination',
|
|
394
|
+
destinationForPlatform(platform),
|
|
395
|
+
'-archivePath',
|
|
396
|
+
archivePath,
|
|
397
|
+
'archive',
|
|
398
|
+
'-allowProvisioningUpdates',
|
|
399
|
+
];
|
|
400
|
+
if (teamId) {
|
|
401
|
+
archiveArgs.push(`DEVELOPMENT_TEAM=${teamId}`);
|
|
402
|
+
}
|
|
403
|
+
await deps.runCommand('xcodebuild', archiveArgs, {
|
|
404
|
+
cwd: repoPath,
|
|
405
|
+
timeout: 1_200_000,
|
|
406
|
+
});
|
|
407
|
+
logSuccess('Archive created successfully');
|
|
408
|
+
// 10. Export IPA / .app
|
|
409
|
+
if (!teamId) {
|
|
410
|
+
logWarning('team_id not configured — skipping export. Set it in build settings.');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const exportPath = join(repoPath, 'build', 'export');
|
|
414
|
+
mkdirSync(exportPath, { recursive: true });
|
|
415
|
+
const plistPath = join(repoPath, 'build', 'ExportOptions.plist');
|
|
416
|
+
writeFileSync(plistPath, generateExportOptionsPlist(teamId, exportMethod));
|
|
417
|
+
logInfo('Exporting archive...');
|
|
418
|
+
await deps.runCommand('xcodebuild', [
|
|
419
|
+
'-exportArchive',
|
|
420
|
+
'-archivePath',
|
|
421
|
+
archivePath,
|
|
422
|
+
'-exportOptionsPlist',
|
|
423
|
+
plistPath,
|
|
424
|
+
'-exportPath',
|
|
425
|
+
exportPath,
|
|
426
|
+
'-allowProvisioningUpdates',
|
|
427
|
+
], { cwd: repoPath, timeout: 300_000 });
|
|
428
|
+
logSuccess('Export completed');
|
|
429
|
+
const ipaFiles = readdirSync(exportPath).filter((f) => f.endsWith('.ipa'));
|
|
430
|
+
if (ipaFiles.length === 0) {
|
|
431
|
+
throw new Error('No IPA file found after export');
|
|
432
|
+
}
|
|
433
|
+
const ipaPath = join(exportPath, ipaFiles[0]);
|
|
434
|
+
logSuccess(`IPA ready: ${ipaPath}`);
|
|
435
|
+
if (options.upload) {
|
|
436
|
+
await uploadIpa(ipaPath, appleConfig, platform, deps);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
logInfo('Build complete. Use --upload to also upload to App Store Connect.');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (options.ipa) {
|
|
443
|
+
if (!existsSync(options.ipa)) {
|
|
444
|
+
throw new Error(`IPA file not found: ${options.ipa}`);
|
|
445
|
+
}
|
|
446
|
+
await uploadIpa(options.ipa, appleConfig, platform, deps);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
removeSignalHandlers();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// ── Upload IPA to App Store Connect ────────────────────────────────
|
|
454
|
+
//
|
|
455
|
+
// Uses `xcrun notarytool submit` for macOS apps and
|
|
456
|
+
// `xcrun altool --upload-app` for iOS (altool is deprecated but
|
|
457
|
+
// notarytool does not support iOS App Store uploads).
|
|
458
|
+
// When altool is removed in a future Xcode, migrate to the
|
|
459
|
+
// App Store Connect API upload protocol (iTMSTransporter).
|
|
460
|
+
async function uploadIpa(ipaPath, appleConfig, platform, deps) {
|
|
461
|
+
const credentials = appleConfig.credentials;
|
|
462
|
+
if (!credentials?.key_id ||
|
|
463
|
+
!credentials?.issuer_id ||
|
|
464
|
+
!credentials?.private_key) {
|
|
465
|
+
throw new Error('Apple App Store Connect API credentials not configured.\n' +
|
|
466
|
+
'Set key_id, issuer_id, and private_key in the App Store tab settings.');
|
|
467
|
+
}
|
|
468
|
+
logInfo('Uploading to App Store Connect...');
|
|
469
|
+
const keyId = credentials.key_id;
|
|
470
|
+
writeApiKeyFile(keyId, credentials.private_key);
|
|
471
|
+
const cleanup = () => cleanupApiKeyFile(keyId);
|
|
472
|
+
process.on('exit', cleanup);
|
|
473
|
+
try {
|
|
474
|
+
if (platform === 'macos') {
|
|
475
|
+
logInfo('Using xcrun notarytool for macOS submission...');
|
|
476
|
+
await deps.runCommand('xcrun', [
|
|
477
|
+
'notarytool',
|
|
478
|
+
'submit',
|
|
479
|
+
ipaPath,
|
|
480
|
+
'--key',
|
|
481
|
+
join(getApiKeyDir(), `AuthKey_${keyId}.p8`),
|
|
482
|
+
'--key-id',
|
|
483
|
+
keyId,
|
|
484
|
+
'--issuer',
|
|
485
|
+
credentials.issuer_id,
|
|
486
|
+
'--wait',
|
|
487
|
+
], { timeout: 1_200_000 });
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
// iOS: altool is the only CLI path for App Store uploads.
|
|
491
|
+
// Apple deprecated altool but has not yet provided a replacement
|
|
492
|
+
// CLI for iOS App Store uploads. When they do, switch here.
|
|
493
|
+
logInfo('Using xcrun altool for iOS upload (note: altool is deprecated by Apple)...');
|
|
494
|
+
await deps.runCommand('xcrun', [
|
|
495
|
+
'altool',
|
|
496
|
+
'--upload-app',
|
|
497
|
+
'-f',
|
|
498
|
+
ipaPath,
|
|
499
|
+
'-t',
|
|
500
|
+
'ios',
|
|
501
|
+
'--apiKey',
|
|
502
|
+
keyId,
|
|
503
|
+
'--apiIssuer',
|
|
504
|
+
credentials.issuer_id,
|
|
505
|
+
], {
|
|
506
|
+
timeout: 1_200_000,
|
|
507
|
+
env: {
|
|
508
|
+
...process.env,
|
|
509
|
+
API_PRIVATE_KEYS_DIR: getApiKeyDir(),
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
logSuccess('Upload to App Store Connect completed!');
|
|
514
|
+
}
|
|
515
|
+
finally {
|
|
516
|
+
cleanup();
|
|
517
|
+
process.removeListener('exit', cleanup);
|
|
518
|
+
}
|
|
519
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runLogin, runLogout, runStatus } from './auth/login.js';
|
|
|
9
9
|
import { runAgentWorkflow } from './commands/agent-workflow/index.js';
|
|
10
10
|
import { runAnalyzeLogs } from './commands/analyze-logs/index.js';
|
|
11
11
|
import { runAppStoreGeneration } from './commands/app-store/index.js';
|
|
12
|
+
import { runBuild } from './commands/build/index.js';
|
|
12
13
|
import { runChecklists } from './commands/checklists/index.js';
|
|
13
14
|
import { runCodeReview } from './commands/code-review/index.js';
|
|
14
15
|
import { runConfigGet, runConfigList, runConfigSet, runConfigUnset, } from './commands/config/index.js';
|
|
@@ -158,6 +159,41 @@ program
|
|
|
158
159
|
}
|
|
159
160
|
});
|
|
160
161
|
// ============================================================
|
|
162
|
+
// Subcommand: edsger build <productId>
|
|
163
|
+
// ============================================================
|
|
164
|
+
program
|
|
165
|
+
.command('build <productId>')
|
|
166
|
+
.description('Build iOS/macOS app and optionally upload to App Store Connect')
|
|
167
|
+
.option('-v, --verbose', 'Verbose output')
|
|
168
|
+
.option('--scheme <name>', 'Xcode scheme (auto-detected if omitted)')
|
|
169
|
+
.option('--project <path>', 'Path to .xcworkspace or .xcodeproj (relative to repo root)')
|
|
170
|
+
.option('--configuration <name>', 'Build configuration (default: Release)', 'Release')
|
|
171
|
+
.option('--platform <platform>', 'Target platform: ios or macos (default: ios)', 'ios')
|
|
172
|
+
.option('--upload', 'Upload IPA to App Store Connect after building')
|
|
173
|
+
.option('--reselect', 'Re-discover project and scheme (ignore saved config)')
|
|
174
|
+
.option('--skip-archive', 'Skip build, only upload existing IPA')
|
|
175
|
+
.option('--ipa <path>', 'Upload a specific IPA file directly')
|
|
176
|
+
.action(async (productId, opts) => {
|
|
177
|
+
try {
|
|
178
|
+
await runBuild({
|
|
179
|
+
buildProductId: productId,
|
|
180
|
+
verbose: opts.verbose,
|
|
181
|
+
scheme: opts.scheme,
|
|
182
|
+
project: opts.project,
|
|
183
|
+
configuration: opts.configuration,
|
|
184
|
+
platform: opts.platform,
|
|
185
|
+
upload: opts.upload,
|
|
186
|
+
reselect: opts.reselect,
|
|
187
|
+
skipArchive: opts.skipArchive,
|
|
188
|
+
ipa: opts.ipa,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
// ============================================================
|
|
161
197
|
// Subcommand: edsger checklists <productId>
|
|
162
198
|
// ============================================================
|
|
163
199
|
program
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
4
4
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
@@ -11,7 +11,9 @@ function parseAppStoreResult(responseText) {
|
|
|
11
11
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
12
|
let jsonResult = null;
|
|
13
13
|
// Try to extract JSON from markdown code block (check all occurrences, prefer last)
|
|
14
|
-
const jsonBlockMatches = [
|
|
14
|
+
const jsonBlockMatches = [
|
|
15
|
+
...responseText.matchAll(/```json\s*\n([\s\S]*?)\n\s*```/g),
|
|
16
|
+
];
|
|
15
17
|
for (let i = jsonBlockMatches.length - 1; i >= 0; i--) {
|
|
16
18
|
try {
|
|
17
19
|
const candidate = JSON.parse(jsonBlockMatches[i][1]);
|
|
@@ -25,8 +27,8 @@ function parseAppStoreResult(responseText) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
if (!jsonResult) {
|
|
28
|
-
// Try to find a JSON object containing "app_store" anywhere in the text
|
|
29
|
-
const appStoreMatch = responseText.match(/\{[\s\S]
|
|
30
|
+
// Try to find a JSON object containing "app_store" anywhere in the text (non-greedy)
|
|
31
|
+
const appStoreMatch = responseText.match(/\{[\s\S]*?"app_store"\s*:\s*\{[\s\S]*?\}\s*\}/);
|
|
30
32
|
if (appStoreMatch) {
|
|
31
33
|
try {
|
|
32
34
|
jsonResult = JSON.parse(appStoreMatch[0]);
|
|
@@ -102,26 +104,23 @@ export async function executeAppStoreQuery(currentPrompt, systemPrompt, config,
|
|
|
102
104
|
logInfo(`\nAI generation completed after ${turnCount} turns, parsing results...`);
|
|
103
105
|
const responseText = message.result || lastAssistantResponse;
|
|
104
106
|
const parsed = parseAppStoreResult(responseText);
|
|
105
|
-
if (parsed.error) {
|
|
107
|
+
if (!parsed.error) {
|
|
108
|
+
structuredResult = parsed.appStore;
|
|
109
|
+
}
|
|
110
|
+
else if (lastAssistantResponse &&
|
|
111
|
+
responseText !== lastAssistantResponse) {
|
|
106
112
|
// Fallback: try accumulated assistant responses (may contain JSON code blocks)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
structuredResult = fallback.appStore;
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
logError(`Failed to parse result: ${parsed.error}`);
|
|
115
|
-
structuredResult = null;
|
|
116
|
-
}
|
|
113
|
+
const fallback = parseAppStoreResult(lastAssistantResponse);
|
|
114
|
+
if (!fallback.error) {
|
|
115
|
+
logInfo('Parsed result from accumulated responses');
|
|
116
|
+
structuredResult = fallback.appStore;
|
|
117
117
|
}
|
|
118
118
|
else {
|
|
119
119
|
logError(`Failed to parse result: ${parsed.error}`);
|
|
120
|
-
structuredResult = null;
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
122
|
else {
|
|
124
|
-
|
|
123
|
+
logError(`Failed to parse result: ${parsed.error}`);
|
|
125
124
|
}
|
|
126
125
|
}
|
|
127
126
|
else {
|
|
@@ -145,11 +144,18 @@ export async function executeAppStoreQuery(currentPrompt, systemPrompt, config,
|
|
|
145
144
|
return structuredResult;
|
|
146
145
|
}
|
|
147
146
|
/**
|
|
148
|
-
* Search for JSON files in the given directory that contain an app_store key.
|
|
147
|
+
* Search for known output JSON files in the given directory that contain an app_store key.
|
|
148
|
+
* Only reads files matching safe patterns to avoid parsing arbitrary repo files.
|
|
149
149
|
*/
|
|
150
|
+
const SAFE_JSON_PATTERNS = [
|
|
151
|
+
/^app[-_]?store[-_]?result/i,
|
|
152
|
+
/^app[-_]?store[-_]?output/i,
|
|
153
|
+
/^output\.json$/i,
|
|
154
|
+
/^result\.json$/i,
|
|
155
|
+
];
|
|
150
156
|
function tryParseJsonFilesInDir(dir) {
|
|
151
157
|
try {
|
|
152
|
-
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
158
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json') && SAFE_JSON_PATTERNS.some((p) => p.test(f)));
|
|
153
159
|
for (const file of files) {
|
|
154
160
|
try {
|
|
155
161
|
const content = readFileSync(join(dir, file), 'utf-8');
|
|
@@ -53,8 +53,25 @@ export const generateAppStoreAssets = async (options, config
|
|
|
53
53
|
if (!aiResult) {
|
|
54
54
|
throw new Error('No results received from AI');
|
|
55
55
|
}
|
|
56
|
-
const
|
|
56
|
+
const rawListings = (aiResult.listings ||
|
|
57
57
|
{});
|
|
58
|
+
// Enforce Apple field limits on AI-generated content
|
|
59
|
+
const listings = {};
|
|
60
|
+
for (const [loc, listing] of Object.entries(rawListings)) {
|
|
61
|
+
listings[loc] = {
|
|
62
|
+
...listing,
|
|
63
|
+
app_name: listing.app_name?.slice(0, 30) ?? '',
|
|
64
|
+
subtitle: listing.subtitle?.slice(0, 30),
|
|
65
|
+
promotional_text: listing.promotional_text?.slice(0, 170),
|
|
66
|
+
short_description: listing.short_description?.slice(0, 80),
|
|
67
|
+
description: listing.description?.slice(0, 4000) ?? '',
|
|
68
|
+
keywords: listing.keywords
|
|
69
|
+
? listing.keywords.length > 100
|
|
70
|
+
? listing.keywords.slice(0, 100).replace(/,[^,]*$/, '')
|
|
71
|
+
: listing.keywords
|
|
72
|
+
: undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
58
75
|
const screenshotSpecs = (aiResult.screenshots || []);
|
|
59
76
|
logInfo(`AI generated: ${Object.keys(listings).length} locale(s), ${screenshotSpecs.length} screenshot spec(s)`);
|
|
60
77
|
// Ensure store configs exist
|