From b8b86e3f30d390b9c94c80c1355e9f7c7557bacd Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Tue, 4 Aug 2026 12:00:40 +0300 Subject: [PATCH] refactor(types): readonly domain shapes with MutableDeep builders Why: Domain types should be immutable at the boundary; local builders need an explicit mutable draft type instead of weakening public shapes. What: Wrap domain types in Readonly / readonly arrays; add MutableDeep; PlannedAction hybrid (readonly description/destructive, mutable status/error); update consumers, Apple/Google adapters, and builders to use MutableDeep drafts. Impact: Stricter types across core; no intentional runtime behavior change (finding 1). --- src/apple/ascClient.ts | 28 +- src/apple/generated/specPatch.ts | 2 +- src/cli/commands/completion.ts | 2 +- src/cli/commands/testflight.ts | 4 +- src/cli/options.ts | 2 +- src/core/adopt/capabilities.ts | 16 +- src/core/adopt/certs.ts | 10 +- src/core/adopt/configWriter.ts | 10 +- src/core/adopt/orchestrator.test.ts | 5 +- src/core/adopt/orchestrator.ts | 6 +- src/core/adopt/products.ts | 30 +- src/core/agents/render.ts | 18 +- src/core/build/appleTargets.ts | 4 +- src/core/build/artifactRetention.ts | 11 +- src/core/build/buildDiagnostics.ts | 2 +- src/core/build/buildHistoryCommand.ts | 6 +- src/core/build/pipeline.ts | 24 +- src/core/build/pipelineEnv.ts | 6 +- src/core/build/pipelineSigning.ts | 9 +- src/core/build/pipelineTypes.ts | 5 +- src/core/build/pipelineVersion.ts | 5 +- src/core/build/remotePipeline.ts | 3 +- src/core/build/resignCommand.ts | 2 +- src/core/config/config.ts | 3 +- src/core/config/configScaffold.ts | 2 +- src/core/config/env.ts | 8 +- src/core/config/jsonSchema.ts | 2 +- src/core/config/schema.ts | 4 +- src/core/config/toolchain.ts | 6 +- src/core/credentials/accounts.ts | 47 ++- src/core/credentials/appleSigning.test.ts | 4 +- src/core/credentials/appleSigning.ts | 9 +- src/core/credentials/capabilities.ts | 2 +- src/core/credentials/pushKeyStore.ts | 3 +- src/core/credentials/signingPreflight.ts | 8 +- src/core/dashboard/render.ts | 8 +- src/core/distribution/updateHistory.ts | 2 +- src/core/docs/commandDocs/commandReference.ts | 2 +- src/core/docs/commandDocs/common.ts | 2 +- src/core/doctor/command.ts | 2 +- src/core/insights/command.ts | 5 +- src/core/listing/apply.ts | 48 ++- src/core/listing/generator.ts | 3 +- src/core/mcp/gate.test.ts | 2 +- src/core/mcp/tools.test.ts | 4 +- src/core/migrate/command.ts | 2 +- src/core/migrate/eas.test.ts | 6 +- src/core/migrate/eas.ts | 16 +- src/core/migrate/fastlane.test.ts | 17 +- src/core/migrate/fastlane.ts | 33 +- src/core/migrate/scaffold.ts | 2 +- src/core/plan/orchestrator.test.ts | 2 +- src/core/plan/orchestrator.ts | 2 +- src/core/plan/planners/appStoreSurface.ts | 7 +- src/core/plan/planners/euDistribution.ts | 3 +- src/core/plan/planners/gameCenter.ts | 3 +- src/core/plan/planners/playProducts.ts | 2 +- .../plan/planners/playSubscriptions.test.ts | 4 +- src/core/plan/planners/playSubscriptions.ts | 5 +- src/core/plan/planners/wallet.ts | 3 +- src/core/privacy/parse.ts | 2 +- src/core/privacy/reconcile.ts | 2 +- src/core/readiness/appScopes.ts | 4 +- src/core/readiness/orchestrator.test.ts | 2 +- src/core/readiness/orchestrator.ts | 2 +- src/core/readiness/probes/iapCodeReference.ts | 2 +- .../readiness/probes/profileEntitlements.ts | 2 +- src/core/release/betaReview.ts | 3 +- src/core/release/statusCommand.test.ts | 3 +- src/core/release/testflightFeedback.ts | 2 +- src/core/release/version.ts | 2 +- src/core/releaseTrain/engine.ts | 2 +- src/core/releaseTrain/orchestrator.ts | 11 +- src/core/releaseTrain/record.ts | 72 ++-- src/core/services/appleCredentialsClient.ts | 2 +- src/core/services/artifactRetention.ts | 4 +- src/core/services/localCredentialsStore.ts | 2 +- src/core/services/logger.ts | 4 +- src/core/services/progress.ts | 8 +- src/core/services/sandbox.ts | 2 +- src/core/services/ssh.ts | 2 +- src/core/snapshot/orchestrator.test.ts | 2 +- src/core/snapshot/orchestrator.ts | 2 +- src/core/snapshot/snapshotCommand.ts | 6 +- src/core/snapshot/sources/appleListing.ts | 32 +- src/core/snapshot/sources/appleProducts.ts | 12 +- .../snapshot/sources/appleSubscriptions.ts | 12 +- src/core/snapshot/sources/playProducts.ts | 5 +- src/core/snapshot/sources/playRestore.ts | 21 +- .../snapshot/sources/playSubscriptions.ts | 6 +- src/core/snapshot/store.ts | 86 ++--- src/core/store/accessibility.ts | 3 +- src/core/store/appEvents.ts | 5 +- src/core/store/ascScreenshots.ts | 10 +- src/core/store/ascSync.ts | 15 +- src/core/store/availability.ts | 2 +- src/core/store/gameCenter.ts | 11 +- src/core/store/offers.test.ts | 4 +- src/core/store/offers.ts | 47 ++- src/core/store/offersCommand.ts | 2 +- src/core/store/playProducts.ts | 3 +- src/core/store/playSubscriptions.ts | 41 +- src/core/store/playTracks.ts | 3 +- src/core/store/reconcile.ts | 22 +- src/core/store/reportsCommand.ts | 5 +- src/core/store/syncJobs.ts | 6 +- src/core/store/syncRun.ts | 12 +- src/core/store/team.ts | 2 +- src/core/store/walletIds.ts | 2 +- src/core/terminal/completion.ts | 2 +- src/core/terminal/halfblock.ts | 2 +- src/core/terminal/wizardCommand.test.ts | 2 +- src/core/terminal/wizardCommand.ts | 4 +- src/core/types/adopt.ts | 66 ++-- src/core/types/agents.ts | 66 ++-- src/core/types/app.ts | 24 +- src/core/types/appleCatalog.ts | 357 +++++++++--------- src/core/types/artifacts.ts | 22 +- src/core/types/catalog.ts | 135 +++---- src/core/types/commandDocs.ts | 26 +- src/core/types/config.ts | 29 +- src/core/types/credentials.ts | 50 +-- src/core/types/dashboard.ts | 42 +-- src/core/types/doctor.ts | 49 +-- src/core/types/googlePlay.ts | 82 ++-- src/core/types/insights.ts | 24 +- src/core/types/listing.ts | 18 +- src/core/types/mcp.ts | 17 +- src/core/types/migrate.ts | 80 ++-- src/core/types/mutable.ts | 7 + src/core/types/plan.ts | 40 +- src/core/types/playPricing.ts | 18 +- src/core/types/privacy.ts | 22 +- src/core/types/providers.ts | 42 +-- src/core/types/readiness.ts | 122 +++--- src/core/types/reconcile.ts | 15 +- src/core/types/releaseTrain.ts | 14 +- src/core/types/remote.ts | 28 +- src/core/types/snapshot.ts | 112 +++--- src/core/types/storeSurface.ts | 84 ++--- src/core/types/vitals.ts | 14 +- src/google/playClient.ts | 70 ++-- src/google/playReporting.ts | 3 +- src/providers/credentials/local.test.ts | 3 +- src/providers/storage/local.ts | 5 +- 145 files changed, 1449 insertions(+), 1254 deletions(-) create mode 100644 src/core/types/mutable.ts diff --git a/src/apple/ascClient.ts b/src/apple/ascClient.ts index a6b8f798..5b49eab3 100644 --- a/src/apple/ascClient.ts +++ b/src/apple/ascClient.ts @@ -95,6 +95,7 @@ import type { WinBackOfferResource, } from '../core/types/appleCatalog.js'; import { ACCESSIBILITY_SUPPORT_KEYS } from '../core/types/appleCatalog.js'; +import type { MutableDeep } from '../core/types/mutable.js'; /** Scheme + host of the App Store Connect API; most resources hang off `/v1`, a few newer ones off `/v2`. */ const API_ORIGIN = 'https://api.appstoreconnect.apple.com'; const BASE_URL = `${API_ORIGIN}/v1`; @@ -321,7 +322,7 @@ const pickListingFields = ( const pickAccessibilitySupport = ( attributes: Partial, ): AccessibilitySupport => { - const support: AccessibilitySupport = {}; + const support: MutableDeep = {}; for (const key of ACCESSIBILITY_SUPPORT_KEYS) { const fieldValue = attributes[key]; if (typeof fieldValue === 'boolean') support[key] = fieldValue; @@ -1042,7 +1043,7 @@ export class AppStoreConnectClient { return appleResources.map((localization) => { let locale = localization.attributes.locale; if (locale === undefined) locale = ''; - const localizedExperience: AppClipLocalizationResource = { + const localizedExperience: MutableDeep = { id: localization.id, locale, }; @@ -1330,7 +1331,7 @@ export class AppStoreConnectClient { name: string, bundleIdResourceId: string, certificateId: string, - deviceIds: string[], + deviceIds: readonly string[], profileType: ProvisioningProfileType = AD_HOC_PROFILE_TYPE, ): Promise { const appleResources = await this.createResource<{ @@ -1457,7 +1458,7 @@ export class AppStoreConnectClient { }); } /** Clear the StoreKit purchase history for one or more sandbox testers (a single batched request). */ - async clearSandboxTesterPurchaseHistory(testerIds: string[]): Promise { + async clearSandboxTesterPurchaseHistory(testerIds: readonly string[]): Promise { await this.createResource(this.v2('/sandboxTestersClearPurchaseHistoryRequest'), { type: 'sandboxTestersClearPurchaseHistoryRequest', relationships: { @@ -1743,7 +1744,7 @@ export class AppStoreConnectClient { type: 'subscriptionOfferCodes', attributes: { name: input.name, - customerEligibilities: input.customerEligibilities, + customerEligibilities: [...input.customerEligibilities], offerEligibility: input.offerEligibility, duration: input.duration, offerMode: input.offerMode, @@ -2066,7 +2067,7 @@ export class AppStoreConnectClient { return { id: appleResources.id, inAppPurchaseId, subscriptionId, enabled, visibleForAllUsers }; } /** Replace the app's promoted-purchase ordering with `orderedIds` (the product-page display order). */ - async reorderPromotedPurchases(appId: string, orderedIds: string[]): Promise { + async reorderPromotedPurchases(appId: string, orderedIds: readonly string[]): Promise { const requestDocument: components['schemas']['AppPromotedPurchasesLinkagesRequest'] = { data: orderedIds.map((id) => ({ type: 'promotedPurchases', id })), }; @@ -2244,13 +2245,13 @@ export class AppStoreConnectClient { return betaTester; } /** Add existing testers to a beta group in one relationship call (invites external testers). */ - async addTestersToGroup(groupId: string, testerIds: string[]): Promise { + async addTestersToGroup(groupId: string, testerIds: readonly string[]): Promise { await this.request('POST', `/betaGroups/${groupId}/relationships/betaTesters`, { data: testerIds.map((id) => ({ type: 'betaTesters', id })), }); } /** Remove testers from a beta group; they keep app access through any other group they're in. */ - async removeTestersFromGroup(groupId: string, testerIds: string[]): Promise { + async removeTestersFromGroup(groupId: string, testerIds: readonly string[]): Promise { await this.request('DELETE', `/betaGroups/${groupId}/relationships/betaTesters`, { data: testerIds.map((id) => ({ type: 'betaTesters', id })), }); @@ -2562,7 +2563,7 @@ export class AppStoreConnectClient { */ async updateAppAvailabilityTerritories( availabilityId: string, - territories: string[], + territories: readonly string[], ): Promise { const desired = new Set(territories); const territoryAvailabilities = await this.listAppTerritoryAvailabilities(availabilityId); @@ -3274,7 +3275,7 @@ export class AppStoreConnectClient { email: invite.email, firstName: invite.firstName, lastName: invite.lastName, - roles: invite.roles, + roles: [...invite.roles], allAppsVisible: invite.allAppsVisible, provisioningAllowed: invite.provisioningAllowed, }, @@ -3282,7 +3283,7 @@ export class AppStoreConnectClient { let email = appleResources.attributes.email; if (email === undefined) email = invite.email; let roles = appleResources.attributes.roles; - if (roles === undefined) roles = invite.roles; + if (roles === undefined) roles = [...invite.roles]; const pendingInvitation: UserInvitationResource = { id: appleResources.id, email, roles }; if (appleResources.attributes.firstName !== undefined) Object.assign(pendingInvitation, { firstName: appleResources.attributes.firstName }); @@ -4474,7 +4475,10 @@ export class AppStoreConnectClient { return { id: reservation.data.id, operations }; } /** PUT a reserved asset's bytes to Apple's CDN, one operation (chunk) at a time, with transient-retry. */ - private async putAssetBytes(operations: UploadOperation[], bytes: Buffer): Promise { + private async putAssetBytes( + operations: readonly UploadOperation[], + bytes: Buffer, + ): Promise { for (const operation of operations) { let offset = operation.offset; if (offset === undefined) offset = 0; diff --git a/src/apple/generated/specPatch.ts b/src/apple/generated/specPatch.ts index 51a9805a..8d19ee73 100644 --- a/src/apple/generated/specPatch.ts +++ b/src/apple/generated/specPatch.ts @@ -53,7 +53,7 @@ export function generatedHeader(spec: AscSpecMeta): string { * Pick the real spec file from a zip's entry list, accepting Apple's occasional download suffix and * skipping the macOS resource-fork sibling. Returns the matching entry or null when absent. */ -export function pickSpecEntry(entries: string[]): string | null { +export function pickSpecEntry(entries: readonly string[]): string | null { const specEntry = entries.find((entry) => { const fileName = entry.split('/').at(-1); return ( diff --git a/src/cli/commands/completion.ts b/src/cli/commands/completion.ts index a0d98006..c1c08100 100644 --- a/src/cli/commands/completion.ts +++ b/src/cli/commands/completion.ts @@ -27,7 +27,7 @@ export const registerCompletionCommand = (program: Command): void => { completion .command(`${COMPLETE_SUBCOMMAND} [words...]`, { hidden: true }) .description('internal: emit completion candidates for the words typed so far') - .action((words: string[]) => { + .action((words: readonly string[]) => { return runCliProgram( completionCommandProgram({ operation: 'complete', words, commandTree: program }), ); diff --git a/src/cli/commands/testflight.ts b/src/cli/commands/testflight.ts index eb7325a5..aa55ae83 100644 --- a/src/cli/commands/testflight.ts +++ b/src/cli/commands/testflight.ts @@ -94,7 +94,7 @@ export const registerTestflightCommand = (program: Command): void => { .option('--csv ', 'import testers from a CSV (email,firstName,lastName per line)') .option('--dry-run', 'report what would change without inviting anyone', false) .option('-y, --yes', 'skip the confirmation prompt', false) - .action((emails: string[], commandOptions: AddTesterOptions) => + .action((emails: readonly string[], commandOptions: AddTesterOptions) => runCliProgram( testflightCommandProgram({ operation: 'add', @@ -118,7 +118,7 @@ export const registerTestflightCommand = (program: Command): void => { .option('-g, --group ', "beta group to remove from (auto-selected if there's only one)") .option('--dry-run', 'report what would change without removing anyone', false) .option('-y, --yes', 'skip the confirmation prompt', false) - .action((emails: string[], commandOptions: TesterMutationOptions) => + .action((emails: readonly string[], commandOptions: TesterMutationOptions) => runCliProgram( testflightCommandProgram({ operation: 'remove', diff --git a/src/cli/options.ts b/src/cli/options.ts index ff91499e..d3f47b68 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -10,7 +10,7 @@ export type EnvFlags = { printEnv: boolean; }; /** Commander reducer: collect a repeatable string option into an array. */ -const collectEnv = (environmentFlag: string, previousFlags: string[]): string[] => { +const collectEnv = (environmentFlag: string, previousFlags: readonly string[]): string[] => { return [...previousFlags, environmentFlag]; }; /** diff --git a/src/core/adopt/capabilities.ts b/src/core/adopt/capabilities.ts index 0f904ff9..9a157cec 100644 --- a/src/core/adopt/capabilities.ts +++ b/src/core/adopt/capabilities.ts @@ -16,14 +16,16 @@ export type PlannedEntitlement = { }; export type CapabilityPlanInput = { - enabledTypes: string[]; - settingsByType: Record; + enabledTypes: readonly string[]; + settingsByType: Record; profileEntitlements: Record | null; existing: Record; }; /** Render capability settings as concise key/value advice. */ -const describeSettings = (settings: CapabilitySetting[] | undefined): string | undefined => { +const describeSettings = ( + settings: readonly CapabilitySetting[] | undefined, +): string | undefined => { if (settings === undefined) return undefined; if (settings.length === 0) return undefined; const settingDescriptions = settings.map((setting) => { @@ -73,7 +75,7 @@ export const planCapabilityEntitlements = (input: CapabilityPlanInput): PlannedE /** Choose the best profile content for entitlement recovery. */ const chooseProfileContent = ( - profiles: { name: string; profileContent: string }[], + profiles: readonly { readonly name: string; readonly profileContent: string }[], ): string | null => { if (profiles.length === 0) return null; const appStoreProfile = profiles.find((profile) => /app\s*store/i.test(profile.name)); @@ -102,7 +104,7 @@ export const capabilitiesAdopter: Adopter = { let profileEntitlements: Record | null = null; if (profileContent !== null) profileEntitlements = yield* extractProfileEntitlements(profileContent); - const settingsByType: Record = {}; + const settingsByType: Record = {}; for (const capability of capabilities) { if (capability.settings !== undefined) settingsByType[capability.capabilityType] = capability.settings; @@ -129,8 +131,8 @@ export const capabilitiesAdopter: Adopter = { value: entitlement.value, }, }; - if (entitlement.note !== undefined) plannedWrite.note = entitlement.note; - return plannedWrite; + if (entitlement.note === undefined) return plannedWrite; + return { ...plannedWrite, note: entitlement.note }; }); }), }; diff --git a/src/core/adopt/certs.ts b/src/core/adopt/certs.ts index c92479bd..5f12e1a0 100644 --- a/src/core/adopt/certs.ts +++ b/src/core/adopt/certs.ts @@ -11,8 +11,8 @@ export type LocalSigningView = { }; export type CertPlanInput = { - certificates: CertificateResource[]; - profiles: ProfileResource[]; + certificates: readonly CertificateResource[]; + profiles: readonly ProfileResource[]; local: LocalSigningView; bundleId: string; }; @@ -29,8 +29,8 @@ const signingReport = (description: string, note?: string): PlannedWrite => { fidelity: 'detect', change: { home: 'keychain' }, }; - if (note !== undefined) plannedWrite.note = note; - return plannedWrite; + if (note === undefined) return plannedWrite; + return { ...plannedWrite, note }; }; /** Compare live signing assets against the locally cached private-key view. */ @@ -78,7 +78,7 @@ export const certsAdopter: Adopter = { Effect.gen(function* () { const bundleResource = yield* appleCatalog.findBundleId(target.bundleId); const certificates = yield* appleCatalog.listDistributionCertificates(); - let profiles: ProfileResource[] = []; + let profiles: readonly ProfileResource[] = []; if (bundleResource !== null) profiles = yield* appleCatalog.listProfilesForBundleId(bundleResource.id); const storedCredentials = yield* describeStoredCredentials(target.keyId); diff --git a/src/core/adopt/configWriter.ts b/src/core/adopt/configWriter.ts index 0ffae195..6a66065d 100644 --- a/src/core/adopt/configWriter.ts +++ b/src/core/adopt/configWriter.ts @@ -6,17 +6,17 @@ import type { SubscriptionGroupConfig, } from '../types/catalog.js'; /** Fold one bundle's imported product pieces into a single {@link AppProducts}, dropping empty arms. */ -export const aggregateProductPieces = (pieces: ProductPiece[]): AppProducts => { +export const aggregateProductPieces = (pieces: readonly ProductPiece[]): AppProducts => { const inAppPurchases: InAppPurchaseConfig[] = []; const subscriptionGroups: SubscriptionGroupConfig[] = []; for (const piece of pieces) { if (piece.type === 'iap') inAppPurchases.push(piece.iap); else subscriptionGroups.push(piece.group); } - const products: AppProducts = {}; - if (inAppPurchases.length > 0) products.inAppPurchases = inAppPurchases; - if (subscriptionGroups.length > 0) products.subscriptionGroups = subscriptionGroups; - return products; + if (inAppPurchases.length === 0 && subscriptionGroups.length === 0) return {}; + if (inAppPurchases.length === 0) return { subscriptionGroups }; + if (subscriptionGroups.length === 0) return { inAppPurchases }; + return { inAppPurchases, subscriptionGroups }; }; /** Serialize a `products` block (keyed by bundle id) as an indented, paste-ready TypeScript section. */ export const serializeProductsSection = ( diff --git a/src/core/adopt/orchestrator.test.ts b/src/core/adopt/orchestrator.test.ts index 64d3eeeb..19a15548 100644 --- a/src/core/adopt/orchestrator.test.ts +++ b/src/core/adopt/orchestrator.test.ts @@ -14,6 +14,7 @@ import { import type { AdoptCatalogApi, AdoptTarget, Adopter, PlannedWrite } from '../types/adopt.js'; import type { AppDescriptor } from '../types/app.js'; import type { InAppPurchaseConfig } from '../types/catalog.js'; +import type { MutableDeep } from '../types/mutable.js'; const makeApi = (overrides: Partial = {}): AdoptCatalogApi => { const base: AdoptCatalogApi = { getAppId: () => Effect.succeed('app1'), @@ -40,12 +41,12 @@ const app = ( bundleId?: string, configPath = `/repo/${name}/app.json`, ): AppDescriptor => { - const appDescriptor: AppDescriptor = { name, dir: `/repo/${name}`, configPath }; + const appDescriptor: MutableDeep = { name, dir: `/repo/${name}`, configPath }; if (bundleId) appDescriptor.bundleId = bundleId; return appDescriptor; }; /** Run local adopt writes with Effect Platform's Node filesystem and path services. */ -const runApplyAdopt = (plans: TargetPlan[], applyContext: ApplyContext) => +const runApplyAdopt = (plans: readonly TargetPlan[], applyContext: ApplyContext) => Effect.runPromise(applyAdopt(plans, applyContext).pipe(Effect.provide(NodeContext.layer))); describe('detectTargets', () => { it('separates apps with a live record from those skipped, with a confirming signal', async () => { diff --git a/src/core/adopt/orchestrator.ts b/src/core/adopt/orchestrator.ts index 937a1c9b..32598165 100644 --- a/src/core/adopt/orchestrator.ts +++ b/src/core/adopt/orchestrator.ts @@ -140,7 +140,7 @@ export const detectTargets = ( export const planTargets = ( appleCatalog: AdoptCatalogApi, detection: Detection, - adopters: Adopter[], + adopters: readonly Adopter[], ): Effect.Effect => Effect.forEach( detection.detected, @@ -194,7 +194,7 @@ export type AdoptApplyResult = { }; /** Collect imported product pieces into a bundle-keyed catalog. */ -const collectProducts = (plans: TargetPlan[]): Record => { +const collectProducts = (plans: readonly TargetPlan[]): Record => { const productsByBundleId: Record = {}; for (const targetPlan of plans) { const productPieces: ProductPiece[] = []; @@ -212,7 +212,7 @@ const collectProducts = (plans: TargetPlan[]): Record => { /** Apply a confirmed adoption plan to local configuration and delegated listing pulls. */ export const applyAdopt = ( - plans: TargetPlan[], + plans: readonly TargetPlan[], applyContext: ApplyContext, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/adopt/products.ts b/src/core/adopt/products.ts index 3537d2fb..adcee37a 100644 --- a/src/core/adopt/products.ts +++ b/src/core/adopt/products.ts @@ -1,5 +1,6 @@ import { Effect } from 'effect'; import type { AdoptCatalogApi, Adopter, PlannedWrite } from '../types/adopt.js'; +import type { MutableDeep } from '../types/mutable.js'; import type { InAppPurchaseResource, LocalizationResource, @@ -44,9 +45,11 @@ const toSubscriptionPeriod = ( }; /** Convert App Store localization resources into config localization entries. */ -const toProductLocalizations = (localizations: LocalizationResource[]): ProductLocalization[] => +const toProductLocalizations = ( + localizations: readonly LocalizationResource[], +): ProductLocalization[] => localizations.map((localization) => { - const productLocalization: ProductLocalization = { + const productLocalization: MutableDeep = { locale: localization.locale, name: localization.name, }; @@ -75,7 +78,7 @@ const importInAppPurchase = ( productId: purchase.productId, referenceName: purchase.name, type: purchaseType, - localizations: toProductLocalizations(localizations), + localizations: [...toProductLocalizations(localizations)], }; const plannedWrite: PlannedWrite = { description: `products: import in-app purchase ${purchase.productId} (${purchaseType})`, @@ -86,11 +89,11 @@ const importInAppPurchase = ( piece: { type: 'iap', iap: purchaseConfig }, }, }; - if (hasPrice) { - plannedWrite.note = - 'priced on App Store Connect - add `price` in config or keep managing it in the UI'; - } - return plannedWrite; + if (!hasPrice) return plannedWrite; + return { + ...plannedWrite, + note: 'priced on App Store Connect - add `price` in config or keep managing it in the UI', + }; }); type ImportedSubscription = Readonly<{ @@ -118,7 +121,7 @@ const importSubscription = ( productId: subscription.productId, referenceName: subscription.name, subscriptionPeriod, - localizations: toProductLocalizations(localizations), + localizations: [...toProductLocalizations(localizations)], }, pricedUnimported: hasPrice, }; @@ -169,10 +172,11 @@ const importSubscriptionGroup = ( piece: { type: 'subscriptionGroup', group: groupConfig }, }, }; - if (unimportedPriceIds.length > 0) { - plannedWrite.note = `priced on App Store Connect, not imported - set \`price\` for: ${unimportedPriceIds.join(', ')}`; - } - return plannedWrite; + if (unimportedPriceIds.length === 0) return plannedWrite; + return { + ...plannedWrite, + note: `priced on App Store Connect, not imported - set \`price\` for: ${unimportedPriceIds.join(', ')}`, + }; }); /** Read products and plan their launch.config imports. */ diff --git a/src/core/agents/render.ts b/src/core/agents/render.ts index 43c76f36..998a9719 100644 --- a/src/core/agents/render.ts +++ b/src/core/agents/render.ts @@ -28,7 +28,7 @@ const renderStep = (step: SkillStep): string => { return `\`${command}\` - ${step.note}`; }; /** Render a skill's reference catalog as a two-column markdown table. */ -const renderReferenceTable = (commands: SkillStep[]): string => { +const renderReferenceTable = (commands: readonly SkillStep[]): string => { const rows = commands.map((step) => { const commandParts = ['npx', 'launch', ...step.path]; if (step.args !== undefined) commandParts.push(...step.args); @@ -38,21 +38,21 @@ const renderReferenceTable = (commands: SkillStep[]): string => { return ['| Command | What it does |', '| --- | --- |', ...rows].join('\n'); }; /** Render a bulleted "Use this when" list from a skill's triggers. */ -const renderTriggers = (triggers: string[]): string => { +const renderTriggers = (triggers: readonly string[]): string => { return ['## Use this when', '', ...triggers.map((t) => `- ${t}`)].join('\n'); }; /** Render a skill's numbered recipe. */ -const renderRecipe = (steps: SkillStep[]): string => { +const renderRecipe = (steps: readonly SkillStep[]): string => { return ['## Steps', '', ...steps.map((step, i) => `${i + 1}. ${renderStep(step)}`)].join('\n'); }; /** Render a skill's cautions block, or `""` when it has none. */ -const renderCautions = (cautions: string[] | undefined): string => { +const renderCautions = (cautions: readonly string[] | undefined): string => { if (!cautions) return ''; if (cautions.length === 0) return ''; return ['## Cautions', '', ...cautions.map((c) => `- ${c}`)].join('\n'); }; /** Join non-empty markdown chunks with blank lines, then end with a single trailing newline. */ -const joinSections = (sections: string[]): string => { +const joinSections = (sections: readonly string[]): string => { return `${sections.filter((s) => s.trim() !== '').join('\n\n')}\n`; }; /** @@ -203,7 +203,7 @@ const renderSkillSection = (skill: ConsumerSkill): string => { export const renderAgentsBlock = ( version: string, base: BaseContext = BASE_CONTEXT, - skills: ConsumerSkill[] = CONSUMER_SKILLS, + skills: readonly ConsumerSkill[] = CONSUMER_SKILLS, ): string => { const sections = [ '## Shipping this app with Launch', @@ -222,7 +222,7 @@ export const renderAgentsBlock = ( */ export const renderClaudeMemoryBlock = ( version: string, - skills: ConsumerSkill[] = CONSUMER_SKILLS, + skills: readonly ConsumerSkill[] = CONSUMER_SKILLS, ): string => { const skillList = skills.map((s) => `\`${s.id}\``).join(', '); const inner = [ @@ -347,7 +347,7 @@ export const renderWindsurfTaskRule = ( export const renderCopilotBlock = ( version: string, base: BaseContext = BASE_CONTEXT, - skills: ConsumerSkill[] = CONSUMER_SKILLS, + skills: readonly ConsumerSkill[] = CONSUMER_SKILLS, ): string => { const skillSummaries = skills.map((s) => `- **${s.title}**: ${s.description}`).join('\n'); const sections = [ @@ -370,7 +370,7 @@ export const renderCopilotBlock = ( export const renderKiroSteering = ( version: string, base: BaseContext = BASE_CONTEXT, - skills: ConsumerSkill[] = CONSUMER_SKILLS, + skills: readonly ConsumerSkill[] = CONSUMER_SKILLS, ): GeneratedAgentFile => { const renderedMarkdown = joinSections([ '# Launch - build, sign & ship to the App Store / Play', diff --git a/src/core/build/appleTargets.ts b/src/core/build/appleTargets.ts index 0894547f..59cc2647 100644 --- a/src/core/build/appleTargets.ts +++ b/src/core/build/appleTargets.ts @@ -195,7 +195,9 @@ export const discoverExtensionBundleIds = ( const targets = parsePbxprojTargets(projectText); return splitMainAndExtensions(targets, mainBundleId).extensions; }); -export const multiTargetSigningWarnings = (readiness: TargetSigningReadiness[]): string[] => { +export const multiTargetSigningWarnings = ( + readiness: readonly TargetSigningReadiness[], +): string[] => { const warnings: string[] = []; for (const target of readiness) { if (!target.registered) { diff --git a/src/core/build/artifactRetention.ts b/src/core/build/artifactRetention.ts index cc872d66..9e02d6f4 100644 --- a/src/core/build/artifactRetention.ts +++ b/src/core/build/artifactRetention.ts @@ -10,6 +10,7 @@ import type { } from '../types/artifacts.js'; import { ArtifactIndexSchema } from '../types/artifacts.js'; import type { LaunchConfig } from '../types/config.js'; +import type { MutableDeep } from '../types/mutable.js'; export const DEFAULT_RETENTION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; @@ -26,7 +27,7 @@ const resolveArtifactIndexPath = ( /** Read and decode the newest-first artifact index, tolerating absent or malformed state. */ export const readArtifactIndex = ( indexPath?: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const artifactIndexPath = yield* resolveArtifactIndexPath(indexPath); @@ -44,7 +45,7 @@ export const readArtifactIndex = ( /** Persist the artifact index, creating its parent directory first. */ export const writeArtifactIndex = ( - artifactIndex: BuildArtifact[], + artifactIndex: readonly BuildArtifact[], indexPath?: string, ): Effect.Effect => Effect.gen(function* () { @@ -82,7 +83,7 @@ const artifactGroupKey = (buildArtifact: BuildArtifact): string => /** Split an artifact index according to the retention and keep-newest policy. */ export const planPrune = ( - artifactIndex: BuildArtifact[], + artifactIndex: readonly BuildArtifact[], pruneOptions: Pick, ): { prune: BuildArtifact[]; keep: BuildArtifact[] } => { const newestArtifactByGroup = new Map(); @@ -148,7 +149,9 @@ export const runArtifactPrune = ( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const artifactIndex = yield* readArtifactIndex(pruneOptions.indexPath); - const policyInput: Pick = { + const policyInput: MutableDeep< + Pick + > = { now: pruneOptions.now, retentionDays: pruneOptions.retentionDays, }; diff --git a/src/core/build/buildDiagnostics.ts b/src/core/build/buildDiagnostics.ts index 87522582..a5c1a832 100644 --- a/src/core/build/buildDiagnostics.ts +++ b/src/core/build/buildDiagnostics.ts @@ -156,7 +156,7 @@ export const diagnoseBuildLog = (log: string): BuildDiagnosis[] => { return matched; }; /** Render diagnoses as an indented, human-readable block for the terminal. Empty input -> empty string. */ -export const formatDiagnoses = (diagnoses: BuildDiagnosis[]): string => { +export const formatDiagnoses = (diagnoses: readonly BuildDiagnosis[]): string => { if (diagnoses.length === 0) return ''; let header = 'Likely causes:'; if (diagnoses.length === 1) header = 'Likely cause:'; diff --git a/src/core/build/buildHistoryCommand.ts b/src/core/build/buildHistoryCommand.ts index 9c377b17..38c60011 100644 --- a/src/core/build/buildHistoryCommand.ts +++ b/src/core/build/buildHistoryCommand.ts @@ -141,7 +141,7 @@ export const toBuildRow = (artifact: BuildArtifact): BuildRow => { /** Narrow build history to the requested app and platform. */ export const filterBuilds = ( - storedBuilds: BuildArtifact[], + storedBuilds: readonly BuildArtifact[], filters: Readonly<{ app?: string; platform?: Platform }>, ): BuildArtifact[] => storedBuilds.filter((storedBuild) => { @@ -152,7 +152,7 @@ export const filterBuilds = ( /** Resolve a full id, build number, or `latest` against newest-first history. */ export const findBuild = ( - storedBuilds: BuildArtifact[], + storedBuilds: readonly BuildArtifact[], reference: string, ): BuildArtifact | undefined => { if (reference === 'latest') return storedBuilds[0]; @@ -290,7 +290,7 @@ const parsePruneDays = ( /** Load newest-first history from the configured storage provider. */ const loadHistory = (): Effect.Effect< - BuildArtifact[], + readonly BuildArtifact[], BuildHistoryCommandFailure, FileSystem.FileSystem | StorageResolverRequirements > => diff --git a/src/core/build/pipeline.ts b/src/core/build/pipeline.ts index bd567560..c90a537f 100644 --- a/src/core/build/pipeline.ts +++ b/src/core/build/pipeline.ts @@ -1,5 +1,7 @@ import { Data, Effect } from 'effect'; +import type { AppDescriptor } from '../types/app.js'; import type { ResolvedBuildContext } from '../types/config.js'; +import type { MutableDeep } from '../types/mutable.js'; import type { NotifyEvent } from '../services/notify.js'; import { notify } from '../services/notify.js'; import { loadConfig } from '../config/config.js'; @@ -99,11 +101,25 @@ export const prepareBuild = (options: BuildRunOptions) => let configCheckDescription = 'no footguns'; if (findings.length > 0) configCheckDescription = `${findings.length} warning(s)`; yield* log.step('config check', configCheckDescription); - let buildContext: ResolvedBuildContext = { + const draftApp: MutableDeep = { + name: app.name, + dir: app.dir, + configPath: app.configPath, + }; + if (app.bundleId !== undefined) draftApp.bundleId = app.bundleId; + if (app.packageName !== undefined) draftApp.packageName = app.packageName; + if (app.version !== undefined) draftApp.version = app.version; + if (app.iosEntitlements !== undefined) draftApp.iosEntitlements = { ...app.iosEntitlements }; + if (app.iosExtensions !== undefined) draftApp.iosExtensions = [...app.iosExtensions]; + if (app.androidVersionCode !== undefined) draftApp.androidVersionCode = app.androidVersionCode; + if (app.usesNonExemptEncryption !== undefined) { + draftApp.usesNonExemptEncryption = app.usesNonExemptEncryption; + } + let buildContext: MutableDeep = { platform, - app, + app: draftApp, profile, - env, + env: { ...env }, explain: options.explain, dryRun, forceClean: options.forceClean === true, @@ -113,7 +129,7 @@ export const prepareBuild = (options: BuildRunOptions) => buildContext = { ...buildContext, android: resolveAndroidRelease(options, profile) }; if (options.distribution !== undefined) buildContext = { ...buildContext, distribution: options.distribution }; - return { config, app, profile, env, buildContext, log }; + return { config, app: draftApp, profile, env, buildContext, log }; }); /** * Run a build, then fire any configured completion notification. Throws with a clear message on any diff --git a/src/core/build/pipelineEnv.ts b/src/core/build/pipelineEnv.ts index 8f2411db..7075b61a 100644 --- a/src/core/build/pipelineEnv.ts +++ b/src/core/build/pipelineEnv.ts @@ -39,7 +39,7 @@ export const makeBuildEnvironmentFailure = * to a fuzzy type-to-search over the name and bundle/package id. With no TTY and more than one app it * refuses to guess and tells the user to pass `--app`, rather than silently building the wrong one. */ -export const selectApp = (apps: AppDescriptor[], appName: string | undefined) => +export const selectApp = (apps: readonly AppDescriptor[], appName: string | undefined) => Effect.gen(function* () { if (apps.length === 0) return yield* Effect.fail( @@ -98,7 +98,7 @@ export const resolveCommandEnv = (input: { profile: BuildProfile; cliEnv?: Record | undefined; includeLocal?: boolean | undefined; - envExclude?: string[] | undefined; + envExclude?: readonly string[] | undefined; }) => Effect.gen(function* () { const secrets = yield* resolveBuildSecrets(input.app.name, input.profile.name).pipe( @@ -134,7 +134,7 @@ export const validateResolvedEnv = ( appDir: string, resolved: ResolvedEnv, log: Logger, - exclude: string[] = [], + exclude: readonly string[] = [], ) => Effect.gen(function* () { const missing = yield* missingKeys(appDir, resolved.values, exclude).pipe( diff --git a/src/core/build/pipelineSigning.ts b/src/core/build/pipelineSigning.ts index ab67f7d0..7b40de08 100644 --- a/src/core/build/pipelineSigning.ts +++ b/src/core/build/pipelineSigning.ts @@ -32,7 +32,10 @@ import { discoverExtensionBundleIds } from './appleTargets.js'; import { nativeProjectDirName } from '../services/platform.js'; import type { BuildRunOptions } from './pipelineTypes.js'; /** The interactive build-time account picker: choose among onboarded accounts and make the pick active. */ -export const pickAccount = (launchPrompt: LaunchPromptService, accounts: AccountRecord[]) => +export const pickAccount = ( + launchPrompt: LaunchPromptService, + accounts: readonly AccountRecord[], +) => Effect.gen(function* () { return yield* launchPrompt.select({ message: 'Which Apple account?', @@ -94,7 +97,7 @@ export const resolveExtensionBundleIds = ( ): Effect.Effect => Effect.gen(function* () { const pathService = yield* Path.Path; - let configured: string[] = []; + let configured: readonly string[] = []; if (app.iosExtensions !== undefined) configured = app.iosExtensions; const nativeDirectoryName = yield* nativeProjectDirName(platform); const nativeDirectory = pathService.join(app.dir, nativeDirectoryName); @@ -116,7 +119,7 @@ export const warnUnreadySigningTargets = ( ascKey: AppleCredentials['ascKey'], app: AppDescriptor, bundleId: string, - extensions: string[], + extensions: readonly string[], log: Logger, ) => Effect.gen(function* () { diff --git a/src/core/build/pipelineTypes.ts b/src/core/build/pipelineTypes.ts index 09e600c7..22fc7625 100644 --- a/src/core/build/pipelineTypes.ts +++ b/src/core/build/pipelineTypes.ts @@ -7,6 +7,7 @@ import type { } from '../types/app.js'; import type { SizeReport } from '../types/artifacts.js'; import type { LaunchConfig, ResolvedBuildContext } from '../types/config.js'; +import type { MutableDeep } from '../types/mutable.js'; import type { RemoteTarget } from '../types/remote.js'; import type { Logger } from '../services/logger.js'; import type { BumpKind } from '../release/version.js'; @@ -43,10 +44,10 @@ export type BuildRunOptions = { */ export type PreparedBuild = { config: LaunchConfig; - app: AppDescriptor; + app: MutableDeep; profile: ResolvedBuildContext['profile']; env: Record; - buildContext: ResolvedBuildContext; + buildContext: MutableDeep; log: Logger; }; /** Placeholder API key used in `--dry-run`, so the flow runs without an imported credential. */ diff --git a/src/core/build/pipelineVersion.ts b/src/core/build/pipelineVersion.ts index cc03da42..a5b52035 100644 --- a/src/core/build/pipelineVersion.ts +++ b/src/core/build/pipelineVersion.ts @@ -20,6 +20,7 @@ import { AppleStoreClientService } from '../services/appleStoreClient.js'; import { GoogleStoreClientService } from '../services/googleStoreClient.js'; import { LaunchPrompt } from '../services/prompt.js'; import type { BuildRunOptions, BumpResolution } from './pipelineTypes.js'; +import type { MutableDeep } from '../types/mutable.js'; export type BuildVersionFailure = Readonly<{ readonly _tag: 'BuildVersionFailure'; @@ -174,7 +175,7 @@ export const promptVersion = (baseline: string, current: string, latest: string * explains the source (e.g. `patch, remembered`) so the line is self-documenting. */ export const applyChosenVersion = ( - app: AppDescriptor, + app: MutableDeep, platform: Platform, chosen: string, latest: string | null, @@ -212,7 +213,7 @@ export const applyChosenVersion = ( export const resolveMarketingVersion = ( ascKey: AppleCredentials['ascKey'], bundleId: string, - app: AppDescriptor, + app: MutableDeep, platform: Platform, options: BuildRunOptions, log: Logger, diff --git a/src/core/build/remotePipeline.ts b/src/core/build/remotePipeline.ts index 8574a2c8..1bf5d2f6 100644 --- a/src/core/build/remotePipeline.ts +++ b/src/core/build/remotePipeline.ts @@ -25,6 +25,7 @@ import { ARTIFACTS_DIR } from '../services/paths.js'; import { autoReleaseAt, costBanner } from './cost.js'; import { clearLiveHost, getLiveHost, setLiveHost } from '../distribution/cloudState.js'; import { ensureRemoteSigningAssets } from '../credentials/appleSigning.js'; +import type { MutableDeep } from '../types/mutable.js'; import { type RemoteBuildInputs, openRemoteSession, @@ -76,7 +77,7 @@ const acquireHost = ( }), ); } - const request: AllocateRequest = { + const request: MutableDeep = { confirm: (message) => interactiveConfirm(launchPrompt, message), onProgress: (message) => { Effect.runFork(log.note(message)); diff --git a/src/core/build/resignCommand.ts b/src/core/build/resignCommand.ts index a0c2a7f7..d0701457 100644 --- a/src/core/build/resignCommand.ts +++ b/src/core/build/resignCommand.ts @@ -293,7 +293,7 @@ const selectStoredArtifact = ( loadedConfig.config, launchPaths.workingDirectory, ); - let buildHistory = yield* storageProvider.list(); + let buildHistory: readonly BuildArtifact[] = yield* storageProvider.list(); if (commandInput.app !== undefined) { buildHistory = buildHistory.filter( (storedArtifact) => storedArtifact.appName === commandInput.app, diff --git a/src/core/config/config.ts b/src/core/config/config.ts index 6ac04832..bf09107c 100644 --- a/src/core/config/config.ts +++ b/src/core/config/config.ts @@ -11,6 +11,7 @@ import { DEFAULT_SUBMITTER, } from '../types/config.js'; import { LaunchPaths, type LaunchPathsService } from '../services/paths.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * Absolute path to THIS package's own public entry (`defineConfig` + the config types), resolved * relative to the loader so it points at whichever copy is actually running - the globally-installed @@ -195,7 +196,7 @@ const toDescriptor = ( appHandle = expoConfig['name']; } if (appHandle === undefined) return null; - const descriptor: AppDescriptor = { + const descriptor: MutableDeep = { name: appHandle.toLowerCase(), dir: appDirectory, configPath, diff --git a/src/core/config/configScaffold.ts b/src/core/config/configScaffold.ts index 82838ed1..a18328f3 100644 --- a/src/core/config/configScaffold.ts +++ b/src/core/config/configScaffold.ts @@ -2,7 +2,7 @@ import { Path } from '@effect/platform'; import { Effect } from 'effect'; import type { AppDescriptor } from '../types/app.js'; /** Derive a single `appRoots` subdir when every discovered app lives under one (e.g. an `apps/` monorepo). */ -export const detectAppRoot = (apps: AppDescriptor[], workingDirectory: string) => +export const detectAppRoot = (apps: readonly AppDescriptor[], workingDirectory: string) => Effect.gen(function* () { const pathService = yield* Path.Path; const segments = new Set(); diff --git a/src/core/config/env.ts b/src/core/config/env.ts index 2f2accbc..a00614de 100644 --- a/src/core/config/env.ts +++ b/src/core/config/env.ts @@ -43,7 +43,7 @@ export const loadDotenvFile = (filePath: string) => * snag a publishable `EXPO_PUBLIC_..._KEY`. The single matching rule shared by {@link resolveEnv} (drops * matches before injection) and {@link missingKeys} (exempts matches from the gate), so the two agree. */ -export const isEnvExcluded = (name: string, patterns: string[]): boolean => { +export const isEnvExcluded = (name: string, patterns: readonly string[]): boolean => { for (const pattern of patterns) { if (pattern.endsWith('*')) { if (name.startsWith(pattern.slice(0, -1))) return true; @@ -64,7 +64,7 @@ export const isEnvExcluded = (name: string, patterns: string[]): boolean => { export const missingKeys = ( appDirectory: string, environment: Record, - excludedPatterns: string[] = [], + excludedPatterns: readonly string[] = [], ) => Effect.gen(function* () { const pathService = yield* Path.Path; @@ -138,7 +138,7 @@ export type ResolveEnvInput = { secrets?: Record | undefined; cliEnv?: Record | undefined; includeLocal?: boolean | undefined; - envExclude?: string[] | undefined; + envExclude?: readonly string[] | undefined; }; /** * Resolve env through the single precedence ladder (lowest -> highest, later overrides earlier): @@ -185,7 +185,7 @@ export const resolveEnv = (input: ResolveEnvInput) => // Hard denylist: an excluded name is skipped in EVERY layer, so it can never land in the result no // matter which layer (incl. the final `--env`) set it - exclusion wins over precedence by design. Names // some layer actually tried to set are recorded, so the build log reports real drops, not the raw list. - let excludedPatterns: string[] = []; + let excludedPatterns: readonly string[] = []; if (input.envExclude !== undefined) excludedPatterns = input.envExclude; const excludedSeen = new Set(); const values: Record = {}; diff --git a/src/core/config/jsonSchema.ts b/src/core/config/jsonSchema.ts index 53c97a6e..cf627d62 100644 --- a/src/core/config/jsonSchema.ts +++ b/src/core/config/jsonSchema.ts @@ -194,7 +194,7 @@ const validateObject = ( /** Validate an `anyOf`/`oneOf`: report a single, concise violation when the right number of branches don't match. */ const validateCombinator = ( candidateValue: unknown, - branches: JsonSchema[], + branches: readonly JsonSchema[], root: JsonSchema, path: string, kind: 'anyOf' | 'oneOf', diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 0fa32e6b..404b4b89 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -1016,7 +1016,7 @@ export const validateLaunchConfig = (candidateConfig: unknown): SchemaViolation[ }; const parseIssueToViolations = ( parseIssue: ParseResult.ParseIssue, - parentPath: PropertyKey[], + parentPath: readonly PropertyKey[], ): SchemaViolation[] => { switch (parseIssue._tag) { case 'Composite': @@ -1053,7 +1053,7 @@ const pathSegments = (path: ParseResult.Path): PropertyKey[] => { }; const parseIssuesToViolations = ( parseIssues: ParseResult.SingleOrNonEmpty, - parentPath: PropertyKey[], + parentPath: readonly PropertyKey[], ): SchemaViolation[] => { let issueList: readonly ParseResult.ParseIssue[]; if ('_tag' in parseIssues) issueList = [parseIssues]; diff --git a/src/core/config/toolchain.ts b/src/core/config/toolchain.ts index 193356f2..c5f02601 100644 --- a/src/core/config/toolchain.ts +++ b/src/core/config/toolchain.ts @@ -197,7 +197,7 @@ export const remoteToolchainPreflight = (mode: 'install' | 'assert'): string => */ export type ToolchainIo = { exists(command: string): Effect.Effect; - run(command: string, args: string[]): Effect.Effect; + run(command: string, args: readonly string[]): Effect.Effect; confirm(message: string): Effect.Effect; confirmText(message: string, expected: string): Effect.Effect; log(message: string): Effect.Effect; @@ -226,7 +226,7 @@ export type EnsureToolchainOptions = { /** Return the tools from `tools` whose command isn't currently on `PATH`. */ const detectMissing = ( io: Pick, - tools: Tool[], + tools: readonly Tool[], ): Effect.Effect => Effect.filter(tools, (tool) => io.exists(tool.command).pipe(Effect.map((exists) => !exists)), { concurrency: 1, @@ -343,7 +343,7 @@ export const ensureCcacheInstalled = (options: { */ const installBrewTools = ( io: ToolchainIo, - brewTools: Tool[], + brewTools: readonly Tool[], assumeYes: boolean, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/credentials/accounts.ts b/src/core/credentials/accounts.ts index 5d96ac8b..d6c45f61 100644 --- a/src/core/credentials/accounts.ts +++ b/src/core/credentials/accounts.ts @@ -11,6 +11,7 @@ import { deleteSecret, getSecret, setSecret } from './keychain.js'; import { migrateLegacySigningIndex, p12PasswordAccount } from './appleSigning.js'; import { AppStoreIdentityService } from '../services/appStoreIdentity.js'; import type { LaunchSecretStoreService } from '../services/secretStore.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Secret-store account holding one Apple account's `.p8` PEM, namespaced by Key ID. */ const p8Account = (keyId: string): string => { return `asc-p8:${keyId}`; @@ -25,23 +26,19 @@ export type AccountFailure = Readonly<{ readonly message: string; }>; export const makeAccountFailure = Data.tagged('AccountFailure'); -const AccountRecordSchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - keyId: Schema.String, - issuerId: Schema.String, - label: Schema.String, - teamId: Schema.optionalWith(Schema.String, { exact: true }), - apps: Schema.optionalWith(Schema.mutable(Schema.Array(Schema.String)), { exact: true }), - addedAt: Schema.String, - resolvedAt: Schema.optionalWith(Schema.String, { exact: true }), - }), -); -const AccountsFileSchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - active: Schema.NullOr(Schema.String), - accounts: Schema.mutable(Schema.Array(AccountRecordSchema)), - }), -); +const AccountRecordSchema: Schema.Schema = Schema.Struct({ + keyId: Schema.String, + issuerId: Schema.String, + label: Schema.String, + teamId: Schema.optionalWith(Schema.String, { exact: true }), + apps: Schema.optionalWith(Schema.Array(Schema.String), { exact: true }), + addedAt: Schema.String, + resolvedAt: Schema.optionalWith(Schema.String, { exact: true }), +}); +const AccountsFileSchema: Schema.Schema = Schema.Struct({ + active: Schema.NullOr(Schema.String), + accounts: Schema.Array(AccountRecordSchema), +}); type AccountStorageRequirements = FileSystem.FileSystem | LaunchPathsService | Path.Path; const emptyAccountsFile = (): AccountsFile => ({ active: null, accounts: [] }); /** ISO-8601 stamp for `addedAt`/`resolvedAt`. */ @@ -139,7 +136,7 @@ export const findAccount = ( ); /** Match an account by its label or Key ID, case-insensitively - the selector form users type. */ export const matchAccount = ( - accounts: AccountRecord[], + accounts: readonly AccountRecord[], selector: string, ): AccountRecord | undefined => { const needle = selector.trim().toLowerCase(); @@ -209,14 +206,14 @@ export const addAccount = ( if (!hasIdentity && input.apps !== undefined) hasIdentity = input.apps.length > 0; let addedAt = existing?.addedAt; if (addedAt === undefined) addedAt = timestamp; - const record: AccountRecord = { + const record: MutableDeep = { keyId: input.keyId, issuerId: input.issuerId, label: input.label, addedAt, }; if (input.teamId !== null && input.teamId !== undefined) record.teamId = input.teamId; - if (input.apps !== undefined && input.apps.length > 0) record.apps = input.apps; + if (input.apps !== undefined && input.apps.length > 0) record.apps = [...input.apps]; if (hasIdentity) record.resolvedAt = timestamp; let accounts = [...file.accounts, record]; if (existing) { @@ -232,16 +229,16 @@ export const addAccount = ( export const updateAccountIdentity = ( keyId: string, teamId: string | null, - apps: string[], + apps: readonly string[], ): Effect.Effect => Effect.gen(function* () { const file = yield* readAccounts(); const timestamp = yield* currentTimestamp(); const accounts = file.accounts.map((account) => { if (account.keyId !== keyId) return account; - const next: AccountRecord = { ...account, resolvedAt: timestamp }; - if (teamId != null) next.teamId = teamId; - if (apps.length > 0) next.apps = apps; + let next: AccountRecord = { ...account, resolvedAt: timestamp }; + if (teamId != null) next = { ...next, teamId }; + if (apps.length > 0) next = { ...next, apps: [...apps] }; return next; }); yield* writeAccounts({ active: file.active, accounts }); @@ -364,7 +361,7 @@ export const decideBuildAccount = (file: AccountsFile, selector?: string): Build export type ResolveBuildAccountOptions = { selector?: string | undefined; interactive: boolean; - pick: (accounts: AccountRecord[]) => Effect.Effect; + pick: (accounts: readonly AccountRecord[]) => Effect.Effect; }; /** * Resolve the account a build should use, applying {@link decideBuildAccount} and then either using diff --git a/src/core/credentials/appleSigning.test.ts b/src/core/credentials/appleSigning.test.ts index 26c09263..e2064ef7 100644 --- a/src/core/credentials/appleSigning.test.ts +++ b/src/core/credentials/appleSigning.test.ts @@ -96,7 +96,7 @@ const seedCredentials = ( uuid: string; } >, - installedUuids: string[], + installedUuids: readonly string[], ): void => { const dir = join(home.dir, '.launch', 'credentials', KEY_ID); mkdirSync(dir, { recursive: true }); @@ -182,7 +182,7 @@ describe('profileStaleAgainstCapabilities - regenerate-vs-reuse decision (#261)' profileContent: 'base64-bytes', }; /** A client stub exposing only the one read this decision makes - no network. */ - function clientWithCapabilities(types: string[]) { + function clientWithCapabilities(types: readonly string[]) { return { listBundleIdCapabilities: vi.fn(() => Effect.succeed( diff --git a/src/core/credentials/appleSigning.ts b/src/core/credentials/appleSigning.ts index 0f70cdc4..83349db0 100644 --- a/src/core/credentials/appleSigning.ts +++ b/src/core/credentials/appleSigning.ts @@ -31,6 +31,7 @@ import { import type { CertificateResource, ProfileResource } from '../types/appleCatalog.js'; import type { LaunchSecretStoreService } from '../services/secretStore.js'; import { randomHexSecret } from './randomSecret.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * Keychain account holding the random password that protects an account's `.p12` backup, namespaced * by Key ID so each Apple account's `.p12` has its own password. Exported so first-run migration can @@ -106,7 +107,7 @@ export type EnsureSigningOptions = { log: Logger; dryRun: boolean; confirmCreate: (message: string) => Effect.Effect; - extensions?: string[]; + extensions?: readonly string[]; }; /** Summarize what signing material is cached locally for one account, for `launch creds status`. */ export const describeStoredCredentials = ( @@ -194,7 +195,7 @@ const plistFirstArrayString = (xml: string, key: string): string | null => { export const loadCachedSigningAssets = ( keyId: string, bundleId: string, - extensions: string[] = [], + extensions: readonly string[] = [], ): Effect.Effect< SigningAssets | null, never, @@ -231,7 +232,7 @@ export const loadCachedSigningAssets = ( return null; extensionProfiles[ext] = extProfile.name; } - const signingAssets: SigningAssets = { + const signingAssets: MutableDeep = { bundleId, teamId: profile.teamId, certName: DISTRIBUTION_CERT_NAME, @@ -1020,7 +1021,7 @@ const createCertificateForUpload = ( /** A cached cert is reusable only if Apple still lists its serial and the local `.p12` backup exists. */ const reusableCertificate = ( index: CredentialsIndex, - liveCerts: CertificateResource[], + liveCerts: readonly CertificateResource[], ): Effect.Effect => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/src/core/credentials/capabilities.ts b/src/core/credentials/capabilities.ts index cb077f62..468913db 100644 --- a/src/core/credentials/capabilities.ts +++ b/src/core/credentials/capabilities.ts @@ -203,7 +203,7 @@ export const APP_GROUP_PORTAL_URL = * to-do: which groups, where to create them, and which bundle ids must join. Pure - for the build path to * warn before archiving and for unit tests. */ -export const appGroupPortalNotice = (containers: string[]): string | null => { +export const appGroupPortalNotice = (containers: readonly string[]): string | null => { if (containers.length === 0) return null; const groups = containers.map((id) => `"${id}"`).join(', '); let plural = 'App Groups'; diff --git a/src/core/credentials/pushKeyStore.ts b/src/core/credentials/pushKeyStore.ts index f5cf191f..10f0a4bf 100644 --- a/src/core/credentials/pushKeyStore.ts +++ b/src/core/credentials/pushKeyStore.ts @@ -10,6 +10,7 @@ import { import { getSecret, setSecret } from './keychain.js'; import { decodeP8, encodeP8 } from './accounts.js'; import type { LaunchSecretStoreService } from '../services/secretStore.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Secret-store account holding one APNs key's `.p8` PEM, namespaced by Key ID. */ const apnsAccount = (keyId: string): string => { return `apns-p8:${keyId}`; @@ -93,7 +94,7 @@ export const importPushKey = ( const timestamp = yield* currentTimestamp(); let importedAt = existing?.importedAt; if (importedAt === undefined) importedAt = timestamp; - const record: ApnsKeyRecord = { + const record: MutableDeep = { keyId: input.keyId, importedAt, }; diff --git a/src/core/credentials/signingPreflight.ts b/src/core/credentials/signingPreflight.ts index 0f690839..90160c83 100644 --- a/src/core/credentials/signingPreflight.ts +++ b/src/core/credentials/signingPreflight.ts @@ -37,7 +37,7 @@ export const resolveExtensionBundleIdsForApp = ( ): Effect.Effect => Effect.gen(function* () { const pathService = yield* Path.Path; - let configured: string[] = []; + let configured: readonly string[] = []; if (app.iosExtensions !== undefined) configured = app.iosExtensions; const nativeDirectory = pathService.join(app.dir, 'ios'); const discovered = yield* discoverExtensionBundleIds(nativeDirectory, app.bundleId); @@ -59,7 +59,7 @@ export const appGroupPreflightNotice = ( export const gatherTargetSigningReadiness = ( asc: SigningPreflightAscApi, bundleId: string, - extensions: string[], + extensions: readonly string[], entitlements: Record | undefined, ): Effect.Effect => { const required = mapEntitlementsToCapabilities(entitlements).enable; @@ -85,7 +85,9 @@ export const gatherTargetSigningReadiness = ( ); }; /** Turn readiness facts into build-time warning strings (best-effort - never throws). */ -export const signingPreflightWarnings = (readiness: TargetSigningReadiness[]): string[] => { +export const signingPreflightWarnings = ( + readiness: readonly TargetSigningReadiness[], +): string[] => { return multiTargetSigningWarnings(readiness); }; /** Turn readiness facts into doctor checks - unregistered/missing-capability targets fail the run. */ diff --git a/src/core/dashboard/render.ts b/src/core/dashboard/render.ts index cd562b23..1a759e9f 100644 --- a/src/core/dashboard/render.ts +++ b/src/core/dashboard/render.ts @@ -46,7 +46,7 @@ const renderSection = (sectionTitle: string, sectionHtml: string): string => const renderProviderChip = (providerLabel: string, providerName: string): string => `${escapeHtml(providerLabel)} ${escapeHtml(providerName)}`; -const renderAppsTable = (apps: DashboardApp[]): string => { +const renderAppsTable = (apps: readonly DashboardApp[]): string => { const appTableRows = apps.map((app) => [ renderTableCell(app.name), renderTableCell(app.version), @@ -65,7 +65,7 @@ const renderAccountStatus = (account: DashboardAccount): string => { return renderTableCell(null); }; -const renderAccountsTable = (accounts: DashboardAccount[]): string => { +const renderAccountsTable = (accounts: readonly DashboardAccount[]): string => { const accountTableRows = accounts.map((account) => [ renderTableCell(account.label), renderTableCell(account.keyId), @@ -90,7 +90,7 @@ const renderArtifactStatus = (buildArtifact: DashboardArtifact): string => { return 'on disk'; }; -const renderArtifactsTable = (buildArtifacts: DashboardArtifact[]): string => { +const renderArtifactsTable = (buildArtifacts: readonly DashboardArtifact[]): string => { const artifactTableRows = buildArtifacts.map((buildArtifact) => [ renderTableCell(buildArtifact.app), renderTableCell(buildArtifact.platform), @@ -112,7 +112,7 @@ const renderSecretScope = (buildSecret: DashboardSecret): string => { return renderTableCell(buildSecret.profile); }; -const renderSecretsTable = (buildSecrets: DashboardSecret[]): string => { +const renderSecretsTable = (buildSecrets: readonly DashboardSecret[]): string => { const secretTableRows = buildSecrets.map((buildSecret) => [ renderTableCell(buildSecret.app), renderSecretScope(buildSecret), diff --git a/src/core/distribution/updateHistory.ts b/src/core/distribution/updateHistory.ts index 5fdba955..2e922a2a 100644 --- a/src/core/distribution/updateHistory.ts +++ b/src/core/distribution/updateHistory.ts @@ -111,7 +111,7 @@ const writeHistory = ( storage: StorageProvider, channel: string, platform: string, - entries: UpdateHistoryEntry[], + entries: readonly UpdateHistoryEntry[], ): Effect.Effect => storage .putObject( diff --git a/src/core/docs/commandDocs/commandReference.ts b/src/core/docs/commandDocs/commandReference.ts index 1a35172d..af49a707 100644 --- a/src/core/docs/commandDocs/commandReference.ts +++ b/src/core/docs/commandDocs/commandReference.ts @@ -1,7 +1,7 @@ import { escapeCell } from './common.js'; import type { CommandSpec, DocStats, OptionSpec } from '@core/types/commandDocs.js'; /** Render a command's flag table, or `""` when it has no options. */ -const renderOptionsTable = (options: OptionSpec[]): string => { +const renderOptionsTable = (options: readonly OptionSpec[]): string => { if (options.length === 0) return ''; const rows = options.map((o) => `| \`${escapeCell(o.flags)}\` | ${escapeCell(o.description)} |`); return ['', '| Flag | Description |', '| --- | --- |', ...rows].join('\n'); diff --git a/src/core/docs/commandDocs/common.ts b/src/core/docs/commandDocs/common.ts index 0f8f5c8d..6ca91c5f 100644 --- a/src/core/docs/commandDocs/common.ts +++ b/src/core/docs/commandDocs/common.ts @@ -9,7 +9,7 @@ export const countAsyncMethods = (source: string): number => { return methodMatches.length; }; /** Count test cases (`it(` / `test(` calls, including `.each` / `.skip`) across the given test sources. */ -export const countTestCases = (sources: string[]): number => { +export const countTestCases = (sources: readonly string[]): number => { let testCount = 0; for (const source of sources) { const testMatches = source.match(/^[ \t]*(?:it|test)(?:\.[a-z]+)?\(/gm); diff --git a/src/core/doctor/command.ts b/src/core/doctor/command.ts index 6e4a301f..d061a273 100644 --- a/src/core/doctor/command.ts +++ b/src/core/doctor/command.ts @@ -130,7 +130,7 @@ const reconcileDoctorExportCompliance = ( /** Best-effort export-compliance repair for selected iOS apps. */ const fixExportCompliance = ( - selectedApps: AppDescriptor[], + selectedApps: readonly AppDescriptor[], ): Effect.Effect => Effect.gen(function* () { const resolveAppleStore = createAscClientResolver(); diff --git a/src/core/insights/command.ts b/src/core/insights/command.ts index 089dc832..f1c640f6 100644 --- a/src/core/insights/command.ts +++ b/src/core/insights/command.ts @@ -18,6 +18,7 @@ import type { StarRating, } from '../types/insights.js'; import { buildInsightsReport, STARS } from './aggregate.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Options accepted by the cross-store insights command. */ export type InsightsCommandOptions = Readonly<{ @@ -66,7 +67,7 @@ const normalizeAscReviews = (customerReviews: readonly CustomerReviewResource[]) for (const customerReview of customerReviews) { const starRating = toStarRating(customerReview.rating); if (starRating === null) continue; - const normalizedReview: ReviewDatum = { + const normalizedReview: MutableDeep = { store: 'appstore', rating: starRating, answered: customerReview.answered, @@ -85,7 +86,7 @@ const normalizePlayReviews = (playReviews: readonly PlayReview[]): ReviewDatum[] for (const playReview of playReviews) { const starRating = toStarRating(playReview.rating); if (starRating === null) continue; - const normalizedReview: ReviewDatum = { + const normalizedReview: MutableDeep = { store: 'play', rating: starRating, answered: playReview.answered, diff --git a/src/core/listing/apply.ts b/src/core/listing/apply.ts index bdb7d626..b61bb566 100644 --- a/src/core/listing/apply.ts +++ b/src/core/listing/apply.ts @@ -1,5 +1,6 @@ import type { AndroidLocaleInfo, AppleLocaleInfo, StoreConfig } from '../store/storeConfig.js'; import type { DraftListing, ListingBrief, LocaleDraft } from '../types/listing.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * App Store field limits, in characters. `keywords` is the limit on the *comma-joined* string (Apple * counts the serialized field, and `storeConfig` joins with `", "`), not the count of keywords. @@ -23,11 +24,11 @@ const clampText = (listingText: string, maxCharacters: number): string => { return listingText.slice(0, maxCharacters).trimEnd(); }; /** The comma-joined serialization Apple counts against the 100-char keyword limit. */ -export const serializeKeywords = (keywords: string[]): string => { +export const serializeKeywords = (keywords: readonly string[]): string => { return keywords.join(', '); }; /** Keep keywords from the front until adding the next would overflow the joined-string limit. */ -const clampKeywords = (keywords: string[], maxCharacters: number): string[] => { +const clampKeywords = (keywords: readonly string[], maxCharacters: number): string[] => { const keptKeywords: string[] = []; for (const keyword of keywords) { if (serializeKeywords([...keptKeywords, keyword]).length > maxCharacters) break; @@ -47,7 +48,7 @@ export const clampDraft = ( warnings: string[]; } => { const warningMessages: string[] = []; - const clampedDraft: DraftListing = {}; + const clampedDraft: MutableDeep = {}; /** Clamp one optional text field, recording a warning when it was over the limit. */ const fitText = ( listingText: string | undefined, @@ -98,7 +99,7 @@ export const briefFor = ( currentListing: AppleLocaleInfo | undefined, aboutOverride: string | undefined, ): ListingBrief => { - const listingBrief: ListingBrief = { locale: localeName, appName: displayName }; + const listingBrief: MutableDeep = { locale: localeName, appName: displayName }; let aboutText = aboutOverride; if (aboutText === undefined && currentListing !== undefined) aboutText = currentListing.promotionalText; @@ -135,6 +136,38 @@ export const deriveAndroidLocale = (listingDraft: DraftListing): AndroidLocaleIn * fields (so untouched fields and other locales survive), per targeted platform. The App Store fields * map 1:1; the Play fields are derived via {@link deriveAndroidLocale}. Returns a new config. */ +/** Merge a draft over one locale's existing App Store listing, copying keywords into a mutable array. */ +const mergeAppleLocale = ( + existingLocale: AppleLocaleInfo | undefined, + listingDraft: DraftListing, +): AppleLocaleInfo => { + const mergedLocale: MutableDeep = {}; + if (existingLocale !== undefined) { + if (existingLocale.title !== undefined) mergedLocale.title = existingLocale.title; + if (existingLocale.subtitle !== undefined) mergedLocale.subtitle = existingLocale.subtitle; + if (existingLocale.description !== undefined) + mergedLocale.description = existingLocale.description; + if (existingLocale.keywords !== undefined) mergedLocale.keywords = [...existingLocale.keywords]; + if (existingLocale.releaseNotes !== undefined) + mergedLocale.releaseNotes = existingLocale.releaseNotes; + if (existingLocale.promotionalText !== undefined) + mergedLocale.promotionalText = existingLocale.promotionalText; + if (existingLocale.marketingUrl !== undefined) + mergedLocale.marketingUrl = existingLocale.marketingUrl; + if (existingLocale.supportUrl !== undefined) + mergedLocale.supportUrl = existingLocale.supportUrl; + if (existingLocale.privacyPolicyUrl !== undefined) + mergedLocale.privacyPolicyUrl = existingLocale.privacyPolicyUrl; + } + if (listingDraft.title !== undefined) mergedLocale.title = listingDraft.title; + if (listingDraft.subtitle !== undefined) mergedLocale.subtitle = listingDraft.subtitle; + if (listingDraft.description !== undefined) mergedLocale.description = listingDraft.description; + if (listingDraft.promotionalText !== undefined) + mergedLocale.promotionalText = listingDraft.promotionalText; + if (listingDraft.keywords !== undefined) mergedLocale.keywords = [...listingDraft.keywords]; + return mergedLocale; +}; + export const applyDraft = ( storeConfiguration: StoreConfig, localeName: string, @@ -148,12 +181,11 @@ export const applyDraft = ( if (listingTargets.ios) { let appleListing = storeConfiguration.apple; if (appleListing === undefined) appleListing = { info: {} }; + const localeInfo: Record = { ...appleListing.info }; + localeInfo[localeName] = mergeAppleLocale(appleListing.info[localeName], listingDraft); updatedStoreConfiguration.apple = { ...appleListing, - info: { - ...appleListing.info, - [localeName]: { ...appleListing.info[localeName], ...listingDraft }, - }, + info: localeInfo, }; } if (listingTargets.android) { diff --git a/src/core/listing/generator.ts b/src/core/listing/generator.ts index b587da3a..84650725 100644 --- a/src/core/listing/generator.ts +++ b/src/core/listing/generator.ts @@ -3,6 +3,7 @@ import { Data, Effect, Redacted, Schema } from 'effect'; import { LaunchEnvironment, type LaunchEnvironmentService } from '../services/environment.js'; import type { DraftListing, ListingBrief, ListingGenerator } from '../types/listing.js'; import { APPLE_LIMITS, serializeKeywords } from './apply.js'; +import type { MutableDeep } from '../types/mutable.js'; const GeneratedListingSchema = Schema.Struct({ title: Schema.optionalWith(Schema.String, { exact: true }), @@ -138,7 +139,7 @@ export const parseDraftListing = ( stripJsonFence(completionText), ).pipe( Effect.map((generatedListing) => { - const listingDraft: DraftListing = {}; + const listingDraft: MutableDeep = {}; const title = normalizeGeneratedText(generatedListing.title); if (title !== undefined) listingDraft.title = title; const subtitle = normalizeGeneratedText(generatedListing.subtitle); diff --git a/src/core/mcp/gate.test.ts b/src/core/mcp/gate.test.ts index d5337820..52b99fa0 100644 --- a/src/core/mcp/gate.test.ts +++ b/src/core/mcp/gate.test.ts @@ -5,7 +5,7 @@ import type { McpTool } from '../types/mcp.js'; import type { McpCapability } from '../types/storeSurface.js'; import { enabledCapabilities, gateTools } from './gate.js'; /** A bare config with an optional `mcp` block - only the fields the gate reads matter here. */ -const config = (capabilities?: McpCapability[]): LaunchConfig => { +const config = (capabilities?: readonly McpCapability[]): LaunchConfig => { const launchConfig: LaunchConfig = { profiles: {}, credentials: 'local', diff --git a/src/core/mcp/tools.test.ts b/src/core/mcp/tools.test.ts index 87d1d5b7..fd7a6e20 100644 --- a/src/core/mcp/tools.test.ts +++ b/src/core/mcp/tools.test.ts @@ -20,7 +20,7 @@ import { GoogleStoreClientLive } from '../services/googleStoreClient.js'; import { makeLaunchPathsTest } from '../services/paths.js'; import { makeLaunchSecretStoreTest } from '../services/secretStore.js'; /** A bare config exposing the given MCP capability tiers - only the fields the gate reads matter here. */ -const config = (capabilities: McpCapability[]): LaunchConfig => { +const config = (capabilities: readonly McpCapability[]): LaunchConfig => { return { profiles: {}, credentials: 'local', @@ -38,7 +38,7 @@ const byName = (name: string): McpTool => { }; /** Parse the JSON a successful read tool emits as its single text block. */ const parseToolOutput = ( - toolOutput: { content: { text: string }[] }, + toolOutput: { content: readonly { readonly text: string }[] }, outputSchema: Schema.Schema, ): DecodedOutput => Schema.decodeUnknownSync(outputSchema)( diff --git a/src/core/migrate/command.ts b/src/core/migrate/command.ts index ebcc5886..b2a924f2 100644 --- a/src/core/migrate/command.ts +++ b/src/core/migrate/command.ts @@ -48,7 +48,7 @@ const readMigration = ( const printMigrationNotes = ( logger: Logger, - notes: MigrationNote[], + notes: readonly MigrationNote[], ): Effect.Effect => Effect.gen(function* () { for (const migrationNote of notes) { diff --git a/src/core/migrate/eas.test.ts b/src/core/migrate/eas.test.ts index b4b2c4c4..38ef25cd 100644 --- a/src/core/migrate/eas.test.ts +++ b/src/core/migrate/eas.test.ts @@ -42,13 +42,13 @@ const app = (over: Partial = {}): AppDescriptor => { }; }; /** The artifact at `path`, asserting it was emitted. */ -const artifact = (artifacts: MigrationArtifact[], path: string): MigrationArtifact => { +const artifact = (artifacts: readonly MigrationArtifact[], path: string): MigrationArtifact => { const found = artifacts.find((entry) => entry.path === path); expect(found, `expected artifact ${path}`).toBeDefined(); return expectDefined(found, `artifact ${path}`); }; /** Notes at a given level. */ -const notesAt = (notes: MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => { +const notesAt = (notes: readonly MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => { return notes.filter((note) => note.level === level); }; describe('parseEasJson', () => { @@ -91,7 +91,7 @@ describe('migrateEas', () => { afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); - const runMigrateEas = (workingDirectory: string, apps: AppDescriptor[]) => + const runMigrateEas = (workingDirectory: string, apps: readonly AppDescriptor[]) => Effect.runPromise(migrateEas(workingDirectory, apps).pipe(Effect.provide(NodeContext.layer))); it('returns a tagged failure when there is no eas.json', async () => { rmSync(join(dir, 'eas.json')); diff --git a/src/core/migrate/eas.ts b/src/core/migrate/eas.ts index 39b33f58..60863c71 100644 --- a/src/core/migrate/eas.ts +++ b/src/core/migrate/eas.ts @@ -14,6 +14,7 @@ import type { MigrationResult, } from '../types/migrate.js'; import { buildEnvExample, scaffoldStoreConfig } from './scaffold.js'; +import type { MutableDeep } from '../types/mutable.js'; export type EasMigrationFailure = Readonly<{ readonly _tag: 'EasMigrationFailure'; @@ -116,7 +117,7 @@ const EasSubmitProfilesSchema = Schema.transform( for (const [profileName, unknownProfile] of Object.entries(unknownProfiles)) { const decodedProfile = Schema.decodeUnknownOption(EasSubmitProfileSchema)(unknownProfile); if (Option.isNone(decodedProfile)) continue; - const submitProfile: EasSubmitProfile = {}; + const submitProfile: MutableDeep = {}; const iosSubmission = decodedProfile.value.ios; if (iosSubmission !== undefined && hasIosSubmitFields(iosSubmission)) { submitProfile.ios = iosSubmission; @@ -161,7 +162,7 @@ const isPlayTrack = (trackName: string): trackName is PlayTrack => const mapProfiles = (easConfiguration: EasJson): Record => { const launchProfiles: Record = {}; for (const profileName of Object.keys(easConfiguration.build)) { - const launchProfile: BuildProfile = { name: profileName, sizeBudgetMB: 200 }; + const launchProfile: MutableDeep = { name: profileName, sizeBudgetMB: 200 }; const submitProfile = easConfiguration.submit[profileName]; const androidSubmission = submitProfile?.android; const trackName = androidSubmission?.track; @@ -210,7 +211,10 @@ const makeProfileEnvironmentArtifacts = (easConfiguration: EasJson): MigrationAr return environmentArtifacts; }; -const buildMigrationNotes = (easConfiguration: EasJson, apps: AppDescriptor[]): MigrationNote[] => { +const buildMigrationNotes = ( + easConfiguration: EasJson, + apps: readonly AppDescriptor[], +): MigrationNote[] => { const migrationNotes: MigrationNote[] = []; for (const [profileName, buildProfile] of Object.entries(easConfiguration.build)) { migrationNotes.push({ @@ -332,7 +336,7 @@ const CredentialsDocumentSchema = Schema.mutable( const summarizeCredentialsDocument = ( credentialsDocument: Schema.Schema.Type, ): CredentialsSummary | null => { - const credentialsSummary: CredentialsSummary = {}; + const credentialsSummary: MutableDeep = {}; const iosCredentials = credentialsDocument.ios; if (iosCredentials !== undefined) { const distributionCertificatePath = iosCredentials.distributionCertificate?.path; @@ -468,7 +472,7 @@ const describeRuntimeVersion = ( return undefined; }; -const buildAppFactNotes = (apps: AppDescriptor[]) => +const buildAppFactNotes = (apps: readonly AppDescriptor[]) => Effect.gen(function* () { const migrationNotes: MigrationNote[] = []; for (const app of apps) { @@ -511,7 +515,7 @@ const buildAppFactNotes = (apps: AppDescriptor[]) => /** Read an EAS project and return the Launch artifacts and follow-up notes without writing them. */ export const migrateEas = ( workingDirectory: string, - apps: AppDescriptor[], + apps: readonly AppDescriptor[], ): Effect.Effect< MigrationResult, EasMigrationFailure | PlatformError, diff --git a/src/core/migrate/fastlane.test.ts b/src/core/migrate/fastlane.test.ts index 200f3049..d1174745 100644 --- a/src/core/migrate/fastlane.test.ts +++ b/src/core/migrate/fastlane.test.ts @@ -72,7 +72,7 @@ const app = (overrides: Partial = {}): AppDescriptor => { }; /** The artifact at `path`, asserting it was emitted. */ const artifact = ( - migrationArtifacts: MigrationArtifact[], + migrationArtifacts: readonly MigrationArtifact[], artifactPath: string, ): MigrationArtifact => { const matchingArtifact = migrationArtifacts.find((entry) => entry.path === artifactPath); @@ -80,12 +80,15 @@ const artifact = ( return expectDefined(matchingArtifact, `artifact ${artifactPath}`); }; /** Notes at a given level. */ -const notesAt = (migrationNotes: MigrationNote[], level: MigrationNoteLevel): MigrationNote[] => { +const notesAt = ( + migrationNotes: readonly MigrationNote[], + level: MigrationNoteLevel, +): MigrationNote[] => { return migrationNotes.filter((note) => note.level === level); }; const runReadFastlaneSetup = (workingDirectory: string) => Effect.runPromise(readFastlaneSetup(workingDirectory).pipe(Effect.provide(NodeContext.layer))); -const runMigrateFastlane = (workingDirectory: string, apps: AppDescriptor[]) => +const runMigrateFastlane = (workingDirectory: string, apps: readonly AppDescriptor[]) => Effect.runPromise( migrateFastlane(workingDirectory, apps).pipe(Effect.provide(NodeContext.layer)), ); @@ -125,16 +128,16 @@ describe('parseSupplyfile', () => { }); describe('parseFastfile', () => { it('collects lane names from both lane and private_lane', () => { - const names = parseFastfile(SAMPLE_FASTFILE) - .lanes.map((lane) => lane.name) - .sort(); + const names = [...parseFastfile(SAMPLE_FASTFILE).lanes.map((lane) => lane.name)].sort(); expect(names).toEqual(['beta', 'play', 'prepare', 'release']); }); it('attributes each lane to its platform block and scopes actions to its body', () => { const lanes = parseFastfile(SAMPLE_FASTFILE).lanes; const beta = lanes.find((lane) => lane.name === 'beta'); expect(beta?.platform).toBe('ios'); - expect(beta?.actions.sort()).toEqual(['gym', 'match', 'pilot']); + let betaActions: readonly string[] = []; + if (beta !== undefined) betaActions = beta.actions; + expect([...betaActions].sort()).toEqual(['gym', 'match', 'pilot']); const play = lanes.find((lane) => lane.name === 'play'); expect(play?.platform).toBe('android'); expect(play?.actions).toEqual(['supply']); diff --git a/src/core/migrate/fastlane.ts b/src/core/migrate/fastlane.ts index fc253e3e..710e2126 100644 --- a/src/core/migrate/fastlane.ts +++ b/src/core/migrate/fastlane.ts @@ -21,6 +21,7 @@ import type { SupplyfileData, } from '../types/migrate.js'; import { buildEnvExample, scaffoldStoreConfig } from './scaffold.js'; +import type { MutableDeep } from '../types/mutable.js'; export type FastlaneMigrationFailure = Readonly<{ readonly _tag: 'FastlaneMigrationFailure'; @@ -43,7 +44,7 @@ const readRubyString = (rubySource: string, directiveName: string): string | und }; export const parseAppfile = (appfileSource: string): AppfileData => { - const appfile: AppfileData = {}; + const appfile: MutableDeep = {}; const appIdentifier = readRubyString(appfileSource, 'app_identifier'); if (appIdentifier !== undefined) appfile.appIdentifier = appIdentifier; const appleId = readRubyString(appfileSource, 'apple_id'); @@ -58,7 +59,7 @@ export const parseAppfile = (appfileSource: string): AppfileData => { }; export const parseMatchfile = (matchfileSource: string): MatchfileData => { - const matchfile: MatchfileData = {}; + const matchfile: MutableDeep = {}; const gitUrl = readRubyString(matchfileSource, 'git_url'); if (gitUrl !== undefined) matchfile.gitUrl = gitUrl; const signingType = readRubyString(matchfileSource, 'type'); @@ -71,7 +72,7 @@ export const parseMatchfile = (matchfileSource: string): MatchfileData => { }; export const parseSupplyfile = (supplyfileSource: string): SupplyfileData => { - const supplyfile: SupplyfileData = {}; + const supplyfile: MutableDeep = {}; const packageName = readRubyString(supplyfileSource, 'package_name'); if (packageName !== undefined) supplyfile.packageName = packageName; const jsonKey = readRubyString(supplyfileSource, 'json_key'); @@ -119,7 +120,7 @@ const findLanePlatform = (fastfileSource: string, laneStartIndex: number): strin /** Parse lane names and recognized actions without attempting to interpret arbitrary Ruby. */ export const parseFastfile = ( fastfileSource: string, -): { lanes: FastlaneLane[]; actions: string[] } => { +): { lanes: readonly FastlaneLane[]; actions: readonly string[] } => { const laneDeclarations = [ ...fastfileSource.matchAll(/^[ \t]*(?:private_)?lane\s+:([A-Za-z_]\w*)\s+do\b/gm), ]; @@ -137,9 +138,11 @@ export const parseFastfile = ( containsAction(laneSource, actionName), ); const lanePlatform = findLanePlatform(fastfileSource, laneStartIndex); - const fastlaneLane: FastlaneLane = { name: laneName, actions: laneActions }; - if (lanePlatform !== undefined) fastlaneLane.platform = lanePlatform; - fastlaneLanes.push(fastlaneLane); + if (lanePlatform !== undefined) { + fastlaneLanes.push({ name: laneName, actions: laneActions, platform: lanePlatform }); + continue; + } + fastlaneLanes.push({ name: laneName, actions: laneActions }); } return { lanes: fastlaneLanes, @@ -214,27 +217,27 @@ export const readFastlaneSetup = ( if (fastlaneSources.supplyfile !== undefined) hasFastlaneSource = true; if (fastlaneSources.deliverfile !== undefined) hasFastlaneSource = true; if (!hasFastlaneSource) return null; - let parsedFastfile: { lanes: FastlaneLane[]; actions: string[] } = { + let parsedFastfile: { lanes: readonly FastlaneLane[]; actions: readonly string[] } = { lanes: [], actions: [], }; if (fastlaneSources.fastfile !== undefined) { parsedFastfile = parseFastfile(fastlaneSources.fastfile); } - const fastlaneSetup: FastlaneSetup = { + let fastlaneSetup: FastlaneSetup = { lanes: parsedFastfile.lanes, actions: parsedFastfile.actions, hasDeliverfile: fastlaneSources.deliverfile !== undefined, envKeys: fastlaneSources.environmentKeys, }; if (fastlaneSources.appfile !== undefined) { - fastlaneSetup.appfile = parseAppfile(fastlaneSources.appfile); + fastlaneSetup = { ...fastlaneSetup, appfile: parseAppfile(fastlaneSources.appfile) }; } if (fastlaneSources.matchfile !== undefined) { - fastlaneSetup.matchfile = parseMatchfile(fastlaneSources.matchfile); + fastlaneSetup = { ...fastlaneSetup, matchfile: parseMatchfile(fastlaneSources.matchfile) }; } if (fastlaneSources.supplyfile !== undefined) { - fastlaneSetup.supply = parseSupplyfile(fastlaneSources.supplyfile); + fastlaneSetup = { ...fastlaneSetup, supply: parseSupplyfile(fastlaneSources.supplyfile) }; } return fastlaneSetup; }); @@ -309,7 +312,7 @@ const laneCommands = (fastlaneLane: FastlaneLane): string[] => { return launchCommands; }; -const buildLaneNotes = (fastlaneLanes: FastlaneLane[]): MigrationNote[] => { +const buildLaneNotes = (fastlaneLanes: readonly FastlaneLane[]): MigrationNote[] => { const migrationNotes: MigrationNote[] = []; const customLaneNames: string[] = []; for (const fastlaneLane of fastlaneLanes) { @@ -338,7 +341,7 @@ const buildLaneNotes = (fastlaneLanes: FastlaneLane[]): MigrationNote[] => { const buildMigrationNotes = ( fastlaneSetup: FastlaneSetup, - apps: AppDescriptor[], + apps: readonly AppDescriptor[], importedMetadata: boolean, ): MigrationNote[] => { const migrationNotes = buildLaneNotes(fastlaneSetup.lanes); @@ -472,7 +475,7 @@ const importFastlaneMetadata = ( /** Read a Fastlane project and return Launch artifacts without writing them. */ export const migrateFastlane = ( workingDirectory: string, - apps: AppDescriptor[], + apps: readonly AppDescriptor[], ): Effect.Effect< MigrationResult, FastlaneMigrationFailure | PlatformError, diff --git a/src/core/migrate/scaffold.ts b/src/core/migrate/scaffold.ts index dfd15411..e004628f 100644 --- a/src/core/migrate/scaffold.ts +++ b/src/core/migrate/scaffold.ts @@ -52,7 +52,7 @@ export const scaffoldStoreConfig = ( * both migration sources treat env. Falls back to the plain starter template when no keys were found, so * the artifact is always valid. Shared by `eas.ts` (EAS `env` keys) and `fastlane.ts` (dotenv keys). */ -export const buildEnvExample = (keys: string[]): string => { +export const buildEnvExample = (keys: readonly string[]): string => { if (keys.length === 0) return ENV_EXAMPLE_TEMPLATE; const header = ENV_EXAMPLE_TEMPLATE.split('\n') .filter((line) => line.startsWith('#')) diff --git a/src/core/plan/orchestrator.test.ts b/src/core/plan/orchestrator.test.ts index 0c0cb28c..4d877015 100644 --- a/src/core/plan/orchestrator.test.ts +++ b/src/core/plan/orchestrator.test.ts @@ -28,7 +28,7 @@ const planner = (plan: SurfacePlan): SurfacePlanner => { /** Execute the planner orchestrator at the test boundary. */ const runPlannerSet = ( planContext: PlanContext, - planners: SurfacePlanner[], + planners: readonly SurfacePlanner[], options: Parameters[2], ) => Effect.runPromise( diff --git a/src/core/plan/orchestrator.ts b/src/core/plan/orchestrator.ts index 0107c6ba..34eba65c 100644 --- a/src/core/plan/orchestrator.ts +++ b/src/core/plan/orchestrator.ts @@ -69,7 +69,7 @@ export const planExitCode = ({ */ export const runPlanners = ( planContext: PlanContext, - planners: SurfacePlanner[], + planners: readonly SurfacePlanner[], options: PlanRunOptions, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/plan/planners/appStoreSurface.ts b/src/core/plan/planners/appStoreSurface.ts index eed5b4ab..e1894ea4 100644 --- a/src/core/plan/planners/appStoreSurface.ts +++ b/src/core/plan/planners/appStoreSurface.ts @@ -28,7 +28,7 @@ export type AppStoreSurfaceSpec = { config: TConfig, ) => Effect.Effect< { - actions: PlannedAction[]; + actions: readonly PlannedAction[]; }, unknown >; @@ -111,7 +111,10 @@ export type TeamSurfaceSpec = { | TConfig | Effect.Effect | undefined; - reconcile: (api: AscSurfacesApi, config: TConfig) => Effect.Effect; + reconcile: ( + api: AscSurfacesApi, + config: TConfig, + ) => Effect.Effect; }; /** * Plan one team-level App Store surface: omit when nothing is declared, skip with a hint when no Apple diff --git a/src/core/plan/planners/euDistribution.ts b/src/core/plan/planners/euDistribution.ts index f737e5fb..5c0bba8d 100644 --- a/src/core/plan/planners/euDistribution.ts +++ b/src/core/plan/planners/euDistribution.ts @@ -5,6 +5,7 @@ import { } from '@core/store/euDistribution.js'; import { planTeamSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { EuDistributionConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan eu-distribution`. */ const SURFACE = 'eu-distribution'; export const euDistributionPlanner: SurfacePlanner = { @@ -15,7 +16,7 @@ export const euDistributionPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', config: () => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.euDistribution, configPath: 'eu-distribution.config.json', explicitPath: false, diff --git a/src/core/plan/planners/gameCenter.ts b/src/core/plan/planners/gameCenter.ts index 22aaf278..e33a13a1 100644 --- a/src/core/plan/planners/gameCenter.ts +++ b/src/core/plan/planners/gameCenter.ts @@ -2,6 +2,7 @@ import { resolveSidecarConfig } from '@core/config/config.js'; import { loadGameCenterConfig, reconcileGameCenter } from '@core/store/gameCenter.js'; import { planAppStoreSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { GameCenterConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan game-center`. */ const SURFACE = 'game-center'; export const gameCenterPlanner: SurfacePlanner = { @@ -12,7 +13,7 @@ export const gameCenterPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', configFor: (bundleId) => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.gameCenter?.[bundleId], configPath: 'gamecenter.config.json', explicitPath: false, diff --git a/src/core/plan/planners/playProducts.ts b/src/core/plan/planners/playProducts.ts index 4f504ec0..0c0ad04d 100644 --- a/src/core/plan/planners/playProducts.ts +++ b/src/core/plan/planners/playProducts.ts @@ -14,7 +14,7 @@ type PlayProductsTarget = { products: InAppPurchaseConfig[]; }; /** Resolve the apps that declare at least one Play-overridden in-app product, with their package + products. */ -const targetsFor = (apps: AppDescriptor[], config: LaunchConfig): PlayProductsTarget[] => { +const targetsFor = (apps: readonly AppDescriptor[], config: LaunchConfig): PlayProductsTarget[] => { const targets: PlayProductsTarget[] = []; for (const app of apps) { if (!app.packageName) continue; diff --git a/src/core/plan/planners/playSubscriptions.test.ts b/src/core/plan/planners/playSubscriptions.test.ts index 80805f75..423ca979 100644 --- a/src/core/plan/planners/playSubscriptions.test.ts +++ b/src/core/plan/planners/playSubscriptions.test.ts @@ -39,7 +39,9 @@ const APPLE_ONLY_SUB: SubscriptionConfig = { localizations: [{ locale: 'en-US', name: 'Pro Yearly' }], }; /** Wrap subscriptions in the one group `products[bundleId].subscriptionGroups` requires. */ -const productsWith = (subscriptions: SubscriptionConfig[]): Record => { +const productsWith = ( + subscriptions: readonly SubscriptionConfig[], +): Record => { return { 'com.acme.alpha': { subscriptionGroups: [ diff --git a/src/core/plan/planners/playSubscriptions.ts b/src/core/plan/planners/playSubscriptions.ts index 8a009c22..5d0039f0 100644 --- a/src/core/plan/planners/playSubscriptions.ts +++ b/src/core/plan/planners/playSubscriptions.ts @@ -17,7 +17,10 @@ type PlaySubscriptionsTarget = { subscriptions: SubscriptionConfig[]; }; /** Resolve the apps that declare at least one Play-overridden subscription, with their package + subscriptions. */ -const targetsFor = (apps: AppDescriptor[], config: LaunchConfig): PlaySubscriptionsTarget[] => { +const targetsFor = ( + apps: readonly AppDescriptor[], + config: LaunchConfig, +): PlaySubscriptionsTarget[] => { const targets: PlaySubscriptionsTarget[] = []; for (const app of apps) { if (!app.packageName) continue; diff --git a/src/core/plan/planners/wallet.ts b/src/core/plan/planners/wallet.ts index e50df5da..d777ae0a 100644 --- a/src/core/plan/planners/wallet.ts +++ b/src/core/plan/planners/wallet.ts @@ -2,6 +2,7 @@ import { resolveSidecarConfig } from '@core/config/config.js'; import { loadWalletConfig, reconcileWalletIds } from '@core/store/walletIds.js'; import { planTeamSurface } from './appStoreSurface.js'; import type { SurfacePlanner } from '@core/types/plan.js'; +import type { WalletConfig } from '@core/types/storeSurface.js'; /** Surface id - also the value users pass as `launch plan wallet`. */ const SURFACE = 'wallet'; export const walletPlanner: SurfacePlanner = { @@ -12,7 +13,7 @@ export const walletPlanner: SurfacePlanner = { surface: SURFACE, direction: 'additive', config: () => - resolveSidecarConfig({ + resolveSidecarConfig({ typed: planContext.config.wallet, configPath: 'wallet.config.json', explicitPath: false, diff --git a/src/core/privacy/parse.ts b/src/core/privacy/parse.ts index d578d206..ab7786ea 100644 --- a/src/core/privacy/parse.ts +++ b/src/core/privacy/parse.ts @@ -12,7 +12,7 @@ const CollectedDataTypeSchema = Schema.Struct({ const USAGE_DESCRIPTION_RE = /(NS\w*UsageDescription)<\/key>\s*(?:([^<]*)<\/string>|)/g; /** De-duplicate while preserving first-seen order. */ -const unique = (strings: string[]): string[] => { +const unique = (strings: readonly string[]): string[] => { return [...new Set(strings)]; }; /** Collect every `...` inside an XML fragment. */ diff --git a/src/core/privacy/reconcile.ts b/src/core/privacy/reconcile.ts index 73e2e920..959b6ff5 100644 --- a/src/core/privacy/reconcile.ts +++ b/src/core/privacy/reconcile.ts @@ -138,7 +138,7 @@ export const reconcilePrivacy = (app: string, surface: PrivacySurface): PrivacyF */ export const buildPrivacyReport = ( findings: PrivacyFinding[], - scanned: string[], + scanned: readonly string[], ): PrivacyReport => { let exitCode: PrivacyReport['exitCode'] = READINESS_EXIT.ok; if (findings.some((finding) => finding.severity === 'blocker')) exitCode = READINESS_EXIT.blocker; diff --git a/src/core/readiness/appScopes.ts b/src/core/readiness/appScopes.ts index 213f3215..6548504b 100644 --- a/src/core/readiness/appScopes.ts +++ b/src/core/readiness/appScopes.ts @@ -4,13 +4,13 @@ export type ScopedApp = { name: string; identifier: string; }; -export const iosApps = (apps: AppDescriptor[]): ScopedApp[] => { +export const iosApps = (apps: readonly AppDescriptor[]): ScopedApp[] => { return apps.flatMap((app) => { if (app.bundleId) return [{ name: app.name, identifier: app.bundleId }]; return []; }); }; -export const androidApps = (apps: AppDescriptor[]): ScopedApp[] => { +export const androidApps = (apps: readonly AppDescriptor[]): ScopedApp[] => { return apps.flatMap((app) => { if (app.packageName) return [{ name: app.name, identifier: app.packageName }]; return []; diff --git a/src/core/readiness/orchestrator.test.ts b/src/core/readiness/orchestrator.test.ts index 4047d11b..48699d74 100644 --- a/src/core/readiness/orchestrator.test.ts +++ b/src/core/readiness/orchestrator.test.ts @@ -35,7 +35,7 @@ const probe = (id: string, fixedProbeResult: ProbeResult | (() => never)): Readi }; /** Run probe aggregation with the platform services available to production probes. */ -const runProbeSet = (readinessProbes: ReadinessProbe[]) => +const runProbeSet = (readinessProbes: readonly ReadinessProbe[]) => Effect.runPromise( runProbes(readinessContext, readinessProbes).pipe( Effect.provide(NodeHttpClient.layer), diff --git a/src/core/readiness/orchestrator.ts b/src/core/readiness/orchestrator.ts index 062cc88a..0090637e 100644 --- a/src/core/readiness/orchestrator.ts +++ b/src/core/readiness/orchestrator.ts @@ -26,7 +26,7 @@ export const readinessExitCode = ({ errorCount, blockerCount }: ReadinessExitInp export const runProbes = ( readinessContext: ReadinessContext, - probes: ReadinessProbe[], + probes: readonly ReadinessProbe[], ): Effect.Effect => Effect.gen(function* () { const reports = yield* Effect.forEach(probes, (probe) => runProbe(readinessContext, probe), { diff --git a/src/core/readiness/probes/iapCodeReference.ts b/src/core/readiness/probes/iapCodeReference.ts index 0c567771..479ea16c 100644 --- a/src/core/readiness/probes/iapCodeReference.ts +++ b/src/core/readiness/probes/iapCodeReference.ts @@ -29,7 +29,7 @@ const MAX_SCAN_BYTES = 8 * 1024 * 1024; /** Find declared product identifiers in a bounded app-source scan. */ const findReferencedIds = ( appDirectory: string, - productIds: string[], + productIds: readonly string[], ): Effect.Effect, never, SourceScanRequirements> => { const referencedIds = new Set(); const pendingIds = new Set(productIds); diff --git a/src/core/readiness/probes/profileEntitlements.ts b/src/core/readiness/probes/profileEntitlements.ts index 407aca7f..1173d801 100644 --- a/src/core/readiness/probes/profileEntitlements.ts +++ b/src/core/readiness/probes/profileEntitlements.ts @@ -13,7 +13,7 @@ type EntitledApp = { identifier: string; required: string[]; }; -const entitledApps = (apps: AppDescriptor[]): EntitledApp[] => { +const entitledApps = (apps: readonly AppDescriptor[]): EntitledApp[] => { return apps.flatMap((app) => { if (!app.bundleId) return []; const required = mapEntitlementsToCapabilities(app.iosEntitlements).enable; diff --git a/src/core/release/betaReview.ts b/src/core/release/betaReview.ts index aa458659..8e96c233 100644 --- a/src/core/release/betaReview.ts +++ b/src/core/release/betaReview.ts @@ -9,6 +9,7 @@ import type { import { errorMessage } from '../services/errorMessage.js'; import { plan, skip, type ReconcileContext } from '../store/reconcile.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; const BUILD_SCAN_LIMIT = 50; @@ -128,7 +129,7 @@ const selectBuild = ( /** Apply one note write while retaining per-action failures in the reconciliation report. */ const applyNote = ( - action: PlannedAction, + action: MutableDeep, noteWrite: Effect.Effect, ): Effect.Effect => noteWrite.pipe( diff --git a/src/core/release/statusCommand.test.ts b/src/core/release/statusCommand.test.ts index 360a18b4..120b8f98 100644 --- a/src/core/release/statusCommand.test.ts +++ b/src/core/release/statusCommand.test.ts @@ -4,6 +4,7 @@ import { NodeHttpClient } from '@effect/platform-node'; import { createLogger, makeLaunchLoggerTest } from '../services/logger.js'; import type { AppDescriptor } from '../types/app.js'; import { classifyVerdict, type ReleaseStatus } from './appStoreRelease.js'; +import type { MutableDeep } from '../types/mutable.js'; import { formatStatusLine, selectIosApps, @@ -32,7 +33,7 @@ const releaseStatus = (overrides: Partial = {}): ReleaseStatus => }; const discoveredApp = (appName: string, bundleId?: string): AppDescriptor => { - const appDescriptor: AppDescriptor = { + const appDescriptor: MutableDeep = { name: appName, dir: `/repo/${appName}`, configPath: `/repo/${appName}/app.json`, diff --git a/src/core/release/testflightFeedback.ts b/src/core/release/testflightFeedback.ts index ca9dca7c..c6d86bbb 100644 --- a/src/core/release/testflightFeedback.ts +++ b/src/core/release/testflightFeedback.ts @@ -138,7 +138,7 @@ const safeFeedbackIdentifier = (feedbackId: string): string => { /** Download screenshot attachments serially into one directory. */ export const downloadFeedbackAttachments = ( appleStore: AscFeedbackApi, - feedbackEntries: BetaFeedback[], + feedbackEntries: readonly BetaFeedback[], outputDirectory: string, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/release/version.ts b/src/core/release/version.ts index 6b917bb7..b79dc997 100644 --- a/src/core/release/version.ts +++ b/src/core/release/version.ts @@ -72,7 +72,7 @@ export const compareVersions = (a: string, b: string): number => { * Used to fold App Store + TestFlight versions into a single "latest on record" without trusting the * store's own (lexical) sort, which would order `1.10.0` below `1.9.0`. */ -export const highestVersion = (versions: string[]): string | null => { +export const highestVersion = (versions: readonly string[]): string | null => { const parseable = versions.filter((version) => parseVersion(version) !== null); if (parseable.length === 0) return null; return parseable.reduce((highest, version) => { diff --git a/src/core/releaseTrain/engine.ts b/src/core/releaseTrain/engine.ts index f2245737..31d13b1b 100644 --- a/src/core/releaseTrain/engine.ts +++ b/src/core/releaseTrain/engine.ts @@ -29,7 +29,7 @@ export const iosCarState = (verdict: ReleaseVerdict): NativeCarState | null => { * fraction - steer it with `launch rollout`). Returns `null` while nothing is on the track yet (still * processing / in Google's opaque review) so the car holds its current state. */ -export const androidCarState = (releases: PlayRelease[]): NativeCarState | null => { +export const androidCarState = (releases: readonly PlayRelease[]): NativeCarState | null => { const release = releases[0]; if (!release) return null; switch (release.status) { diff --git a/src/core/releaseTrain/orchestrator.ts b/src/core/releaseTrain/orchestrator.ts index 0bb9ea0c..3afd3825 100644 --- a/src/core/releaseTrain/orchestrator.ts +++ b/src/core/releaseTrain/orchestrator.ts @@ -9,6 +9,7 @@ import type { TrainState, } from '../types/releaseTrain.js'; import { isCarTerminal, isNativeCar, isOtaCar } from './guards.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Store and OTA operations driven by the release-train state machine. */ export type TrainEngine = Readonly<{ @@ -82,7 +83,7 @@ export const startTrain = ( Effect.gen(function* () { const trainCars: Car[] = []; for (const platform of trainInput.platforms) { - const nativeCar: NativeCar = { + const nativeCar: MutableDeep = { kind: platform, state: 'building', updatedAt: trainInput.now, @@ -130,7 +131,7 @@ export const advanceTrain = ( if (trainRecord.state === 'done') return trainRecord; if (trainRecord.state === 'aborted') return trainRecord; const forced = advanceOptions.force === true; - const trainCars = trainRecord.cars.map((trainCar): Car => ({ ...trainCar })); + const trainCars = trainRecord.cars.map((trainCar): MutableDeep => ({ ...trainCar })); for (const trainCar of trainCars) { if (!isNativeCar(trainCar)) continue; @@ -142,7 +143,11 @@ export const advanceTrain = ( if (!isNativeFailure(trainCar)) delete trainCar.error; } - const nativeCars = trainCars.filter(isNativeCar); + const nativeCars: MutableDeep[] = []; + for (const trainCar of trainCars) { + if (!isNativeCar(trainCar)) continue; + nativeCars.push(trainCar); + } const hasNativeFailure = nativeCars.some(isNativeFailure); const blocked = trainRecord.hold && !forced && hasNativeFailure; const allApproved = nativeCars.every((nativeCar) => { diff --git a/src/core/releaseTrain/record.ts b/src/core/releaseTrain/record.ts index 565c6460..00fb2270 100644 --- a/src/core/releaseTrain/record.ts +++ b/src/core/releaseTrain/record.ts @@ -7,47 +7,41 @@ import { } from '../services/paths.js'; import type { TrainRecord } from '../types/releaseTrain.js'; -const NativeCarSchema = Schema.mutable( - Schema.Struct({ - kind: Schema.Literal('ios', 'android'), - state: Schema.Literal( - 'building', - 'submitted', - 'in-review', - 'approved', - 'released', - 'rejected', - 'failed', - ), - buildId: Schema.optionalWith(Schema.String, { exact: true }), - error: Schema.optionalWith(Schema.String, { exact: true }), - updatedAt: Schema.String, - }), -); +const NativeCarSchema = Schema.Struct({ + kind: Schema.Literal('ios', 'android'), + state: Schema.Literal( + 'building', + 'submitted', + 'in-review', + 'approved', + 'released', + 'rejected', + 'failed', + ), + buildId: Schema.optionalWith(Schema.String, { exact: true }), + error: Schema.optionalWith(Schema.String, { exact: true }), + updatedAt: Schema.String, +}); -const OtaCarSchema = Schema.mutable( - Schema.Struct({ - kind: Schema.Literal('ota'), - platform: Schema.Literal('ios', 'android'), - channel: Schema.String, - runtimeVersion: Schema.String, - state: Schema.Literal('pending', 'published'), - manifestId: Schema.optionalWith(Schema.String, { exact: true }), - updatedAt: Schema.String, - }), -); +const OtaCarSchema = Schema.Struct({ + kind: Schema.Literal('ota'), + platform: Schema.Literal('ios', 'android'), + channel: Schema.String, + runtimeVersion: Schema.String, + state: Schema.Literal('pending', 'published'), + manifestId: Schema.optionalWith(Schema.String, { exact: true }), + updatedAt: Schema.String, +}); -const TrainRecordSchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - id: Schema.String, - app: Schema.String, - hold: Schema.Boolean, - state: Schema.Literal('running', 'blocked', 'done', 'aborted'), - createdAt: Schema.String, - updatedAt: Schema.String, - cars: Schema.mutable(Schema.Array(Schema.Union(NativeCarSchema, OtaCarSchema))), - }), -); +const TrainRecordSchema: Schema.Schema = Schema.Struct({ + id: Schema.String, + app: Schema.String, + hold: Schema.Boolean, + state: Schema.Literal('running', 'blocked', 'done', 'aborted'), + createdAt: Schema.String, + updatedAt: Schema.String, + cars: Schema.Array(Schema.Union(NativeCarSchema, OtaCarSchema)), +}); /** A persisted release-train record could not be read or written. */ export type TrainRecordFailure = Readonly<{ diff --git a/src/core/services/appleCredentialsClient.ts b/src/core/services/appleCredentialsClient.ts index ed57626c..ca0fccfc 100644 --- a/src/core/services/appleCredentialsClient.ts +++ b/src/core/services/appleCredentialsClient.ts @@ -46,7 +46,7 @@ export type AppleCredentialsClient = Readonly<{ name: string, bundleIdResourceId: string, certificateId: string, - deviceIds: string[], + deviceIds: readonly string[], profileType: 'IOS_APP_ADHOC' | 'TVOS_APP_ADHOC', ) => Effect.Effect; readonly listBundleIdCapabilities: ( diff --git a/src/core/services/artifactRetention.ts b/src/core/services/artifactRetention.ts index 1c6dc55c..e1db8479 100644 --- a/src/core/services/artifactRetention.ts +++ b/src/core/services/artifactRetention.ts @@ -10,9 +10,9 @@ import type { BuildArtifact, PruneOptions, PruneResult } from '../types/artifact /** Artifact-index persistence and retention operations used by storage providers. */ export type ArtifactRetentionService = Readonly<{ - readonly readIndex: (indexPath: string) => Effect.Effect; + readonly readIndex: (indexPath: string) => Effect.Effect; readonly writeIndex: ( - artifactIndex: BuildArtifact[], + artifactIndex: readonly BuildArtifact[], indexPath: string, ) => Effect.Effect; readonly prune: ( diff --git a/src/core/services/localCredentialsStore.ts b/src/core/services/localCredentialsStore.ts index 5333f564..e6f1f470 100644 --- a/src/core/services/localCredentialsStore.ts +++ b/src/core/services/localCredentialsStore.ts @@ -35,7 +35,7 @@ export type LocalCredentialsStoreService = Readonly<{ readonly loadAppleSigningAssets: ( keyId: string, bundleId: string, - extensions?: string[], + extensions?: readonly string[], ) => Effect.Effect; readonly loadPlayServiceAccount: () => Effect.Effect; readonly loadAndroidKeystore: () => Effect.Effect; diff --git a/src/core/services/logger.ts b/src/core/services/logger.ts index 1675262b..87bfaee5 100644 --- a/src/core/services/logger.ts +++ b/src/core/services/logger.ts @@ -24,8 +24,8 @@ export type Logger = Readonly<{ readonly note: (message: string) => LogWrite; readonly tip: (message: string) => LogWrite; readonly notice: (lead: string, ...details: string[]) => LogWrite; - readonly box: (title: string, receiptLines: string[]) => LogWrite; - readonly shipped: (receiptLines: string[]) => LogWrite; + readonly box: (title: string, receiptLines: readonly string[]) => LogWrite; + readonly shipped: (receiptLines: readonly string[]) => LogWrite; readonly line: (message: string) => LogWrite; readonly gap: () => LogWrite; }>; diff --git a/src/core/services/progress.ts b/src/core/services/progress.ts index cb1703a4..0807429f 100644 --- a/src/core/services/progress.ts +++ b/src/core/services/progress.ts @@ -135,7 +135,7 @@ const logStamp = (epochMilliseconds: number): string => { * the full log on disk (falling back to the in-memory tail if it can't be read), since the real cause * sometimes precedes the trailing lines. */ -const reportFailure = (label: string, tail: string[], logFile: string) => +const reportFailure = (label: string, tail: readonly string[], logFile: string) => Effect.gen(function* () { const lines = [ `${label} failed. Last lines:`, @@ -160,7 +160,11 @@ const reportFailure = (label: string, tail: string[], logFile: string) => * shows the live step from `parseStep` and a running clock; on failure the tail and log path are * printed before the error propagates. In stream mode it is exactly {@link run} (inherited stdio). */ -export const runWithProgress = (command: string, args: string[], options: ProgressRunOptions) => +export const runWithProgress = ( + command: string, + args: readonly string[], + options: ProgressRunOptions, +) => Effect.gen(function* () { const { label, parseStep, ...progressCommandOptions } = options; const commandOptions: { diff --git a/src/core/services/sandbox.ts b/src/core/services/sandbox.ts index a8a8c067..5e7ae247 100644 --- a/src/core/services/sandbox.ts +++ b/src/core/services/sandbox.ts @@ -3,7 +3,7 @@ import type { SandboxTesterResource } from '../types/appleCatalog.js'; /** The exact slice of {@link AppStoreConnectClient} the sandbox domain depends on. */ export type AscSandboxApi = { listSandboxTesters(): Effect.Effect; - clearSandboxTesterPurchaseHistory(testerIds: string[]): Effect.Effect; + clearSandboxTesterPurchaseHistory(testerIds: readonly string[]): Effect.Effect; }; export type SandboxRequestFailure = Readonly<{ readonly _tag: 'SandboxRequestFailure'; diff --git a/src/core/services/ssh.ts b/src/core/services/ssh.ts index b4153f1f..7b99fe59 100644 --- a/src/core/services/ssh.ts +++ b/src/core/services/ssh.ts @@ -80,7 +80,7 @@ export const rsyncUp = ( target: SshTarget, localDir: string, remoteDir: string, - excludes: string[], + excludes: readonly string[], ) => { const sshCommand = ['ssh', ...sshFlags(target)].join(' '); const args = ['-az', '--delete', '-e', sshCommand]; diff --git a/src/core/snapshot/orchestrator.test.ts b/src/core/snapshot/orchestrator.test.ts index 0a60a8c1..3df2bd13 100644 --- a/src/core/snapshot/orchestrator.test.ts +++ b/src/core/snapshot/orchestrator.test.ts @@ -33,7 +33,7 @@ const source = ( return { id, title: id, store, capture }; }; const META = { name: 'before-sync', capturedAt: '2026-06-16T00:00:00.000Z' }; -const runCapture = (sources: SnapshotSource[]) => +const runCapture = (sources: readonly SnapshotSource[]) => Effect.runPromise(captureSnapshot(makeCtx(), sources, META)); /** A captured surface holding `count` entities under one app. */ const captured = (count: number): SourceCapture => { diff --git a/src/core/snapshot/orchestrator.ts b/src/core/snapshot/orchestrator.ts index 09e20aed..bb6a18d5 100644 --- a/src/core/snapshot/orchestrator.ts +++ b/src/core/snapshot/orchestrator.ts @@ -37,7 +37,7 @@ export const SNAPSHOT_VERSION = 1; */ export const captureSnapshot = ( snapshotContext: SnapshotContext, - sources: SnapshotSource[], + sources: readonly SnapshotSource[], meta: CaptureMeta, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/snapshot/snapshotCommand.ts b/src/core/snapshot/snapshotCommand.ts index ed8cef55..8c327cc1 100644 --- a/src/core/snapshot/snapshotCommand.ts +++ b/src/core/snapshot/snapshotCommand.ts @@ -166,9 +166,9 @@ const currentIsoTime = (): Effect.Effect => /** Narrow discovered apps through the snapshot selector in the typed error channel. */ const selectSnapshotApps = ( - discoveredApps: AppDescriptor[], + discoveredApps: readonly AppDescriptor[], appSelector: string | undefined, -): Effect.Effect => +): Effect.Effect => selectApps(discoveredApps, appSelector).pipe( Effect.mapError((cause) => snapshotFailure('select snapshot apps', cause)), ); @@ -640,7 +640,7 @@ const savedEntitiesFor = ( savedSnapshot: Snapshot, sourceId: string, appSelector: string | undefined, -): AppEntities[] => { +): readonly AppEntities[] => { const captureReport = savedSnapshot.reports.find( (snapshotReport) => snapshotReport.id === sourceId, ); diff --git a/src/core/snapshot/sources/appleListing.ts b/src/core/snapshot/sources/appleListing.ts index a0c697a0..29f41cdb 100644 --- a/src/core/snapshot/sources/appleListing.ts +++ b/src/core/snapshot/sources/appleListing.ts @@ -32,7 +32,7 @@ const captureListing = ( ): Effect.Effect => Effect.gen(function* () { const byLocale = new Map>(); - const merge = (localizations: ListingLocalization[]): void => { + const merge = (localizations: readonly ListingLocalization[]): void => { for (const localization of localizations) { byLocale.set(localization.locale, { ...byLocale.get(localization.locale), @@ -48,8 +48,20 @@ const captureListing = ( .sort(([a], [b]) => a.localeCompare(b)) .map(([locale, fields]) => toEntity(locale, fields)); }); +/** Narrow a captured {@link JsonValue} to a plain object (rejecting arrays and null). */ +const isJsonObject = ( + capturedNode: JsonValue, +): capturedNode is Readonly<{ [key: string]: JsonValue }> => { + if (typeof capturedNode !== 'object') return false; + if (capturedNode === null) return false; + if (Array.isArray(capturedNode)) return false; + return true; +}; /** Read a string-valued field from a captured listing's `fields` map, or undefined when absent/non-string. */ -const fieldString = (fields: Record, key: string): string | undefined => { +const fieldString = ( + fields: Readonly<{ [key: string]: JsonValue }>, + key: string, +): string | undefined => { const listingField = fields[key]; if (typeof listingField === 'string') return listingField; return undefined; @@ -59,7 +71,7 @@ const fieldString = (fields: Record, key: string): string | u * `ascSync.routeListing`. Only present fields are carried, and the comma-joined `keywords` string is split * back into the array shape `store.config.json` uses. */ -const toLocaleInfo = (fields: Record): AppleLocaleInfo => { +const toLocaleInfo = (fields: Readonly<{ [key: string]: JsonValue }>): AppleLocaleInfo => { const localeInfo: AppleLocaleInfo = {}; const title = fieldString(fields, 'name'); if (title !== undefined) localeInfo.title = title; @@ -92,15 +104,12 @@ const toListing = (saved: AppEntities): AppleStoreConfig => { const localeInfoByLocale: Record = {}; for (const entity of saved.entities) { const capturedListing = entity.data; - if (typeof capturedListing !== 'object') continue; - if (capturedListing === null) continue; - if (Array.isArray(capturedListing)) continue; + if (!isJsonObject(capturedListing)) continue; const locale = capturedListing['locale']; const fields = capturedListing['fields']; if (typeof locale !== 'string') continue; - if (typeof fields !== 'object') continue; - if (fields === null) continue; - if (Array.isArray(fields)) continue; + if (fields === undefined) continue; + if (!isJsonObject(fields)) continue; localeInfoByLocale[locale] = toLocaleInfo(fields); } return { info: localeInfoByLocale }; @@ -133,7 +142,10 @@ export const appleListingSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/appleProducts.ts b/src/core/snapshot/sources/appleProducts.ts index 26fef848..83a378a9 100644 --- a/src/core/snapshot/sources/appleProducts.ts +++ b/src/core/snapshot/sources/appleProducts.ts @@ -1,10 +1,5 @@ import { Effect } from 'effect'; -import type { - AppEntities, - SnapshotContext, - SnapshotEntity, - SnapshotSource, -} from '@core/types/snapshot.js'; +import type { SnapshotContext, SnapshotEntity, SnapshotSource } from '@core/types/snapshot.js'; import { iosApps } from '@core/readiness/appScopes.js'; /** One captured in-app purchase -> a snapshot entity keyed by its product id. */ const toEntity = (iap: { @@ -55,7 +50,10 @@ export const appleProductsSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/appleSubscriptions.ts b/src/core/snapshot/sources/appleSubscriptions.ts index 9a841cab..c100d0d9 100644 --- a/src/core/snapshot/sources/appleSubscriptions.ts +++ b/src/core/snapshot/sources/appleSubscriptions.ts @@ -1,10 +1,5 @@ import { Effect } from 'effect'; -import type { - AppEntities, - SnapshotContext, - SnapshotEntity, - SnapshotSource, -} from '@core/types/snapshot.js'; +import type { SnapshotContext, SnapshotEntity, SnapshotSource } from '@core/types/snapshot.js'; import { iosApps } from '@core/readiness/appScopes.js'; /** One captured subscription -> a snapshot entity keyed by its product id. */ const toEntity = ( @@ -66,7 +61,10 @@ export const appleSubscriptionsSource: SnapshotSource = { ); return { state: 'captured', - apps: captured.filter((app): app is AppEntities => app !== null), + apps: captured.flatMap((app) => { + if (app === null) return []; + return [app]; + }), }; }); }, diff --git a/src/core/snapshot/sources/playProducts.ts b/src/core/snapshot/sources/playProducts.ts index 44bda4a3..3a29865e 100644 --- a/src/core/snapshot/sources/playProducts.ts +++ b/src/core/snapshot/sources/playProducts.ts @@ -15,6 +15,7 @@ import type { InAppProductResource, PlayMoney } from '@core/types/googlePlay.js' import type { PlannedAction } from '@core/types/reconcile.js'; import { reconcilePlayProducts } from '@core/store/playProducts.js'; import { androidApps } from '@core/readiness/appScopes.js'; +import type { MutableDeep } from '@core/types/mutable.js'; import { jsonRecord, restoreErrorMessage, @@ -78,7 +79,7 @@ const toLocalizations = ( let name: string | undefined; if (fields) name = stringField(fields, 'title'); if (name === undefined) continue; - const localization: ProductLocalization = { locale, name }; + const localization: MutableDeep = { locale, name }; let description: string | undefined; if (fields) description = stringField(fields, 'description'); if (description !== undefined) localization.description = description; @@ -111,7 +112,7 @@ const toProductConfig = (entity: SnapshotEntity): InAppPurchaseConfig | null => stringField(productFields, 'defaultLanguage'), ); if (localizations.length === 0) return null; - const play: PlayProductOverride = { sku }; + const play: MutableDeep = { sku }; const defaultPrice = toPriceConfig(productFields['defaultPrice']); if (defaultPrice) play.defaultPrice = defaultPrice; return { productId: sku, referenceName: sku, type: 'NON_CONSUMABLE', localizations, play }; diff --git a/src/core/snapshot/sources/playRestore.ts b/src/core/snapshot/sources/playRestore.ts index 742f6641..2ea3c12f 100644 --- a/src/core/snapshot/sources/playRestore.ts +++ b/src/core/snapshot/sources/playRestore.ts @@ -5,16 +5,27 @@ import type { PlannedAction } from '@core/types/reconcile.js'; * Narrow a captured {@link JsonValue} to a plain object (rejecting arrays and null), or `null`, so a * malformed captured section is skipped rather than slipping through as an empty record. */ +const isJsonRecord = ( + capturedNode: JsonValue, +): capturedNode is Readonly<{ [key: string]: JsonValue }> => { + if (typeof capturedNode !== 'object') return false; + if (capturedNode === null) return false; + if (Array.isArray(capturedNode)) return false; + return true; +}; + export const jsonRecord = ( capturedNode: JsonValue | undefined, -): Record | null => { - if (typeof capturedNode !== 'object') return null; - if (capturedNode === null) return null; - if (Array.isArray(capturedNode)) return null; +): Readonly<{ [key: string]: JsonValue }> | null => { + if (capturedNode === undefined) return null; + if (!isJsonRecord(capturedNode)) return null; return capturedNode; }; /** Read a string-valued field from a captured record, or `undefined` when absent/non-string. */ -export const stringField = (record: Record, key: string): string | undefined => { +export const stringField = ( + record: Readonly<{ [key: string]: JsonValue }>, + key: string, +): string | undefined => { const capturedField = record[key]; if (typeof capturedField === 'string') return capturedField; return undefined; diff --git a/src/core/snapshot/sources/playSubscriptions.ts b/src/core/snapshot/sources/playSubscriptions.ts index a12cfae3..567fdd1b 100644 --- a/src/core/snapshot/sources/playSubscriptions.ts +++ b/src/core/snapshot/sources/playSubscriptions.ts @@ -38,7 +38,7 @@ import { * money as `units`+`nanos`, converted to micro-units here so a restore reads it straight back as a * {@link PlayPriceConfig}. Regions Play left price-less are dropped. */ -const regionalPrices = (configs: RegionalBasePlanConfig[]): Record => { +const regionalPrices = (configs: readonly RegionalBasePlanConfig[]): Record => { const prices: Record = {}; for (const config of [...configs].sort((a, b) => a.regionCode.localeCompare(b.regionCode))) { if (config.price) { @@ -51,7 +51,7 @@ const regionalPrices = (configs: RegionalBasePlanConfig[]): Record { +const basePlans = (plans: readonly BasePlan[]): JsonValue => { return plans.map((plan): JsonValue => { let prices: Record = {}; if (plan.regionalConfigs) prices = regionalPrices(plan.regionalConfigs); @@ -64,7 +64,7 @@ const basePlans = (plans: BasePlan[]): JsonValue => { }); }; /** A subscription's listings, normalized to language + title pairs. */ -const listings = (items: SubscriptionListing[]): JsonValue => { +const listings = (items: readonly SubscriptionListing[]): JsonValue => { return items.map( (listing): JsonValue => ({ languageCode: listing.languageCode, title: listing.title }), ); diff --git a/src/core/snapshot/store.ts b/src/core/snapshot/store.ts index e2ec6e26..813e44a9 100644 --- a/src/core/snapshot/store.ts +++ b/src/core/snapshot/store.ts @@ -21,68 +21,54 @@ const JsonValueSchema: Schema.Schema = Schema.suspend(() => Schema.Number, Schema.Boolean, Schema.Null, - Schema.mutable(Schema.Array(JsonValueSchema)), - Schema.mutable(Schema.Record({ key: Schema.String, value: JsonValueSchema })), + Schema.Array(JsonValueSchema), + Schema.Record({ key: Schema.String, value: JsonValueSchema }), ), ); -const SnapshotEntitySchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - key: Schema.String, - summary: Schema.String, - data: JsonValueSchema, - }), -); +const SnapshotEntitySchema: Schema.Schema = Schema.Struct({ + key: Schema.String, + summary: Schema.String, + data: JsonValueSchema, +}); -const AppEntitiesSchema: Schema.Schema = Schema.mutable( - Schema.Struct({ - app: Schema.String, - identifier: Schema.String, - entities: Schema.mutable(Schema.Array(SnapshotEntitySchema)), - }), -); +const AppEntitiesSchema: Schema.Schema = Schema.Struct({ + app: Schema.String, + identifier: Schema.String, + entities: Schema.Array(SnapshotEntitySchema), +}); const CaptureOutcomeSchema: Schema.Schema = Schema.Union( - Schema.mutable(Schema.Struct({ state: Schema.Literal('omitted') })), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('skipped'), - reason: Schema.String, - hint: Schema.optionalWith(Schema.String, { exact: true }), - }), - ), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('captured'), - apps: Schema.mutable(Schema.Array(AppEntitiesSchema)), - }), - ), - Schema.mutable( - Schema.Struct({ - state: Schema.Literal('errored'), - error: Schema.String, - }), - ), -); - -const CaptureReportSchema: Schema.Schema = Schema.mutable( + Schema.Struct({ state: Schema.Literal('omitted') }), Schema.Struct({ - id: Schema.String, - title: Schema.String, - store: Schema.Literal('appstore', 'play'), - outcome: CaptureOutcomeSchema, + state: Schema.Literal('skipped'), + reason: Schema.String, + hint: Schema.optionalWith(Schema.String, { exact: true }), + }), + Schema.Struct({ + state: Schema.Literal('captured'), + apps: Schema.Array(AppEntitiesSchema), }), -); - -const SnapshotSchema: Schema.Schema = Schema.mutable( Schema.Struct({ - version: Schema.Number, - name: Schema.String, - capturedAt: Schema.String, - reports: Schema.mutable(Schema.Array(CaptureReportSchema)), + state: Schema.Literal('errored'), + error: Schema.String, }), ); +const CaptureReportSchema: Schema.Schema = Schema.Struct({ + id: Schema.String, + title: Schema.String, + store: Schema.Literal('appstore', 'play'), + outcome: CaptureOutcomeSchema, +}); + +const SnapshotSchema: Schema.Schema = Schema.Struct({ + version: Schema.Number, + name: Schema.String, + capturedAt: Schema.String, + reports: Schema.Array(CaptureReportSchema), +}); + type SnapshotStoreRequirements = FileSystem.FileSystem | LaunchPathsService | Path.Path; /** Resolve the persisted snapshot directory or an explicit test/CLI override. */ diff --git a/src/core/store/accessibility.ts b/src/core/store/accessibility.ts index 3bc77906..39f825a9 100644 --- a/src/core/store/accessibility.ts +++ b/src/core/store/accessibility.ts @@ -9,6 +9,7 @@ import { import { appRecordMissing, plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; import { decodeStoreSurfaceConfig, loadStoreSurfaceConfig, @@ -126,7 +127,7 @@ const supportEquals = ( }; /** Expand omitted accessibility flags to `false`. */ const normalizeSupport = (support: AccessibilitySupport): AccessibilitySupport => { - const normalizedSupport: AccessibilitySupport = {}; + const normalizedSupport: MutableDeep = {}; for (const key of ACCESSIBILITY_SUPPORT_KEYS) normalizedSupport[key] = support[key] === true; return normalizedSupport; }; diff --git a/src/core/store/appEvents.ts b/src/core/store/appEvents.ts index 7b9eb73b..4b69ddc8 100644 --- a/src/core/store/appEvents.ts +++ b/src/core/store/appEvents.ts @@ -1,4 +1,5 @@ import { Data, Effect } from 'effect'; +import type { MutableDeep } from '../types/mutable.js'; import type { AppEventLocalizationInput, AppEventLocalizationResource, @@ -191,7 +192,7 @@ export const createEvent = ( appEventsStore.getAppId(bundleId), ); if (appId === null) return yield* Effect.fail(missingAppRecord(bundleId)); - const eventAttributes: NewAppEvent = { referenceName }; + const eventAttributes: MutableDeep = { referenceName }; if (badge !== undefined) eventAttributes.badge = badge; if (eventRequest.primaryLocale !== undefined) { eventAttributes.primaryLocale = eventRequest.primaryLocale.trim(); @@ -224,7 +225,7 @@ export const localizeEvent = ( }), ); } - const localizationAttributes: AppEventLocalizationInput = {}; + const localizationAttributes: MutableDeep = {}; if (localizationRequest.name !== undefined) { localizationAttributes.name = localizationRequest.name; } diff --git a/src/core/store/ascScreenshots.ts b/src/core/store/ascScreenshots.ts index 9c9900ff..e98e11cf 100644 --- a/src/core/store/ascScreenshots.ts +++ b/src/core/store/ascScreenshots.ts @@ -64,7 +64,7 @@ export type ScreenshotReconcileInput = { }; /** Group members by a derived string key. */ const groupBy = ( - members: Member[], + members: readonly Member[], keyOf: (member: Member) => string, ): Map => { const groups = new Map(); @@ -122,7 +122,7 @@ const reconcileAppScreenshots = ( api: ScreenshotsApi, log: ActionLog, appId: string, - screenshots: LocalScreenshot[], + screenshots: readonly LocalScreenshot[], ): Effect.Effect => Effect.gen(function* () { const versionId = yield* api.getEditableVersionId(appId); @@ -178,7 +178,7 @@ const reconcileScreenshotSet = ( existingSet: ScreenshotSetResource | undefined, displayType: string, locale: string, - screenshots: LocalScreenshot[], + screenshots: readonly LocalScreenshot[], ): Effect.Effect => Effect.gen(function* () { const label = appleDisplayTypeLabel(displayType); @@ -227,7 +227,7 @@ const reconcileSubscriptionReviewScreenshots = ( api: ScreenshotsApi, log: ActionLog, appId: string, - reviewScreenshots: SubscriptionReviewScreenshot[], + reviewScreenshots: readonly SubscriptionReviewScreenshot[], ): Effect.Effect => Effect.gen(function* () { const subscriptionIdByProduct = new Map(); @@ -365,7 +365,7 @@ const reconcilePreviewSet = ( existingSet: PreviewSetResource | undefined, previewType: string, locale: string, - previews: LocalPreview[], + previews: readonly LocalPreview[], ): Effect.Effect => Effect.gen(function* () { const label = applePreviewTypeLabel(previewType); diff --git a/src/core/store/ascSync.ts b/src/core/store/ascSync.ts index 9bfb6978..9d512b5c 100644 --- a/src/core/store/ascSync.ts +++ b/src/core/store/ascSync.ts @@ -14,6 +14,7 @@ import { errorMessage } from '../services/errorMessage.js'; import type { AppleLocaleInfo, AppleStoreConfig } from './storeConfig.js'; import type { AppProducts, InAppPurchaseConfig, SubscriptionConfig } from '../types/catalog.js'; import type { ActionStatus, PlannedAction, ReconcileReport } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * The exact slice of {@link AppStoreConnectClient} the reconciler depends on. Declaring it here (rather * than taking the concrete client) keeps the diff logic unit-testable with a hand-rolled fake and @@ -161,7 +162,7 @@ const ALWAYS_ENABLED_CAPABILITIES = new Set(['IN_APP_PURCHASE', 'GAME_CE * on the catalog surface. This is the one shared seam between the two reconcilers. */ export type ActionLog = { - actions: PlannedAction[]; + actions: MutableDeep[]; dryRun: boolean; allowDestructive: boolean; }; @@ -190,7 +191,7 @@ export const act = ( status: ActionStatus; actionValue?: CreatedResource; }> => { - const plannedAction: PlannedAction = { description, destructive, status: 'planned' }; + const plannedAction: MutableDeep = { description, destructive, status: 'planned' }; actionLog.actions.push(plannedAction); if (actionLog.dryRun) return Effect.succeed({ status: plannedAction.status }); if (destructive && !actionLog.allowDestructive) { @@ -256,12 +257,12 @@ export const reconcileApp = ( }; const appId = yield* resolveAppId(api, input.bundleId); yield* reconcileCapabilities(reconcileContext, input.bundleId, input.capabilities); - let desiredInAppPurchases: InAppPurchaseConfig[] = []; + let desiredInAppPurchases: readonly InAppPurchaseConfig[] = []; if (input.products.inAppPurchases !== undefined) { desiredInAppPurchases = input.products.inAppPurchases; } yield* reconcileInAppPurchases(reconcileContext, appId, desiredInAppPurchases); - let desiredSubscriptionGroups: AppProducts['subscriptionGroups'] = []; + let desiredSubscriptionGroups: NonNullable = []; if (input.products.subscriptionGroups !== undefined) { desiredSubscriptionGroups = input.products.subscriptionGroups; } @@ -301,7 +302,7 @@ export const reconcileAppListing = ( const reconcileCapabilities = ( reconcileContext: ReconcileContext, bundleId: string, - desired: CapabilityType[], + desired: readonly CapabilityType[], ): Effect.Effect => Effect.gen(function* () { const resource = yield* reconcileContext.api.findBundleId(bundleId); @@ -342,7 +343,7 @@ const reconcileCapabilities = ( const reconcileInAppPurchases = ( reconcileContext: ReconcileContext, appId: string, - desired: InAppPurchaseConfig[], + desired: readonly InAppPurchaseConfig[], ): Effect.Effect => Effect.gen(function* () { if (desired.length === 0) return; @@ -472,7 +473,7 @@ const reconcileSubscriptionGroups = ( const reconcileSubscription = ( reconcileContext: ReconcileContext, groupId: string, - existingSubs: SubscriptionResource[], + existingSubs: readonly SubscriptionResource[], subscription: SubscriptionConfig, groupLevel: number, ): Effect.Effect => diff --git a/src/core/store/availability.ts b/src/core/store/availability.ts index 0edf34f9..a8b7256d 100644 --- a/src/core/store/availability.ts +++ b/src/core/store/availability.ts @@ -68,7 +68,7 @@ export type AvailabilityReconcileInput = { dryRun: boolean; }; /** Uppercase, trim, and de-duplicate a list of territory codes into a stable set. */ -const normalizeTerritories = (territories: string[]): Set => { +const normalizeTerritories = (territories: readonly string[]): Set => { return new Set(territories.map((code) => code.trim().toUpperCase())); }; /** Sorted difference `a \ b` - codes in `a` not in `b`. */ diff --git a/src/core/store/gameCenter.ts b/src/core/store/gameCenter.ts index e6b73ec0..76aec9a6 100644 --- a/src/core/store/gameCenter.ts +++ b/src/core/store/gameCenter.ts @@ -13,6 +13,7 @@ import { import { appRecordMissing, plan, skip, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; import type { AchievementConfig, GameCenterConfig, @@ -202,9 +203,9 @@ export const reconcileGameCenter = ( ); return { bundleId: input.bundleId, actions: reconcileContext.actions }; } - let achievements: AchievementConfig[] = []; + let achievements: readonly AchievementConfig[] = []; if (config.achievements !== undefined) achievements = config.achievements; - let leaderboards: LeaderboardConfig[] = []; + let leaderboards: readonly LeaderboardConfig[] = []; if (config.leaderboards !== undefined) leaderboards = config.leaderboards; yield* reconcileAchievements(reconcileContext, api, detail, achievements); yield* reconcileLeaderboards(reconcileContext, api, detail, leaderboards); @@ -240,7 +241,7 @@ const reconcileAchievements = ( reconcileContext: ReconcileContext, api: AscGameCenterApi, detail: NonNullable, - declared: AchievementConfig[], + declared: readonly AchievementConfig[], ): Effect.Effect => Effect.gen(function* () { let existingIdentifiers = new Set(); @@ -309,7 +310,7 @@ const reconcileLeaderboards = ( reconcileContext: ReconcileContext, api: AscGameCenterApi, detail: NonNullable, - declared: LeaderboardConfig[], + declared: readonly LeaderboardConfig[], ): Effect.Effect => Effect.gen(function* () { let existingIdentifiers = new Set(); @@ -377,7 +378,7 @@ const reconcileLeaderboards = ( * Connect) rather than failed. */ const applyLocalization = ( - action: PlannedAction, + action: MutableDeep, versionId: string | null, vendorIdentifier: string, createLocalization: (confirmedVersionId: string) => Effect.Effect, diff --git a/src/core/store/offers.test.ts b/src/core/store/offers.test.ts index 355145ff..916a0bd0 100644 --- a/src/core/store/offers.test.ts +++ b/src/core/store/offers.test.ts @@ -39,7 +39,7 @@ class FakeOffersApi implements AscOffersApi { readonly createdWinBack: WinBackOfferCreate[] = []; readonly createdPromoted: PromotedPurchaseCreate[] = []; introCreateCount = 0; - reorderedTo: string[] | null = null; + reorderedTo: readonly string[] | null = null; getAppId(): Effect.Effect { return Effect.succeed(this.appId); } @@ -115,7 +115,7 @@ class FakeOffersApi implements AscOffersApi { visibleForAllUsers: input.visibleForAllUsers, }); } - reorderPromotedPurchases(_appId: string, orderedIds: string[]): Effect.Effect { + reorderPromotedPurchases(_appId: string, orderedIds: readonly string[]): Effect.Effect { this.reorderedTo = orderedIds; return Effect.void; } diff --git a/src/core/store/offers.ts b/src/core/store/offers.ts index c2756f15..8185449b 100644 --- a/src/core/store/offers.ts +++ b/src/core/store/offers.ts @@ -27,6 +27,7 @@ import type { WinBackOfferConfig, } from '../types/catalog.js'; import { errorMessage } from '../services/errorMessage.js'; +import type { MutableDeep } from '../types/mutable.js'; /** * The exact slice of {@link AppStoreConnectClient} the offers reconciler depends on. Declared here (not * the concrete client) so the diff logic is unit-testable with a hand-rolled fake, mirroring @@ -58,7 +59,10 @@ export type AscOffersApi = { createPromotedPurchase( input: PromotedPurchaseCreate, ): Effect.Effect; - reorderPromotedPurchases(appId: string, orderedIds: string[]): Effect.Effect; + reorderPromotedPurchases( + appId: string, + orderedIds: readonly string[], + ): Effect.Effect; }; /** Default territory for an {@link OfferPrice} that doesn't name one - matches the rest of the catalog. */ const DEFAULT_TERRITORY = 'USA'; @@ -71,7 +75,7 @@ export type ReconcileOffersInput = { /** Mutable per-run context threaded through the reconcile walk (mirrors `core/store/ascSync.ts`). */ type OffersContext = { api: AscOffersApi; - actions: PlannedAction[]; + actions: MutableDeep[]; dryRun: boolean; }; /** @@ -84,7 +88,7 @@ const act = ( description: string, run: () => Effect.Effect, ): Effect.Effect => { - const action: PlannedAction = { description, destructive: false, status: 'planned' }; + const action: MutableDeep = { description, destructive: false, status: 'planned' }; offersContext.actions.push(action); if (offersContext.dryRun) return Effect.succeed(action.status); return run().pipe( @@ -114,7 +118,7 @@ const makeOfferPricePointFailure = Data.tagged('OfferPri const resolvePrices = ( api: AscOffersApi, subscriptionId: string, - prices: OfferPrice[], + prices: readonly OfferPrice[], ): Effect.Effect => Effect.gen(function* () { const resolved: ResolvedOfferPrice[] = []; @@ -140,7 +144,10 @@ const resolvePrices = ( * Validate a price-bearing offer (offer code, promotional, win-back) at the boundary: `FREE_TRIAL` must * carry no prices; any other mode needs at least one. Returns a human reason when invalid, else null. */ -const priceModeError = (offerMode: string, prices: OfferPrice[] | undefined): string | null => { +const priceModeError = ( + offerMode: string, + prices: readonly OfferPrice[] | undefined, +): string | null => { let priceCount = 0; if (prices !== undefined) priceCount = prices.length; if (offerMode === 'FREE_TRIAL') { @@ -155,7 +162,7 @@ const reconcileOfferCodes = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: OfferCodeConfig[], + desired: readonly OfferCodeConfig[], ): Effect.Effect => Effect.gen(function* () { const codes = yield* offersContext.api.listSubscriptionOfferCodes(subscriptionId); @@ -172,7 +179,7 @@ const reconcileOfferCodes = ( `create offer code "${offer.name}" on ${productId} (${offer.offerMode})`, () => Effect.gen(function* () { - let offerPrices: OfferPrice[] = []; + let offerPrices: readonly OfferPrice[] = []; if (offer.prices !== undefined) offerPrices = offer.prices; const prices = yield* resolvePrices(offersContext.api, subscriptionId, offerPrices); const create: OfferCodeCreate = { @@ -195,7 +202,7 @@ const reconcilePromotionalOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: PromotionalOfferConfig[], + desired: readonly PromotionalOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const offers = yield* offersContext.api.listPromotionalOffers(subscriptionId); @@ -215,7 +222,7 @@ const reconcilePromotionalOffers = ( `create promotional offer "${offer.offerCode}" on ${productId} (${offer.offerMode})`, () => Effect.gen(function* () { - let offerPrices: OfferPrice[] = []; + let offerPrices: readonly OfferPrice[] = []; if (offer.prices !== undefined) offerPrices = offer.prices; const prices = yield* resolvePrices(offersContext.api, subscriptionId, offerPrices); const create: PromotionalOfferCreate = { @@ -237,7 +244,7 @@ const reconcileIntroductoryOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: IntroductoryOfferConfig[], + desired: readonly IntroductoryOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const introductoryOffers = yield* offersContext.api.listIntroductoryOffers(subscriptionId); @@ -275,7 +282,7 @@ const reconcileIntroductoryOffers = ( ]); if (resolvedPrices[0] !== undefined) resolvedPrice = resolvedPrices[0]; } - const introductoryOffer: IntroductoryOfferCreate = { + const introductoryOffer: MutableDeep = { subscriptionId, duration: offer.duration, offerMode: offer.offerMode, @@ -295,7 +302,7 @@ const reconcileWinBackOffers = ( offersContext: OffersContext, subscriptionId: string, productId: string, - desired: WinBackOfferConfig[], + desired: readonly WinBackOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const winBackOffers = yield* offersContext.api.listWinBackOffers(subscriptionId); @@ -322,12 +329,12 @@ const reconcileWinBackOffers = ( `create win-back offer "${offer.offerId}" on ${productId} (${offer.offerMode})`, () => Effect.gen(function* () { - let offerPrices: OfferPrice[] = []; + let offerPrices: readonly OfferPrice[] = []; if (offer.prices !== undefined) offerPrices = offer.prices; const prices = yield* resolvePrices(offersContext.api, subscriptionId, offerPrices); let priority: WinBackOfferCreate['priority'] = 'NORMAL'; if (offer.priority !== undefined) priority = offer.priority; - const create: WinBackOfferCreate = { + const create: MutableDeep = { subscriptionId, offerId: offer.offerId, referenceName: offer.referenceName, @@ -335,7 +342,7 @@ const reconcileWinBackOffers = ( offerMode: offer.offerMode, numberOfPeriods: offer.numberOfPeriods, eligiblePaidMonths: offer.eligiblePaidMonths, - monthsSinceLastSubscribed: offer.monthsSinceLastSubscribed, + monthsSinceLastSubscribed: { ...offer.monthsSinceLastSubscribed }, startDate: offer.startDate, priority, prices, @@ -423,7 +430,7 @@ const reconcilePromotedPurchases = ( declaredOrder.push(existingPromotionId); continue; } - const create: PromotedPurchaseCreate = { + const create: MutableDeep = { appId, visibleForAllUsers: true, enabled: true, @@ -498,17 +505,17 @@ export const reconcileOffers = ( ); continue; } - let offerCodes: OfferCodeConfig[] = []; + let offerCodes: readonly OfferCodeConfig[] = []; if (subscription.offerCodes !== undefined) offerCodes = subscription.offerCodes; - let promotionalOffers: PromotionalOfferConfig[] = []; + let promotionalOffers: readonly PromotionalOfferConfig[] = []; if (subscription.promotionalOffers !== undefined) { promotionalOffers = subscription.promotionalOffers; } - let introductoryOffers: IntroductoryOfferConfig[] = []; + let introductoryOffers: readonly IntroductoryOfferConfig[] = []; if (subscription.introductoryOffers !== undefined) { introductoryOffers = subscription.introductoryOffers; } - let winBackOffers: WinBackOfferConfig[] = []; + let winBackOffers: readonly WinBackOfferConfig[] = []; if (subscription.winBackOffers !== undefined) { winBackOffers = subscription.winBackOffers; } diff --git a/src/core/store/offersCommand.ts b/src/core/store/offersCommand.ts index 01ba097d..c4f73436 100644 --- a/src/core/store/offersCommand.ts +++ b/src/core/store/offersCommand.ts @@ -256,7 +256,7 @@ const reconcileOffersJob = ( /** Count action outcomes for one app receipt. */ const summarizeOfferActions = ( - actions: PlannedAction[], + actions: readonly PlannedAction[], ): Readonly<{ applied: number; failed: number; skipped: number }> => { let applied = 0; let failed = 0; diff --git a/src/core/store/playProducts.ts b/src/core/store/playProducts.ts index 76c1d160..9df0b501 100644 --- a/src/core/store/playProducts.ts +++ b/src/core/store/playProducts.ts @@ -4,6 +4,7 @@ import type { InAppPurchaseConfig, PlayPriceConfig } from '../types/catalog.js'; import type { PlannedAction } from '../types/reconcile.js'; import { plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Play's purchase type for a one-off managed (non-subscription) product. */ const MANAGED_PRODUCT = 'managedUser'; /** Status Launch publishes products as - declaring a `play` override means "this product should be sellable". */ @@ -85,7 +86,7 @@ export const toPlayProduct = ( prices[region] = toMoney(price); } } - const desiredProduct: InAppProductResource = { + const desiredProduct: MutableDeep = { sku, status: ACTIVE_STATUS, purchaseType: MANAGED_PRODUCT, diff --git a/src/core/store/playSubscriptions.ts b/src/core/store/playSubscriptions.ts index ab709721..7a957653 100644 --- a/src/core/store/playSubscriptions.ts +++ b/src/core/store/playSubscriptions.ts @@ -17,6 +17,7 @@ import type { import type { PlannedAction } from '../types/reconcile.js'; import { plan, type ReconcileContext } from './reconcile.js'; import { errorMessage } from '../services/errorMessage.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Apple billing period -> ISO-8601 duration, the form Play's base plans and offer phases want. */ const PERIOD_ISO: Record = { ONE_WEEK: 'P1W', @@ -103,7 +104,7 @@ export const unitsToMicros = (money: PlayMoneyUnits): string => { return (BigInt(money.units) * 1000000n + BigInt(money.nanos) / 1000n).toString(); }; /** Map the shared localizations to Play subscription listings (Play requires a description; fall back to the title). */ -const buildListings = (localizations: ProductLocalization[]): SubscriptionListing[] => { +const buildListings = (localizations: readonly ProductLocalization[]): SubscriptionListing[] => { return localizations.map((localization) => { let description = localization.name; if (localization.description !== undefined) description = localization.description; @@ -116,8 +117,8 @@ const buildListings = (localizations: ProductLocalization[]): SubscriptionListin }; /** Whether every desired listing has a title/description-equal counterpart already live. */ const listingsInSync = ( - existing: SubscriptionListing[], - desired: SubscriptionListing[], + existing: readonly SubscriptionListing[], + desired: readonly SubscriptionListing[], ): boolean => { const byLanguage = new Map(existing.map((listing) => [listing.languageCode, listing])); return desired.every((listing) => { @@ -128,8 +129,8 @@ const listingsInSync = ( }; /** Merge desired listings over the live ones (by language) so a patch never drops locales Launch doesn't manage. */ const mergeListings = ( - existing: SubscriptionListing[], - desired: SubscriptionListing[], + existing: readonly SubscriptionListing[], + desired: readonly SubscriptionListing[], ): SubscriptionListing[] => { const byLanguage = new Map(existing.map((listing) => [listing.languageCode, listing])); for (const listing of desired) { @@ -157,14 +158,18 @@ const buildBasePlan = ( }; /** Re-encode a live base plan for a patch that only appends a new one - dropping the output-only `state`. */ const resendableBasePlan = (basePlan: BasePlan): BasePlan => { - const resendablePlan: BasePlan = { basePlanId: basePlan.basePlanId }; + const resendablePlan: MutableDeep = { basePlanId: basePlan.basePlanId }; if (basePlan.autoRenewingBasePlanType !== undefined) { resendablePlan.autoRenewingBasePlanType = basePlan.autoRenewingBasePlanType; } if (basePlan.regionalConfigs !== undefined) { - resendablePlan.regionalConfigs = basePlan.regionalConfigs; + resendablePlan.regionalConfigs = basePlan.regionalConfigs.map((regionalConfig) => ({ + ...regionalConfig, + })); + } + if (basePlan.offerTags !== undefined) { + resendablePlan.offerTags = basePlan.offerTags.map((offerTag) => ({ ...offerTag })); } - if (basePlan.offerTags !== undefined) resendablePlan.offerTags = basePlan.offerTags; return resendablePlan; }; /** @@ -185,7 +190,7 @@ export const makePlayOfferConfigFailure = export const buildOffer = ( productId: string, basePlanId: string, - basePlanRegions: string[], + basePlanRegions: readonly string[], config: PlaySubscriptionOfferConfig, ): Effect.Effect => { const phases: SubscriptionOfferPhase[] = []; @@ -249,8 +254,8 @@ const resolveOffers = ( reconcileContext: ReconcileContext, productId: string, basePlanId: string, - basePlanRegions: string[], - configs: PlaySubscriptionOfferConfig[], + basePlanRegions: readonly string[], + configs: readonly PlaySubscriptionOfferConfig[], ): Effect.Effect => Effect.gen(function* () { const offers: SubscriptionOfferResource[] = []; @@ -277,15 +282,15 @@ const resolveOffers = ( type DesiredSubscription = { productId: string; basePlanId: string; - listings: SubscriptionListing[]; + listings: readonly SubscriptionListing[]; basePlan: BasePlan; - basePlanRegions: string[]; - offerConfigs: PlaySubscriptionOfferConfig[]; + basePlanRegions: readonly string[]; + offerConfigs: readonly PlaySubscriptionOfferConfig[]; }; /** Apply one Play write and record its outcome on the planned action. */ const applyAction = ( write: Effect.Effect, - action: PlannedAction, + action: MutableDeep, ): Effect.Effect => write.pipe( Effect.match({ @@ -389,7 +394,7 @@ const reconcileExistingSubscription = ( desired: DesiredSubscription, ): Effect.Effect => Effect.gen(function* () { - let existingListings: SubscriptionListing[] = []; + let existingListings: readonly SubscriptionListing[] = []; if (existing.listings !== undefined) existingListings = existing.listings; if (!listingsInSync(existingListings, desired.listings)) { const mergedListings = mergeListings(existingListings, desired.listings); @@ -405,7 +410,7 @@ const reconcileExistingSubscription = ( ); } } - let existingBasePlans: BasePlan[] = []; + let existingBasePlans: readonly BasePlan[] = []; if (existing.basePlans !== undefined) existingBasePlans = existing.basePlans; const liveBasePlan = existingBasePlans.find( (basePlan) => basePlan.basePlanId === desired.basePlanId, @@ -493,7 +498,7 @@ export const reconcilePlaySubscriptions = ( if (playOverrides.productId !== undefined) productId = playOverrides.productId; let basePlanId = PERIOD_ISO[subscription.subscriptionPeriod].toLowerCase(); if (playOverrides.basePlanId !== undefined) basePlanId = playOverrides.basePlanId; - let offerConfigs: PlaySubscriptionOfferConfig[] = []; + let offerConfigs: readonly PlaySubscriptionOfferConfig[] = []; if (playOverrides.offers !== undefined) offerConfigs = playOverrides.offers; const desired: DesiredSubscription = { productId, diff --git a/src/core/store/playTracks.ts b/src/core/store/playTracks.ts index 0dc1ac85..475efaf7 100644 --- a/src/core/store/playTracks.ts +++ b/src/core/store/playTracks.ts @@ -1,5 +1,6 @@ import { Data, Effect, Schema } from 'effect'; import type { PlayRelease } from '../types/googlePlay.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Play release statuses accepted by the Android Publisher API. */ export const RELEASE_STATUSES = ['draft', 'inProgress', 'halted', 'completed'] as const; @@ -105,7 +106,7 @@ export const buildRelease = ( }), ); } - const playRelease: PlayRelease = { + const playRelease: MutableDeep = { status: releaseInput.status, versionCodes: [...releaseInput.versionCodes], }; diff --git a/src/core/store/reconcile.ts b/src/core/store/reconcile.ts index 8930c96b..5fead184 100644 --- a/src/core/store/reconcile.ts +++ b/src/core/store/reconcile.ts @@ -1,10 +1,11 @@ import { Data, Effect } from 'effect'; import { errorMessage } from '../services/errorMessage.js'; import type { PlannedAction } from '../types/reconcile.js'; +import type { MutableDeep } from '../types/mutable.js'; /** Mutable state for one reconciliation pass. */ export type ReconcileContext = { - actions: PlannedAction[]; + actions: MutableDeep[]; dryRun: boolean; }; @@ -23,7 +24,11 @@ export const act = ( description: string, runAction: () => Effect.Effect, ): Effect.Effect => { - const plannedAction: PlannedAction = { description, destructive: false, status: 'planned' }; + const plannedAction: MutableDeep = { + description, + destructive: false, + status: 'planned', + }; reconcileContext.actions.push(plannedAction); if (reconcileContext.dryRun) return Effect.void; return runAction().pipe( @@ -40,8 +45,15 @@ export const act = ( }; /** Record a planned action and return its mutable status handle. */ -export const plan = (reconcileContext: ReconcileContext, description: string): PlannedAction => { - const plannedAction: PlannedAction = { description, destructive: false, status: 'planned' }; +export const plan = ( + reconcileContext: ReconcileContext, + description: string, +): MutableDeep => { + const plannedAction: MutableDeep = { + description, + destructive: false, + status: 'planned', + }; reconcileContext.actions.push(plannedAction); return plannedAction; }; @@ -72,7 +84,7 @@ export const skip = (reconcileContext: ReconcileContext, description: string): v }; /** Tally a reconcile report's action statuses for the run-summary footer (applied / failed / skipped). */ export const summarize = ( - actions: PlannedAction[], + actions: readonly PlannedAction[], ): { applied: number; failed: number; diff --git a/src/core/store/reportsCommand.ts b/src/core/store/reportsCommand.ts index 1585b5c6..79147e63 100644 --- a/src/core/store/reportsCommand.ts +++ b/src/core/store/reportsCommand.ts @@ -14,6 +14,7 @@ import { parseTsv, } from './reports.js'; import { resolveStoreBundleId, type StoreAppSelectionRequirements } from './selectStoreApp.js'; +import type { MutableDeep } from '../types/mutable.js'; const SalesReportsCommandInputSchema = Schema.Struct({ operation: Schema.Literal('sales'), @@ -244,7 +245,7 @@ const downloadSalesReports = ( yield* Effect.forEach( reportDates, (reportDate) => { - const reportQuery: SalesReportQuery = { + const reportQuery: MutableDeep = { vendorNumber, frequency: commandInput.frequency, reportType: commandInput.reportType, @@ -282,7 +283,7 @@ const downloadFinanceReport = ( const vendorNumber = yield* selectVendorNumber(commandInput.vendorNumber); const outputDirectory = yield* projectOutputDirectory(commandInput.out); const appleStore = yield* loadActiveAppleStore(); - const reportQuery: FinanceReportQuery = { + const reportQuery: MutableDeep = { vendorNumber, reportDate: commandInput.date, regionCode: commandInput.region, diff --git a/src/core/store/syncJobs.ts b/src/core/store/syncJobs.ts index 0efc5d74..60b45db0 100644 --- a/src/core/store/syncJobs.ts +++ b/src/core/store/syncJobs.ts @@ -87,9 +87,9 @@ export const makeAppSelectionFailure = Data.tagged('AppSele /** Resolve discovered apps from an optional comma-separated selector. */ export const selectApps = ( - apps: AppDescriptor[], + apps: readonly AppDescriptor[], selector: string | undefined, -): Effect.Effect => { +): Effect.Effect => { if (selector === undefined) return Effect.succeed(apps); if (selector === '') return Effect.succeed(apps); const selectedNames = selector @@ -113,7 +113,7 @@ export const selectApps = ( }); }; /** Build the job list, dropping apps with no iOS bundle id and nothing (capabilities, products, listing, or assets) to sync. */ -export const buildJobs = (apps: AppDescriptor[], config: LaunchConfig) => +export const buildJobs = (apps: readonly AppDescriptor[], config: LaunchConfig) => Effect.gen(function* () { const jobs: SyncJob[] = []; for (const app of apps) { diff --git a/src/core/store/syncRun.ts b/src/core/store/syncRun.ts index f0ce27f4..85fda8e7 100644 --- a/src/core/store/syncRun.ts +++ b/src/core/store/syncRun.ts @@ -38,7 +38,7 @@ export type SyncAppReport = { app: string; bundleId: string; error?: string; - actions?: PlannedAction[]; + actions?: readonly PlannedAction[]; summary?: { applied: number; failed: number; @@ -187,8 +187,14 @@ export const reconcileJob = ( }; if (job.listing) reconcileInput.listing = job.listing; const report = yield* reconcileApp(client, reconcileInput); - report.actions.push(...(yield* reconcileAssetActions(client, job, dryRun, allowDestructive))); - return { job, report }; + const assetActions = yield* reconcileAssetActions(client, job, dryRun, allowDestructive); + return { + job, + report: { + ...report, + actions: [...report.actions, ...assetActions], + }, + }; }).pipe( Effect.catchAll((failure) => Effect.succeed({ diff --git a/src/core/store/team.ts b/src/core/store/team.ts index b1195976..a11bd2c9 100644 --- a/src/core/store/team.ts +++ b/src/core/store/team.ts @@ -81,7 +81,7 @@ export const getTeam = (teamClient: AscTeamApi): Effect.Effect teamFailure('list', cause))); /** Normalize comma-separated role fragments into Apple's canonical role names. */ -const normalizeRoles = (declaredRoles: string[]): string[] => { +const normalizeRoles = (declaredRoles: readonly string[]): string[] => { const normalizedRoles = new Set(); for (const declaredRole of declaredRoles) { const normalizedRole = declaredRole.trim().toUpperCase(); diff --git a/src/core/store/walletIds.ts b/src/core/store/walletIds.ts index f9f37752..0425b362 100644 --- a/src/core/store/walletIds.ts +++ b/src/core/store/walletIds.ts @@ -68,7 +68,7 @@ const reconcileFamily = ( reconcileContext: ReconcileContext, label: string, existing: Set, - declared: WalletIdConfig[], + declared: readonly WalletIdConfig[], create: (identifier: string, name: string) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { diff --git a/src/core/terminal/completion.ts b/src/core/terminal/completion.ts index 0c84db72..54d52e8f 100644 --- a/src/core/terminal/completion.ts +++ b/src/core/terminal/completion.ts @@ -178,7 +178,7 @@ const optionFlags = (command: Command): string[] => { */ const descendCommandTree = ( program: Command, - words: string[], + words: readonly string[], ): { command: Command; commandPath: string[]; diff --git a/src/core/terminal/halfblock.ts b/src/core/terminal/halfblock.ts index dbf14708..803e81a5 100644 --- a/src/core/terminal/halfblock.ts +++ b/src/core/terminal/halfblock.ts @@ -46,7 +46,7 @@ const paint = ( return `\x1b[${params.join(';')}m${text}\x1b[0m`; }; /** Render a row to a string, coalescing runs of same-color cells into one ANSI span (or plain text). */ -const renderRow = (cellRow: Cell[], depth: ColorDepth): string => { +const renderRow = (cellRow: readonly Cell[], depth: ColorDepth): string => { if (depth === 'none') return cellRow.map((cell) => cell.ch).join(''); let out = ''; let i = 0; diff --git a/src/core/terminal/wizardCommand.test.ts b/src/core/terminal/wizardCommand.test.ts index 2ffa4ee8..3a906a80 100644 --- a/src/core/terminal/wizardCommand.test.ts +++ b/src/core/terminal/wizardCommand.test.ts @@ -10,7 +10,7 @@ import { validateCustomBudget, } from './wizardCommand.js'; -const configWith = (profileNames: string[]): LaunchConfig => ({ +const configWith = (profileNames: readonly string[]): LaunchConfig => ({ profiles: Object.fromEntries( profileNames.map((profileName) => [profileName, { name: profileName }]), ), diff --git a/src/core/terminal/wizardCommand.ts b/src/core/terminal/wizardCommand.ts index 0e21c313..36569a6e 100644 --- a/src/core/terminal/wizardCommand.ts +++ b/src/core/terminal/wizardCommand.ts @@ -67,7 +67,7 @@ const teach = (topic: GlossaryTopic, title: string) => }); /** Select the app platform while showing whether each store is configured. */ -const selectPlatform = (configuredApps: AppDescriptor[]) => +const selectPlatform = (configuredApps: readonly AppDescriptor[]) => Effect.gen(function* () { const hasIosApp = configuredApps.some((configuredApp) => configuredApp.bundleId !== undefined); const hasAndroidApp = configuredApps.some( @@ -358,7 +358,7 @@ const isPromptSelectionFailure = (cause: unknown): cause is PromptSelectionFailu export const flowInvalidReason = ( rememberedFlow: LastFlow, launchConfig: LaunchConfig, - configuredApps: AppDescriptor[], + configuredApps: readonly AppDescriptor[], accountKeyIds: Set, ): string | null => { let platformConfigured = configuredApps.some( diff --git a/src/core/types/adopt.ts b/src/core/types/adopt.ts index dc826035..b82cc8e4 100644 --- a/src/core/types/adopt.ts +++ b/src/core/types/adopt.ts @@ -30,9 +30,9 @@ export type EntitlementValue = | boolean | null | EntitlementValue[] - | { + | Readonly<{ [key: string]: EntitlementValue; - }; + }>; /** * The read-only slice of the App Store Connect client the adopters depend on. Declared here (rather than * taking the concrete client) so each adopter unit-tests against a hand-rolled fake - exactly the pattern @@ -40,54 +40,60 @@ export type EntitlementValue = * design: adopt never mutates App Store Connect (it writes local config), so no create/update methods * belong here. */ -export type AdoptCatalogApi = { +export type AdoptCatalogApi = Readonly<{ getAppId(bundleId: string): Effect.Effect; getLatestMarketingVersion(bundleId: string): Effect.Effect; getLatestBuildNumber(bundleId: string): Effect.Effect; findBundleId(identifier: string): Effect.Effect; listBundleIdCapabilities( bundleIdResourceId: string, - ): Effect.Effect; - listProfilesForBundleId(bundleIdResourceId: string): Effect.Effect; - listMerchantIds(): Effect.Effect; - listInAppPurchases(appId: string): Effect.Effect; - listInAppPurchaseLocalizations(iapId: string): Effect.Effect; + ): Effect.Effect; + listProfilesForBundleId( + bundleIdResourceId: string, + ): Effect.Effect; + listMerchantIds(): Effect.Effect; + listInAppPurchases(appId: string): Effect.Effect; + listInAppPurchaseLocalizations( + iapId: string, + ): Effect.Effect; inAppPurchaseHasPrice(iapId: string): Effect.Effect; - listSubscriptionGroups(appId: string): Effect.Effect; + listSubscriptionGroups( + appId: string, + ): Effect.Effect; listSubscriptionGroupLocalizations( groupId: string, - ): Effect.Effect; - listSubscriptions(groupId: string): Effect.Effect; + ): Effect.Effect; + listSubscriptions(groupId: string): Effect.Effect; listSubscriptionLocalizations( subscriptionId: string, - ): Effect.Effect; + ): Effect.Effect; subscriptionHasPrice(subscriptionId: string): Effect.Effect; - listDistributionCertificates(): Effect.Effect; -}; + listDistributionCertificates(): Effect.Effect; +}>; /** * One app being adopted, resolved by the orchestrator before any adopter runs. `appId`/`bundleId` are * guaranteed present - detection only enqueues an app once its App Store Connect record resolves - so * adopters never re-resolve them or guard against null. `keyId` is the active account (the certs adopter * matches profiles against the keychain under it); `cwd` is where `launch.config.ts` lives. */ -export type AdoptTarget = { +export type AdoptTarget = Readonly<{ app: AppDescriptor; appId: string; bundleId: string; keyId: string; cwd: string; hasLaunchConfig: boolean; -}; +}>; /** One imported product piece destined for `products[bundleId]` in `launch.config.ts`. */ export type ProductPiece = - | { + | Readonly<{ type: 'iap'; iap: InAppPurchaseConfig; - } - | { + }> + | Readonly<{ type: 'subscriptionGroup'; group: SubscriptionGroupConfig; - }; + }>; /** * The concrete change a {@link PlannedWrite} carries, discriminated by its `home` (which file/store it * targets). The orchestrator groups writes by `home` to apply them coherently - products pieces merge @@ -95,26 +101,26 @@ export type ProductPiece = * structured data rather than ascSync's apply-closure: a closure can't be aggregated across adopters. */ export type AdoptChange = - | { + | Readonly<{ home: 'launch.config'; bundleId: string; piece: ProductPiece; - } - | { + }> + | Readonly<{ home: 'app.json'; configPath: string; key: string; value: EntitlementValue; - } - | { + }> + | Readonly<{ home: 'store.config'; bundleId: string; configPath: string; appName: string; - } - | { + }> + | Readonly<{ home: 'keychain'; - }; + }>; /** * One proposed change surfaced in the plan and (after confirm) applied. `description` is the plan line; * `note` is an advisory caveat shown beneath it (a {@link import("../adopt/capabilities.js").NEEDS_VALUE} @@ -122,12 +128,12 @@ export type AdoptChange = * detect-only: it's reported, never applied. Mirrors `ascSync.ts`'s `PlannedAction`, adapted from * "write to ASC" to "write to local config". */ -export type PlannedWrite = { +export type PlannedWrite = Readonly<{ description: string; fidelity: Fidelity; note?: string; change: AdoptChange; -}; +}>; /** * One domain's importer. Registered like a provider (see * {@link import("../adopt/registry.js").registerAdopter}); the orchestrator resolves every registered diff --git a/src/core/types/agents.ts b/src/core/types/agents.ts index 18313083..3fe5290b 100644 --- a/src/core/types/agents.ts +++ b/src/core/types/agents.ts @@ -16,11 +16,11 @@ export type AgentTarget = * catch both a renamed top-level command and a renamed subcommand, while leaving action words like * `creds set-key` (where `set-key` is an argument, not a subcommand) correctly unvalidated. */ -export type SkillStep = { - path: string[]; - args?: string[]; +export type SkillStep = Readonly<{ + path: readonly string[]; + args?: readonly string[]; note: string; -}; +}>; /** * A consumer-facing, task-scoped skill: one coherent thing an agent gets asked to do with Launch * ("ship to TestFlight", "publish an OTA update"). The {@link description} is the trigger an agent @@ -28,62 +28,62 @@ export type SkillStep = { * person and leads with the intent. `id` doubles as the file stem (`.claude/skills//SKILL.md`, * `.cursor/rules/.mdc`). */ -export type ConsumerSkill = { +export type ConsumerSkill = Readonly<{ id: string; title: string; description: string; - triggers: string[]; - steps: SkillStep[]; + triggers: readonly string[]; + steps: readonly SkillStep[]; body: string; - cautions?: string[]; - reference?: { + cautions?: readonly string[]; + reference?: Readonly<{ /** one-line lead-in for the reference section. */ intro: string; /** the catalog, each command validated against the live program. */ - commands: SkillStep[]; - }; -}; + commands: readonly SkillStep[]; + }>; +}>; /** One row of the EAS -> Launch command map shown in the always-on base context. */ -export type CommandMapRow = { +export type CommandMapRow = Readonly<{ eas: string; launch: string; note: string; -}; +}>; /** * The autonomy boundary an agent is given when driving Launch: which commands it may run unattended, * and which irreversible ones demand explicit human confirmation first. Mirrors Launch's own * plan -> confirm -> apply ethos so an over-eager agent can't publish to production with `--yes`. */ -export type Guardrail = { - free: string[]; - confirm: string[]; -}; +export type Guardrail = Readonly<{ + free: readonly string[]; + confirm: readonly string[]; +}>; /** * The always-on base context: what every agent should know about a repo that ships with Launch, * regardless of the specific task. Rendered into the Cursor base rule (`alwaysApply`), the `AGENTS.md` * Launch section (which Codex always loads and a consumer's `CLAUDE.md` imports), so the agent always * knows Launch is present, how its commands map from EAS, and where the guardrails are. */ -export type BaseContext = { +export type BaseContext = Readonly<{ intro: string; - commandMap: CommandMapRow[]; - rails: string[]; + commandMap: readonly CommandMapRow[]; + rails: readonly string[]; guardrail: Guardrail; - bootstrap: string[]; -}; + bootstrap: readonly string[]; +}>; /** * A contributor-facing Cursor rule for working ON the launch-store codebase. `AGENTS.md` stays the * canonical prose; these add what a flat file can't - PATH-triggered guidance Cursor attaches only when * the relevant files are open (e.g. the provider-registration rule when editing `src/providers/**`). The * base rule (`alwaysApply`, empty `globs`) simply points Cursor at `AGENTS.md`. */ -export type ContributorRule = { +export type ContributorRule = Readonly<{ file: string; description: string; - globs: string[]; + globs: readonly string[]; alwaysApply: boolean; body: string; -}; +}>; /** * A contributor-facing, task-scoped skill for working ON the launch-store codebase - the Claude Skills * counterpart to {@link ContributorRule} (which targets Cursor). Rendered to `.claude/skills//SKILL.md` @@ -95,22 +95,22 @@ export type ContributorRule = { * walks {@link ConsumerSkill}s). `AGENTS.md` stays the canonical prose; these add Claude-native, intent- * triggered task recipes on top of it. */ -export type ContributorSkill = { +export type ContributorSkill = Readonly<{ id: string; title: string; description: string; - triggers: string[]; - steps: string[]; + triggers: readonly string[]; + steps: readonly string[]; body: string; - cautions?: string[]; -}; + cautions?: readonly string[]; +}>; /** * A rendered artifact: a path relative to the repo it is written into, plus the full file contents. * Consumer files are written into a user's repo by `launch agents init`; contributor files are written * here under `.cursor/rules/` (Cursor) and `.claude/skills/` (Claude) by `npm run docs:gen` and gated by * `docs:check`. */ -export type GeneratedAgentFile = { +export type GeneratedAgentFile = Readonly<{ path: string; body: string; -}; +}>; diff --git a/src/core/types/app.ts b/src/core/types/app.ts index 01ce6cc2..1071f8e2 100644 --- a/src/core/types/app.ts +++ b/src/core/types/app.ts @@ -60,10 +60,10 @@ export type OpenTarget = * Google Play submitter reads a single source of truth. Resolved from `--track`/`--rollout`, then the * profile's defaults, then the safe fallback. Present only for Android builds; absent on iOS. */ -export type AndroidReleaseOptions = { +export type AndroidReleaseOptions = Readonly<{ track: PlayTrack; rollout: number; -}; +}>; /** * Which kind of TestFlight beta feedback a {@link BetaFeedback} carries - Apple keeps the two on * separate resources (`betaFeedbackCrashSubmissions` / `betaFeedbackScreenshotSubmissions`), which is @@ -75,11 +75,11 @@ export type BetaFeedbackKind = 'crash' | 'screenshot'; * pixel dimensions. The URL expires (Apple signs it for a short window), so it's for immediate viewing * or download, not long-term storage; `launch testflight feedback --out` fetches it before it lapses. */ -export type BetaFeedbackScreenshot = { +export type BetaFeedbackScreenshot = Readonly<{ url: string; width?: number; height?: number; -}; +}>; /** * One piece of TestFlight beta feedback, normalized across Apple's two submission resources into the * single shape `launch testflight feedback` renders. `kind` discriminates the two: a `crash` carries no @@ -87,7 +87,7 @@ export type BetaFeedbackScreenshot = { * this is the product-facing read model the CLI and `--json` output share, so it omits Apple ids beyond * the feedback's own and keeps only the fields a developer triages from. */ -export type BetaFeedback = { +export type BetaFeedback = Readonly<{ id: string; kind: BetaFeedbackKind; createdDate?: string; @@ -96,8 +96,8 @@ export type BetaFeedback = { deviceModel?: string; osVersion?: string; buildVersion?: string; - screenshots?: BetaFeedbackScreenshot[]; -}; + screenshots?: readonly BetaFeedbackScreenshot[]; +}>; /** * One app discovered in the surrounding monorepo. * @@ -105,7 +105,7 @@ export type BetaFeedback = { * facts here (bundle id, version) come straight from Expo's config and are never * duplicated in Launch's own config - `app.json` stays the single source of truth. */ -export type AppDescriptor = { +export type AppDescriptor = Readonly<{ name: string; dir: string; configPath: string; @@ -113,15 +113,15 @@ export type AppDescriptor = { packageName?: string; version?: string; iosEntitlements?: Record; - iosExtensions?: string[]; + iosExtensions?: readonly string[]; androidVersionCode?: number; usesNonExemptEncryption?: boolean; -}; +}>; /** * A named build profile from `launch.config.ts` (e.g. `production`, `preview`). * Holds only Launch-specific settings; app facts stay in `app.json`. */ -export type BuildProfile = { +export type BuildProfile = Readonly<{ name: string; envFile?: string; env?: Record; @@ -129,4 +129,4 @@ export type BuildProfile = { sizeBudgetMB?: number; track?: PlayTrack; rollout?: number; -}; +}>; diff --git a/src/core/types/appleCatalog.ts b/src/core/types/appleCatalog.ts index 09de4d24..dd436185 100644 --- a/src/core/types/appleCatalog.ts +++ b/src/core/types/appleCatalog.ts @@ -5,32 +5,32 @@ import type { OfferMode, } from './catalog.js'; /** A registered Bundle ID resource (an App ID in the Developer portal). */ -export type BundleIdResource = { +export type BundleIdResource = Readonly<{ id: string; identifier: string; seedId?: string | undefined; -}; +}>; /** A signing certificate resource, with the bytes needed to package a `.p12`. */ -export type CertificateResource = { +export type CertificateResource = Readonly<{ id: string; serialNumber: string; certificateContent: string; expirationDate?: string | undefined; -}; +}>; /** A device registered in the Developer portal, eligible to receive ad-hoc builds. */ -export type DeviceResource = { +export type DeviceResource = Readonly<{ id: string; udid: string; name: string; status?: string | undefined; -}; +}>; /** A provisioning profile resource, with the bytes needed to install it locally. */ -export type ProfileResource = { +export type ProfileResource = Readonly<{ id: string; name: string; uuid: string; profileContent: string; -}; +}>; /** * One configured key on a capability's `settings` - Apple's per-capability toggle list (e.g. the * iCloud version or the data-protection permission level). The `key` is Apple's setting enum value @@ -39,33 +39,33 @@ export type ProfileResource = { * group ids, container ids) - those live in the provisioning profile, which is why `launch adopt` * reads capability *values* from the profile and uses these settings only as advisory detail. */ -export type CapabilitySetting = { +export type CapabilitySetting = Readonly<{ key: string; - options?: { + options?: Readonly<{ key: string; - }[]; -}; + }>[]; +}>; /** A capability enabled on a bundle id (App ID), e.g. `PUSH_NOTIFICATIONS`. */ -export type BundleIdCapabilityResource = { +export type BundleIdCapabilityResource = Readonly<{ id: string; capabilityType: string; - settings?: CapabilitySetting[]; -}; + settings?: readonly CapabilitySetting[]; +}>; /** An in-app purchase (the `inAppPurchasesV2` resource) on an app. */ -export type InAppPurchaseResource = { +export type InAppPurchaseResource = Readonly<{ id: string; productId: string; name: string; inAppPurchaseType: string; state?: string; -}; +}>; /** * One **sandbox tester** (`sandboxTesters`) - a fake Apple ID for StoreKit testing of purchases and * subscriptions, created in App Store Connect (Apple exposes no API to create one). `acAccountName` is the * tester's sandbox email - the key `launch sandbox clear` matches on. `subscriptionRenewalRate` is the * accelerated renewal interval Apple uses for sandbox subscriptions. */ -export type SandboxTesterResource = { +export type SandboxTesterResource = Readonly<{ id: string; acAccountName: string; firstName?: string; @@ -74,50 +74,50 @@ export type SandboxTesterResource = { applePayCompatible?: boolean; interruptPurchases?: boolean; subscriptionRenewalRate?: string; -}; +}>; /** A subscription group - the container for mutually-exclusive subscription levels. */ -export type SubscriptionGroupResource = { +export type SubscriptionGroupResource = Readonly<{ id: string; referenceName: string; -}; +}>; /** One auto-renewable subscription within a group. */ -export type SubscriptionResource = { +export type SubscriptionResource = Readonly<{ id: string; productId: string; name: string; subscriptionPeriod?: string; state?: string; -}; +}>; /** * One locale's stored copy for a product, group, or subscription. The same shape serves in-app-purchase, * subscription, and subscription-group localizations - Apple keys them all on `locale`, with `name` * always present and `description` only on the product/subscription variants. */ -export type LocalizationResource = { +export type LocalizationResource = Readonly<{ id: string; locale: string; name: string; description?: string; -}; +}>; /** * A price point - one rung of Apple's fixed price ladder for a product in a territory. `customerPrice` * is the amount the buyer pays (e.g. `"9.99"`); a price is set by linking the product to one of these. */ -export type PricePointResource = { +export type PricePointResource = Readonly<{ id: string; customerPrice: string; territory: string; -}; +}>; /** * An existing App Encryption Declaration on an app - the reusable, one-time export-compliance answer * for builds that use non-exempt encryption. Only an `APPROVED` declaration clears a build without a * fresh, document-backed submission, so `state` is what gates reuse. See * {@link AppStoreConnectClient.listEncryptionDeclarations}. */ -export type EncryptionDeclarationResource = { +export type EncryptionDeclarationResource = Readonly<{ id: string; state: string; -}; +}>; /** * One locale's stored App Store listing copy, normalized to the present (non-empty) fields only. The * same shape serves both the app-level `appInfoLocalizations` (name/subtitle/privacy URL - persists @@ -125,52 +125,52 @@ export type EncryptionDeclarationResource = { * - per release); the caller picks which level. `fields` is keyed by Apple's attribute name, so a diff * against desired config is a plain key-by-key comparison. */ -export type ListingLocalization = { +export type ListingLocalization = Readonly<{ id: string; locale: string; fields: Record; -}; +}>; /** * One App Store screenshot **set** - the per-(version localization x device target) bucket screenshots * hang off. `screenshotDisplayType` is Apple's device-target enum (e.g. `APP_IPHONE_67`); the reconciler * matches a local folder's target to the set with the same type, creating the set when none exists. */ -export type ScreenshotSetResource = { +export type ScreenshotSetResource = Readonly<{ id: string; screenshotDisplayType: string; -}; +}>; /** * One uploaded App Store screenshot. `sourceFileChecksum` is the MD5 Apple stored at commit time - the * reconciler's idempotency key: a local file whose MD5 already appears here is skipped. `assetDeliveryState` * (`UPLOAD_COMPLETE` / `COMPLETE` / `FAILED`) flags a half-finished upload worth re-sending. Both are * absent until requested/committed. */ -export type ScreenshotResource = { +export type ScreenshotResource = Readonly<{ id: string; fileName: string; sourceFileChecksum?: string; assetDeliveryState?: string; -}; +}>; /** * A subscription's App Review screenshot (`subscriptionAppStoreReviewScreenshots`) - the single image * Apple requires to submit a subscription. Same idempotency key as {@link ScreenshotResource}: re-upload * only when the stored MD5 differs from the local file's. */ -export type ReviewScreenshotResource = { +export type ReviewScreenshotResource = Readonly<{ id: string; sourceFileChecksum?: string; assetDeliveryState?: string; -}; +}>; /** * One App Store app-preview-video **set** - the per-(version localization x device target) bucket previews * hang off, the video counterpart of {@link ScreenshotSetResource}. `previewType` is Apple's device-target * enum (e.g. `IPHONE_67`); the reconciler matches a local folder's target to the set with the same type, * creating the set when none exists. */ -export type PreviewSetResource = { +export type PreviewSetResource = Readonly<{ id: string; previewType: string; -}; +}>; /** * One uploaded App Store app preview video. Same idempotency contract as {@link ScreenshotResource}: * `sourceFileChecksum` is the MD5 Apple stored at commit time (the reconciler's skip key), and @@ -178,36 +178,36 @@ export type PreviewSetResource = { * asynchronously after commit, but the checksum is recorded at commit - so a re-run while processing is * still in flight matches by checksum and re-uploads nothing. */ -export type PreviewResource = { +export type PreviewResource = Readonly<{ id: string; fileName: string; sourceFileChecksum?: string; assetDeliveryState?: string; -}; +}>; /** * A TestFlight beta group - a named tester bucket that belongs to exactly one app. External groups * invite testers by email (and can gate distribution on Beta App Review); the internal group holds * App Store Connect team users. A tester reaches an app's TestFlight by being in one of its groups, * which is why every `launch testflight` tester operation goes through a group. */ -export type BetaGroupResource = { +export type BetaGroupResource = Readonly<{ id: string; name: string; isInternal?: boolean; publicLink?: string; -}; +}>; /** * A TestFlight tester. Apple keys testers on `email` and scopes them to the team - the same person is * one tester resource reused across apps/groups - so adding an existing email to a new group links * rather than duplicates. `firstName`/`lastName` are optional and shown in the invite. */ -export type BetaTesterResource = { +export type BetaTesterResource = Readonly<{ id: string; email: string; firstName?: string; lastName?: string; state?: string; -}; +}>; /** * Common attributes shared by Apple's two beta-feedback submission resources * (`betaFeedbackCrashSubmissions` / `betaFeedbackScreenshotSubmissions`) - the device/OS context, the @@ -215,7 +215,7 @@ export type BetaTesterResource = { * is the part {@link BetaFeedbackCrashSubmissionResource} and {@link BetaFeedbackScreenshotSubmissionResource} * both expose. Optional fields are absent (not empty) when Apple omits them. */ -export type BetaFeedbackSubmissionResource = { +export type BetaFeedbackSubmissionResource = Readonly<{ id: string; createdDate?: string; comment?: string; @@ -223,7 +223,7 @@ export type BetaFeedbackSubmissionResource = { deviceModel?: string; osVersion?: string; buildVersion?: string; -}; +}>; /** A TestFlight crash-feedback submission (`betaFeedbackCrashSubmissions`) - the device/OS context plus the tester's comment. */ export type BetaFeedbackCrashSubmissionResource = BetaFeedbackSubmissionResource; /** @@ -231,27 +231,28 @@ export type BetaFeedbackCrashSubmissionResource = BetaFeedbackSubmissionResource * submission, plus the attached screenshots. Each screenshot carries a short-lived presigned `url` * (and pixel dimensions when Apple reports them) suitable for immediate viewing or download. */ -export type BetaFeedbackScreenshotSubmissionResource = BetaFeedbackSubmissionResource & { - screenshots: { - url: string; - width?: number; - height?: number; - }[]; -}; +export type BetaFeedbackScreenshotSubmissionResource = BetaFeedbackSubmissionResource & + Readonly<{ + screenshots: Readonly<{ + url: string; + width?: number; + height?: number; + }>[]; + }>; /** * Server-side narrowing for the beta-feedback readers - Apple's `filter[build]` takes a *build resource * id* (not a version string), so the caller resolves a `--build ` to its id first. Absent means * "all builds". Kept as a named query type to mirror the other ASC `*Query` params in this file. */ -export type BetaFeedbackQuery = { +export type BetaFeedbackQuery = Readonly<{ buildId?: string; -}; +}>; /** * One customer review of an app. `answered` is derived from the `response` relationship so a caller * can filter unanswered reviews without a follow-up request per review. Optional text fields are absent * (not empty) when Apple omits them - a review can have a rating but no title or body. */ -export type CustomerReviewResource = { +export type CustomerReviewResource = Readonly<{ id: string; rating: number; title?: string | undefined; @@ -260,92 +261,92 @@ export type CustomerReviewResource = { territory?: string | undefined; createdDate?: string | undefined; answered: boolean; -}; +}>; /** A developer's response to a customer review - the editable, moderated reply shown under the review. */ -export type CustomerReviewResponseResource = { +export type CustomerReviewResponseResource = Readonly<{ id: string; responseBody: string; state?: string | undefined; lastModifiedDate?: string | undefined; -}; +}>; /** * One App Store Connect **team member** (`users`) - a person who has accepted access to the account. * `username` is their Apple ID email (the key `launch team remove` matches on); `roles` is Apple's * permission set (`ADMIN`, `APP_MANAGER`, `DEVELOPER`, ...). Contrast {@link UserInvitationResource}, which * is a member who's been invited but hasn't accepted yet. */ -export type UserResource = { +export type UserResource = Readonly<{ id: string; username: string; firstName?: string; lastName?: string; - roles: string[]; -}; + roles: readonly string[]; +}>; /** * One **pending** team invitation (`userInvitations`) - an invited member who hasn't accepted. Mirrors * {@link UserResource} but keyed by `email` and carrying the invite's `expirationDate`. Removing one cancels * the invitation rather than revoking access. */ -export type UserInvitationResource = { +export type UserInvitationResource = Readonly<{ id: string; email: string; firstName?: string; lastName?: string; - roles: string[]; + roles: readonly string[]; expirationDate?: string; -}; +}>; /** * The attributes required to invite a new team member, passed straight to `POST /v1/userInvitations`. * `allAppsVisible` grants visibility to every app (the default for a CLI invite); `provisioningAllowed` * lets the member create signing assets. Per-app visibility scoping (the `visibleApps` relationship) is a * portal/follow-up concern and intentionally not modeled here. */ -export type NewUserInvitation = { +export type NewUserInvitation = Readonly<{ email: string; firstName: string; lastName: string; - roles: string[]; + roles: readonly string[]; allAppsVisible: boolean; provisioningAllowed: boolean; -}; +}>; /** * An analytics report request - the subscription that makes reports available for an app. `ONGOING` * keeps producing daily/weekly/monthly instances; `ONE_TIME_SNAPSHOT` is a single historical pull. */ -export type AnalyticsReportRequestResource = { +export type AnalyticsReportRequestResource = Readonly<{ id: string; accessType: string; stoppedDueToInactivity?: boolean | undefined; -}; +}>; /** One report available within a request (e.g. "App Store Installations"), grouped by category. */ -export type AnalyticsReportResource = { +export type AnalyticsReportResource = Readonly<{ id: string; name: string; category?: string | undefined; -}; +}>; /** One time-period instance of a report - a single day/week/month of generated data. */ -export type AnalyticsReportInstanceResource = { +export type AnalyticsReportInstanceResource = Readonly<{ id: string; granularity: string; processingDate?: string | undefined; -}; +}>; /** * One downloadable segment of a report instance: a presigned `url` to a gzipped TSV plus its * `checksum`. A large instance is split across several segments that the caller concatenates. */ -export type AnalyticsReportSegmentResource = { +export type AnalyticsReportSegmentResource = Readonly<{ id: string; url: string; checksum?: string | undefined; sizeInBytes?: number | undefined; -}; +}>; /** * One **in-app event** (`appEvents`) - a time-bounded happening (a live event, premiere, challenge, ...) * surfaced on the App Store product page. `eventState` is Apple's lifecycle (`DRAFT` -> `READY_FOR_REVIEW` * -> ... -> `PUBLISHED` -> `PAST`/`ARCHIVED`); `referenceName` is the internal label. Localized copy and * territory schedules hang off it separately. */ -export type AppEventResource = { +export type AppEventResource = Readonly<{ id: string; referenceName: string; badge?: string; @@ -354,69 +355,69 @@ export type AppEventResource = { deepLink?: string; priority?: string; purpose?: string; -}; +}>; /** One locale's copy for an in-app event (`appEventLocalizations`) - name + short/long descriptions. */ -export type AppEventLocalizationResource = { +export type AppEventLocalizationResource = Readonly<{ id: string; locale: string; name?: string; shortDescription?: string; longDescription?: string; -}; +}>; /** Attributes to create an in-app event, passed to `POST /v1/appEvents` (alongside the app relationship). */ -export type NewAppEvent = { +export type NewAppEvent = Readonly<{ referenceName: string; badge?: string; primaryLocale?: string; deepLink?: string; priority?: string; purpose?: string; -}; +}>; /** The editable copy fields of an in-app event localization (create or update). */ -export type AppEventLocalizationInput = { +export type AppEventLocalizationInput = Readonly<{ name?: string; shortDescription?: string; longDescription?: string; -}; +}>; /** * Parameters for a Sales & Trends report download - Apple's `filter[...]` query for `/v1/salesReports`. * `reportDate`'s format follows `frequency` (a day `2026-06-01` for DAILY, a year `2026` for YEARLY). * A disallowed `reportType`/`reportSubType` combination surfaces as a misleading "invalid vendor number". */ -export type SalesReportQuery = { +export type SalesReportQuery = Readonly<{ vendorNumber: string; frequency: string; reportType: string; reportSubType: string; reportDate: string; version?: string | undefined; -}; +}>; /** Parameters for a Finance report download - Apple's `filter[...]` query for `/v1/financeReports`. */ -export type FinanceReportQuery = { +export type FinanceReportQuery = Readonly<{ vendorNumber: string; reportDate: string; regionCode: string; reportType?: string | undefined; -}; +}>; /* -------------------------------------------------------------------------- */ /* App Store release-lifecycle resources - consumed by core/appStoreRelease.ts */ /* (the version -> build -> review -> rollout state machine). Untouched by the */ /* build/sign path and the `launch sync` catalog reconciler. */ /* -------------------------------------------------------------------------- */ /** A build (one uploaded binary) on App Store Connect, with the fields the release flow reads. */ -export type BuildResource = { +export type BuildResource = Readonly<{ id: string; version: string; processingState: string; uploadedDate?: string; expired: boolean; -}; +}>; /** One locale's TestFlight "What to Test" note for a build (`betaBuildLocalizations`). */ -export type BetaBuildLocalizationResource = { +export type BetaBuildLocalizationResource = Readonly<{ id: string; locale: string; whatsNew?: string; -}; +}>; /** Apple's Beta App Review verdict for a build's submission. */ export type BetaReviewState = 'WAITING_FOR_REVIEW' | 'IN_REVIEW' | 'REJECTED' | 'APPROVED'; /** @@ -424,112 +425,112 @@ export type BetaReviewState = 'WAITING_FOR_REVIEW' | 'IN_REVIEW' | 'REJECTED' | * TestFlight testers install it. One per build; `state` is Apple's verdict. Absent until the build is * submitted for beta review. */ -export type BetaAppReviewSubmissionResource = { +export type BetaAppReviewSubmissionResource = Readonly<{ id: string; state?: BetaReviewState; -}; +}>; /** * An App Store version - the per-release container carrying lifecycle state, release type, and the * attached build. One per marketing version + platform; `launch release` reuses an editable one or * creates the next. */ -export type AppStoreVersionResource = { +export type AppStoreVersionResource = Readonly<{ id: string; versionString: string; appStoreState: string; releaseType?: string; -}; +}>; /** One locale's editable version copy. Launch only ever writes `whatsNew` (the release notes). */ -export type AppStoreVersionLocalizationResource = { +export type AppStoreVersionLocalizationResource = Readonly<{ id: string; locale: string; whatsNew?: string; -}; +}>; /** A version's phased-release schedule (Apple's 7-day staged rollout), present only once one exists. */ -export type PhasedReleaseResource = { +export type PhasedReleaseResource = Readonly<{ id: string; phasedReleaseState: string; currentDayNumber?: number; -}; +}>; /** * An App Store review submission - Apple's current submission model: a per-app container the version is * added to as an item, then submitted as a unit. `state` distinguishes an addable draft * (`READY_FOR_REVIEW`) from one already in Apple's queue. */ -export type ReviewSubmissionResource = { +export type ReviewSubmissionResource = Readonly<{ id: string; state: string; -}; +}>; /** * One territory's resolved offer price: the territory code plus the Apple `subscriptionPricePoints` id * the customer-facing amount maps to. The reconciler resolves each {@link OfferPrice} to one of these * (via {@link AppStoreConnectClient.findSubscriptionPricePoint}) before any offer is created, so the * client only ever builds wire bodies from already-validated price points. */ -export type ResolvedOfferPrice = { +export type ResolvedOfferPrice = Readonly<{ territory: string; pricePointId: string; -}; +}>; /** An offer-code campaign on a subscription, as listed for idempotent reconcile. `name` is the key. */ -export type OfferCodeResource = { +export type OfferCodeResource = Readonly<{ id: string; name: string; active: boolean; -}; +}>; /** A promotional offer on a subscription. `offerCode` (the StoreKit-facing id) is the reconciler's key. */ -export type PromotionalOfferResource = { +export type PromotionalOfferResource = Readonly<{ id: string; name: string; offerCode: string; -}; +}>; /** * An introductory offer on a subscription. `territory` is the territory code it applies to, or null for * an all-territories offer - the reconciler's key (Apple permits at most one intro offer per territory). */ -export type IntroductoryOfferResource = { +export type IntroductoryOfferResource = Readonly<{ id: string; territory: string | null; -}; +}>; /** A win-back offer on a subscription, keyed by its stable `offerId`. */ -export type WinBackOfferResource = { +export type WinBackOfferResource = Readonly<{ id: string; offerId: string; -}; +}>; /** * A promoted purchase on an app's product page. Exactly one of `inAppPurchaseId` / `subscriptionId` is * set - the live *resource* id of the promoted product (not its `productId`); the reconciler maps config * `productId`s onto these via the subscription/IAP listings to find what's already promoted. */ -export type PromotedPurchaseResource = { +export type PromotedPurchaseResource = Readonly<{ id: string; inAppPurchaseId: string | null; subscriptionId: string | null; enabled: boolean; visibleForAllUsers: boolean; -}; +}>; /** Create input for an offer-code campaign - prices already resolved to {@link ResolvedOfferPrice}s. */ -export type OfferCodeCreate = { +export type OfferCodeCreate = Readonly<{ subscriptionId: string; name: string; - customerEligibilities: OfferCustomerEligibility[]; + customerEligibilities: readonly OfferCustomerEligibility[]; offerEligibility: OfferEligibility; duration: OfferDuration; offerMode: OfferMode; numberOfPeriods: number; - prices: ResolvedOfferPrice[]; -}; + prices: readonly ResolvedOfferPrice[]; +}>; /** Create input for a promotional offer - `offerCode` is the StoreKit-facing id; prices pre-resolved. */ -export type PromotionalOfferCreate = { +export type PromotionalOfferCreate = Readonly<{ subscriptionId: string; name: string; offerCode: string; duration: OfferDuration; offerMode: OfferMode; numberOfPeriods: number; - prices: ResolvedOfferPrice[]; -}; + prices: readonly ResolvedOfferPrice[]; +}>; /** Create input for an introductory offer - at most one per territory (null = all territories). */ -export type IntroductoryOfferCreate = { +export type IntroductoryOfferCreate = Readonly<{ subscriptionId: string; duration: OfferDuration; offerMode: OfferMode; @@ -538,9 +539,9 @@ export type IntroductoryOfferCreate = { territory: string | null; startDate?: string; endDate?: string; -}; +}>; /** Create input for a win-back offer - carries Apple's lapsed-customer eligibility windows. */ -export type WinBackOfferCreate = { +export type WinBackOfferCreate = Readonly<{ subscriptionId: string; offerId: string; referenceName: string; @@ -548,36 +549,36 @@ export type WinBackOfferCreate = { offerMode: OfferMode; numberOfPeriods: number; eligiblePaidMonths: number; - monthsSinceLastSubscribed: { + monthsSinceLastSubscribed: Readonly<{ min: number; max: number; - }; + }>; waitBetweenOffersMonths?: number; startDate: string; endDate?: string; priority: 'HIGH' | 'NORMAL'; promotionIntent?: 'NOT_PROMOTED' | 'USE_AUTO_GENERATED_ASSETS'; - prices: ResolvedOfferPrice[]; -}; + prices: readonly ResolvedOfferPrice[]; +}>; /** A promoted product to register - exactly one of the two ids is set (resolved from a config `productId`). */ -export type PromotedPurchaseCreate = { +export type PromotedPurchaseCreate = Readonly<{ appId: string; inAppPurchaseId?: string; subscriptionId?: string; visibleForAllUsers: boolean; enabled: boolean; -}; +}>; /** * An app's App Info record - the container that owns the App Store category relationships and the * age-rating declaration. An app can have more than one (a live one plus an editable one); the * reconciler edits the editable one, falling back to the first when no state is reported. */ -export type AppInfoResource = { +export type AppInfoResource = Readonly<{ id: string; state?: string; primaryCategoryId?: string; secondaryCategoryId?: string; -}; +}>; /** One age-rating answer: a content-descriptor enum string (`NONE` / `INFREQUENT_OR_MILD` / ...) or a boolean flag. */ export type AgeRatingValue = string | boolean; /** @@ -586,10 +587,10 @@ export type AgeRatingValue = string | boolean; * carried as an open `name -> value` map rather than a fixed shape: config supplies the answers verbatim * and the reconciler PATCHes only the ones that differ, so a new descriptor needs no code change here. */ -export type AgeRatingDeclarationResource = { +export type AgeRatingDeclarationResource = Readonly<{ id: string; attributes: Record; -}; +}>; /** The device families an accessibility declaration can target - Apple's `DeviceFamily` enum, restated for public signatures. */ export type DeviceFamily = 'IPHONE' | 'IPAD' | 'APPLE_TV' | 'APPLE_WATCH' | 'MAC' | 'VISION'; /** The ordered list of `DeviceFamily` values, for validating config and iterating. */ @@ -609,7 +610,7 @@ export const DEVICE_FAMILIES: readonly DeviceFamily[] = [ * optional: an omitted flag means "the app does not support this feature" - the reconciler normalizes * absent to `false` so diffs are deterministic. */ -export type AccessibilitySupport = { +export type AccessibilitySupport = Readonly<{ supportsAudioDescriptions?: boolean; supportsCaptions?: boolean; supportsDarkInterface?: boolean; @@ -619,7 +620,7 @@ export type AccessibilitySupport = { supportsSufficientContrast?: boolean; supportsVoiceControl?: boolean; supportsVoiceover?: boolean; -}; +}>; /** The nine `AccessibilitySupport` keys, used to diff a declaration against config and to build requests. */ export const ACCESSIBILITY_SUPPORT_KEYS: readonly (keyof AccessibilitySupport)[] = [ 'supportsAudioDescriptions', @@ -637,60 +638,60 @@ export const ACCESSIBILITY_SUPPORT_KEYS: readonly (keyof AccessibilitySupport)[] * declaration per family; `state` distinguishes a `DRAFT` (editable, not yet shown to users) from the * live `PUBLISHED` one and the `REPLACED` history a publish leaves behind. */ -export type AccessibilityDeclarationResource = { +export type AccessibilityDeclarationResource = Readonly<{ id: string; deviceFamily: DeviceFamily; state: 'DRAFT' | 'PUBLISHED' | 'REPLACED'; support: AccessibilitySupport; -}; +}>; /** * An app's store availability (Apple's v2 model): whether it auto-enables in territories Apple adds later, * plus the territory codes it's currently for sale in. `availableTerritories` holds Apple territory codes * (e.g. `USA`, `GBR`) - the `territories` resource ids, used directly when setting availability. */ -export type AppAvailabilityResource = { +export type AppAvailabilityResource = Readonly<{ id: string; availableInNewTerritories: boolean; - availableTerritories: string[]; -}; + availableTerritories: readonly string[]; +}>; /** A custom product page - an alternate App Store listing (marketing variant), matched by its `name`. */ -export type CustomProductPageResource = { +export type CustomProductPageResource = Readonly<{ id: string; name: string; -}; +}>; /** One version of a custom product page; its localizations hang off it. `state` decides whether it's editable. */ -export type CustomProductPageVersionResource = { +export type CustomProductPageVersionResource = Readonly<{ id: string; state: string; -}; +}>; /** One locale's custom-product-page copy. Launch writes the `promotionalText`; screenshots are out of scope. */ -export type CustomProductPageLocalizationResource = { +export type CustomProductPageLocalizationResource = Readonly<{ id: string; locale: string; promotionalText?: string; -}; +}>; /** A product-page A/B experiment (Apple's v2 model), matched by its `name`. */ -export type VersionExperimentResource = { +export type VersionExperimentResource = Readonly<{ id: string; name: string; state: string; trafficProportion?: number; -}; +}>; /** One treatment (variant arm) of a version experiment, matched by its `name`. */ -export type ExperimentTreatmentResource = { +export type ExperimentTreatmentResource = Readonly<{ id: string; name: string; -}; +}>; /** * An app version's App Review details - the contact info and demo-account credentials Apple's reviewer * uses. Carried as an open attribute map for the same forward-compat reason as the age-rating * declaration. Note `demoAccountPassword` is write-only on Apple's side: it is never returned on a read * (so a change to the password alone can't be diffed) and Launch never logs it. */ -export type AppStoreReviewDetailResource = { +export type AppStoreReviewDetailResource = Readonly<{ id: string; attributes: Record; -}; +}>; /** * The App Clip card's call-to-action button - Apple's `AppClipAction` enum. Array-first (SSOT) so the * config schemas (`AppClipConfig.action` in `core/types`) reuse it without duplicating @@ -704,63 +705,63 @@ export type AppClipActionValue = (typeof APP_CLIP_ACTIONS)[number]; * uploading a build with an App Clip target (Apple has no API to create one), so this is read-only; the * reconciler matches a config entry to a clip by its own `bundleId` and skips clips no build produced yet. */ -export type AppClipResource = { +export type AppClipResource = Readonly<{ id: string; bundleId?: string; -}; +}>; /** * One default App Clip experience - the App Clip card configuration for a specific App Store version. * `action` is the card's call-to-action; `versionId` is the editable version it releases with (from the * `releaseWithAppStoreVersion` relationship), used to pick the experience for the version being prepared. */ -export type AppClipDefaultExperienceResource = { +export type AppClipDefaultExperienceResource = Readonly<{ id: string; action?: string; versionId?: string; -}; +}>; /** One locale of an App Clip's default experience - the card subtitle shown to users in that locale. */ -export type AppClipLocalizationResource = { +export type AppClipLocalizationResource = Readonly<{ id: string; locale: string; subtitle?: string; -}; +}>; /** * One alternative-distribution domain - a domain the team is authorized to distribute apps from under * the EU DMA (web distribution / alternative marketplaces). Team-level, not per-app; matched to config * on `domain`. */ -export type AlternativeDistributionDomainResource = { +export type AlternativeDistributionDomainResource = Readonly<{ id: string; domain?: string; referenceName?: string; -}; +}>; /** * The team's alternative-distribution **public** key - the package-signing key Apple verifies EU * distribution packages against (the developer keeps the private half). Not a secret; registered once * at the team level. */ -export type AlternativeDistributionKeyResource = { +export type AlternativeDistributionKeyResource = Readonly<{ id: string; publicKey?: string; -}; +}>; /** * An Apple Pay **merchant id** (e.g. `merchant.com.acme.app`) registered on the team. Team-level, like a * bundle id; matched to config on `identifier`. (Its payment-processing certificate is a separate flow.) */ -export type MerchantIdResource = { +export type MerchantIdResource = Readonly<{ id: string; identifier?: string; name?: string; -}; +}>; /** * A Wallet **pass type id** (e.g. `pass.com.acme.coupon`) registered on the team - the identifier a * `.pkpass` is signed against. Team-level; matched to config on `identifier`. */ -export type PassTypeIdResource = { +export type PassTypeIdResource = Readonly<{ id: string; identifier?: string; name?: string; -}; +}>; /** * Apple's leaderboard score formatters - the closed enum a leaderboard's `defaultFormatter` must be. * `satisfies` keeps this runtime list in lockstep with the generated OpenAPI enum: drift fails the build. @@ -797,32 +798,32 @@ export type LeaderboardSubmissionType = (typeof LEADERBOARD_SUBMISSION_TYPES)[nu export const LEADERBOARD_SORT_TYPES = ['ASC', 'DESC'] as const; export type LeaderboardSortType = (typeof LEADERBOARD_SORT_TYPES)[number]; /** An app's Game Center configuration container (read-only attributes; created to enable Game Center). */ -export type GameCenterDetailResource = { +export type GameCenterDetailResource = Readonly<{ id: string; -}; +}>; /** A Game Center achievement, identified for config matching by its developer-chosen `vendorIdentifier`. */ -export type GameCenterAchievementResource = { +export type GameCenterAchievementResource = Readonly<{ id: string; vendorIdentifier?: string; -}; +}>; /** A Game Center leaderboard, identified for config matching by its developer-chosen `vendorIdentifier`. */ -export type GameCenterLeaderboardResource = { +export type GameCenterLeaderboardResource = Readonly<{ id: string; vendorIdentifier?: string; -}; +}>; /** Attributes for creating a Game Center achievement (mirrors Apple's `GameCenterAchievementV2CreateRequest`). */ -export type GameCenterAchievementCreate = { +export type GameCenterAchievementCreate = Readonly<{ referenceName: string; vendorIdentifier: string; points: number; showBeforeEarned: boolean; repeatable: boolean; -}; +}>; /** Attributes for creating a Game Center leaderboard (mirrors Apple's `GameCenterLeaderboardV2CreateRequest`). */ -export type GameCenterLeaderboardCreate = { +export type GameCenterLeaderboardCreate = Readonly<{ referenceName: string; vendorIdentifier: string; defaultFormatter: LeaderboardFormatter; submissionType: LeaderboardSubmissionType; scoreSortType: LeaderboardSortType; -}; +}>; diff --git a/src/core/types/artifacts.ts b/src/core/types/artifacts.ts index aee84059..851f4758 100644 --- a/src/core/types/artifacts.ts +++ b/src/core/types/artifacts.ts @@ -59,11 +59,11 @@ export type SizeReport = Schema.Schema.Type; * to feed every embedded extension's bundle id into provisioning. `productType` distinguishes the main * app (`com.apple.product-type.application`) from its app-extension targets. */ -export type DiscoveredTarget = { +export type DiscoveredTarget = Readonly<{ name: string; bundleId: string; productType: string; -}; +}>; /** * A built, signed artifact plus the metadata Launch records about it. * @@ -75,38 +75,38 @@ export type BuildArtifact = Schema.Schema.Type; * presentation-ready projection of the pruned {@link BuildArtifact} plus the bytes it freed - what the * `builds prune` preview/table renders and `--json` emits, kept stable apart from the persisted record. */ -export type PrunedArtifact = { +export type PrunedArtifact = Readonly<{ app: string; platform: Platform; version: string; buildNumber: number; bytes: number; path: string; -}; +}>; /** * Options for an artifact-retention sweep ({@link StorageProvider.prune}). `now` is injected (not read * from the clock) so the policy is deterministic and unit-testable; `retentionDays` is the resolved * window. An absent `app`/`platform` matches everything; `dryRun` plans without deleting. */ -export type PruneOptions = { +export type PruneOptions = Readonly<{ now: number; retentionDays: number; app?: string; platform?: Platform; dryRun?: boolean; -}; +}>; /** * The outcome of an artifact-retention sweep. `pruned` is empty when nothing was eligible (a no-op); * `freedBytes` sums the removed binaries' sizes. When `dryRun` is true, `pruned`/`freedBytes` describe * what *would* be removed and nothing was deleted. */ -export type PruneResult = { - pruned: PrunedArtifact[]; +export type PruneResult = Readonly<{ + pruned: readonly PrunedArtifact[]; freedBytes: number; dryRun: boolean; -}; +}>; /** A pointer to an artifact after a {@link StorageProvider} has stored it. */ -export type StoredArtifact = { +export type StoredArtifact = Readonly<{ id: string; location: string; -}; +}>; diff --git a/src/core/types/catalog.ts b/src/core/types/catalog.ts index 74d9ee16..c4e20b39 100644 --- a/src/core/types/catalog.ts +++ b/src/core/types/catalog.ts @@ -24,51 +24,53 @@ export type OfferDuration = /** How an offer discounts (`SubscriptionOfferMode`). */ export type OfferMode = 'PAY_AS_YOU_GO' | 'PAY_UP_FRONT' | 'FREE_TRIAL'; /** One locale's customer-facing copy for a subscription or IAP. */ -export type ProductLocalization = { +export type ProductLocalization = Readonly<{ locale: string; name: string; description?: string; -}; +}>; /** One locale's display name for a subscription group. */ -export type GroupLocalization = { +export type GroupLocalization = Readonly<{ locale: string; name: string; -}; +}>; /** Product baseline price in a base territory (Apple price-point ladder). */ -export type ProductPrice = { +export type ProductPrice = Readonly<{ baseTerritory?: string; customerPrice: number; -}; +}>; /** One territory's discounted price for an offer. */ -export type OfferPrice = { +export type OfferPrice = Readonly<{ territory?: string; customerPrice: number; -}; +}>; /** Google Play price in micro-units + ISO currency. */ -export type PlayPriceConfig = { +export type PlayPriceConfig = Readonly<{ priceMicros: string; currency: string; -}; +}>; /** Fields shared by price-bearing offer kinds. */ -export type OfferConfigBase = { +export type OfferConfigBase = Readonly<{ duration: OfferDuration; offerMode: OfferMode; numberOfPeriods: number; - prices?: OfferPrice[]; -}; + prices?: readonly OfferPrice[]; +}>; /** Subscription offer-code campaign. */ -export type OfferCodeConfig = OfferConfigBase & { - name: string; - customerEligibilities: OfferCustomerEligibility[]; - offerEligibility: OfferEligibility; -}; +export type OfferCodeConfig = OfferConfigBase & + Readonly<{ + name: string; + customerEligibilities: readonly OfferCustomerEligibility[]; + offerEligibility: OfferEligibility; + }>; /** Promotional offer. */ -export type PromotionalOfferConfig = OfferConfigBase & { - name: string; - offerCode: string; -}; +export type PromotionalOfferConfig = OfferConfigBase & + Readonly<{ + name: string; + offerCode: string; + }>; /** Introductory offer (single price, not prices[]). */ -export type IntroductoryOfferConfig = { +export type IntroductoryOfferConfig = Readonly<{ duration: OfferDuration; offerMode: OfferMode; numberOfPeriods: number; @@ -76,80 +78,81 @@ export type IntroductoryOfferConfig = { price?: OfferPrice; startDate?: string; endDate?: string; -}; +}>; /** Win-back offer. */ -export type WinBackOfferConfig = OfferConfigBase & { - offerId: string; - referenceName: string; - eligiblePaidMonths: number; - monthsSinceLastSubscribed: { - min: number; - max: number; - }; - waitBetweenOffersMonths?: number; - startDate: string; - endDate?: string; - priority?: 'HIGH' | 'NORMAL'; - promotionIntent?: 'NOT_PROMOTED' | 'USE_AUTO_GENERATED_ASSETS'; -}; +export type WinBackOfferConfig = OfferConfigBase & + Readonly<{ + offerId: string; + referenceName: string; + eligiblePaidMonths: number; + monthsSinceLastSubscribed: Readonly<{ + min: number; + max: number; + }>; + waitBetweenOffersMonths?: number; + startDate: string; + endDate?: string; + priority?: 'HIGH' | 'NORMAL'; + promotionIntent?: 'NOT_PROMOTED' | 'USE_AUTO_GENERATED_ASSETS'; + }>; /** One Google Play offer on a subscription base plan. */ -export type PlaySubscriptionOfferConfig = { +export type PlaySubscriptionOfferConfig = Readonly<{ offerId: string; freeTrialDuration?: string; introPrices?: Record; introRecurrenceCount?: number; -}; +}>; /** Google Play overrides for a subscription. */ -export type PlaySubscriptionOverride = { +export type PlaySubscriptionOverride = Readonly<{ productId?: string; basePlanId?: string; prices: Record; - offers?: PlaySubscriptionOfferConfig[]; -}; + offers?: readonly PlaySubscriptionOfferConfig[]; +}>; /** One auto-renewable subscription product inside a group. */ -export type SubscriptionConfig = { +export type SubscriptionConfig = Readonly<{ productId: string; referenceName: string; subscriptionPeriod: SubscriptionPeriod; - localizations: ProductLocalization[]; + localizations: readonly ProductLocalization[]; price?: ProductPrice; - offerCodes?: OfferCodeConfig[]; - promotionalOffers?: PromotionalOfferConfig[]; - introductoryOffers?: IntroductoryOfferConfig[]; - winBackOffers?: WinBackOfferConfig[]; + offerCodes?: readonly OfferCodeConfig[]; + promotionalOffers?: readonly PromotionalOfferConfig[]; + introductoryOffers?: readonly IntroductoryOfferConfig[]; + winBackOffers?: readonly WinBackOfferConfig[]; reviewScreenshot?: string; play?: PlaySubscriptionOverride; -}; +}>; /** Subscription group (mutually exclusive levels). */ -export type SubscriptionGroupConfig = { +export type SubscriptionGroupConfig = Readonly<{ referenceName: string; - localizations: GroupLocalization[]; - subscriptions: SubscriptionConfig[]; -}; + localizations: readonly GroupLocalization[]; + subscriptions: readonly SubscriptionConfig[]; +}>; /** Google Play overrides for a one-off IAP. */ -export type PlayProductOverride = { +export type PlayProductOverride = Readonly<{ sku?: string; defaultPrice?: PlayPriceConfig; prices?: Record; -}; +}>; /** Non-subscription in-app purchase. */ -export type InAppPurchaseConfig = { +export type InAppPurchaseConfig = Readonly<{ productId: string; referenceName: string; type: InAppPurchaseType; - localizations: ProductLocalization[]; + localizations: readonly ProductLocalization[]; price?: ProductPrice; play?: PlayProductOverride; -}; +}>; /** Promoted purchase on the App Store product page. */ -export type PromotedPurchaseConfig = { +export type PromotedPurchaseConfig = Readonly<{ productId: string; visibleForAllUsers?: boolean; enabled?: boolean; -}; +}>; /** Declarative product catalog for one app (keyed under `LaunchConfig.products`). */ -export type AppProducts = { - subscriptionGroups?: SubscriptionGroupConfig[]; - inAppPurchases?: InAppPurchaseConfig[]; - promotedPurchases?: PromotedPurchaseConfig[]; -}; +export type AppProducts = Readonly<{ + subscriptionGroups?: readonly SubscriptionGroupConfig[]; + inAppPurchases?: readonly InAppPurchaseConfig[]; + promotedPurchases?: readonly PromotedPurchaseConfig[]; +}>; diff --git a/src/core/types/commandDocs.ts b/src/core/types/commandDocs.ts index d57dbe8e..466f6f8d 100644 --- a/src/core/types/commandDocs.ts +++ b/src/core/types/commandDocs.ts @@ -1,42 +1,42 @@ -export type OptionSpec = { +export type OptionSpec = Readonly<{ flags: string; description: string; -}; +}>; /** * A `launch` (sub)command flattened to exactly what the reference needs, recursive via * {@link subcommands}. `path` is the command words after `launch` (e.g. `metadata pull`) so a heading * can be rendered without threading parent state through the walk. */ -export type CommandSpec = { +export type CommandSpec = Readonly<{ path: string; args: string; description: string; - options: OptionSpec[]; - subcommands: CommandSpec[]; -}; + options: readonly OptionSpec[]; + subcommands: readonly CommandSpec[]; +}>; /** * The live numbers in the reference's headline blockquote - computed at generation time so they can * never go stale. `operations` is the public async-method count across the two store API clients * (`ascClient` + `playClient`), i.e. the store operations Launch wraps. */ -export type DocStats = { +export type DocStats = Readonly<{ commands: number; operations: number; tests: number; -}; +}>; /** A generated file the script writes (or diffs under `--check`): repo-relative path + full contents. */ -export type GeneratedDoc = { +export type GeneratedDoc = Readonly<{ path: string; body: string; -}; +}>; /** * One titled group in the {@link FEATURE_SECTIONS} capability map: a bold section label, an optional * one-line lead, and the single-line capability statements under it. Kept as data (not prose) so * {@link renderFeaturesList} can number every item continuously (1..N) across sections and the README + * `llms.txt` feature lists render from one source instead of two hand-maintained copies. */ -export type FeatureSection = { +export type FeatureSection = Readonly<{ title: string; intro?: string; - features: string[]; -}; + features: readonly string[]; +}>; diff --git a/src/core/types/config.ts b/src/core/types/config.ts index 3b5e364a..4e3e37e5 100644 --- a/src/core/types/config.ts +++ b/src/core/types/config.ts @@ -36,24 +36,24 @@ export type SubmitByPlatform = Partial>; * Non-secret settings for a cloud {@link StorageProvider}. * Credentials are NEVER stored here - keys resolve from env or the OS secret store. */ -export type StorageConfig = { +export type StorageConfig = Readonly<{ endpoint?: string; bucket: string; region?: string; publicBaseUrl: string; supabaseUrl?: string; -}; +}>; /** * Fully-resolved configuration for one `launch` invocation (provider defaults filled). * Names here (`storage`, `credentials`, `buildEngine`) are looked up in the provider registry at runtime. */ -export type LaunchConfig = { +export type LaunchConfig = Readonly<{ profiles: Record; credentials: string; storage: string; buildEngine: string; submit: string | SubmitByPlatform; - appRoots?: string[]; + appRoots?: readonly string[]; products?: Record; notify?: NotifyConfig; release?: ReleaseConfig; @@ -67,9 +67,9 @@ export type LaunchConfig = { storageConfig?: StorageConfig; artifactDir?: string; artifactRetentionDays?: number; - envExclude?: string[]; + envExclude?: readonly string[]; mcp?: McpConfig; -}; +}>; /** * Input to {@link defineConfig}: the shape a user authors in `launch.config.ts`. * Provider names are optional (they default via {@link DEFAULT_CREDENTIALS_PROVIDER} etc.). @@ -77,19 +77,20 @@ export type LaunchConfig = { export type LaunchConfigInput = Omit< LaunchConfig, 'credentials' | 'storage' | 'buildEngine' | 'submit' -> & { - credentials?: string; - storage?: string; - buildEngine?: string; - submit?: string | SubmitByPlatform; -}; +> & + Readonly<{ + credentials?: string; + storage?: string; + buildEngine?: string; + submit?: string | SubmitByPlatform; + }>; /** * Everything a single build needs, assembled before any work starts. * * This is the value threaded through the whole pipeline and into every provider, so a provider * never has to re-derive the app, profile, or environment. */ -export type ResolvedBuildContext = { +export type ResolvedBuildContext = Readonly<{ platform: Platform; app: AppDescriptor; profile: BuildProfile; @@ -101,4 +102,4 @@ export type ResolvedBuildContext = { android?: AndroidReleaseOptions; distribution?: Distribution; account?: string; -}; +}>; diff --git a/src/core/types/credentials.ts b/src/core/types/credentials.ts index deebedd3..597f9ac5 100644 --- a/src/core/types/credentials.ts +++ b/src/core/types/credentials.ts @@ -1,8 +1,8 @@ -export type AscKey = { +export type AscKey = Readonly<{ keyId: string; issuerId: string; p8: string; -}; +}>; /** * One imported APNs authentication key (`.p8`) in Launch's push-key vault (`~/.launch/push-keys.json`). * @@ -13,12 +13,12 @@ export type AscKey = { * the vault exists so a download-once secret isn't lost. This record is non-secret metadata only - the * `.p8` PEM stays in the OS secret store under `apns-p8:`. An APNs key is team-wide, not per-app. */ -export type ApnsKeyRecord = { +export type ApnsKeyRecord = Readonly<{ keyId: string; teamId?: string; label?: string; importedAt: string; -}; +}>; /** * One onboarded Apple account in Launch's registry (`~/.launch/accounts.json`). * @@ -28,24 +28,24 @@ export type ApnsKeyRecord = { * `apps` are resolved from Apple once at add-time and cached for an instant, offline-capable picker; * `resolvedAt` being absent means they were never fetched (e.g. the key was added while offline). */ -export type AccountRecord = { +export type AccountRecord = Readonly<{ keyId: string; issuerId: string; label: string; teamId?: string; - apps?: string[]; + apps?: readonly string[]; addedAt: string; resolvedAt?: string; -}; +}>; /** * The on-disk shape of `~/.launch/accounts.json`: the set of onboarded Apple accounts plus which one * is active. `active` is the Key ID a build uses when no `--account`/`ASC_ACCOUNT` override is given; * `null` means none is selected yet (a fresh install, or the active account was just removed). */ -export type AccountsFile = { +export type AccountsFile = Readonly<{ active: string | null; - accounts: AccountRecord[]; -}; + accounts: readonly AccountRecord[]; +}>; /** * The signing assets a release build needs, resolved (reused or freshly created) before export. * @@ -56,7 +56,7 @@ export type AccountsFile = { * bundle in the `.ipa`, not just the main app. The pipeline hands this to the build engine, which feeds * it straight into the export options. */ -export type SigningAssets = { +export type SigningAssets = Readonly<{ bundleId: string; teamId: string; certName: string; @@ -65,7 +65,7 @@ export type SigningAssets = { profileUuid: string; profilePath: string; extensionProfiles?: Record; -}; +}>; /** * Apple credentials resolved for a build. * @@ -73,10 +73,10 @@ export type SigningAssets = { * non-secret references plus the in-memory key bytes a build/submit step needs right now. * `signing` is absent for steps that only need the API key (e.g. submission, build-number lookup). */ -export type AppleCredentials = { +export type AppleCredentials = Readonly<{ ascKey: AscKey; signing?: SigningAssets; -}; +}>; /** * The upload keystore Launch owns (or imported) to sign Android App Bundles - the Android twin of * {@link SigningAssets}. @@ -86,12 +86,12 @@ export type AppleCredentials = { * in the {@link SecretStore}, never beside the file; this shape carries the non-secret references plus * the in-memory passwords a `gradle`/`bundletool` step needs right now. */ -export type KeystoreAssets = { +export type KeystoreAssets = Readonly<{ path: string; alias: string; storePassword: string; keyPassword: string; -}; +}>; /** * Android credentials resolved for a build - the Android twin of {@link AppleCredentials}. * @@ -99,17 +99,17 @@ export type KeystoreAssets = { * this shape carries the in-memory bytes/paths a build/submit step needs right now. `keystore` is * absent for steps that only need the Play API (e.g. submission, `versionCode` lookup). */ -export type AndroidCredentials = { +export type AndroidCredentials = Readonly<{ serviceAccountJson: string; keystore?: KeystoreAssets; -}; +}>; -export type ServiceAccount = { +export type ServiceAccount = Readonly<{ clientEmail: string; privateKey: string; tokenUri: string; privateKeyId?: string; -}; +}>; /** * Credentials for one build, discriminated by `platform` so a single pipeline + registry serve both * stores. Every provider interface ({@link CredentialsProvider}, {@link BuildEngine}, {@link Submitter}) @@ -118,9 +118,11 @@ export type ServiceAccount = { * with no `any` and no unchecked casts. */ export type BuildCredentials = - | ({ + | (Readonly<{ platform: 'ios'; - } & AppleCredentials) - | ({ + }> & + AppleCredentials) + | (Readonly<{ platform: 'android'; - } & AndroidCredentials); + }> & + AndroidCredentials); diff --git a/src/core/types/dashboard.ts b/src/core/types/dashboard.ts index 1e6b69f6..db763259 100644 --- a/src/core/types/dashboard.ts +++ b/src/core/types/dashboard.ts @@ -1,32 +1,32 @@ import type { Platform } from './app.js'; /** One discovered app, reduced to the identity fields the dashboard shows. */ -export type DashboardApp = { +export type DashboardApp = Readonly<{ name: string; version: string | null; bundleId: string | null; packageName: string | null; -}; +}>; /** The provider wiring + discovered apps/profiles from `launch.config.ts`. */ -export type DashboardProject = { - providers: { +export type DashboardProject = Readonly<{ + providers: Readonly<{ credentials: string; storage: string; buildEngine: string; submit: string; - }; - profiles: string[]; - apps: DashboardApp[]; -}; + }>; + profiles: readonly string[]; + apps: readonly DashboardApp[]; +}>; /** One onboarded Apple account, reduced to non-secret display fields. */ -export type DashboardAccount = { +export type DashboardAccount = Readonly<{ label: string; keyId: string; teamId: string | null; appCount: number; active: boolean; -}; +}>; /** One recent local build artifact, reduced to the fields the dashboard lists. */ -export type DashboardArtifact = { +export type DashboardArtifact = Readonly<{ app: string; platform: Platform; version: string; @@ -34,36 +34,36 @@ export type DashboardArtifact = { createdAt: string; sizeMB: number | null; pruned: boolean; -}; +}>; /** * One build secret's non-secret coordinates. The value lives in the OS keychain and is **never** read * or rendered - only the env-var name and its scope appear, exactly what `launch secret list` shows. */ -export type DashboardSecret = { +export type DashboardSecret = Readonly<{ app: string; profile: string | null; name: string; -}; +}>; /** The live remote build host, when one is currently allocated. */ -export type DashboardCloudHost = { +export type DashboardCloudHost = Readonly<{ provider: string; region: string | null; instanceType: string | null; instanceId: string | null; allocatedAt: string; -}; +}>; /** * The complete snapshot the dashboard serves - everything readable from local state with no network or * App Store Connect call, so the page renders instantly and offline. Live store-side panels (review * status, Play tracks, drift) are intentionally out of this first cut: they need ASC auth and belong on * the confirmed `plan`/`reports` read path, not an always-on local page. */ -export type DashboardState = { +export type DashboardState = Readonly<{ generatedAt: string; launchHome: string; project: DashboardProject; - accounts: DashboardAccount[]; - artifacts: DashboardArtifact[]; - secrets: DashboardSecret[]; + accounts: readonly DashboardAccount[]; + artifacts: readonly DashboardArtifact[]; + secrets: readonly DashboardSecret[]; cloudHost: DashboardCloudHost | null; -}; +}>; diff --git a/src/core/types/doctor.ts b/src/core/types/doctor.ts index 1dea400e..9b967980 100644 --- a/src/core/types/doctor.ts +++ b/src/core/types/doctor.ts @@ -20,48 +20,49 @@ export type DoctorStatus = 'ok' | 'fail' | 'info'; * a `fail`/`info`. A check is always a finished read - never an error in disguise (a read that throws is * caught by the caller and surfaced as a `fail`). */ -export type DoctorCheck = { +export type DoctorCheck = Readonly<{ status: DoctorStatus; title: string; detail?: string; hint?: string; -}; +}>; /** * The aggregate result of a doctor run, structured so the command renders it, `--json` serializes it, and * an MCP tool returns it. `ok` is the gate: `true` exactly when no check is `fail` (advisory `info` lines * never fail the run), which is what `launch doctor`'s exit code and the wizard's branch both read. */ -export type DoctorReport = { +export type DoctorReport = Readonly<{ platform: DoctorPlatform; - checks: DoctorCheck[]; + checks: readonly DoctorCheck[]; ok: boolean; -}; +}>; /** * The read-only App Store Connect surface the doctor inspection uses - `assertReady` (agreement health) * and `getAppId` (app-record existence) on top of the {@link AscPermissionProbeApi} reads the role * preflight needs. `AppStoreConnectClient` satisfies it structurally, so the resolver from * `core/storeClients.ts` is assignable here with no cast (return-type covariance). */ -export type DoctorAscApi = AscPermissionProbeApi & { - assertReady(): Effect.Effect; - getAppId(bundleId: string): Effect.Effect; - findBundleId(identifier: string): Effect.Effect< - { - id: string; - } | null, - unknown - >; - listBundleIdCapabilities(bundleIdResourceId: string): Effect.Effect< - { - capabilityType: string; - }[], - unknown - >; -}; +export type DoctorAscApi = AscPermissionProbeApi & + Readonly<{ + assertReady(): Effect.Effect; + getAppId(bundleId: string): Effect.Effect; + findBundleId(identifier: string): Effect.Effect< + Readonly<{ + id: string; + }> | null, + unknown + >; + listBundleIdCapabilities(bundleIdResourceId: string): Effect.Effect< + Readonly<{ + capabilityType: string; + }>[], + unknown + >; + }>; /** The read-only Google Play surface the doctor uses: prove the service account can reach an app. */ -export type DoctorPlayApi = { +export type DoctorPlayApi = Readonly<{ assertAppExists(packageName: string): Effect.Effect; -}; +}>; /** * What {@link inspectDoctor} is handed. The pure config/apps plus every impure input injected as a * function, so the inspection itself performs no I/O it doesn't go through this seam - which is what lets @@ -70,7 +71,7 @@ export type DoctorPlayApi = { */ export type DoctorContext = { config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; platform: DoctorPlatform; os: HostOs; cwd: string; diff --git a/src/core/types/googlePlay.ts b/src/core/types/googlePlay.ts index dda51414..32db5323 100644 --- a/src/core/types/googlePlay.ts +++ b/src/core/types/googlePlay.ts @@ -1,77 +1,77 @@ import type { PlayMoneyUnits } from './playPricing.js'; -export type PlayRelease = { +export type PlayRelease = Readonly<{ name?: string; - versionCodes?: string[]; + versionCodes?: readonly string[]; status?: string; userFraction?: number; - releaseNotes?: { language: string; text: string }[]; -}; -export type PlayTrackInfo = { track: string; releases: PlayRelease[] }; -export type PlayCountryAvailability = { + releaseNotes?: Readonly<{ language: string; text: string }>[]; +}>; +export type PlayTrackInfo = Readonly<{ track: string; releases: readonly PlayRelease[] }>; +export type PlayCountryAvailability = Readonly<{ restOfWorld?: boolean; - countries: { countryCode: string }[]; -}; -export type PlayMoney = { priceMicros?: string; currency?: string }; -export type InAppProductResource = { + countries: Readonly<{ countryCode: string }>[]; +}>; +export type PlayMoney = Readonly<{ priceMicros?: string; currency?: string }>; +export type InAppProductResource = Readonly<{ sku: string; status?: string; purchaseType?: string; defaultLanguage?: string; defaultPrice?: PlayMoney; prices?: Record; - listings?: Record; -}; -export type SubscriptionListing = { + listings?: Record>; +}>; +export type SubscriptionListing = Readonly<{ languageCode: string; title: string; description: string; - benefits?: string[]; -}; -export type RegionalBasePlanConfig = { + benefits?: readonly string[]; +}>; +export type RegionalBasePlanConfig = Readonly<{ regionCode: string; newSubscriberAvailability?: boolean; price?: PlayMoneyUnits; -}; -export type AutoRenewingBasePlanType = { billingPeriodDuration: string }; -export type BasePlan = { +}>; +export type AutoRenewingBasePlanType = Readonly<{ billingPeriodDuration: string }>; +export type BasePlan = Readonly<{ basePlanId: string; state?: string; autoRenewingBasePlanType?: AutoRenewingBasePlanType; - regionalConfigs?: RegionalBasePlanConfig[]; - offerTags?: { tag: string }[]; -}; -export type SubscriptionResource = { + regionalConfigs?: readonly RegionalBasePlanConfig[]; + offerTags?: Readonly<{ tag: string }>[]; +}>; +export type SubscriptionResource = Readonly<{ packageName?: string; productId: string; - basePlans?: BasePlan[]; - listings?: SubscriptionListing[]; -}; -export type RegionalSubscriptionOfferConfig = { + basePlans?: readonly BasePlan[]; + listings?: readonly SubscriptionListing[]; +}>; +export type RegionalSubscriptionOfferConfig = Readonly<{ regionCode: string; newSubscriberAvailability?: boolean; -}; -export type OfferPhaseRegionalConfig = { +}>; +export type OfferPhaseRegionalConfig = Readonly<{ regionCode: string; price?: PlayMoneyUnits; free?: Record; -}; -export type SubscriptionOfferPhase = { +}>; +export type SubscriptionOfferPhase = Readonly<{ recurrenceCount: number; duration?: string; - regionalConfigs: OfferPhaseRegionalConfig[]; -}; -export type SubscriptionOfferResource = { + regionalConfigs: readonly OfferPhaseRegionalConfig[]; +}>; +export type SubscriptionOfferResource = Readonly<{ packageName?: string; productId?: string; basePlanId?: string; offerId: string; state?: string; - phases: SubscriptionOfferPhase[]; - regionalConfigs: RegionalSubscriptionOfferConfig[]; - offerTags?: { tag: string }[]; -}; -export type PlayReview = { + phases: readonly SubscriptionOfferPhase[]; + regionalConfigs: readonly RegionalSubscriptionOfferConfig[]; + offerTags?: Readonly<{ tag: string }>[]; +}>; +export type PlayReview = Readonly<{ reviewId: string; authorName?: string; rating: number; @@ -82,5 +82,5 @@ export type PlayReview = { lastModified?: string; answered: boolean; developerReply?: string; -}; -export type PlayReplyResult = { replyText: string; lastEdited?: string }; +}>; +export type PlayReplyResult = Readonly<{ replyText: string; lastEdited?: string }>; diff --git a/src/core/types/insights.ts b/src/core/types/insights.ts index 8a76f742..441758cb 100644 --- a/src/core/types/insights.ts +++ b/src/core/types/insights.ts @@ -12,47 +12,47 @@ export type Sentiment = 'positive' | 'neutral' | 'negative'; * bodies stay in `reviews list` / `play-reviews list`; insights only needs the rating, whether it was * answered, and when it landed (for the monthly trend). */ -export type ReviewDatum = { +export type ReviewDatum = Readonly<{ store: InsightsStore; rating: StarRating; answered: boolean; date?: string; -}; +}>; /** * The headline rollup over a set of reviews: count, mean rating, the per-star distribution, how many * carry a developer response, and the sentiment split. `average` and `answeredRate` are 0 for an empty * set so callers never divide by zero or branch on emptiness mid-render. */ -export type RatingSummary = { +export type RatingSummary = Readonly<{ total: number; average: number; distribution: Record; answered: number; answeredRate: number; sentiment: Record; -}; +}>; /** One point on the monthly ratings trend: the calendar month plus the volume and mean for it. */ -export type MonthlyRatingPoint = { +export type MonthlyRatingPoint = Readonly<{ month: string; count: number; average: number; -}; +}>; /** * Everything insights synthesizes for a single app: the combined rating summary, the same summary * split per store, and the chronological monthly trend. `byStore` omits a store the app doesn't target * or that returned no reviews, so the renderer shows only what's real. */ -export type AppInsights = { +export type AppInsights = Readonly<{ app: string; ratings: RatingSummary; byStore: Partial>; - trend: MonthlyRatingPoint[]; -}; + trend: readonly MonthlyRatingPoint[]; +}>; /** * The full insights report: one entry per app that yielded data, plus an `overall` summary across all * of them. This is the exact shape emitted by `--json`, so it doubles as the automation contract. */ -export type InsightsReport = { - apps: AppInsights[]; +export type InsightsReport = Readonly<{ + apps: readonly AppInsights[]; overall: RatingSummary; -}; +}>; diff --git a/src/core/types/listing.ts b/src/core/types/listing.ts index 2e4054db..bd778ce9 100644 --- a/src/core/types/listing.ts +++ b/src/core/types/listing.ts @@ -5,25 +5,25 @@ import type { AppleLocaleInfo } from '../store/storeConfig.js'; * model refines what's there rather than inventing from nothing: `about` and `keywords` carry the * existing pitch/keywords (or a `--about` override), and `current` is the locale's existing listing. */ -export type ListingBrief = { +export type ListingBrief = Readonly<{ locale: string; appName: string; about?: string; - keywords?: string[]; + keywords?: readonly string[]; current?: AppleLocaleInfo; -}; +}>; /** * A drafted listing for one locale - the App Store copy fields a generator produces. This is the * superset; the Play (`android`) fields are derived from it when the user targets Android. Every field * is optional so a generator (or a length clamp) can omit what it can't produce within store limits. */ -export type DraftListing = { +export type DraftListing = Readonly<{ title?: string; subtitle?: string; description?: string; - keywords?: string[]; + keywords?: readonly string[]; promotionalText?: string; -}; +}>; /** * The generation seam: turn a {@link ListingBrief} into a {@link DraftListing}. Implemented by the * default Anthropic-backed generator and trivially by a test fake, so the command and the applier are @@ -38,8 +38,8 @@ export type ListingGenerator = { * records what the length clamp had to trim, so the user sees it in the preview before anything is * written. Produced by the command, consumed by `renderDraftPreview` and `applyDraft`. */ -export type LocaleDraft = { +export type LocaleDraft = Readonly<{ locale: string; draft: DraftListing; - warnings: string[]; -}; + warnings: readonly string[]; +}>; diff --git a/src/core/types/mcp.ts b/src/core/types/mcp.ts index 6b44e4a1..d7b43f6d 100644 --- a/src/core/types/mcp.ts +++ b/src/core/types/mcp.ts @@ -6,29 +6,30 @@ import type { McpCapability } from './storeSurface.js'; * pretty-printed JSON), but the field is kept as a discriminated shape so it matches the protocol's * content-block union and could carry other kinds later without a breaking change. */ -export type McpTextContent = { +export type McpTextContent = Readonly<{ type: 'text'; text: string; -}; +}>; /** * What a tool handler returns: the content blocks the agent sees, plus an `isError` flag. Per the protocol * (and the locked design) `isError` marks a genuine failure the agent should treat as an error - NOT a * valid-but-negative finding like "drift detected", which is a successful read. Handlers return success * results; the server turns a thrown error into an `isError` result centrally, so handlers never set it. */ -export type McpToolResult = { - content: McpTextContent[]; +export type McpToolResult = Readonly<{ + content: readonly McpTextContent[]; isError?: boolean; -}; +}>; /** * The raw input schema a tool advertises. It is the draft-07 object subset the protocol requires * (`{ type: "object", properties?, required? }`) expressed as our own {@link JsonSchema}, so the SAME * value both advertises the tool (via `tools/list`) and validates incoming arguments through the * hand-rolled {@link import("../config/jsonSchema.js").validate} - one schema, no zod, no second validator. */ -export type McpInputSchema = JsonSchema & { - type: 'object'; -}; +export type McpInputSchema = JsonSchema & + Readonly<{ + type: 'object'; + }>; /** * One MCP tool: a stable name, a one-line description the agent reads, the capability tier that gates it, * the input schema, and the handler. The handler receives the already-validated argument object (the diff --git a/src/core/types/migrate.ts b/src/core/types/migrate.ts index f30eedbf..4cce3e28 100644 --- a/src/core/types/migrate.ts +++ b/src/core/types/migrate.ts @@ -8,130 +8,130 @@ export type MigrationSource = 'eas' | 'fastlane'; */ export type MigrationNoteLevel = 'mapped' | 'manual' | 'skipped' | 'info'; /** One line in the migration report: what happened to a piece of the source setup, and (when `manual`) how to finish it. */ -export type MigrationNote = { +export type MigrationNote = Readonly<{ level: MigrationNoteLevel; message: string; -}; +}>; /** * A file the migration would write, as a path relative to the output directory plus its full contents. * Existence/overwrite is decided at write time against the output dir (see `write.ts`), so an artifact * carries no `exists` flag - the same artifact can be previewed (`--dry-run`) or written unchanged. */ -export type MigrationArtifact = { +export type MigrationArtifact = Readonly<{ path: string; contents: string; -}; +}>; /** * The outcome of one migration run: which toolchain it read, the artifacts to write, and the per-item * report. Returned by a source's migrate function (e.g. {@link import("./eas.js").migrateEas}) and * consumed by `report.ts` (render) and `write.ts` (persist) - both shared across every source. */ -export type MigrationResult = { +export type MigrationResult = Readonly<{ source: MigrationSource; - artifacts: MigrationArtifact[]; - notes: MigrationNote[]; -}; + artifacts: readonly MigrationArtifact[]; + notes: readonly MigrationNote[]; +}>; /** * One `build.` block in `eas.json`. Only the fields Launch maps or reports on are modeled: * `channel`/`distribution`/`developmentClient` become report notes, `env` keys seed `.env.example`. */ -export type EasBuildProfile = { +export type EasBuildProfile = Readonly<{ channel?: string; distribution?: string; env?: Record; autoIncrement?: boolean | string; developmentClient?: boolean; -}; +}>; /** The iOS half of a `submit.` block - Apple account details that map to `launch creds`, not config. */ -export type EasSubmitIos = { +export type EasSubmitIos = Readonly<{ appleId?: string; ascAppId?: string; appleTeamId?: string; -}; +}>; /** The Android half of a `submit.` block - the Play track maps to a profile; the key path to `launch creds`. */ -export type EasSubmitAndroid = { +export type EasSubmitAndroid = Readonly<{ serviceAccountKeyPath?: string; track?: string; -}; +}>; /** One `submit.` block in `eas.json`. */ -export type EasSubmitProfile = { +export type EasSubmitProfile = Readonly<{ ios?: EasSubmitIos; android?: EasSubmitAndroid; -}; +}>; /** The `cli` block in `eas.json` - only `appVersionSource` informs the report (it matches Launch's store-driven bumping). */ -export type EasCli = { +export type EasCli = Readonly<{ appVersionSource?: string; -}; +}>; /** * The parsed `eas.json`, narrowed to what Launch reads. `build`/`submit` default to `{}` so a file with * only one of them (or neither) still migrates cleanly; `cli` is optional. */ -export type EasJson = { +export type EasJson = Readonly<{ cli?: EasCli; build: Record; submit: Record; -}; +}>; /** * A non-secret summary of an EAS `credentials.json` (present when `eas.json` sets * `credentialsSource: "local"`). Only paths and the keystore alias are surfaced; the certificate/keystore * Password fields are discarded by the boundary schema and never enter this shape. */ -export type CredentialsSummary = { - ios?: { +export type CredentialsSummary = Readonly<{ + ios?: Readonly<{ distributionCertificatePath?: string; provisioningProfilePath?: string; - }; - android?: { + }>; + android?: Readonly<{ keystorePath?: string; keyAlias?: string; - }; -}; + }>; +}>; /** The `Appfile` - the app/account identifiers fastlane shares across actions. */ -export type AppfileData = { +export type AppfileData = Readonly<{ appIdentifier?: string; appleId?: string; teamId?: string; itcTeamId?: string; packageName?: string; -}; +}>; /** * The `Matchfile` - fastlane `match`'s signing strategy. Launch manages its own certificates in the OS * keychain, so every field here becomes a `manual` note (you don't carry match over), not config. */ -export type MatchfileData = { +export type MatchfileData = Readonly<{ gitUrl?: string; type?: string; storageMode?: string; appIdentifier?: string; -}; +}>; /** The `Supplyfile` - fastlane `supply`'s Play upload defaults. */ -export type SupplyfileData = { +export type SupplyfileData = Readonly<{ packageName?: string; jsonKey?: string; track?: string; -}; +}>; /** * One lane parsed from a `Fastfile`: its name, the `platform` block it sits in (when any), and the * recognized fastlane actions found in its body. The body is captured by line-scan (see fastlane.ts * `parseFastfile`) - tolerant, not a Ruby parser - so `actions` is best-effort, and a lane with no * recognized actions still appears (with an empty `actions`) so the report can flag it as custom. */ -export type FastlaneLane = { +export type FastlaneLane = Readonly<{ name: string; platform?: string; - actions: string[]; -}; + actions: readonly string[]; +}>; /** * A parsed fastlane setup, narrowed to what Launch reads from the standard files. Lanes and recognized * actions drive the report (Launch's pipeline replaces lanes); the per-file blocks are present only when * that file existed. Mirrors {@link EasJson} as the file-based input to a migration source. */ -export type FastlaneSetup = { +export type FastlaneSetup = Readonly<{ appfile?: AppfileData; matchfile?: MatchfileData; supply?: SupplyfileData; - lanes: FastlaneLane[]; - actions: string[]; + lanes: readonly FastlaneLane[]; + actions: readonly string[]; hasDeliverfile: boolean; - envKeys: string[]; -}; + envKeys: readonly string[]; +}>; diff --git a/src/core/types/mutable.ts b/src/core/types/mutable.ts new file mode 100644 index 00000000..ab6a0e17 --- /dev/null +++ b/src/core/types/mutable.ts @@ -0,0 +1,7 @@ +export type MutableDeep = Type extends (...args: infer Args) => infer Return + ? (...args: Args) => Return + : Type extends readonly (infer Item)[] + ? MutableDeep[] + : Type extends object + ? { -readonly [Key in keyof Type]: MutableDeep } + : Type; diff --git a/src/core/types/plan.ts b/src/core/types/plan.ts index e8469468..1672902b 100644 --- a/src/core/types/plan.ts +++ b/src/core/types/plan.ts @@ -24,7 +24,7 @@ export type PlanStore = 'appstore' | 'play'; * {@link AscCatalogApi} backs every App Store surface); each planner uses only the slice it needs, and * `GooglePlayClient` satisfies the whole thing structurally. */ -export type PlayCatalogApi = PlayProductsApi & PlaySubscriptionsApi & {}; +export type PlayCatalogApi = PlayProductsApi & PlaySubscriptionsApi; /** * The full read surface of App Store Connect the App Store planners share - the union of every ASC * surface reconciler's API slice (mirrors how {@link PlayCatalogApi} unions the two Play interfaces). One @@ -45,19 +45,19 @@ export type AscSurfacesApi = AscCatalogApi & AscEuDistributionApi & AscOffersApi & ScreenshotsApi & - PreviewsApi & {}; + PreviewsApi; /** * One app's slice of a surface's plan. `actions` is the reconciler's existing {@link PlannedAction} list * (all `planned` in dry-run, with advisory `skipped` lines for length-limit/precondition notes); empty * means in sync. `error` is set instead when the app couldn't be planned at all - a precondition the * user must fix, e.g. no App Store Connect app record - so the gate never silently certifies it as clean. */ -export type AppPlan = { +export type AppPlan = Readonly<{ app: string; identifier: string; - actions: PlannedAction[]; + actions: readonly PlannedAction[]; error?: string; -}; +}>; /** * How completely a surface detects drift - surfaced on the plan and in `--json` so a `drift` gate's * guarantee is legible (ADR 0003 A3): @@ -81,57 +81,57 @@ export type PlanDirection = 'two-way' | 'additive'; * Every `planned` variant carries its {@link PlanDirection} so the renderer can flag additive surfaces. */ export type SurfacePlan = - | { + | Readonly<{ surface: string; store: PlanStore; state: 'omitted'; - } - | { + }> + | Readonly<{ surface: string; store: PlanStore; state: 'skipped'; reason: string; hint?: string; - } - | { + }> + | Readonly<{ surface: string; store: PlanStore; state: 'planned'; scope: 'app'; direction: PlanDirection; - apps: AppPlan[]; - } - | { + apps: readonly AppPlan[]; + }> + | Readonly<{ surface: string; store: PlanStore; state: 'planned'; scope: 'team'; direction: PlanDirection; - actions: PlannedAction[]; - }; + actions: readonly PlannedAction[]; + }>; /** * What a {@link SurfacePlanner} is handed: the loaded config, the apps to consider (already narrowed by * `-a`), and lazy store-client resolvers. A resolver returns `null` when the account isn't configured, * letting the planner emit a `skipped` surface rather than throw. Resolvers are memoized by the command, * so several planners over the same store share one client (and one credential read). */ -export type PlanContext = { +export type PlanContext = Readonly<{ config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; resolveAscApi(): Effect.Effect; resolvePlayApi(): Effect.Effect; -}; +}>; /** * One config-as-code surface's planner. {@link plan} is **read-only**: it resolves live state and returns * the diff it *would* apply without performing any write, so the same call powers both `launch plan` and * the `launch drift` gate. Registered like a provider/adopter (see {@link import("./registry.js")}); the * orchestrator resolves every registered planner and never names a concrete one. */ -export type SurfacePlanner = { +export type SurfacePlanner = Readonly<{ id: string; store: PlanStore; plan( planContext: PlanContext, ): Effect.Effect; -}; +}>; import type { FileSystem, Path } from '@effect/platform'; diff --git a/src/core/types/playPricing.ts b/src/core/types/playPricing.ts index d15c012a..0f9a2232 100644 --- a/src/core/types/playPricing.ts +++ b/src/core/types/playPricing.ts @@ -1,20 +1,20 @@ -export type PlayMoneyUnits = { +export type PlayMoneyUnits = Readonly<{ readonly currencyCode: string; readonly units: string; readonly nanos: number; -}; +}>; /** One Play market's recommended local price. */ -export type ConvertedRegionPrice = { +export type ConvertedRegionPrice = Readonly<{ readonly regionCode: string; readonly price: PlayMoneyUnits; -}; +}>; /** Fallback prices for markets where Play does not support a local currency. */ -export type OtherRegionsPrice = { +export type OtherRegionsPrice = Readonly<{ readonly usdPrice: PlayMoneyUnits; readonly eurPrice: PlayMoneyUnits; -}; +}>; /** Recommended regional prices normalized from Android Publisher's generated DTO. */ -export type ConvertedPrices = { - readonly regions: ConvertedRegionPrice[]; +export type ConvertedPrices = Readonly<{ + readonly regions: readonly ConvertedRegionPrice[]; otherRegions?: OtherRegionsPrice; -}; +}>; diff --git a/src/core/types/privacy.ts b/src/core/types/privacy.ts index 69e61bd0..4f586b07 100644 --- a/src/core/types/privacy.ts +++ b/src/core/types/privacy.ts @@ -4,33 +4,33 @@ export type PrivacySeverity = 'blocker' | 'warning' | 'info'; * machine-readable id (e.g. `ios.usage.empty`) so `--json` consumers can filter without parsing prose; * `message` is the human one-liner. */ -export type PrivacyFinding = { +export type PrivacyFinding = Readonly<{ app: string; platform: 'ios' | 'android'; severity: PrivacySeverity; code: string; message: string; -}; +}>; /** * The parsed privacy surface of one app - the pure input to {@link reconcilePrivacy}. Assembled by the * command from native files (`Info.plist`, `PrivacyInfo.xcprivacy`, `AndroidManifest.xml`) when a native * project exists, else from the resolved Expo config (`ios.infoPlist`, `ios.privacyManifests`, * `android.permissions`). Keeping it a plain data shape is what makes the reconcile testable without I/O. */ -export type PrivacySurface = { +export type PrivacySurface = Readonly<{ usageDescriptions: Record; hasManifest: boolean; - collectedDataTypes: string[]; + collectedDataTypes: readonly string[]; tracking: boolean; - trackingDomains: string[]; - androidPermissions: string[]; -}; + trackingDomains: readonly string[]; + androidPermissions: readonly string[]; +}>; /** * The full result of a scan: every finding across the scanned apps plus the resolved process exit code * (0 clear - 2 blockers - 1 unreadable, per the shared readiness contract). This is the `--json` payload. */ -export type PrivacyReport = { - findings: PrivacyFinding[]; - scanned: string[]; +export type PrivacyReport = Readonly<{ + findings: readonly PrivacyFinding[]; + scanned: readonly string[]; exitCode: number; -}; +}>; diff --git a/src/core/types/providers.ts b/src/core/types/providers.ts index e1078b96..bc29cc01 100644 --- a/src/core/types/providers.ts +++ b/src/core/types/providers.ts @@ -32,36 +32,36 @@ export const makeProviderInputFailure = Data.tagged('Provi * (Android) as a {@link BuildCredentials}. A future `team`/`s3` implementation could fetch shared, encrypted * credentials instead - the pipeline neither knows nor cares which backend answered. */ -export type CredentialsProvider = { +export type CredentialsProvider = Readonly<{ readonly name: string; resolveBuildCredentials( buildContext: ResolvedBuildContext, ): Effect.Effect; status(): Effect.Effect; -}; +}>; /** * Compiles and signs the native project into a distributable artifact. * * `fastlane` runs `gym` -> `.ipa` (iOS); `gradle` runs `bundleRelease` -> `.aab` (Android). Each engine * narrows {@link BuildCredentials} to the platform it serves and rejects the other. */ -export type BuildEngine = { +export type BuildEngine = Readonly<{ readonly name: string; buildArtifact( buildContext: ResolvedBuildContext, buildCredentials: BuildCredentials, ): Effect.Effect< - { + Readonly<{ artifactPath: string; sizeReport: SizeReport; cleanBuilt: boolean; - }, + }>, unknown >; -}; +}>; /** Runs a hosted build service that owns its authentication, build number, and optional submission. */ -export type HostedBuildProvider = { +export type HostedBuildProvider = Readonly<{ readonly name: string; describeCli(): Effect.Effect; authenticate(): Effect.Effect; @@ -69,11 +69,11 @@ export type HostedBuildProvider = { buildContext: ResolvedBuildContext, profileName: string, ): Effect.Effect< - { + Readonly<{ artifactPath: string; sizeReport: SizeReport; buildNumber: number; - }, + }>, unknown >; submit( @@ -81,7 +81,7 @@ export type HostedBuildProvider = { artifactPath: string, profileName: string, ): Effect.Effect; -}; +}>; /** * Persists build artifacts and hands back a retrievable location. * @@ -90,10 +90,10 @@ export type HostedBuildProvider = { * providers (R2, S3, Supabase) are thin drop-ins. `local` writes under `~/.launch`; the cloud * providers upload to the user's own bucket and serve from {@link StorageConfig.publicBaseUrl}. */ -export type StorageProvider = { +export type StorageProvider = Readonly<{ readonly name: string; put(artifact: BuildArtifact): Effect.Effect; - list(): Effect.Effect; + list(): Effect.Effect; url(id: string): Effect.Effect; putObject( key: string, @@ -103,7 +103,7 @@ export type StorageProvider = { getObject(key: string): Effect.Effect; publicUrl(key: string): string; prune?(options: PruneOptions): Effect.Effect; -}; +}>; export type StorageProviderOptions = Readonly<{ readonly artifactDirectory?: string; @@ -120,11 +120,11 @@ export type StorageProviderResolver = Readonly<{ * Uploads a built artifact to a distribution destination. * * `app-store-connect` submits to TestFlight/App Store via fastlane `pilot`/`deliver`; `google-play` - * submits to a Play track via fastlane `supply`. Each narrows {@link BuildCredentials} to its platform - * and maps the neutral {@link SubmitTarget} onto its store's concept (Android also reads + * submits to a Play track via fastlane `supply`. Each narrows Readonly<{@link BuildCredentials}> to its platform + * and maps the neutral Readonly<{@link SubmitTarget}> onto its store's concept (Android also reads * `buildContext.android`). */ -export type Submitter = { +export type Submitter = Readonly<{ readonly name: string; submit( artifactPath: string, @@ -132,7 +132,7 @@ export type Submitter = { buildCredentials: BuildCredentials, buildContext: ResolvedBuildContext, ): Effect.Effect; -}; +}>; /** * Generic OS-native secret storage - the cross-platform widening of the macOS-only Keychain. * @@ -141,12 +141,12 @@ export type Submitter = { * no Keychain; this seam gives them a real OS-native store. NOTE: importing a cert into a *codesign* * keychain (the `security import` calls) is a different concern and stays in `core/keychain.ts`. */ -export type SecretStore = { +export type SecretStore = Readonly<{ readonly name: string; get(account: string): Effect.Effect; set(account: string, secretText: string): Effect.Effect; delete(account: string): Effect.Effect; -}; +}>; /** * Provisions, connects to, and tears down a remote Mac for off-Mac iOS builds. * @@ -155,10 +155,10 @@ export type SecretStore = { * fastlane build/sign/submit spine over the SSH connection, so the host backend and the build logic * stay independent. SSH command execution lives in `core/ssh.ts`, shared by every host impl. */ -export type ComputeHost = { +export type ComputeHost = Readonly<{ readonly name: string; allocate(request: AllocateRequest): Effect.Effect; status(handle: HostHandle): Effect.Effect; teardown(handle: HostHandle): Effect.Effect; doctor?(awsConfiguration: AwsConfig): Effect.Effect; -}; +}>; diff --git a/src/core/types/readiness.ts b/src/core/types/readiness.ts index 81ba217c..e459c06e 100644 --- a/src/core/types/readiness.ts +++ b/src/core/types/readiness.ts @@ -10,27 +10,27 @@ export type ReadinessStore = 'appstore' | 'play'; export type ReadinessCategory = 'account' | 'iap' | 'listing' | 'privacy' | 'signing' | 'submit'; /** One app's finding from one successful probe read. */ -export type AppReadiness = { +export type AppReadiness = Readonly<{ app: string; identifier: string; status: 'ok' | 'warn' | 'blocker'; detail: string; hint?: string; -}; +}>; /** Probe outcome before unexpected failures are added by the orchestrator. */ export type ProbeResult = - | { + | Readonly<{ state: 'omitted'; - } - | { + }> + | Readonly<{ state: 'skipped'; reason: string; hint?: string; - } - | { + }> + | Readonly<{ state: 'checked'; - apps: AppReadiness[]; - }; + apps: readonly AppReadiness[]; + }>; /** Platform capabilities available to every readiness probe. */ export type ReadinessProbeRequirements = FileSystem.FileSystem | HttpClient.HttpClient | Path.Path; @@ -40,60 +40,60 @@ export type ProbeCheckResult = Effect.Effect; /** Identified probe outcome used by terminal and JSON renderers. */ -export type ProbeReport = { +export type ProbeReport = Readonly<{ id: string; title: string; store: ReadinessStore; outcome: ProbeOutcome; -}; +}>; /** Read-only App Store Connect methods used by readiness probes. */ -export type AscReadinessApi = { +export type AscReadinessApi = Readonly<{ getAppId(bundleId: string): Effect.Effect; checkRequiredAgreements(): Effect.Effect; listSubscriptionGroups(appId: string): Effect.Effect< - { + Readonly<{ id: string; - }[], + }>[], unknown >; findBundleId(identifier: string): Effect.Effect< - { + Readonly<{ id: string; - } | null, + }> | null, unknown >; listDistributionCertificates(): Effect.Effect< - { + Readonly<{ id: string; expirationDate?: string | undefined; - }[], + }>[], unknown >; listInAppPurchases(appId: string): Effect.Effect< - { + Readonly<{ id?: string; productId: string; state?: string | undefined; - }[], + }>[], unknown >; listSubscriptions(groupId: string): Effect.Effect< - { + Readonly<{ id?: string; productId: string; state?: string | undefined; - }[], + }>[], unknown >; listSandboxTesters(): Effect.Effect< - { + Readonly<{ id: string; - }[], + }>[], unknown >; findInAppPurchasePricePoint( @@ -101,9 +101,9 @@ export type AscReadinessApi = { territory: string, customerPrice: number, ): Effect.Effect< - { + Readonly<{ id: string; - } | null, + }> | null, unknown >; findSubscriptionPricePoint( @@ -111,106 +111,106 @@ export type AscReadinessApi = { territory: string, customerPrice: number, ): Effect.Effect< - { + Readonly<{ id: string; - } | null, + }> | null, unknown >; listSubscriptionOfferCodes(subscriptionId: string): Effect.Effect< - { + Readonly<{ name: string; - }[], + }>[], unknown >; getEditableAppInfoId(appId: string): Effect.Effect; getAgeRatingDeclaration(appInfoId: string): Effect.Effect< - { + Readonly<{ attributes: Record; - } | null, + }> | null, unknown >; listAccountDeletionUrls(appInfoId: string): Effect.Effect< - { + Readonly<{ locale: string; url: string; - }[], + }>[], unknown >; findEditableAppStoreVersion( appId: string, platform: string, ): Effect.Effect< - { + Readonly<{ id: string; - } | null, + }> | null, unknown >; getAppStoreReviewDetail(versionId: string): Effect.Effect< - { + Readonly<{ attributes: Record; - } | null, + }> | null, unknown >; listBundleIdCapabilities(bundleIdResourceId: string): Effect.Effect< - { + Readonly<{ capabilityType: string; - }[], + }>[], unknown >; listAppStoreVersionLocalizations(versionId: string): Effect.Effect< - { + Readonly<{ id: string; locale: string; - }[], + }>[], unknown >; listScreenshotSets(versionLocalizationId: string): Effect.Effect< - { + Readonly<{ id: string; screenshotDisplayType: string; - }[], + }>[], unknown >; listScreenshots(setId: string): Effect.Effect< - { + Readonly<{ id: string; - }[], + }>[], unknown >; -}; +}>; /** Read-only Google Play methods used by readiness probes. */ -export type PlayReadinessApi = { +export type PlayReadinessApi = Readonly<{ assertAppExists(packageName: string): Effect.Effect; getLatestVersionCode(packageName: string): Effect.Effect; listTracks(packageName: string): Effect.Effect< - { + Readonly<{ track: string; - }[], + }>[], unknown >; -}; +}>; /** Config, selected apps, and memoized clients shared by readiness probes. */ -export type ReadinessContext = { +export type ReadinessContext = Readonly<{ config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; resolveAscApi(): Effect.Effect; resolvePlayApi(): Effect.Effect; -}; +}>; /** Registered read-only probe selected by category. */ -export type ReadinessProbe = { +export type ReadinessProbe = Readonly<{ id: string; title: string; store: ReadinessStore; categories: readonly ReadinessCategory[]; check(readinessContext: ReadinessContext): ProbeCheckResult; -}; +}>; /** Aggregate readiness report and its process exit code. */ -export type ReadinessOutcome = { - reports: ProbeReport[]; +export type ReadinessOutcome = Readonly<{ + reports: readonly ProbeReport[]; okCount: number; warnCount: number; blockerCount: number; errorCount: number; skippedCount: number; exitCode: number; -}; +}>; diff --git a/src/core/types/reconcile.ts b/src/core/types/reconcile.ts index 0a3a574d..aa9e21b6 100644 --- a/src/core/types/reconcile.ts +++ b/src/core/types/reconcile.ts @@ -1,13 +1,16 @@ export type ActionStatus = 'planned' | 'applied' | 'skipped' | 'failed'; -/** One unit of store-reconcile work shown in plans and apply summaries. */ +/** + * One unit of store-reconcile work shown in plans and apply summaries. + * Description and destructive are fixed when planned; status/error are filled in by the owning apply path. + */ export type PlannedAction = { - description: string; - destructive: boolean; + readonly description: string; + readonly destructive: boolean; status: ActionStatus; error?: string; }; /** Result of reconciling one app's store surface. */ -export type ReconcileReport = { +export type ReconcileReport = Readonly<{ bundleId: string; - actions: PlannedAction[]; -}; + actions: readonly PlannedAction[]; +}>; diff --git a/src/core/types/releaseTrain.ts b/src/core/types/releaseTrain.ts index b90cc1cb..89be01ed 100644 --- a/src/core/types/releaseTrain.ts +++ b/src/core/types/releaseTrain.ts @@ -22,19 +22,19 @@ export type TrainState = 'running' | 'blocked' | 'done' | 'aborted'; * A native platform car - the iOS or Android leg of the release. Carries the build it submitted and, on a * `rejected` / `failed` car, the reason, so a reconcile can report why the train is holding. */ -export type NativeCar = { +export type NativeCar = Readonly<{ kind: TrainPlatform; state: NativeCarState; buildId?: string; error?: string; updatedAt: string; -}; +}>; /** * An OTA car - a JS bundle that follows its native platform live. Gated per-platform (D4): it publishes * only once the native build carrying its `runtimeVersion` is released in that platform's store, so JS is * never pushed to a runtime version users don't have yet. */ -export type OtaCar = { +export type OtaCar = Readonly<{ kind: 'ota'; platform: TrainPlatform; channel: string; @@ -42,7 +42,7 @@ export type OtaCar = { state: OtaCarState; manifestId?: string; updatedAt: string; -}; +}>; /** One car of a train - a native platform leg or an OTA follower. */ export type Car = NativeCar | OtaCar; /** @@ -50,12 +50,12 @@ export type Car = NativeCar | OtaCar; * `release-train start`, advanced by `release-train status`. Holds every car plus the train-wide gate * (`hold`) and lifecycle (`state`). */ -export type TrainRecord = { +export type TrainRecord = Readonly<{ id: string; app: string; hold: boolean; state: TrainState; createdAt: string; updatedAt: string; - cars: Car[]; -}; + cars: readonly Car[]; +}>; diff --git a/src/core/types/remote.ts b/src/core/types/remote.ts index 2b558bb5..60a950cf 100644 --- a/src/core/types/remote.ts +++ b/src/core/types/remote.ts @@ -23,12 +23,12 @@ export type Shell = 'bash' | 'zsh' | 'fish'; * Filled by a {@link ComputeHost}: `aws-ec2-mac` from a freshly-provisioned instance, `byo-ssh` from * a user-supplied `user@host` string. Consumed by the SSH transport helpers in `core/ssh.ts`. */ -export type SshTarget = { +export type SshTarget = Readonly<{ host: string; user: string; port: number; identityFile?: string; -}; +}>; /** * A handle to an allocated (or connected) remote Mac. * @@ -36,7 +36,7 @@ export type SshTarget = { * accrued cost, and release it. For `byo-ssh` the AWS fields are absent - there is nothing to bill or * release; Launch only borrows the connection. */ -export type HostHandle = { +export type HostHandle = Readonly<{ provider: string; ssh: SshTarget; allocatedAt: string; @@ -44,7 +44,7 @@ export type HostHandle = { hostId?: string; region?: string; instanceType?: string; -}; +}>; /** * A live host's status, for `launch cloud status` and the per-command cost banner. * @@ -52,22 +52,22 @@ export type HostHandle = { * the 24h minimum (see `core/cost.ts`). `releasableAt` is when AWS first allows releasing the * Dedicated Host with no further commitment. */ -export type HostStatus = { +export type HostStatus = Readonly<{ handle: HostHandle; ageMs: number; estimatedCostUsd: number; releasableAt: string; -}; +}>; /** * AWS settings for the EC2 Mac compute host, declared in `launch.config.ts` under `aws`. * Launch stores NO AWS secrets: credentials resolve through the standard SDK chain. */ -export type AwsConfig = { +export type AwsConfig = Readonly<{ region: string; profile?: string; amiId?: string; instanceType?: string; -}; +}>; /** One read-only AWS readiness probe shown by `launch cloud doctor`. */ export type CloudCheck = Readonly<{ @@ -87,13 +87,13 @@ export type CloudDoctorReport = Readonly<{ * - `ssh`: connect to an already-reachable Mac via the `byo-ssh` {@link ComputeHost}. */ export type RemoteTarget = - | { + | Readonly<{ kind: 'aws'; - } - | { + }> + | Readonly<{ kind: 'ssh'; target: string; - }; + }>; /** * Request passed to {@link ComputeHost.allocate}. * @@ -102,9 +102,9 @@ export type RemoteTarget = * the first billable action, and an optional progress sink. Reuse of a live host is handled by the * caller (`core/remotePipeline.ts`), so `allocate` always provisions fresh. */ -export type AllocateRequest = { +export type AllocateRequest = Readonly<{ aws?: AwsConfig; sshTarget?: string; confirm(message: string): Effect.Effect; onProgress?: (message: string) => void; -}; +}>; diff --git a/src/core/types/snapshot.ts b/src/core/types/snapshot.ts index 01b90783..c9fbc226 100644 --- a/src/core/types/snapshot.ts +++ b/src/core/types/snapshot.ts @@ -19,10 +19,10 @@ export type JsonValue = | number | boolean | null - | JsonValue[] - | { + | readonly JsonValue[] + | Readonly<{ [key: string]: JsonValue; - }; + }>; /** * One captured item within a surface - e.g. a single in-app purchase or subscription. * - `key` is the item's natural, stable identifier (product id / SKU); it pairs items across two @@ -30,17 +30,17 @@ export type JsonValue = * - `summary` is a one-line human description shown in `snapshot diff` output. * - `data` is the normalized state used for the structural change check and `snapshot export`. */ -export type SnapshotEntity = { +export type SnapshotEntity = Readonly<{ key: string; summary: string; data: JsonValue; -}; +}>; /** One app's captured entities for one source (the per-app grouping inside a captured surface). */ -export type AppEntities = { +export type AppEntities = Readonly<{ app: string; identifier: string; - entities: SnapshotEntity[]; -}; + entities: readonly SnapshotEntity[]; +}>; /** * What a source returns, as a discriminated union mirroring {@link import("./plan.js").SurfacePlan}: * - `omitted` - nothing in scope (e.g. no iOS apps); dropped from the record entirely. @@ -49,18 +49,18 @@ export type AppEntities = { * - `captured` - the source read successfully; `apps` carries the per-app entities. */ export type SourceCapture = - | { + | Readonly<{ state: 'omitted'; - } - | { + }> + | Readonly<{ state: 'skipped'; reason: string; hint?: string; - } - | { + }> + | Readonly<{ state: 'captured'; - apps: AppEntities[]; - }; + apps: readonly AppEntities[]; + }>; /** * A {@link SourceCapture} plus the `errored` state the orchestrator synthesizes when a source throws * unexpectedly (a real read failure, not an empty surface). Kept distinct so `snapshot create` can exit @@ -68,81 +68,85 @@ export type SourceCapture = */ export type CaptureOutcome = | SourceCapture - | { + | Readonly<{ state: 'errored'; error: string; - }; + }>; /** * One source's stamped result in a saved snapshot. The orchestrator records the source's identity onto its * {@link CaptureOutcome} so a source never restates its own id/title/store, and so the on-disk record is * self-describing for `diff`/`export`. Omitted sources are dropped before persisting. */ -export type CaptureReport = { +export type CaptureReport = Readonly<{ id: string; title: string; store: SnapshotStore; outcome: CaptureOutcome; -}; +}>; /** * The persisted snapshot record - the JSON written under `~/.launch/snapshots/.json` and the unit * `diff`/`export`/`list` operate on. `version` guards the on-disk format; `reports` excludes omitted * surfaces so an Apple-only project never carries empty Play blocks. */ -export type Snapshot = { +export type Snapshot = Readonly<{ version: number; name: string; capturedAt: string; - reports: CaptureReport[]; -}; + reports: readonly CaptureReport[]; +}>; /** * The read-only App Store Connect surface the snapshot sources share - exactly the methods they call, * nothing more. `AppStoreConnectClient` satisfies it structurally (every method already exists on it), so * the resolver from `core/storeClients.ts` is assignable here with no cast. Mirrors * {@link import("./readiness.js").AscReadinessApi}; grows by one method as each Apple source lands. */ -export type SnapshotAscApi = { +export type SnapshotAscApi = Readonly<{ getAppId(bundleId: string): Effect.Effect; listInAppPurchases(appId: string): Effect.Effect< - { + Readonly<{ productId: string; inAppPurchaseType: string; state?: string | undefined; - }[], + }>[], unknown >; listSubscriptionGroups(appId: string): Effect.Effect< - { + Readonly<{ id: string; referenceName: string; - }[], + }>[], unknown >; listSubscriptions(groupId: string): Effect.Effect< - { + Readonly<{ productId: string; subscriptionPeriod?: string | undefined; state?: string | undefined; - }[], + }>[], unknown >; getEditableAppInfoId(appId: string): Effect.Effect; - listAppInfoLocalizations(appInfoId: string): Effect.Effect; + listAppInfoLocalizations( + appInfoId: string, + ): Effect.Effect; getEditableVersionId(appId: string): Effect.Effect; - listVersionLocalizations(versionId: string): Effect.Effect; + listVersionLocalizations( + versionId: string, + ): Effect.Effect; findBundleId(identifier: string): Effect.Effect< - { + Readonly<{ id: string; identifier: string; - } | null, + }> | null, unknown >; listBundleIdCapabilities(bundleIdResourceId: string): Effect.Effect< - { + Readonly<{ capabilityType: string; - }[], + }>[], unknown >; -}; +}>; /** * The read-only Google Play surface the snapshot sources share - the Play counterpart to * {@link SnapshotAscApi}: exactly the two readers they call, never the reconcilers' write methods, so the @@ -150,21 +154,21 @@ export type SnapshotAscApi = { * types (`InAppProductResource` / `SubscriptionResource`) rather than re-declaring the wire shape, keeping * one source of truth; `GooglePlayClient` satisfies it structurally with no cast. */ -export type SnapshotPlayApi = { - listInAppProducts(packageName: string): Effect.Effect; - listSubscriptions(packageName: string): Effect.Effect; -}; +export type SnapshotPlayApi = Readonly<{ + listInAppProducts(packageName: string): Effect.Effect; + listSubscriptions(packageName: string): Effect.Effect; +}>; /** * What a {@link SnapshotSource} is handed: the loaded config, the apps in scope (already narrowed by `-a`), * and the lazy, memoized store-client resolvers from `core/storeClients.ts`. A resolver returns `null` when * the account isn't configured, letting a source emit a `skipped` capture instead of throwing. */ -export type SnapshotContext = { +export type SnapshotContext = Readonly<{ config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; resolveAscApi(): Effect.Effect; resolvePlayApi(): Effect.Effect; -}; +}>; /** * What a {@link SnapshotSource.restore} pass is handed: the write-capable counterpart to * {@link SnapshotContext}. Each resolver returns the reconciler write surface its store's sources need - @@ -173,26 +177,26 @@ export type SnapshotContext = { * instead of throwing. The concrete clients satisfy these structurally, like the read side's * {@link SnapshotAscApi} / {@link SnapshotPlayApi}. */ -export type RestoreContext = { +export type RestoreContext = Readonly<{ config: LaunchConfig; - apps: AppDescriptor[]; + apps: readonly AppDescriptor[]; resolveAscWriteClient(): Effect.Effect; resolvePlayWriteClient(): Effect.Effect; -}; +}>; /** * One source's restore request: the write context plus the per-app entities loaded from the saved snapshot * (already narrowed by `-a`). `dryRun` drives the same plan-then-apply contract the reconcilers use - a * dry-run produces the planned actions for the preview and performs no writes. */ -export type RestoreInput = { +export type RestoreInput = Readonly<{ ctx: RestoreContext; - saved: AppEntities[]; + saved: readonly AppEntities[]; dryRun: boolean; -}; +}>; /** The result of a restore pass: the actions planned (dry-run) or applied, in order. */ -export type RestoreReport = { - actions: PlannedAction[]; -}; +export type RestoreReport = Readonly<{ + actions: readonly PlannedAction[]; +}>; /** * One captured surface. {@link capture} is **read-only**: it resolves live state and serializes it, never * writing. Registered like a provider/planner (see {@link import("./registry.js")}); the orchestrator @@ -206,10 +210,10 @@ export type RestoreReport = { * in-app purchase's current price/territory, so their capture is summary-grade and can't be faithfully * restored. A source without `restore` is preview-only in `snapshot restore`. */ -export type SnapshotSource = { +export type SnapshotSource = Readonly<{ id: string; title: string; store: SnapshotStore; capture(snapshotContext: SnapshotContext): Effect.Effect; restore?(input: RestoreInput): Effect.Effect; -}; +}>; diff --git a/src/core/types/storeSurface.ts b/src/core/types/storeSurface.ts index 606fd5d9..36f35a45 100644 --- a/src/core/types/storeSurface.ts +++ b/src/core/types/storeSurface.ts @@ -7,22 +7,22 @@ import type { /** How an approved iOS build reaches the public App Store. */ export type ReleaseType = 'AFTER_APPROVAL' | 'MANUAL' | 'SCHEDULED'; /** iOS public-release policy under `LaunchConfig.release`. */ -export type ReleaseConfig = { +export type ReleaseConfig = Readonly<{ releaseType?: ReleaseType; earliestReleaseDate?: string; phasedRelease?: boolean; usesNonExemptEncryption?: boolean; releaseNotes?: string | Record; primaryLocale?: string; -}; +}>; /** Transition notifications under `LaunchConfig.notify`. */ -export type NotifyConfig = { +export type NotifyConfig = Readonly<{ webhookUrl?: string; command?: string; events?: Array<'build' | 'submit' | 'review' | 'rollout'>; -}; +}>; /** One Game Center achievement declaration. */ -export type AchievementConfig = { +export type AchievementConfig = Readonly<{ vendorIdentifier: string; referenceName: string; points: number; @@ -32,9 +32,9 @@ export type AchievementConfig = { beforeEarnedDescription: string; afterEarnedDescription: string; locale?: string; -}; +}>; /** One Game Center leaderboard declaration. */ -export type LeaderboardConfig = { +export type LeaderboardConfig = Readonly<{ vendorIdentifier: string; referenceName: string; defaultFormatter: (typeof LEADERBOARD_FORMATTERS)[number]; @@ -42,56 +42,56 @@ export type LeaderboardConfig = { scoreSortType: (typeof LEADERBOARD_SORT_TYPES)[number]; name: string; locale?: string; -}; +}>; /** Game Center achievements & leaderboards for one app. */ -export type GameCenterConfig = { - achievements?: AchievementConfig[]; - leaderboards?: LeaderboardConfig[]; -}; +export type GameCenterConfig = Readonly<{ + achievements?: readonly AchievementConfig[]; + leaderboards?: readonly LeaderboardConfig[]; +}>; /** One locale of an App Clip card. */ -export type AppClipLocalizationConfig = { +export type AppClipLocalizationConfig = Readonly<{ subtitle: string; -}; +}>; /** One App Clip's card metadata. */ -export type AppClipConfig = { +export type AppClipConfig = Readonly<{ action?: (typeof APP_CLIP_ACTIONS)[number]; localizations?: Record; -}; +}>; /** An app's App Clips, keyed by clip bundle id. */ -export type AppClipsConfig = { +export type AppClipsConfig = Readonly<{ clips: Record; -}; +}>; /** One authorized EU distribution domain. */ -export type EuDistributionDomainConfig = { +export type EuDistributionDomainConfig = Readonly<{ domain: string; referenceName: string; -}; +}>; /** Team-level EU alternative-distribution domains. */ -export type EuDistributionConfig = { - domains: EuDistributionDomainConfig[]; -}; +export type EuDistributionConfig = Readonly<{ + domains: readonly EuDistributionDomainConfig[]; +}>; /** One Apple Pay merchant id or Wallet pass type id. */ -export type WalletIdConfig = { +export type WalletIdConfig = Readonly<{ identifier: string; name: string; -}; +}>; /** Team-level Apple Pay / Wallet identifiers. */ -export type WalletConfig = { - merchantIds?: WalletIdConfig[]; - passTypeIds?: WalletIdConfig[]; -}; +export type WalletConfig = Readonly<{ + merchantIds?: readonly WalletIdConfig[]; + passTypeIds?: readonly WalletIdConfig[]; +}>; /** Declared primary/secondary App Store categories. */ -export type ReleaseCategories = { +export type ReleaseCategories = Readonly<{ primary?: string; secondary?: string; -}; +}>; /** Declared base price for release attributes. */ -export type ReleasePricing = { +export type ReleasePricing = Readonly<{ baseTerritory?: string; customerPrice: number; -}; +}>; /** Declared App Review contact / demo details. */ -export type ReviewDetailsConfig = { +export type ReviewDetailsConfig = Readonly<{ contactFirstName?: string; contactLastName?: string; contactPhone?: string; @@ -100,27 +100,27 @@ export type ReviewDetailsConfig = { demoAccountName?: string; demoAccountPassword?: string; notes?: string; -}; +}>; /** * App Store release attributes (age rating, categories, price, review details). * Distinct from {@link ReleaseConfig} (when/how a version goes live). */ -export type ReleaseAttributesConfig = { +export type ReleaseAttributesConfig = Readonly<{ ageRating?: Record; categories?: ReleaseCategories; pricing?: ReleasePricing; reviewDetails?: ReviewDetailsConfig; -}; +}>; /** Non-default paths for sidecar-only `*.config.json` surfaces. */ -export type SurfaceConfigFiles = { +export type SurfaceConfigFiles = Readonly<{ availability?: string; accessibility?: string; experiments?: string; customPages?: string; -}; +}>; /** MCP tool capability tier. */ export type McpCapability = 'read' | 'dryRun' | 'write' | 'dangerous'; /** `mcp` block of `launch.config.ts`. */ -export type McpConfig = { - capabilities?: McpCapability[]; -}; +export type McpConfig = Readonly<{ + capabilities?: readonly McpCapability[]; +}>; diff --git a/src/core/types/vitals.ts b/src/core/types/vitals.ts index ddec2dba..00b2eaf1 100644 --- a/src/core/types/vitals.ts +++ b/src/core/types/vitals.ts @@ -10,29 +10,29 @@ export type PlayVitalsMetric = 'crash' | 'anr'; * `undefined` when Google returned no value for that day (sparse rows are expected near the freshness * edge). `distinctUsers` is the denominator population for the day. */ -export type PlayVitalsRow = { +export type PlayVitalsRow = Readonly<{ metric: PlayVitalsMetric; date: string; rate?: number; userPerceivedRate?: number; distinctUsers?: number; -}; +}>; /** * A day window for a vitals query, as ISO `YYYY-MM-DD` strings (both ends inclusive). Produced by * `resolveVitalsWindow` from a metric set's freshness and consumed by the Play Developer Reporting * query methods, so a request never reaches past the data Google has finished aggregating. */ -export type VitalsWindow = { +export type VitalsWindow = Readonly<{ startDate: string; endDate: string; -}; +}>; /** * One vital's resolved timeline: the metric, the window actually queried (after freshness bounding), * and its normalized daily rows. The result of `PlayReportingClient.vitalsTimeline` - the unit the * `launch play-reports vitals` command renders. */ -export type VitalsTimeline = { +export type VitalsTimeline = Readonly<{ metric: PlayVitalsMetric; window: VitalsWindow; - rows: PlayVitalsRow[]; -}; + rows: readonly PlayVitalsRow[]; +}>; diff --git a/src/google/playClient.ts b/src/google/playClient.ts index bc921019..ddeaca53 100644 --- a/src/google/playClient.ts +++ b/src/google/playClient.ts @@ -5,6 +5,13 @@ import { } from '@googleapis/androidpublisher'; import { Data, Effect, Option, Schema } from 'effect'; import type { ServiceAccount } from '../core/types/credentials.js'; +import type { MutableDeep } from '../core/types/mutable.js'; + +/** Deep-clone a Launch boundary value into a mutable plain object for Google's generated client. */ +const mutableGoogleRequest = (requestShape: unknown): GoogleRequest => { + const clonedRequest: GoogleRequest = JSON.parse(JSON.stringify(requestShape)); + return clonedRequest; +}; import type { BasePlan, InAppProductResource, @@ -91,7 +98,7 @@ const normalizeReview = ( )?.developerComment; let rating = 0; if (typeof userComment?.starRating === 'number') rating = userComment.starRating; - const review: PlayReview = { + const review: MutableDeep = { reviewId: googleReview.reviewId, rating, answered: developerComment !== undefined, @@ -128,7 +135,7 @@ const normalizeMoney = (money: androidpublisher_v3.Schema$Money | undefined): Pl const normalizeTrackRelease = ( googleRelease: androidpublisher_v3.Schema$TrackRelease, ): PlayRelease => { - const release: PlayRelease = {}; + const release: MutableDeep = {}; if (typeof googleRelease.name === 'string') release.name = googleRelease.name; if (Array.isArray(googleRelease.versionCodes)) release.versionCodes = googleRelease.versionCodes; if (typeof googleRelease.status === 'string') release.status = googleRelease.status; @@ -160,7 +167,7 @@ const normalizeProductMoney = ( googlePrice: androidpublisher_v3.Schema$Price | undefined, ): PlayMoney | undefined => { if (googlePrice === undefined) return; - const price: PlayMoney = {}; + const price: MutableDeep = {}; if (typeof googlePrice.priceMicros === 'string') price.priceMicros = googlePrice.priceMicros; if (typeof googlePrice.currency === 'string') price.currency = googlePrice.currency; return price; @@ -170,7 +177,7 @@ const normalizeInAppProduct = ( googleProduct: androidpublisher_v3.Schema$InAppProduct, ): InAppProductResource | undefined => { if (typeof googleProduct.sku !== 'string') return; - const product: InAppProductResource = { sku: googleProduct.sku }; + const product: MutableDeep = { sku: googleProduct.sku }; if (typeof googleProduct.status === 'string') product.status = googleProduct.status; if (typeof googleProduct.purchaseType === 'string') { product.purchaseType = googleProduct.purchaseType; @@ -214,19 +221,21 @@ const normalizeInAppProduct = ( /** Normalize one generated subscription base plan to the fields Launch reconciles. */ const normalizeBasePlan = ( googlePlan: androidpublisher_v3.Schema$BasePlan, -): BasePlan | undefined => { +): MutableDeep | undefined => { if (typeof googlePlan.basePlanId !== 'string') return; - const basePlan: BasePlan = { basePlanId: googlePlan.basePlanId }; + const basePlan: MutableDeep = { basePlanId: googlePlan.basePlanId }; if (typeof googlePlan.state === 'string') basePlan.state = googlePlan.state; const billingPeriod = googlePlan.autoRenewingBasePlanType?.billingPeriodDuration; if (typeof billingPeriod === 'string') { basePlan.autoRenewingBasePlanType = { billingPeriodDuration: billingPeriod }; } if (Array.isArray(googlePlan.regionalConfigs)) { - const regionalConfigs: RegionalBasePlanConfig[] = []; + const regionalConfigs: MutableDeep[] = []; for (const googleRegion of googlePlan.regionalConfigs) { if (typeof googleRegion.regionCode !== 'string') continue; - const regionalConfig: RegionalBasePlanConfig = { regionCode: googleRegion.regionCode }; + const regionalConfig: MutableDeep = { + regionCode: googleRegion.regionCode, + }; if (typeof googleRegion.newSubscriberAvailability === 'boolean') { regionalConfig.newSubscriberAvailability = googleRegion.newSubscriberAvailability; } @@ -252,12 +261,14 @@ const normalizeSubscription = ( googleSubscription: androidpublisher_v3.Schema$Subscription, ): SubscriptionResource | undefined => { if (typeof googleSubscription.productId !== 'string') return; - const subscription: SubscriptionResource = { productId: googleSubscription.productId }; + const subscription: MutableDeep = { + productId: googleSubscription.productId, + }; if (typeof googleSubscription.packageName === 'string') { subscription.packageName = googleSubscription.packageName; } if (Array.isArray(googleSubscription.basePlans)) { - const basePlans: BasePlan[] = []; + const basePlans: MutableDeep[] = []; for (const googlePlan of googleSubscription.basePlans) { const basePlan = normalizeBasePlan(googlePlan); if (basePlan !== undefined) basePlans.push(basePlan); @@ -265,12 +276,12 @@ const normalizeSubscription = ( subscription.basePlans = basePlans; } if (Array.isArray(googleSubscription.listings)) { - const listings: SubscriptionListing[] = []; + const listings: MutableDeep[] = []; for (const googleListing of googleSubscription.listings) { if (typeof googleListing.languageCode !== 'string') continue; if (typeof googleListing.title !== 'string') continue; if (typeof googleListing.description !== 'string') continue; - const listing: SubscriptionListing = { + const listing: MutableDeep = { languageCode: googleListing.languageCode, title: googleListing.title, description: googleListing.description, @@ -287,7 +298,7 @@ const normalizeSubscriptionOffer = ( googleOffer: androidpublisher_v3.Schema$SubscriptionOffer, ): SubscriptionOfferResource | undefined => { if (typeof googleOffer.offerId !== 'string') return; - const offer: SubscriptionOfferResource = { + const offer: MutableDeep = { offerId: googleOffer.offerId, phases: [], regionalConfigs: [], @@ -299,7 +310,7 @@ const normalizeSubscriptionOffer = ( if (Array.isArray(googleOffer.regionalConfigs)) { for (const googleRegion of googleOffer.regionalConfigs) { if (typeof googleRegion.regionCode !== 'string') continue; - const regionalConfig: RegionalSubscriptionOfferConfig = { + const regionalConfig: MutableDeep = { regionCode: googleRegion.regionCode, }; if (typeof googleRegion.newSubscriberAvailability === 'boolean') { @@ -311,7 +322,7 @@ const normalizeSubscriptionOffer = ( if (Array.isArray(googleOffer.phases)) { for (const googlePhase of googleOffer.phases) { if (typeof googlePhase.recurrenceCount !== 'number') continue; - const phase: SubscriptionOfferPhase = { + const phase: MutableDeep = { recurrenceCount: googlePhase.recurrenceCount, regionalConfigs: [], }; @@ -319,7 +330,9 @@ const normalizeSubscriptionOffer = ( if (Array.isArray(googlePhase.regionalConfigs)) { for (const googleRegion of googlePhase.regionalConfigs) { if (typeof googleRegion.regionCode !== 'string') continue; - const regionalConfig: OfferPhaseRegionalConfig = { regionCode: googleRegion.regionCode }; + const regionalConfig: MutableDeep = { + regionCode: googleRegion.regionCode, + }; if (googleRegion.price !== undefined) regionalConfig.price = normalizeMoney(googleRegion.price); if (googleRegion.free !== undefined) regionalConfig.free = {}; @@ -385,7 +398,7 @@ export const parseServiceAccount = ( Effect.map((serviceAccountKey) => { let tokenUri = 'https://oauth2.googleapis.com/token'; if (serviceAccountKey.token_uri !== undefined) tokenUri = serviceAccountKey.token_uri; - const serviceAccount: ServiceAccount = { + const serviceAccount: MutableDeep = { clientEmail: serviceAccountKey.client_email, privateKey: serviceAccountKey.private_key, tokenUri, @@ -615,7 +628,7 @@ export class GooglePlayClient { packageName, editId, track, - requestBody: { track, releases: [...releases] }, + requestBody: mutableGoogleRequest({ track, releases }), }), ).pipe(Effect.asVoid), ); @@ -668,7 +681,7 @@ export class GooglePlayClient { countries.push({ countryCode: googleCountry.countryCode }); } } - const availability: PlayCountryAvailability = { countries }; + const availability: MutableDeep = { countries }; if (typeof countryAvailability.restOfWorld === 'boolean') { availability.restOfWorld = countryAvailability.restOfWorld; } @@ -788,7 +801,7 @@ export class GooglePlayClient { regions.sort((leftPrice, rightPrice) => leftPrice.regionCode.localeCompare(rightPrice.regionCode), ); - const convertedPrices: ConvertedPrices = { regions }; + const convertedPrices: MutableDeep = { regions }; const fallbackPrice = priceConversion.convertedOtherRegionsPrice; if (fallbackPrice !== undefined) { convertedPrices.otherRegions = { @@ -840,7 +853,10 @@ export class GooglePlayClient { packageName, productId: subscription.productId, 'regionsVersion.version': REGIONS_VERSION, - requestBody: { ...subscription, packageName }, + requestBody: mutableGoogleRequest({ + ...subscription, + packageName, + }), }), ).pipe(Effect.asVoid); } @@ -860,7 +876,10 @@ export class GooglePlayClient { productId: subscription.productId, updateMask, 'regionsVersion.version': REGIONS_VERSION, - requestBody: { ...subscription, packageName }, + requestBody: mutableGoogleRequest({ + ...subscription, + packageName, + }), }), ).pipe(Effect.asVoid); } @@ -922,7 +941,10 @@ export class GooglePlayClient { basePlanId, offerId: offer.offerId, 'regionsVersion.version': REGIONS_VERSION, - requestBody: { ...offer, packageName }, + requestBody: mutableGoogleRequest({ + ...offer, + packageName, + }), }), ).pipe(Effect.asVoid); } @@ -1017,7 +1039,7 @@ export class GooglePlayClient { if (typeof replyConfirmation.result?.replyText === 'string') { storedReplyText = replyConfirmation.result.replyText; } - const reply: PlayReplyResult = { replyText: storedReplyText }; + const reply: MutableDeep = { replyText: storedReplyText }; const lastEdited = timestampToIso(replyConfirmation.result?.lastEdited); if (lastEdited !== undefined) reply.lastEdited = lastEdited; return reply; diff --git a/src/google/playReporting.ts b/src/google/playReporting.ts index 5bbd8b20..53b68793 100644 --- a/src/google/playReporting.ts +++ b/src/google/playReporting.ts @@ -6,6 +6,7 @@ import { import { Data, Effect } from 'effect'; import type { ServiceAccount } from '../core/types/credentials.js'; import { describePlayErrors } from './playClient.js'; +import type { MutableDeep } from '../core/types/mutable.js'; import type { PlayVitalsMetric, PlayVitalsRow, @@ -287,7 +288,7 @@ export class PlayReportingClient { for (const metricEntry of metricPage.rows) { const date = dateTimeToIso(metricEntry.startTime); if (!date) continue; - const normalized: PlayVitalsRow = { metric, date }; + const normalized: MutableDeep = { metric, date }; const rate = metricNumber(metricEntry, set.rate); if (rate !== undefined) normalized.rate = rate; const userPerceivedRate = metricNumber(metricEntry, set.userPerceivedRate); diff --git a/src/providers/credentials/local.test.ts b/src/providers/credentials/local.test.ts index a03b441e..535444cd 100644 --- a/src/providers/credentials/local.test.ts +++ b/src/providers/credentials/local.test.ts @@ -9,6 +9,7 @@ import type { ResolvedBuildContext } from '@core/types/config.js'; import type { AscKey } from '@core/types/credentials.js'; import type { CredentialsProvider } from '@core/types/providers.js'; import { makeLocalCredentialsProvider } from './local.js'; +import type { MutableDeep } from '@core/types/mutable.js'; type LocalCredentialsTestState = { appleKeys: Map; @@ -55,7 +56,7 @@ const runWithLocalCredentials = ( /** Build context containing only the fields the local credentials provider reads. */ const iosContext = (account?: string): ResolvedBuildContext => { - const buildContext: ResolvedBuildContext = { + const buildContext: MutableDeep = { platform: 'ios', app: { name: 'sampleapp', diff --git a/src/providers/storage/local.ts b/src/providers/storage/local.ts index 18aa9e8e..d2157b7f 100644 --- a/src/providers/storage/local.ts +++ b/src/providers/storage/local.ts @@ -23,7 +23,7 @@ export const makeLocalStorageProvider = (directoryOverride?: string) => const objectsDirectory = pathService.join(baseDirectory, 'objects'); const artifactIndexPath = pathService.join(baseDirectory, 'index.json'); const readIndex = () => artifactRetention.readIndex(artifactIndexPath); - const writeIndex = (artifactIndex: BuildArtifact[]) => + const writeIndex = (artifactIndex: readonly BuildArtifact[]) => artifactRetention.writeIndex(artifactIndex, artifactIndexPath); const objectPath = (objectKey: string): string => pathService.join(objectsDirectory, ...objectKey.split('/')); @@ -37,8 +37,7 @@ export const makeLocalStorageProvider = (directoryOverride?: string) => const destination = pathService.join(baseDirectory, artifactId); yield* fileSystem.copy(artifact.path, destination); const artifactIndex = yield* readIndex(); - artifactIndex.unshift({ ...artifact, path: destination }); - yield* writeIndex(artifactIndex); + yield* writeIndex([{ ...artifact, path: destination }, ...artifactIndex]); return { id: artifactId, location: destination }; }), list: readIndex,