#!/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 certificateFileName(type, id) { return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`; } function csrContentForApi(csrFile) { const bytes = fs.readFileSync(csrFile); const text = bytes.toString("ascii"); const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/); if (pemMatch) { return pemMatch[1].replace(/\s+/g, ""); } return bytes.toString("base64"); } async function main() { const args = parseArgs(process.argv); if (args.help) { console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR"); process.exit(0); } const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID; const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID; const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE; const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION"; const csrFile = args["csr-file"]; const outDir = args["out-dir"] ?? "."; const outFile = args["out-file"]; const metadataFile = args["metadata-file"]; if (!keyId || !issuerId || !keyFile || !csrFile) { throw new Error("required: --key-id --issuer-id --key-file --csr-file"); } const jwt = await makeJwt({ keyId, issuerId, keyPem: fs.readFileSync(keyFile, "utf8"), }); const csrContent = csrContentForApi(csrFile); const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", { method: "POST", body: JSON.stringify({ data: { type: "certificates", attributes: { certificateType, csrContent, }, }, }), }); const cert = created?.data; const id = cert?.id; const content = cert?.attributes?.certificateContent; if (!id || !content) { throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`); } fs.mkdirSync(outDir, { recursive: true }); const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id)); fs.writeFileSync(certificatePath, Buffer.from(content, "base64")); const metadata = { id, certificateType: cert?.attributes?.certificateType ?? certificateType, displayName: cert?.attributes?.displayName, name: cert?.attributes?.name, platform: cert?.attributes?.platform, expirationDate: cert?.attributes?.expirationDate, serialNumber: cert?.attributes?.serialNumber, certificatePath, }; if (metadataFile) { fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`); } process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`); } main().catch((err) => { console.error(err?.stack || String(err)); process.exit(1); });