#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { webcrypto } from "node:crypto"; const crypto = globalThis.crypto ?? webcrypto; function b64url(input) { const buf = Buffer.isBuffer(input) ? input : Buffer.from(input); return buf .toString("base64") .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); } function parseArgs(argv) { const out = { _: [] }; for (let i = 2; i < argv.length; i++) { const arg = argv[i]; if (arg === "--help" || arg === "-h") out.help = true; else if (arg.startsWith("--")) { const key = arg.slice(2); const value = argv[i + 1]; if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`); out[key] = value; i++; } else { out._.push(arg); } } return out; } async function makeJwt({ keyId, issuerId, keyPem }) { const header = { alg: "ES256", kid: keyId, typ: "JWT" }; const now = Math.floor(Date.now() / 1000); const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" }; const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`; const pkcs8Der = Buffer.from( keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""), "base64", ); const key = await crypto.subtle.importKey( "pkcs8", pkcs8Der, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(input), ); return `${input}.${b64url(Buffer.from(sig))}`; } async function ascFetch(jwt, url, opts = {}) { const res = await fetch(url, { ...opts, headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${jwt}`, "Content-Type": "application/json", }, }); const text = await res.text(); let body; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } if (!res.ok) { throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`); } return body; } function truthy(value) { return ["1", "true", "yes", "on"].includes(String(value ?? "").toLowerCase()); } function safeFileName(identifier, suffix) { return `${identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase()}${suffix}`; } function bundleDisplayName(identifier, appId) { return identifier === appId ? "Burrow App" : "Burrow Network Extension"; } async function findCertificate(jwt, preferredTypes, preferredId) { const certs = await ascFetch( jwt, "https://api.appstoreconnect.apple.com/v1/certificates?limit=200", ); const data = certs.data ?? []; if (preferredId) { const cert = data.find((item) => item?.id === preferredId); if (!cert) throw new Error(`unable to locate certificate id ${preferredId} via ASC API`); const type = cert?.attributes?.certificateType; if (preferredTypes.length && !preferredTypes.includes(type)) { throw new Error(`certificate ${preferredId} has type ${type}, expected ${preferredTypes.join(" or ")}`); } return cert; } for (const type of preferredTypes) { const cert = data.find((item) => item?.attributes?.certificateType === type); if (cert) return cert; } throw new Error(`unable to locate certificate type ${preferredTypes.join(" or ")} via ASC API`); } async function resolveBundle(jwt, target, { createMissing, platform }) { const bundleResp = await ascFetch( jwt, `https://api.appstoreconnect.apple.com/v1/bundleIds?limit=5&filter[identifier]=${encodeURIComponent(target.identifier)}`, ); let bundle = (bundleResp.data ?? []).find( (item) => item?.attributes?.identifier === target.identifier, ); if (bundle || !createMissing) return bundle; const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIds", { method: "POST", body: JSON.stringify({ data: { type: "bundleIds", attributes: { identifier: target.identifier, name: target.bundleName ?? target.name, platform, }, }, }), }); bundle = created?.data; if (bundle?.id) { process.stdout.write(`created bundle id ${target.identifier} (${platform})\n`); } return bundle; } async function listCapabilityTypes(jwt, bundleId) { const resp = await ascFetch( jwt, `https://api.appstoreconnect.apple.com/v1/bundleIds/${bundleId}/bundleIdCapabilities`, ); return new Set((resp.data ?? []).map((item) => item?.attributes?.capabilityType).filter(Boolean)); } async function enableCapability(jwt, bundleId, capabilityType, settings) { await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/bundleIdCapabilities", { method: "POST", body: JSON.stringify({ data: { type: "bundleIdCapabilities", attributes: { capabilityType, ...(settings ? { settings } : {}), }, relationships: { bundleId: { data: { id: bundleId, type: "bundleIds", }, }, }, }, }), }); } async function profileIncludesCertificate(jwt, profileId, certificateId) { const resp = await ascFetch( jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}/certificates?limit=200`, ); return (resp.data ?? []).some((item) => item?.id === certificateId); } async function ensureCapabilities(jwt, bundle, capabilities) { const existing = await listCapabilityTypes(jwt, bundle.id); for (const capability of capabilities) { if (existing.has(capability.type)) continue; try { await enableCapability(jwt, bundle.id, capability.type, capability.settings); process.stdout.write(`enabled ${capability.type} for ${bundle.attributes.identifier}\n`); } catch (err) { process.stderr.write(`warning: could not enable ${capability.type} for ${bundle.attributes.identifier}: ${err.message}\n`); } } } async function syncTarget(jwt, cert, profileType, target, outDir, options) { const bundle = await resolveBundle(jwt, target, options); if (!bundle) throw new Error(`bundleId not found in ASC: ${target.identifier}`); if (options.enableCapabilities && target.capabilities?.length) { await ensureCapabilities(jwt, bundle, target.capabilities); } const existing = await ascFetch( jwt, `https://api.appstoreconnect.apple.com/v1/profiles?limit=10&filter[profileType]=${encodeURIComponent(profileType)}&filter[name]=${encodeURIComponent(target.name)}`, ); let profileId = (existing.data ?? []).find((item) => item?.attributes?.name === target.name)?.id; if (profileId && options.replaceCertificateMismatch) { const includesCertificate = await profileIncludesCertificate(jwt, profileId, cert.id); if (!includesCertificate) { await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`, { method: "DELETE", }); process.stdout.write(`deleted stale profile ${target.name}; it did not include certificate ${cert.id}\n`); profileId = undefined; } } if (!profileId) { const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/profiles", { method: "POST", body: JSON.stringify({ data: { type: "profiles", attributes: { name: target.name, profileType }, relationships: { bundleId: { data: { type: "bundleIds", id: bundle.id } }, certificates: { data: [{ type: "certificates", id: cert.id }] }, }, }, }), }); profileId = created?.data?.id; } if (!profileId) throw new Error(`failed to resolve/create profile for ${target.identifier}`); const profile = await ascFetch(jwt, `https://api.appstoreconnect.apple.com/v1/profiles/${profileId}`); const content = profile?.data?.attributes?.profileContent; if (!content) throw new Error(`profileContent missing for ${target.name} (${profileId})`); fs.mkdirSync(outDir, { recursive: true }); const profileBytes = Buffer.from(content, "base64"); const profilePath = path.join(outDir, target.file); fs.writeFileSync(profilePath, profileBytes); if (target.mobileFile) { fs.writeFileSync(path.join(outDir, target.mobileFile), profileBytes); } process.stdout.write(`synced ${target.identifier} -> ${profilePath}\n`); } async function main() { const args = parseArgs(process.argv); if (args.help) { console.log("Usage: sync-provisioning-profiles.mjs --platform ios|macos|all --key-id ID --issuer-id ID --key-file AuthKey.p8 --out-dir DIR [--ios-certificate-id ID] [--macos-certificate-id ID] [--replace-certificate-mismatch true]"); process.exit(0); } const platform = args.platform ?? "all"; const keyId = args["key-id"]; const issuerId = args["issuer-id"]; const keyFile = args["key-file"]; const outDir = args["out-dir"] ?? ".forgejo/actions/export"; const appId = args["app-id"] ?? "com.hackclub.burrow"; const networkId = args["network-id"] ?? `${appId}.network`; const iosCertificateId = args["ios-certificate-id"] ?? process.env.BURROW_IOS_DISTRIBUTION_CERTIFICATE_ID; const macosCertificateId = args["macos-certificate-id"] ?? process.env.BURROW_DEVELOPER_ID_CERTIFICATE_ID; const createMissing = truthy(args["create-missing-bundle-ids"]); const enableCapabilities = createMissing || truthy(args["enable-capabilities"]); const replaceCertificateMismatch = truthy(args["replace-certificate-mismatch"]); const bundlePlatform = args["bundle-platform"] ?? "UNIVERSAL"; const associatedDomains = (args["associated-domains"] ?? "applinks:burrow.rs?mode=developer,webcredentials:burrow.rs?mode=developer") .split(",") .map((item) => item.trim()) .filter(Boolean); const appCapabilities = [ { type: "NETWORK_EXTENSIONS" }, { type: "APP_GROUPS" }, { type: "ASSOCIATED_DOMAINS", settings: [{ key: "ASSOCIATED_DOMAIN_IDS", options: associatedDomains.map((key) => ({ key, enabled: true })) }], }, ]; const extensionCapabilities = [ { type: "NETWORK_EXTENSIONS" }, { type: "APP_GROUPS" }, ]; const options = { createMissing, enableCapabilities, replaceCertificateMismatch, platform: bundlePlatform }; if (!keyId || !issuerId || !keyFile) throw new Error("required: --key-id --issuer-id --key-file"); const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8") }); if (platform === "ios" || platform === "all") { const cert = await findCertificate(jwt, ["IOS_DISTRIBUTION", "DISTRIBUTION", "APPLE_DISTRIBUTION"], iosCertificateId); const targets = [appId, networkId].map((identifier) => ({ identifier, name: `${identifier}.app-store`, bundleName: bundleDisplayName(identifier, appId), file: safeFileName(identifier, "appstore.provisionprofile"), mobileFile: safeFileName(identifier, "appstore.mobileprovision"), capabilities: identifier === appId ? appCapabilities : extensionCapabilities, })); for (const target of targets) { await syncTarget(jwt, cert, "IOS_APP_STORE", target, outDir, options); } } if (platform === "macos" || platform === "all") { const cert = await findCertificate(jwt, ["DEVELOPER_ID_APPLICATION"], macosCertificateId); const targets = [appId, networkId].map((identifier) => ({ identifier, name: `${identifier}.developer-id`, bundleName: bundleDisplayName(identifier, appId), file: safeFileName(identifier, "developerid.provisionprofile"), capabilities: identifier === appId ? appCapabilities : extensionCapabilities, })); for (const target of targets) { await syncTarget(jwt, cert, "MAC_APP_DIRECT", target, outDir, options); } } } main().catch((err) => { console.error(err?.stack || String(err)); process.exit(1); });