331 lines
12 KiB
JavaScript
Executable file
331 lines
12 KiB
JavaScript
Executable file
#!/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"] ?? "net.burrow.app";
|
|
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", "DEVELOPER_ID_APPLICATION_G2"],
|
|
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);
|
|
});
|