import { AppError } from "@/packages/common/errors/app-error";
import { ERROR_CODES } from "@/packages/common/errors/error-codes";
import { HTTP_STATUS } from "@/packages/common/errors/status-codes";
import type {
AsoData,
AppStoreMultilingualAsoData,
AppStoreReleaseNote,
} from "@/packages/configs/aso-config/types";
import { getAsoPushDir } from "@/packages/configs/aso-config/utils";
import type { EnvConfig } from "@/packages/configs/secrets-config/types";
import type { AppStoreClient } from "@/packages/stores/app-store/client";
import { verifyAppStoreAuth } from "@/packages/stores/app-store/verify-auth";
import { createAppStoreClient } from "@/core/clients/app-store-factory";
import {
parseAppStoreScreenshots,
hasScreenshots,
APP_STORE_DEVICE_TYPES,
} from "@/core/helpers/screenshot-helpers";
import {
checkPushPrerequisites,
serviceFailure,
toServiceResult,
updateRegisteredLocales,
} from "./service-helpers";
import {
type MaybeResult,
type ServiceResult,
type StoreAppSummary,
type AppStoreVersionInfo,
type UpdatedReleaseNotesResult,
type PushAsoResult,
type CreatedAppStoreVersion,
type VerifyAuthResult,
} from "./types";
interface AppStoreAppInfo {
appId?: string;
name?: string;
supportedLocales?: string[];
}
/**
* App Store-facing service layer that wraps client creation and common operations.
* Keeps MCP tools independent from client factories and SDK details.
*/
export class AppStoreService {
private getClientOrThrow(
bundleId: string,
existingClient?: AppStoreClient
): AppStoreClient {
if (existingClient) return existingClient;
const clientResult = this.createClient(bundleId);
if (!clientResult.success) {
throw clientResult.error;
}
return clientResult.data;
}
createClient(bundleId: string): ServiceResult<AppStoreClient> {
return toServiceResult(createAppStoreClient({ bundleId }));
}
/**
* List released apps. Uses a fresh client to ensure working directory independence.
*/
async listReleasedApps(): Promise<ServiceResult<StoreAppSummary[]>> {
try {
const client = this.getClientOrThrow("dummy");
const apps = await client.listAllApps({ onlyReleased: true });
return { success: true, data: apps };
} catch (error) {
return serviceFailure(
AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_LIST_APPS_FAILED
)
);
}
}
/**
* Fetch a single app info (with locales) by bundleId.
*/
async fetchAppInfo(
bundleId: string,
existingClient?: AppStoreClient
): Promise<MaybeResult<AppStoreAppInfo>> {
try {
const client = this.getClientOrThrow(bundleId || "dummy", existingClient);
const apps = await client.listAllApps({ onlyReleased: true });
const app = apps.find((a) => a.bundleId === bundleId);
if (!app) {
return { found: false };
}
const supportedLocales = await client.getSupportedLocales(app.id);
return {
found: true,
appId: app.id,
name: app.name,
supportedLocales,
};
} catch (error) {
return {
found: false,
error: AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_FETCH_APP_INFO_FAILED,
"Failed to fetch App Store app info"
),
};
}
}
async getLatestVersion(
bundleId: string,
existingClient?: AppStoreClient
): Promise<MaybeResult<AppStoreVersionInfo>> {
try {
const client = this.getClientOrThrow(bundleId, existingClient);
const latestVersion = await client.getLatestVersion();
if (!latestVersion) {
return { found: false };
}
const versionString = latestVersion.attributes?.versionString ?? "";
const appStoreState = latestVersion.attributes?.appStoreState;
return {
found: true,
versionString,
state: appStoreState ?? "UNKNOWN",
};
} catch (error) {
return {
found: false,
error: AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_GET_LATEST_VERSION_FAILED,
"Failed to fetch latest App Store version"
),
};
}
}
async updateReleaseNotes(
bundleId: string,
releaseNotes: Record<string, string>,
versionId?: string,
supportedLocales?: string[]
): Promise<ServiceResult<UpdatedReleaseNotesResult>> {
try {
const client = this.getClientOrThrow(bundleId);
// Determine target versionId
let targetVersionId = versionId;
if (!targetVersionId) {
const versions = await client.getAllVersions();
const editableVersion = versions.find(
(v) => v.attributes?.appStoreState === "PREPARE_FOR_SUBMISSION"
);
if (!editableVersion) {
return serviceFailure(
AppError.notFound(
ERROR_CODES.APP_STORE_VERSION_NOT_EDITABLE,
"No editable version found for release notes update"
)
);
}
targetVersionId = editableVersion.id;
}
// Filter locales if supportedLocales provided
const localesToUpdate = supportedLocales
? Object.keys(releaseNotes).filter((locale) =>
supportedLocales.includes(locale)
)
: Object.keys(releaseNotes);
const updated: string[] = [];
const failed: Array<{ locale: string; error: string }> = [];
for (const locale of localesToUpdate) {
try {
await client.updateWhatsNew({
versionId: targetVersionId!,
locale,
whatsNew: releaseNotes[locale],
});
updated.push(locale);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
failed.push({ locale, error: msg });
}
}
const success = failed.length === 0;
const partialError = !success
? AppError.wrap(
failed[0]?.error ?? "Failed to update some release notes",
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_UPDATE_RELEASE_NOTES_PARTIAL
)
: undefined;
if (!success) {
return serviceFailure(
partialError ??
AppError.internal(
ERROR_CODES.APP_STORE_UPDATE_RELEASE_NOTES_FAILED,
"Failed to update App Store release notes"
)
);
}
return {
success: true,
data: { updated, failed },
};
} catch (error) {
return serviceFailure(
AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_UPDATE_RELEASE_NOTES_FAILED,
"Failed to update App Store release notes"
)
);
}
}
async pullReleaseNotes(
bundleId: string
): Promise<ServiceResult<AppStoreReleaseNote[]>> {
try {
const client = this.getClientOrThrow(bundleId);
const releaseNotes = await client.pullReleaseNotes();
return { success: true, data: releaseNotes };
} catch (error) {
return serviceFailure(
AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_PULL_RELEASE_NOTES_FAILED,
"Failed to pull App Store release notes"
)
);
}
}
async createVersion(
bundleId: string,
versionString: string,
autoIncrement?: boolean
): Promise<ServiceResult<CreatedAppStoreVersion>> {
try {
const client = this.getClientOrThrow(bundleId);
const version = autoIncrement
? await client.createNewVersionWithAutoIncrement(versionString)
: await client.createNewVersion(versionString);
return {
success: true,
data: {
id: version.id,
versionString: version.attributes?.versionString ?? "",
state: version.attributes?.appStoreState,
},
};
} catch (error) {
return serviceFailure(
AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_CREATE_VERSION_FAILED,
"Failed to create App Store version"
)
);
}
}
async pushAsoData({
config,
bundleId,
localAsoData,
appStoreDataPath,
uploadImages = false,
slug,
}: {
config: EnvConfig;
bundleId?: string;
localAsoData: AsoData;
appStoreDataPath: string;
uploadImages?: boolean;
slug?: string;
}): Promise<PushAsoResult> {
const skip = checkPushPrerequisites({
storeLabel: "App Store",
configured: Boolean(config.appStore),
identifierLabel: "bundleId",
identifier: bundleId,
hasData: Boolean(localAsoData.appStore),
dataPath: appStoreDataPath,
});
if (skip) return { success: false, error: skip };
const ensuredBundleId = bundleId as string;
const appStoreData = localAsoData.appStore as AppStoreMultilingualAsoData;
const client = this.getClientOrThrow(ensuredBundleId);
console.error(`[MCP] π€ Pushing to App Store...`);
console.error(`[MCP] Bundle ID: ${bundleId}`);
try {
// Push locale data (supportUrl/marketingUrl already set by prepareAsoDataForPush)
const localesToPush = Object.keys(appStoreData.locales);
const failedFieldsList: { locale: string; fields: string[] }[] = [];
for (const [locale, localeData] of Object.entries(appStoreData.locales)) {
console.error(`[AppStore] π€ Pushing ${locale}...`);
const localeResult = await client.pushAsoData(localeData);
if (localeResult.failedFields && localeResult.failedFields.length > 0) {
failedFieldsList.push({
locale,
fields: localeResult.failedFields,
});
console.error(
`[AppStore] β οΈ ${locale} partially updated (failed fields: ${localeResult.failedFields.join(", ")})`
);
} else {
console.error(`[AppStore] β
${locale} uploaded successfully`);
}
}
// Upload screenshots if enabled
if (uploadImages && slug) {
console.error(`[AppStore] π€ Uploading screenshots...`);
const pushDataDir = getAsoPushDir();
const screenshotsBaseDir = `${pushDataDir}/products/${slug}/store`;
const uploadedLocales: string[] = [];
const skippedLocales: string[] = [];
const failedLocales: string[] = [];
for (const locale of localesToPush) {
try {
const localeData = appStoreData.locales[locale];
// Check if screenshots are defined in aso-data.json
const hasScreenshotsInJson =
localeData?.screenshots &&
((localeData.screenshots.iphone65 &&
localeData.screenshots.iphone65.length > 0) ||
(localeData.screenshots.ipadPro129 &&
localeData.screenshots.ipadPro129.length > 0));
let screenshotsToUpload: Array<{
path: string;
displayType: string;
filename: string;
}> = [];
if (hasScreenshotsInJson) {
// Use screenshots from aso-data.json (relative paths)
console.error(
`[AppStore] π Using screenshots from aso-data.json for ${locale}`
);
const relativePaths = localeData.screenshots;
// Map iphone65 screenshots
if (relativePaths.iphone65 && relativePaths.iphone65.length > 0) {
for (const relPath of relativePaths.iphone65) {
screenshotsToUpload.push({
path: `${screenshotsBaseDir}/${relPath}`,
displayType: APP_STORE_DEVICE_TYPES.iphone65,
filename: relPath.split("/").pop() || relPath,
});
}
}
// Map ipadPro129 screenshots
if (
relativePaths.ipadPro129 &&
relativePaths.ipadPro129.length > 0
) {
for (const relPath of relativePaths.ipadPro129) {
screenshotsToUpload.push({
path: `${screenshotsBaseDir}/${relPath}`,
displayType: APP_STORE_DEVICE_TYPES.ipadPro129,
filename: relPath.split("/").pop() || relPath,
});
}
}
} else {
// Fallback: Parse from file system (backward compatibility)
const screenshotsFsDir = `${screenshotsBaseDir}/app-store/screenshots`;
if (!hasScreenshots(screenshotsFsDir, locale)) {
console.error(
`[AppStore] βοΈ Skipping ${locale} - no screenshots in aso-data.json or file system`
);
skippedLocales.push(locale);
continue;
}
console.error(
`[AppStore] π Parsing screenshots from file system for ${locale}`
);
const result = parseAppStoreScreenshots(screenshotsFsDir, locale);
// Report parsing issues
if (result.invalid.length > 0) {
console.error(
`[AppStore] β οΈ Invalid filenames: ${result.invalid.join(", ")}`
);
}
if (result.unknown.length > 0) {
console.error(
`[AppStore] β οΈ Unknown device types: ${result.unknown.join(", ")}`
);
}
// Convert parsed screenshots to upload format
for (const [displayType, screenshots] of Object.entries(
result.valid
)) {
for (const screenshot of screenshots) {
screenshotsToUpload.push({
path: screenshot.path,
displayType,
filename: screenshot.filename,
});
}
}
}
if (screenshotsToUpload.length === 0) {
console.error(
`[AppStore] β οΈ Skipping ${locale} - no valid screenshots found`
);
skippedLocales.push(locale);
continue;
}
console.error(
`[AppStore] π€ Uploading screenshots for ${locale} (batch mode - will replace existing)...`
);
// Use batch upload method - deletes existing and uploads new
const uploadResult = await client.uploadScreenshotsForLocale({
locale,
screenshots: screenshotsToUpload,
});
console.error(
`[AppStore] β
Screenshots for ${locale}: ${uploadResult.uploaded} uploaded, ${uploadResult.deleted} deleted, ${uploadResult.failed} failed`
);
uploadedLocales.push(locale);
} catch (error) {
console.error(
`[AppStore] β Failed to upload screenshots for ${locale}: ${
error instanceof Error ? error.message : String(error)
}`
);
failedLocales.push(locale);
}
}
console.error(
`[AppStore] π Screenshot upload summary: ${uploadedLocales.length} succeeded, ${skippedLocales.length} skipped, ${failedLocales.length} failed`
);
if (uploadedLocales.length > 0) {
console.error(
`[AppStore] β
Uploaded: ${uploadedLocales.join(", ")}`
);
}
if (skippedLocales.length > 0) {
console.error(
`[AppStore] βοΈ Skipped: ${skippedLocales.join(", ")}`
);
}
if (failedLocales.length > 0) {
console.error(
`[AppStore] β Failed: ${failedLocales.join(", ")}`
);
}
}
try {
const updated = updateRegisteredLocales(
ensuredBundleId,
"appStore",
localesToPush
);
if (updated) {
console.error(
`[MCP] β
Updated registered-apps.json with ${localesToPush.length} App Store locales`
);
}
} catch (updateError) {
console.error(
`[MCP] β οΈ Failed to update registered-apps.json: ${
updateError instanceof Error
? updateError.message
: String(updateError)
}`
);
}
if (failedFieldsList.length > 0) {
return {
success: true,
localesPushed: localesToPush,
failedFields: failedFieldsList,
};
}
return {
success: true,
localesPushed: localesToPush,
};
} catch (error) {
const wrapped = AppError.wrap(
error,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_PUSH_FAILED,
error instanceof Error ? error.message : String(error)
);
if (
wrapped.message.includes("409 Conflict") &&
wrapped.message.includes("STATE_ERROR")
) {
console.error(
`[AppStore] π STATE_ERROR detected. New version needed.`
);
try {
const version = await client.createNewVersionWithAutoIncrement();
const versionId = version.id;
const versionString = version.attributes?.versionString ?? "";
const locales = Object.keys(appStoreData.locales);
console.error(
`[AppStore] β
New version ${versionString} created.`
);
return {
success: false,
error: AppError.conflict(
ERROR_CODES.APP_STORE_STATE_ERROR,
"New version required"
),
needsNewVersion: true,
versionInfo: {
versionId,
versionString,
locales,
},
};
} catch (versionError) {
const versionMsg =
versionError instanceof Error
? versionError.message
: String(versionError);
return {
success: false,
error: AppError.wrap(
versionError,
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_CREATE_VERSION_FOR_STATE_ERROR_FAILED,
`Failed to create new version: ${versionMsg}`
),
};
}
}
console.error(`[AppStore] β Push failed`, error);
return { success: false, error: wrapped };
}
}
async verifyAuth(expirationSeconds = 300): Promise<
VerifyAuthResult<{
header: Record<string, unknown>;
payload: Record<string, unknown>;
}>
> {
const result = await verifyAppStoreAuth({ expirationSeconds });
if (!result.success) {
return {
success: false,
error: AppError.wrap(
result.error ?? "Unknown error",
HTTP_STATUS.INTERNAL_SERVER_ERROR,
ERROR_CODES.APP_STORE_VERIFY_AUTH_FAILED,
"Failed to verify App Store auth"
),
};
}
return { success: true, data: result.data };
}
}