Skip to main content
Glama
client.ts16.2 kB
/** * App Store Connect API Client * * Authentication: API Key (Issuer ID, Key ID, Private Key) required * API Documentation: https://developer.apple.com/documentation/appstoreconnectapi */ import { createAppStoreJWT } from "./auth"; import type { AppStoreAsoData, AppStoreMultilingualAsoData, AppStoreReleaseNote, } from "@/packages/configs/aso-config/types"; import { DEFAULT_LOCALE } from "@/packages/configs/aso-config/constants"; import { AppStoreApiEndpoints } from "./api-endpoints"; import { convertToAsoData, convertToMultilingualAsoData, convertToReleaseNote, fetchScreenshotsForLocalization, mapLocalizationsByLocale, selectEnglishAppName, sortReleaseNotes, sortVersions, } from "./api-converters"; import { APP_STORE_PLATFORM, DEFAULT_APP_LIST_LIMIT, DEFAULT_VERSIONS_FETCH_LIMIT, } from "./constants"; import type { AppStoreClientConfig, AppStoreVersion } from "./types"; export class AppStoreClient { private issuerId: string; private keyId: string; private privateKey: string; private bundleId: string; private apiEndpoints: AppStoreApiEndpoints; constructor(config: AppStoreClientConfig) { this.issuerId = config.issuerId; this.keyId = config.keyId; this.privateKey = config.privateKey; this.bundleId = config.bundleId; this.apiEndpoints = new AppStoreApiEndpoints( () => this.generateToken(), this.issuerId, this.keyId ); } private async generateToken(): Promise<string> { if (!this.privateKey || !this.privateKey.includes("BEGIN PRIVATE KEY")) { throw new Error( "Invalid Private Key format. PEM format private key is required." ); } const normalizedKey = this.privateKey.replace(/\\n/g, "\n").trim(); return await createAppStoreJWT( { issuerId: this.issuerId, keyId: this.keyId, privateKey: normalizedKey, }, { expirationSeconds: 1200 } ); } /** * List all apps registered in the account * @param options.onlyReleased - If true, returns only released apps (apps with READY_FOR_SALE status) */ async listAllApps(options: { onlyReleased?: boolean } = {}): Promise< Array<{ id: string; name: string; bundleId: string; sku: string; isReleased: boolean; }> > { const { onlyReleased = false } = options; const apps: Array<{ id: string; name: string; bundleId: string; sku: string; isReleased: boolean; }> = []; let nextUrl: string | null = `/apps?limit=${DEFAULT_APP_LIST_LIMIT}`; while (nextUrl) { const response = await this.apiEndpoints.listApps(nextUrl); for (const app of response.data || []) { const isReleased = await this.checkAppReleased(app.id); if (onlyReleased && !isReleased) { continue; } const englishName = await this.getEnglishAppName(app.id); apps.push({ id: app.id, name: englishName || app.attributes.name, bundleId: app.attributes.bundleId, sku: app.attributes.sku, isReleased, }); } nextUrl = this.apiEndpoints.normalizeNextLink(response.links?.next); } return apps; } private async checkAppReleased(appId: string): Promise<boolean> { try { const response = await this.apiEndpoints.listAppStoreVersions(appId, { platform: APP_STORE_PLATFORM, state: "READY_FOR_SALE", limit: 1, }); return (response.data?.length || 0) > 0; } catch { return false; } } private async getEnglishAppName(appId: string): Promise<string | null> { try { const appInfoId = await this.findAppInfoId(appId); const localizationsResponse = await this.apiEndpoints.listAppInfoLocalizations(appInfoId); return selectEnglishAppName(localizationsResponse.data || []); } catch { return null; } } /** * Get supported locales for an app */ async getSupportedLocales(appId: string): Promise<string[]> { try { const appInfoId = await this.findAppInfoId(appId); const localizationsResponse = await this.apiEndpoints.listAppInfoLocalizations(appInfoId); return (localizationsResponse.data || []) .map((l) => l.attributes.locale) .filter((locale): locale is string => !!locale) .sort(); } catch { return []; } } private async findAppId(): Promise<string> { const response = await this.apiEndpoints.findAppByBundleId(this.bundleId); const app = response.data?.[0]; if (!app) { throw new Error(`App not found for Bundle ID "${this.bundleId}".`); } return app.id; } private async findAppInfoId(appId?: string): Promise<string> { const targetAppId = appId || (await this.findAppId()); const appInfosResponse = await this.apiEndpoints.listAppInfos(targetAppId); const appInfo = appInfosResponse.data?.[0]; if (!appInfo) { throw new Error(`App Info not found for App ID "${targetAppId}".`); } return appInfo.id; } async pullAsoData( options: { locale?: string } = {} ): Promise<AppStoreAsoData> { const locale = options.locale || DEFAULT_LOCALE; const appId = await this.findAppId(); const appInfoId = await this.findAppInfoId(appId); const [appResponse, versionsResponse, appInfoLocalizationResponse] = await Promise.all([ this.apiEndpoints.getApp(appId), this.apiEndpoints.listAppStoreVersions(appId, { platform: APP_STORE_PLATFORM, limit: DEFAULT_VERSIONS_FETCH_LIMIT, }), this.apiEndpoints.listAppInfoLocalizations(appInfoId, locale), ]); const app = appResponse.data; const version = sortVersions(versionsResponse.data || [])[0]; if (!version) throw new Error("App Store version not found."); const localizationsResponse = await this.apiEndpoints.listAppStoreVersionLocalizations( version.id, locale ); const localization = localizationsResponse.data?.[0]; const detailedLocalization = localization ? ( await this.apiEndpoints.getAppStoreVersionLocalization( localization.id ) ).data : null; const screenshots = await fetchScreenshotsForLocalization( localization?.id, (localizationId) => this.apiEndpoints.listScreenshotSets(localizationId), (setId) => this.apiEndpoints.listScreenshots(setId) ); const appInfoLocalization = appInfoLocalizationResponse.data?.[0]; return convertToAsoData({ app, appInfoLocalization, localization: detailedLocalization, screenshots, locale, bundleId: this.bundleId, }); } async pullAllLocalesAsoData(): Promise<AppStoreMultilingualAsoData> { const appId = await this.findAppId(); const appInfoId = await this.findAppInfoId(appId); const [appResponse, appInfoLocalizationsResponse, versionsResponse] = await Promise.all([ this.apiEndpoints.getApp(appId), this.apiEndpoints.listAppInfoLocalizations(appInfoId), this.apiEndpoints.listAppStoreVersions(appId, { platform: APP_STORE_PLATFORM, limit: DEFAULT_VERSIONS_FETCH_LIMIT, }), ]); const app = appResponse.data; const appInfoLocalizationMap = mapLocalizationsByLocale( appInfoLocalizationsResponse.data || [] ); const version = sortVersions(versionsResponse.data || [])[0]; if (!version) throw new Error("App Store version not found."); const localizationsResponse = await this.apiEndpoints.listAppStoreVersionLocalizations(version.id); const allLocalizations = localizationsResponse.data || []; console.log( `🌍 Found ${ allLocalizations.length } App Store localizations: ${allLocalizations .map((l) => l.attributes.locale) .join(", ")}` ); const locales: Record<string, AppStoreAsoData> = {}; let defaultLocale: string | undefined; for (const localization of allLocalizations) { const locale = localization.attributes.locale; if (!locale) continue; const detailedLocalization = ( await this.apiEndpoints.getAppStoreVersionLocalization( localization.id ) ).data ?? null; const screenshots = await fetchScreenshotsForLocalization( localization.id, (localizationId) => this.apiEndpoints.listScreenshotSets(localizationId), (setId) => this.apiEndpoints.listScreenshots(setId) ); locales[locale] = convertToAsoData({ app, appInfoLocalization: appInfoLocalizationMap[locale], localization: detailedLocalization, screenshots, locale, bundleId: this.bundleId, }); if (!defaultLocale && locale === DEFAULT_LOCALE) { defaultLocale = locale; } } return convertToMultilingualAsoData(locales, defaultLocale); } async pushAsoData(data: Partial<AppStoreAsoData>): Promise<{ failedFields?: string[]; }> { const appId = await this.findAppId(); const appInfoId = await this.findAppInfoId(appId); const locale = data.locale || DEFAULT_LOCALE; const appInfoLocalizationResponse = await this.apiEndpoints.listAppInfoLocalizations(appInfoId, locale); const appInfoAttributes: Record<string, string> = {}; const attemptedFields: string[] = []; if (typeof data.name === "string") { appInfoAttributes.name = data.name; attemptedFields.push("name"); } if (typeof data.subtitle === "string") { appInfoAttributes.subtitle = data.subtitle; attemptedFields.push("subtitle"); } const failedFields: string[] = []; if (Object.keys(appInfoAttributes).length > 0) { try { if (appInfoLocalizationResponse.data?.[0]) { const localizationId = appInfoLocalizationResponse.data[0].id; await this.apiEndpoints.updateAppInfoLocalization( localizationId, appInfoAttributes ); } else { await this.apiEndpoints.createAppInfoLocalization( appInfoId, locale, appInfoAttributes ); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes("STATE_ERROR") || msg.includes("409 Conflict")) { failedFields.push(...attemptedFields); console.error( `[AppStore] ⚠️ Name/Subtitle update failed for ${locale}: ${msg}` ); console.error( `[AppStore] ℹ️ Name and Subtitle can only be updated when a new version is created.` ); console.error( `[AppStore] ⏭️ Continuing with other ASO fields (description, keywords, etc.)...` ); } else { throw error; } } } const versionsResponse = await this.apiEndpoints.listAppStoreVersions( appId, { platform: APP_STORE_PLATFORM, limit: DEFAULT_VERSIONS_FETCH_LIMIT, } ); const version = sortVersions(versionsResponse.data || [])[0]; if (!version) throw new Error("App Store version not found."); const localizationsResponse = await this.apiEndpoints.listAppStoreVersionLocalizations( version.id, locale ); let localizationId: string; if (localizationsResponse.data?.[0]) { localizationId = localizationsResponse.data[0].id; await this.apiEndpoints.updateAppStoreVersionLocalization( localizationId, { description: data.description, keywords: data.keywords, promotionalText: data.promotionalText, supportUrl: data.supportUrl, marketingUrl: data.marketingUrl, } ); } else { const createResponse = await this.apiEndpoints.createAppStoreVersionLocalization( version.id, locale, { description: data.description, keywords: data.keywords, promotionalText: data.promotionalText, supportUrl: data.supportUrl, marketingUrl: data.marketingUrl, } ); localizationId = createResponse.data.id; } return failedFields.length > 0 ? { failedFields } : {}; } async getAllVersions(limit = 50): Promise<AppStoreVersion[]> { const appId = await this.findAppId(); const response = await this.apiEndpoints.listAppStoreVersions(appId, { platform: APP_STORE_PLATFORM, limit, }); return sortVersions(response.data || []); } async getLatestVersion(): Promise<AppStoreVersion | null> { const versions = await this.getAllVersions(); return versions[0] || null; } incrementVersion(versionString: string): string { const parts = versionString.split(".").map(Number); while (parts.length < 3) parts.push(0); parts[parts.length - 1] = (parts[parts.length - 1] || 0) + 1; return parts.join("."); } async createNewVersion(versionString: string): Promise<AppStoreVersion> { const appId = await this.findAppId(); const versionsResponse = await this.apiEndpoints.listAppStoreVersions( appId, { platform: APP_STORE_PLATFORM, limit: 50, } ); const existing = versionsResponse.data?.find( (v) => v.attributes.versionString === versionString ); if (existing) { console.log(`⚠️ Version ${versionString} already exists.`); return existing; } const createResponse = await this.apiEndpoints.createAppStoreVersion( appId, versionString, APP_STORE_PLATFORM ); console.log(`✅ Created new version: ${versionString}`); return createResponse.data; } async createNewVersionWithAutoIncrement( baseVersion?: string ): Promise<AppStoreVersion> { let newVersionString: string; if (baseVersion) { newVersionString = this.incrementVersion(baseVersion); } else { const latestVersion = await this.getLatestVersion(); if (!latestVersion) { newVersionString = "1.0.0"; console.log( `📦 No existing versions. Starting with ${newVersionString}` ); } else { const latest = latestVersion.attributes.versionString; newVersionString = this.incrementVersion(latest); console.log(`📦 Latest: ${latest} → New: ${newVersionString}`); } } return this.createNewVersion(newVersionString); } async updateWhatsNew(options: { versionId: string; locale: string; whatsNew: string; }): Promise<void> { const { versionId, locale, whatsNew } = options; const localizationsResponse = await this.apiEndpoints.listAppStoreVersionLocalizations( versionId, locale ); if (localizationsResponse.data?.[0]) { const localizationId = localizationsResponse.data[0].id; await this.apiEndpoints.updateAppStoreVersionLocalization( localizationId, { whatsNew } ); } else { await this.apiEndpoints.createAppStoreVersionLocalization( versionId, locale, { whatsNew } ); } console.log(`✅ Updated What's New for ${locale}`); } async pullReleaseNotes(): Promise<AppStoreReleaseNote[]> { const appId = await this.findAppId(); const versionsResponse = await this.apiEndpoints.listAppStoreVersions( appId, { platform: APP_STORE_PLATFORM, limit: 50, } ); const releaseNotes: AppStoreReleaseNote[] = []; for (const version of versionsResponse.data || []) { const localizationsResponse = await this.apiEndpoints.listAppStoreVersionLocalizations(version.id); const releaseNote = convertToReleaseNote( version, localizationsResponse.data || [] ); if (releaseNote) { releaseNotes.push(releaseNote); } } return sortReleaseNotes(releaseNotes); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/quartz-labs-dev/pabal-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server