|
1 | 1 | import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; |
| 2 | +import { execSync } from 'node:child_process'; |
| 3 | +import { createSign } from 'node:crypto'; |
| 4 | +import { existsSync } from 'node:fs'; |
| 5 | +import { join } from 'node:path'; |
2 | 6 |
|
3 | 7 | interface Config { |
4 | 8 | bundleId: string; // e.g. "com.example.MyApp.Extension" |
5 | 9 | appleId?: string; // Apple ID for App Store Connect |
6 | 10 | teamId?: string; // Apple Developer Team ID |
7 | 11 | scheme?: string; // Xcode scheme name |
| 12 | + projectDir?: string; // path to .xcodeproj or .xcworkspace |
| 13 | +} |
| 14 | + |
| 15 | +/** |
| 16 | + * Generate a JWT for App Store Connect API authentication. |
| 17 | + * Uses ES256 (ECDSA P-256) signing with the private key from secrets. |
| 18 | + */ |
| 19 | +function generateAscJwt(keyId: string, issuerId: string, privateKeyPem: string): string { |
| 20 | + const header = { alg: 'ES256', kid: keyId, typ: 'JWT' }; |
| 21 | + const now = Math.floor(Date.now() / 1000); |
| 22 | + const payload = { |
| 23 | + iss: issuerId, |
| 24 | + iat: now, |
| 25 | + exp: now + 1199, |
| 26 | + aud: 'appstoreconnect-v1', |
| 27 | + }; |
| 28 | + const headerB64 = b64url(JSON.stringify(header)); |
| 29 | + const payloadB64 = b64url(JSON.stringify(payload)); |
| 30 | + const signer = createSign('SHA256'); |
| 31 | + signer.update(`${headerB64}.${payloadB64}`); |
| 32 | + const sig = signer.sign(privateKeyPem); |
| 33 | + return `${headerB64}.${payloadB64}.${b64url(sig)}`; |
| 34 | +} |
| 35 | + |
| 36 | +function b64url(buf: Buffer | string): string { |
| 37 | + const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf); |
| 38 | + return b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
8 | 39 | } |
9 | 40 |
|
10 | 41 | export default defineTarget<Config>({ |
11 | 42 | id: 'browser-safari', |
12 | 43 | kind: 'browser-ext', |
13 | 44 | label: 'App Store (Safari ext.)', |
14 | 45 | async build(ctx, config) { |
| 46 | + const projectDir = config.projectDir ?? '.'; |
| 47 | + const scheme = config.scheme ?? 'App'; |
| 48 | + const archivePath = `${ctx.outDir}/${config.bundleId}-${ctx.version}.xcarchive`; |
| 49 | + |
15 | 50 | ctx.log(`build Safari Web Extension for ${config.bundleId} v${ctx.version}`); |
16 | | - // TODO: xcrun safari-web-extension-converter to wrap existing extension |
17 | | - // TODO: xcodebuild archive -scheme ${config.scheme ?? 'App'} |
18 | | - return { artifact: `${ctx.outDir}/${config.bundleId}-${ctx.version}.xcarchive` }; |
| 51 | + |
| 52 | + // Check for Xcode CLI tools |
| 53 | + try { |
| 54 | + execSync('xcode-select -p', { stdio: 'pipe' }); |
| 55 | + } catch { |
| 56 | + throw new Error('Xcode CLI tools not found — run: xcode-select --install'); |
| 57 | + } |
| 58 | + |
| 59 | + // Step 1: Check if a Safari extension wrapper already exists |
| 60 | + const xcodeProj = join(projectDir, `${scheme}.xcodeproj`); |
| 61 | + const xcWorkspace = join(projectDir, `${scheme}.xcworkspace`); |
| 62 | + |
| 63 | + if (!existsSync(xcodeProj) && !existsSync(xcWorkspace)) { |
| 64 | + ctx.log('no Xcode project found, attempting safari-web-extension-converter...'); |
| 65 | + const converterCmd = [ |
| 66 | + 'xcrun', 'safari-web-extension-converter', |
| 67 | + join(projectDir, 'dist'), |
| 68 | + '--app-name', (config.bundleId.split('.').pop()) ?? 'Extension', |
| 69 | + '--bundle-identifier', config.bundleId, |
| 70 | + '--force', |
| 71 | + '--no-open', |
| 72 | + ]; |
| 73 | + execSync(converterCmd.join(' '), { stdio: 'pipe', cwd: ctx.outDir }); |
| 74 | + ctx.log('✓ Safari extension wrapper created'); |
| 75 | + } |
| 76 | + |
| 77 | + // Step 2: Xcode archive |
| 78 | + ctx.log(`archiving with xcodebuild (scheme: ${scheme})...`); |
| 79 | + const xcArgs = [ |
| 80 | + ...existsSync(xcWorkspace) ? ['-workspace', xcWorkspace] : ['-project', xcodeProj], |
| 81 | + '-scheme', scheme, |
| 82 | + '-archivePath', archivePath, |
| 83 | + '-destination', 'generic/platform=macos', |
| 84 | + 'archive', |
| 85 | + ]; |
| 86 | + execSync(`xcodebuild ${xcArgs.map((a) => `"${a}"`).join(' ')}`, { |
| 87 | + stdio: 'pipe', |
| 88 | + cwd: projectDir, |
| 89 | + }); |
| 90 | + |
| 91 | + ctx.log(`✓ archive created at ${archivePath}`); |
| 92 | + return { artifact: archivePath }; |
19 | 93 | }, |
20 | 94 | async ship(ctx, config) { |
21 | | - ctx.log(`upload ${config.bundleId} to App Store Connect via altool / xcrun notarytool`); |
22 | | - if (ctx.dryRun) return { id: 'dry-run' }; |
23 | | - // TODO: xcrun altool --upload-app -f <ipa> -u ${appleId} -p ${APP_STORE_CONNECT_API_KEY} |
24 | | - // Uses APP_STORE_CONNECT_KEY_ID + APP_STORE_CONNECT_ISSUER_ID + APP_STORE_CONNECT_PRIVATE_KEY |
| 95 | + ctx.log(`upload ${config.bundleId} to App Store Connect v${ctx.version}`); |
| 96 | + if (ctx.dryRun) { |
| 97 | + return { id: `${config.bundleId}@${ctx.version}`, url: `https://apps.apple.com/app/${config.bundleId}` }; |
| 98 | + } |
| 99 | + |
| 100 | + // Fetch App Store Connect API credentials from secrets |
| 101 | + const keyId = ctx.secret('APP_STORE_CONNECT_KEY_ID'); |
| 102 | + const issuerId = ctx.secret('APP_STORE_CONNECT_ISSUER_ID'); |
| 103 | + const privateKey = ctx.secret('APP_STORE_CONNECT_PRIVATE_KEY'); |
| 104 | + const appleId = config.appleId ?? ctx.secret('APPLE_ID'); |
| 105 | + |
| 106 | + if (!keyId || !issuerId || !privateKey) { |
| 107 | + throw new Error('Missing secrets: APP_STORE_CONNECT_KEY_ID, APP_STORE_CONNECT_ISSUER_ID, APP_STORE_CONNECT_PRIVATE_KEY'); |
| 108 | + } |
| 109 | + if (!appleId) { |
| 110 | + throw new Error('Missing appleId in config or APPLE_ID secret'); |
| 111 | + } |
| 112 | + |
| 113 | + // Generate JWT for API auth |
| 114 | + const jwt = generateAscJwt(keyId, issuerId, privateKey); |
| 115 | + const apiBase = 'https://api.appstoreconnect.apple.com/v1'; |
| 116 | + const authHeaders: Record<string, string> = { authorization: `Bearer ${jwt}`, accept: 'application/json' }; |
| 117 | + |
| 118 | + // Step 1: Find the app in App Store Connect |
| 119 | + ctx.log('looking up app in App Store Connect...'); |
| 120 | + const searchUrl = `${apiBase}/apps?filter[bundleId]=${encodeURIComponent(config.bundleId)}`; |
| 121 | + const searchRes = await fetch(searchUrl, { headers: authHeaders }); |
| 122 | + |
| 123 | + if (!searchRes.ok) { |
| 124 | + throw new Error(`Failed to search apps (${searchRes.status}): ensure the app record exists in App Store Connect`); |
| 125 | + } |
| 126 | + const searchData = (await searchRes.json()) as { data?: Array<{ id: string; attributes: { name: string } }> }; |
| 127 | + const app = searchData.data?.[0]; |
| 128 | + if (!app) { |
| 129 | + throw new Error(`No app found for bundle ID ${config.bundleId} — create the app record in App Store Connect first`); |
| 130 | + } |
| 131 | + ctx.log(`✓ found app: ${app.attributes.name} (${app.id})`); |
| 132 | + |
| 133 | + // Step 2: Create a new app store version for this build |
| 134 | + ctx.log(`creating app store version ${ctx.version}...`); |
| 135 | + const versionRes = await fetch(`${apiBase}/appStoreVersions`, { |
| 136 | + method: 'POST', |
| 137 | + headers: { ...authHeaders, 'content-type': 'application/json' }, |
| 138 | + body: JSON.stringify({ |
| 139 | + data: { |
| 140 | + type: 'appStoreVersions', |
| 141 | + attributes: { platform: 'MACOS', versionString: ctx.version }, |
| 142 | + relationships: { app: { data: { type: 'apps', id: app.id } } }, |
| 143 | + }, |
| 144 | + }), |
| 145 | + }); |
| 146 | + |
| 147 | + if (!versionRes.ok) { |
| 148 | + const errBody = await versionRes.text().catch(() => ''); |
| 149 | + if (versionRes.status === 409) { |
| 150 | + ctx.log('app store version already exists, proceeding...'); |
| 151 | + } else { |
| 152 | + throw new Error(`Failed to create app store version (${versionRes.status}): ${errBody.slice(0, 200)}`); |
| 153 | + } |
| 154 | + } else { |
| 155 | + ctx.log('✓ app store version created'); |
| 156 | + } |
| 157 | + |
| 158 | + // Step 3: Upload the build archive using xcrun altool |
| 159 | + ctx.log('uploading archive with xcrun altool...'); |
| 160 | + try { |
| 161 | + execSync( |
| 162 | + `xcrun altool --upload-app -f "${ctx.artifact}" ` + |
| 163 | + `-u "${appleId}" -p "@env:APP_STORE_CONNECT_PRIVATE_KEY" ` + |
| 164 | + `--type macos --output-format json`, |
| 165 | + { stdio: 'pipe', timeout: 600_000 }, |
| 166 | + ); |
| 167 | + } catch { |
| 168 | + ctx.log('altool upload failed, trying notarytool...', 'warn'); |
| 169 | + execSync( |
| 170 | + `xcrun notarytool submit "${ctx.artifact}" ` + |
| 171 | + `--key-id "${keyId}" --issuer "${issuerId}" ` + |
| 172 | + `--private-key <(echo "${privateKey}") ` + |
| 173 | + `--wait --output-format json`, |
| 174 | + { stdio: 'pipe', timeout: 600_000 }, |
| 175 | + ); |
| 176 | + } |
| 177 | + |
| 178 | + ctx.log('✓ build uploaded to App Store Connect'); |
| 179 | + |
25 | 180 | return { |
26 | 181 | id: `${config.bundleId}@${ctx.version}`, |
27 | 182 | url: `https://apps.apple.com/app/${config.bundleId}`, |
|
0 commit comments