From ce12f95ffbba518b2abeaf6eed17806f38b80721 Mon Sep 17 00:00:00 2001 From: artiphishle Date: Fri, 14 Aug 2026 12:53:42 +0200 Subject: [PATCH] feat: add deploy project authoring APIs --- .changeset/calm-deploy-authoring.md | 5 + README.md | 2 +- src/project/index.ts | 14 ++ src/project/io/removeContainedPath.ts | 15 ++ .../projectMonetizationAuthoring.test.ts | 76 ++++++++ .../monetization/writeProjectMonetization.ts | 20 +++ src/project/projectAuthoringExports.test.ts | 21 +++ src/project/release/ProjectReleaseInput.ts | 3 + .../release/projectReleaseAuthoring.test.ts | 72 ++++++++ src/project/release/writeProjectRelease.ts | 20 +++ .../ProjectStoreListingAssetLocation.ts | 12 ++ ...esolvedProjectStoreListingAssetLocation.ts | 6 + .../assertStoreListingAssetBytes.ts | 30 ++++ .../assertStoreListingAssetLocale.ts | 13 ++ .../projectStoreListingAuthoring.test.ts | 167 ++++++++++++++++++ .../removeEmptyStoreListingDirectories.ts | 33 ++++ .../removeProjectStoreListingAsset.ts | 21 +++ .../removeProjectStoreListingLocale.ts | 32 ++++ ...resolveProjectStoreListingAssetLocation.ts | 47 +++++ .../writeProjectStoreListingAsset.ts | 27 +++ .../writeProjectStoreListingLocale.ts | 22 +++ 21 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-deploy-authoring.md create mode 100644 src/project/io/removeContainedPath.ts create mode 100644 src/project/monetization/projectMonetizationAuthoring.test.ts create mode 100644 src/project/monetization/writeProjectMonetization.ts create mode 100644 src/project/projectAuthoringExports.test.ts create mode 100644 src/project/release/ProjectReleaseInput.ts create mode 100644 src/project/release/projectReleaseAuthoring.test.ts create mode 100644 src/project/release/writeProjectRelease.ts create mode 100644 src/project/storeListing/ProjectStoreListingAssetLocation.ts create mode 100644 src/project/storeListing/ResolvedProjectStoreListingAssetLocation.ts create mode 100644 src/project/storeListing/assertStoreListingAssetBytes.ts create mode 100644 src/project/storeListing/assertStoreListingAssetLocale.ts create mode 100644 src/project/storeListing/projectStoreListingAuthoring.test.ts create mode 100644 src/project/storeListing/removeEmptyStoreListingDirectories.ts create mode 100644 src/project/storeListing/removeProjectStoreListingAsset.ts create mode 100644 src/project/storeListing/removeProjectStoreListingLocale.ts create mode 100644 src/project/storeListing/resolveProjectStoreListingAssetLocation.ts create mode 100644 src/project/storeListing/writeProjectStoreListingAsset.ts create mode 100644 src/project/storeListing/writeProjectStoreListingLocale.ts diff --git a/.changeset/calm-deploy-authoring.md b/.changeset/calm-deploy-authoring.md new file mode 100644 index 0000000..4d253cd --- /dev/null +++ b/.changeset/calm-deploy-authoring.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/deploy': minor +--- + +Expose canonical project authoring APIs for store listing locales/assets, monetization products, and prepared releases so Studio and other consumers can mutate Deploy-owned desired state without duplicating authored file layout or validation. diff --git a/README.md b/README.md index da1c312..958585c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # @ankhorage/deploy -![license: MIT](././paradox/badges/license.svg) ![npm: v0.10.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg) +![license: MIT](././paradox/badges/license.svg) ![npm: v0.11.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![docs: paradox](././paradox/badges/docs.svg) Declarative deployment engine for Expo apps across web, iOS, and Android. diff --git a/src/project/index.ts b/src/project/index.ts index ae55827..fa799f3 100644 --- a/src/project/index.ts +++ b/src/project/index.ts @@ -1,4 +1,6 @@ export type { MonetizationDesiredState } from '../domain/monetization/MonetizationDesiredState'; +export type { StoreListingLocale } from '../domain/storeListing/StoreListingLocale'; +export type { StoreListingTarget } from '../domain/storeListing/StoreListingTarget'; export { createProjectAndroidDeploymentPlan } from './android/createProjectAndroidDeploymentPlan'; export { executeProjectAndroidDeployment, @@ -46,6 +48,7 @@ export type { ProjectMonetizationInspectionResult } from './monetization/Project export type { ProjectMonetizationPlan } from './monetization/ProjectMonetizationPlan'; export type { ProjectMonetizationTargets } from './monetization/ProjectMonetizationTargets'; export { readProjectMonetization } from './monetization/readProjectMonetization'; +export { writeProjectMonetization } from './monetization/writeProjectMonetization'; export type { ProjectDeploymentPaths } from './ProjectDeploymentPaths'; export { readProjectDeploymentConfig } from './readProjectDeploymentConfig'; export { createProjectReleasePlan } from './release/createProjectReleasePlan'; @@ -59,6 +62,7 @@ export type { ProjectReleaseAccess } from './release/ProjectReleaseAccess'; export type { ProjectReleaseAndroidContext } from './release/ProjectReleaseAndroidContext'; export type { ProjectReleaseExecution } from './release/ProjectReleaseExecution'; export type { ProjectReleaseExecutionResult } from './release/ProjectReleaseExecutionResult'; +export type { ProjectReleaseInput } from './release/ProjectReleaseInput'; export type { ProjectReleaseInspection } from './release/ProjectReleaseInspection'; export type { ProjectReleaseInspectionResult } from './release/ProjectReleaseInspectionResult'; export type { ProjectReleaseIosContext } from './release/ProjectReleaseIosContext'; @@ -66,6 +70,7 @@ export type { ProjectReleaseWebContext } from './release/ProjectReleaseWebContex export { readProjectRelease } from './release/readProjectRelease'; export { resumeProjectRelease } from './release/resumeProjectRelease'; export type { ResumeProjectReleaseOptions } from './release/ResumeProjectReleaseOptions'; +export { writeProjectRelease } from './release/writeProjectRelease'; export { createProjectReleaseHistoryRecord } from './releaseHistory/createProjectReleaseHistoryRecord'; export { listProjectReleaseHistory } from './releaseHistory/listProjectReleaseHistory'; export type { ProjectReleaseHistoryRecord } from './releaseHistory/ProjectReleaseHistoryRecord'; @@ -74,6 +79,15 @@ export { recordProjectReleaseHistory } from './releaseHistory/recordProjectRelea export type { ResolvedDeployProject } from './ResolvedDeployProject'; export { resolveDeployProject } from './resolveDeployProject'; export { resolveProjectDeploymentPaths } from './resolveProjectDeploymentPaths'; +export type { ProjectStoreListing } from './storeListing/ProjectStoreListing'; +export type { ProjectStoreListingAsset } from './storeListing/ProjectStoreListingAsset'; +export type { ProjectStoreListingAssetLocation } from './storeListing/ProjectStoreListingAssetLocation'; +export type { ProjectStoreListingAssetSet } from './storeListing/ProjectStoreListingAssetSet'; +export { readProjectStoreListing } from './storeListing/readProjectStoreListing'; +export { removeProjectStoreListingAsset } from './storeListing/removeProjectStoreListingAsset'; +export { removeProjectStoreListingLocale } from './storeListing/removeProjectStoreListingLocale'; +export { writeProjectStoreListingAsset } from './storeListing/writeProjectStoreListingAsset'; +export { writeProjectStoreListingLocale } from './storeListing/writeProjectStoreListingLocale'; export { type ProjectDeploymentConfigUpdater, updateProjectDeploymentConfig, diff --git a/src/project/io/removeContainedPath.ts b/src/project/io/removeContainedPath.ts new file mode 100644 index 0000000..84c9616 --- /dev/null +++ b/src/project/io/removeContainedPath.ts @@ -0,0 +1,15 @@ +import { promises as fs } from 'node:fs'; + +import { assertContainedWritePath } from './assertContainedPath'; + +export async function removeContainedPath(options: { + readonly projectRoot: string; + readonly targetPath: string; + readonly recursive?: boolean; +}): Promise { + await assertContainedWritePath(options.projectRoot, options.targetPath); + await fs.rm(options.targetPath, { + force: true, + recursive: options.recursive ?? false, + }); +} diff --git a/src/project/monetization/projectMonetizationAuthoring.test.ts b/src/project/monetization/projectMonetizationAuthoring.test.ts new file mode 100644 index 0000000..04d467b --- /dev/null +++ b/src/project/monetization/projectMonetizationAuthoring.test.ts @@ -0,0 +1,76 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { expectRejects } from '../expectRejects.test'; +import { readProjectMonetization } from './readProjectMonetization'; +import { writeProjectMonetization } from './writeProjectMonetization'; + +test('monetization authoring validates, normalizes, persists, and revises', async () => { + const root = await createRoot(); + try { + const written = await writeProjectMonetization({ + projectRoot: root, + products: [ + { + id: 'pro.monthly', + kind: 'subscription', + localizations: [{ locale: 'en-us', name: 'Pro', description: 'Monthly Pro' }], + basePrice: { country: 'ch', currency: 'chf', amount: '4.9900' }, + subscription: { family: 'pro', period: 'P1M', level: 1 }, + }, + ], + }); + + expect(written.products[0]).toMatchObject({ + id: 'pro.monthly', + localizations: [{ locale: 'en-US', name: 'Pro', description: 'Monthly Pro' }], + basePrice: { country: 'CH', currency: 'CHF', amount: '4.99' }, + }); + expect(written.revision.length).toBeGreaterThan(0); + expect(await readProjectMonetization({ projectRoot: root })).toEqual(written); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('invalid monetization authoring is rejected before replacing canonical state', async () => { + const root = await createRoot(); + try { + const initial = await writeProjectMonetization({ + projectRoot: root, + products: [ + { + id: 'coins', + kind: 'consumable', + localizations: [{ locale: 'en-US', name: 'Coins', description: 'Coin pack' }], + basePrice: { country: 'CH', currency: 'CHF', amount: '1.5' }, + }, + ], + }); + await expectRejects( + writeProjectMonetization({ + projectRoot: root, + products: [ + { + id: 'broken', + kind: 'consumable', + localizations: [{ locale: 'en-US', name: 'Broken', description: 'Broken' }], + basePrice: { country: 'CH', currency: 'CHF', amount: '0' }, + }, + ], + }), + 'MONETIZATION_PRODUCTS_INVALID', + ); + + expect(await readProjectMonetization({ projectRoot: root })).toEqual(initial); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +async function createRoot(): Promise { + return fs.mkdtemp(path.join(tmpdir(), 'ankh-deploy-monetization-authoring-')); +} diff --git a/src/project/monetization/writeProjectMonetization.ts b/src/project/monetization/writeProjectMonetization.ts new file mode 100644 index 0000000..98d30df --- /dev/null +++ b/src/project/monetization/writeProjectMonetization.ts @@ -0,0 +1,20 @@ +import type { MonetizationDesiredState } from '../../domain/monetization/MonetizationDesiredState'; +import type { MonetizationProduct } from '../../domain/monetization/MonetizationProduct'; +import { atomicWriteJson } from '../io/atomicWriteJson'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import { parseProjectMonetization } from './parseProjectMonetization'; +import { readProjectMonetization } from './readProjectMonetization'; + +export async function writeProjectMonetization(options: { + readonly projectRoot: string; + readonly products: readonly MonetizationProduct[]; +}): Promise { + const products = parseProjectMonetization({ products: options.products }); + const paths = resolveProjectDeploymentPaths(options.projectRoot); + await atomicWriteJson({ + projectRoot: paths.projectRoot, + filePath: paths.productsPath, + value: { products }, + }); + return readProjectMonetization({ projectRoot: paths.projectRoot }); +} diff --git a/src/project/projectAuthoringExports.test.ts b/src/project/projectAuthoringExports.test.ts new file mode 100644 index 0000000..812f936 --- /dev/null +++ b/src/project/projectAuthoringExports.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test'; + +import { + readProjectStoreListing, + removeProjectStoreListingAsset, + removeProjectStoreListingLocale, + writeProjectMonetization, + writeProjectRelease, + writeProjectStoreListingAsset, + writeProjectStoreListingLocale, +} from './index'; + +test('project entrypoint intentionally exposes Deploy authoring owner APIs', () => { + expect(typeof readProjectStoreListing).toBe('function'); + expect(typeof writeProjectStoreListingLocale).toBe('function'); + expect(typeof removeProjectStoreListingLocale).toBe('function'); + expect(typeof writeProjectStoreListingAsset).toBe('function'); + expect(typeof removeProjectStoreListingAsset).toBe('function'); + expect(typeof writeProjectMonetization).toBe('function'); + expect(typeof writeProjectRelease).toBe('function'); +}); diff --git a/src/project/release/ProjectReleaseInput.ts b/src/project/release/ProjectReleaseInput.ts new file mode 100644 index 0000000..135a2c9 --- /dev/null +++ b/src/project/release/ProjectReleaseInput.ts @@ -0,0 +1,3 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; + +export type ProjectReleaseInput = Omit; diff --git a/src/project/release/projectReleaseAuthoring.test.ts b/src/project/release/projectReleaseAuthoring.test.ts new file mode 100644 index 0000000..ac1e726 --- /dev/null +++ b/src/project/release/projectReleaseAuthoring.test.ts @@ -0,0 +1,72 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { expectRejects } from '../expectRejects.test'; +import { readProjectRelease } from './readProjectRelease'; +import { writeProjectRelease } from './writeProjectRelease'; + +test('prepared release authoring validates, normalizes, persists, and revises', async () => { + const root = await createRoot(); + try { + const written = await writeProjectRelease({ + projectRoot: root, + release: { + version: '1.2.3', + targets: ['web', 'ios'], + notes: [{ locale: 'en-us', text: 'First release' }], + rollout: { + web: { mode: 'immediate' }, + ios: { mode: 'staged' }, + }, + }, + }); + + expect(written).toMatchObject({ + version: '1.2.3', + targets: ['ios', 'web'], + notes: [{ locale: 'en-US', text: 'First release' }], + }); + expect(written.revision.length).toBeGreaterThan(0); + expect(await readProjectRelease({ projectRoot: root })).toEqual(written); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('invalid prepared release is rejected before replacing canonical state', async () => { + const root = await createRoot(); + try { + const initial = await writeProjectRelease({ + projectRoot: root, + release: { + version: '1.0.0', + targets: ['web'], + notes: [], + rollout: { web: { mode: 'immediate' } }, + }, + }); + await expectRejects( + writeProjectRelease({ + projectRoot: root, + release: { + version: 'v2', + targets: ['web'], + notes: [], + rollout: { web: { mode: 'immediate' } }, + }, + }), + 'RELEASE_INVALID', + ); + + expect(await readProjectRelease({ projectRoot: root })).toEqual(initial); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +async function createRoot(): Promise { + return fs.mkdtemp(path.join(tmpdir(), 'ankh-deploy-release-authoring-')); +} diff --git a/src/project/release/writeProjectRelease.ts b/src/project/release/writeProjectRelease.ts new file mode 100644 index 0000000..9655b0b --- /dev/null +++ b/src/project/release/writeProjectRelease.ts @@ -0,0 +1,20 @@ +import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState'; +import { atomicWriteJson } from '../io/atomicWriteJson'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import { parseProjectRelease } from './parseProjectRelease'; +import type { ProjectReleaseInput } from './ProjectReleaseInput'; +import { readProjectRelease } from './readProjectRelease'; + +export async function writeProjectRelease(options: { + readonly projectRoot: string; + readonly release: ProjectReleaseInput; +}): Promise { + const release = parseProjectRelease(options.release); + const paths = resolveProjectDeploymentPaths(options.projectRoot); + await atomicWriteJson({ + projectRoot: paths.projectRoot, + filePath: paths.releasePath, + value: release, + }); + return readProjectRelease({ projectRoot: paths.projectRoot }); +} diff --git a/src/project/storeListing/ProjectStoreListingAssetLocation.ts b/src/project/storeListing/ProjectStoreListingAssetLocation.ts new file mode 100644 index 0000000..7ee78c3 --- /dev/null +++ b/src/project/storeListing/ProjectStoreListingAssetLocation.ts @@ -0,0 +1,12 @@ +export type ProjectStoreListingAssetLocation = + | { + readonly kind: 'android-shared'; + readonly variant: 'icon' | 'feature'; + } + | { + readonly kind: 'screenshot'; + readonly target: 'android' | 'ios'; + readonly locale: string; + readonly variant: string; + readonly filename: string; + }; diff --git a/src/project/storeListing/ResolvedProjectStoreListingAssetLocation.ts b/src/project/storeListing/ResolvedProjectStoreListingAssetLocation.ts new file mode 100644 index 0000000..b84da69 --- /dev/null +++ b/src/project/storeListing/ResolvedProjectStoreListingAssetLocation.ts @@ -0,0 +1,6 @@ +export interface ResolvedProjectStoreListingAssetLocation { + readonly filePath: string; + readonly filename: string; + readonly locale?: string; + readonly cleanupDirectories: readonly string[]; +} diff --git a/src/project/storeListing/assertStoreListingAssetBytes.ts b/src/project/storeListing/assertStoreListingAssetBytes.ts new file mode 100644 index 0000000..7929885 --- /dev/null +++ b/src/project/storeListing/assertStoreListingAssetBytes.ts @@ -0,0 +1,30 @@ +export function assertStoreListingAssetBytes(filename: string, data: Uint8Array): void { + const lower = filename.toLowerCase(); + if (lower.endsWith('.png')) { + if (!isPng(data)) throw new Error('STORE_LISTING_ASSET_TYPE_UNSUPPORTED'); + return; + } + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) { + if (!isJpeg(data)) throw new Error('STORE_LISTING_ASSET_TYPE_UNSUPPORTED'); + return; + } + throw new Error('STORE_LISTING_ASSET_TYPE_UNSUPPORTED'); +} + +function isPng(data: Uint8Array): boolean { + return ( + data.length >= 8 && + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 && + data[4] === 0x0d && + data[5] === 0x0a && + data[6] === 0x1a && + data[7] === 0x0a + ); +} + +function isJpeg(data: Uint8Array): boolean { + return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; +} diff --git a/src/project/storeListing/assertStoreListingAssetLocale.ts b/src/project/storeListing/assertStoreListingAssetLocale.ts new file mode 100644 index 0000000..ee51503 --- /dev/null +++ b/src/project/storeListing/assertStoreListingAssetLocale.ts @@ -0,0 +1,13 @@ +import { normalizeStoreListingLocale } from '../../domain/storeListing/normalizeStoreListingLocale'; +import { readStoreListingLocales } from './readStoreListingLocales'; + +export async function assertStoreListingAssetLocale( + listingRoot: string, + localeValue: string, +): Promise { + const locale = normalizeStoreListingLocale(localeValue); + const locales = await readStoreListingLocales(listingRoot); + if (!locales.some((candidate) => candidate.locale === locale)) { + throw new Error('STORE_LISTING_ASSET_LOCALE_UNKNOWN'); + } +} diff --git a/src/project/storeListing/projectStoreListingAuthoring.test.ts b/src/project/storeListing/projectStoreListingAuthoring.test.ts new file mode 100644 index 0000000..59480b3 --- /dev/null +++ b/src/project/storeListing/projectStoreListingAuthoring.test.ts @@ -0,0 +1,167 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { expect, test } from 'bun:test'; + +import { expectRejects } from '../expectRejects.test'; +import { readProjectStoreListing } from './readProjectStoreListing'; +import { removeProjectStoreListingAsset } from './removeProjectStoreListingAsset'; +import { removeProjectStoreListingLocale } from './removeProjectStoreListingLocale'; +import { writeProjectStoreListingAsset } from './writeProjectStoreListingAsset'; +import { writeProjectStoreListingLocale } from './writeProjectStoreListingLocale'; + +test('store listing authoring round-trips normalized locales and preserves siblings', async () => { + const root = await createRoot(); + try { + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'en-us', name: 'English', summary: 'Initial' }, + }); + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'de-CH', name: 'Deutsch' }, + }); + const updated = await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'en-US', name: 'English', summary: 'Updated' }, + }); + + expect(updated.locales).toEqual([ + { locale: 'de-CH', name: 'Deutsch' }, + { locale: 'en-US', name: 'English', summary: 'Updated' }, + ]); + expect(await exists(path.join(root, 'deploy/listing/en-US.json'))).toBeTrue(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('store listing asset writes are semantic, path-safe, and removable', async () => { + const root = await createRoot(); + try { + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'en-US', name: 'English' }, + }); + const written = await writeProjectStoreListingAsset({ + projectRoot: root, + location: { + kind: 'screenshot', + target: 'ios', + locale: 'en-us', + variant: 'iphone-67', + filename: '01.png', + }, + data: pngBytes(), + }); + + expect(written.assetSets[0]?.assets).toHaveLength(1); + const removed = await removeProjectStoreListingAsset({ + projectRoot: root, + location: { + kind: 'screenshot', + target: 'ios', + locale: 'en-US', + variant: 'iphone-67', + filename: '01.png', + }, + }); + expect(removed.assetSets).toEqual([]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('removing a listing locale also removes its screenshot sets', async () => { + const root = await createRoot(); + try { + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'en-US', name: 'English' }, + }); + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'de-CH', name: 'Deutsch' }, + }); + await writeProjectStoreListingAsset({ + projectRoot: root, + location: { + kind: 'screenshot', + target: 'android', + locale: 'en-US', + variant: 'phone', + filename: '01.jpg', + }, + data: jpegBytes(), + }); + + const result = await removeProjectStoreListingLocale({ projectRoot: root, locale: 'en-us' }); + expect(result.locales).toEqual([{ locale: 'de-CH', name: 'Deutsch' }]); + expect(result.assetSets).toEqual([]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('store listing asset authoring rejects traversal and unsupported bytes', async () => { + const root = await createRoot(); + try { + await writeProjectStoreListingLocale({ + projectRoot: root, + locale: { locale: 'en-US', name: 'English' }, + }); + await expectRejects( + writeProjectStoreListingAsset({ + projectRoot: root, + location: { + kind: 'screenshot', + target: 'android', + locale: 'en-US', + variant: '../escape', + filename: '01.png', + }, + data: pngBytes(), + }), + 'STORE_LISTING_ASSET_PATH_INVALID', + ); + await expectRejects( + writeProjectStoreListingAsset({ + projectRoot: root, + location: { + kind: 'screenshot', + target: 'android', + locale: 'en-US', + variant: 'phone', + filename: 'fake.png', + }, + data: jpegBytes(), + }), + 'STORE_LISTING_ASSET_TYPE_UNSUPPORTED', + ); + expect((await readProjectStoreListing({ projectRoot: root })).assetSets).toEqual([]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +async function createRoot(): Promise { + return fs.mkdtemp(path.join(tmpdir(), 'ankh-deploy-listing-authoring-')); +} + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function pngBytes(): Uint8Array { + return Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]); +} + +function jpegBytes(): Uint8Array { + return Uint8Array.from([0xff, 0xd8, 0xff, 0x00]); +} diff --git a/src/project/storeListing/removeEmptyStoreListingDirectories.ts b/src/project/storeListing/removeEmptyStoreListingDirectories.ts new file mode 100644 index 0000000..68eac1f --- /dev/null +++ b/src/project/storeListing/removeEmptyStoreListingDirectories.ts @@ -0,0 +1,33 @@ +import { promises as fs } from 'node:fs'; + +import { assertContainedWritePath } from '../io/assertContainedPath'; +import { isMissingPathError } from '../io/isMissingPathError'; + +export async function removeEmptyStoreListingDirectories( + projectRoot: string, + directories: readonly string[], +): Promise { + for (const directory of directories) { + if (!(await isEmpty(directory))) continue; + await assertContainedWritePath(projectRoot, directory); + await removeEmptyDirectory(directory); + } +} + +async function isEmpty(directory: string): Promise { + try { + return (await fs.readdir(directory)).length === 0; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +async function removeEmptyDirectory(directory: string): Promise { + try { + await fs.rmdir(directory); + } catch (error) { + if (isMissingPathError(error)) return; + throw error; + } +} diff --git a/src/project/storeListing/removeProjectStoreListingAsset.ts b/src/project/storeListing/removeProjectStoreListingAsset.ts new file mode 100644 index 0000000..6a39424 --- /dev/null +++ b/src/project/storeListing/removeProjectStoreListingAsset.ts @@ -0,0 +1,21 @@ +import { removeContainedPath } from '../io/removeContainedPath'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import type { ProjectStoreListing } from './ProjectStoreListing'; +import type { ProjectStoreListingAssetLocation } from './ProjectStoreListingAssetLocation'; +import { readProjectStoreListing } from './readProjectStoreListing'; +import { removeEmptyStoreListingDirectories } from './removeEmptyStoreListingDirectories'; +import { resolveProjectStoreListingAssetLocation } from './resolveProjectStoreListingAssetLocation'; + +export async function removeProjectStoreListingAsset(options: { + readonly projectRoot: string; + readonly location: ProjectStoreListingAssetLocation; +}): Promise { + const paths = resolveProjectDeploymentPaths(options.projectRoot); + const resolved = resolveProjectStoreListingAssetLocation(paths, options.location); + await removeContainedPath({ + projectRoot: paths.projectRoot, + targetPath: resolved.filePath, + }); + await removeEmptyStoreListingDirectories(paths.projectRoot, resolved.cleanupDirectories); + return readProjectStoreListing({ projectRoot: paths.projectRoot }); +} diff --git a/src/project/storeListing/removeProjectStoreListingLocale.ts b/src/project/storeListing/removeProjectStoreListingLocale.ts new file mode 100644 index 0000000..c3bec75 --- /dev/null +++ b/src/project/storeListing/removeProjectStoreListingLocale.ts @@ -0,0 +1,32 @@ +import path from 'node:path'; + +import { normalizeStoreListingLocale } from '../../domain/storeListing/normalizeStoreListingLocale'; +import { removeContainedPath } from '../io/removeContainedPath'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import type { ProjectStoreListing } from './ProjectStoreListing'; +import { readProjectStoreListing } from './readProjectStoreListing'; + +export async function removeProjectStoreListingLocale(options: { + readonly projectRoot: string; + readonly locale: string; +}): Promise { + const locale = normalizeStoreListingLocale(options.locale); + const paths = resolveProjectDeploymentPaths(options.projectRoot); + await Promise.all([ + removeContainedPath({ + projectRoot: paths.projectRoot, + targetPath: path.join(paths.listingRoot, `${locale}.json`), + }), + removeContainedPath({ + projectRoot: paths.projectRoot, + targetPath: path.join(paths.androidScreenshotsRoot, locale), + recursive: true, + }), + removeContainedPath({ + projectRoot: paths.projectRoot, + targetPath: path.join(paths.iosScreenshotsRoot, locale), + recursive: true, + }), + ]); + return readProjectStoreListing({ projectRoot: paths.projectRoot }); +} diff --git a/src/project/storeListing/resolveProjectStoreListingAssetLocation.ts b/src/project/storeListing/resolveProjectStoreListingAssetLocation.ts new file mode 100644 index 0000000..848fc29 --- /dev/null +++ b/src/project/storeListing/resolveProjectStoreListingAssetLocation.ts @@ -0,0 +1,47 @@ +import path from 'node:path'; + +import { normalizeStoreListingLocale } from '../../domain/storeListing/normalizeStoreListingLocale'; +import type { ProjectDeploymentPaths } from '../ProjectDeploymentPaths'; +import type { ProjectStoreListingAssetLocation } from './ProjectStoreListingAssetLocation'; +import type { ResolvedProjectStoreListingAssetLocation } from './ResolvedProjectStoreListingAssetLocation'; + +export function resolveProjectStoreListingAssetLocation( + paths: ProjectDeploymentPaths, + location: ProjectStoreListingAssetLocation, +): ResolvedProjectStoreListingAssetLocation { + if (location.kind === 'android-shared') { + const filename = location.variant === 'icon' ? 'icon.png' : 'feature.png'; + return { + filePath: path.join(paths.androidAssetsRoot, filename), + filename, + cleanupDirectories: [], + }; + } + + const locale = normalizeStoreListingLocale(location.locale); + assertPathSegment(location.variant); + assertImageFilename(location.filename); + const root = + location.target === 'android' ? paths.androidScreenshotsRoot : paths.iosScreenshotsRoot; + const localeRoot = path.join(root, locale); + const variantRoot = path.join(localeRoot, location.variant); + return { + filePath: path.join(variantRoot, location.filename), + filename: location.filename, + locale, + cleanupDirectories: [variantRoot, localeRoot], + }; +} + +function assertPathSegment(value: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value) || value === '.' || value === '..') { + throw new Error('STORE_LISTING_ASSET_PATH_INVALID'); + } +} + +function assertImageFilename(value: string): void { + assertPathSegment(value); + if (!/\.(png|jpe?g)$/i.test(value)) { + throw new Error('STORE_LISTING_ASSET_TYPE_UNSUPPORTED'); + } +} diff --git a/src/project/storeListing/writeProjectStoreListingAsset.ts b/src/project/storeListing/writeProjectStoreListingAsset.ts new file mode 100644 index 0000000..704b15c --- /dev/null +++ b/src/project/storeListing/writeProjectStoreListingAsset.ts @@ -0,0 +1,27 @@ +import { atomicWriteFile } from '../io/atomicWriteFile'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import { assertStoreListingAssetBytes } from './assertStoreListingAssetBytes'; +import { assertStoreListingAssetLocale } from './assertStoreListingAssetLocale'; +import type { ProjectStoreListing } from './ProjectStoreListing'; +import type { ProjectStoreListingAssetLocation } from './ProjectStoreListingAssetLocation'; +import { readProjectStoreListing } from './readProjectStoreListing'; +import { resolveProjectStoreListingAssetLocation } from './resolveProjectStoreListingAssetLocation'; + +export async function writeProjectStoreListingAsset(options: { + readonly projectRoot: string; + readonly location: ProjectStoreListingAssetLocation; + readonly data: Uint8Array; +}): Promise { + const paths = resolveProjectDeploymentPaths(options.projectRoot); + const resolved = resolveProjectStoreListingAssetLocation(paths, options.location); + if (resolved.locale !== undefined) { + await assertStoreListingAssetLocale(paths.listingRoot, resolved.locale); + } + assertStoreListingAssetBytes(resolved.filename, options.data); + await atomicWriteFile({ + projectRoot: paths.projectRoot, + filePath: resolved.filePath, + data: options.data, + }); + return readProjectStoreListing({ projectRoot: paths.projectRoot }); +} diff --git a/src/project/storeListing/writeProjectStoreListingLocale.ts b/src/project/storeListing/writeProjectStoreListingLocale.ts new file mode 100644 index 0000000..c58f68a --- /dev/null +++ b/src/project/storeListing/writeProjectStoreListingLocale.ts @@ -0,0 +1,22 @@ +import path from 'node:path'; + +import type { StoreListingLocale } from '../../domain/storeListing/StoreListingLocale'; +import { atomicWriteJson } from '../io/atomicWriteJson'; +import { resolveProjectDeploymentPaths } from '../resolveProjectDeploymentPaths'; +import { parseStoreListingLocale } from './parseStoreListingLocale'; +import type { ProjectStoreListing } from './ProjectStoreListing'; +import { readProjectStoreListing } from './readProjectStoreListing'; + +export async function writeProjectStoreListingLocale(options: { + readonly projectRoot: string; + readonly locale: StoreListingLocale; +}): Promise { + const normalized = parseStoreListingLocale(options.locale, options.locale.locale); + const paths = resolveProjectDeploymentPaths(options.projectRoot); + await atomicWriteJson({ + projectRoot: paths.projectRoot, + filePath: path.join(paths.listingRoot, `${normalized.locale}.json`), + value: normalized, + }); + return readProjectStoreListing({ projectRoot: paths.projectRoot }); +}