Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-deploy-authoring.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions src/project/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand All @@ -59,13 +62,15 @@ 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';
export type { ProjectReleaseWebContext } from './release/ProjectReleaseWebContext';
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';
Expand All @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/project/io/removeContainedPath.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await assertContainedWritePath(options.projectRoot, options.targetPath);
await fs.rm(options.targetPath, {
force: true,
recursive: options.recursive ?? false,
});
}
76 changes: 76 additions & 0 deletions src/project/monetization/projectMonetizationAuthoring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return fs.mkdtemp(path.join(tmpdir(), 'ankh-deploy-monetization-authoring-'));
}
20 changes: 20 additions & 0 deletions src/project/monetization/writeProjectMonetization.ts
Original file line number Diff line number Diff line change
@@ -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<MonetizationDesiredState> {
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 });
}
21 changes: 21 additions & 0 deletions src/project/projectAuthoringExports.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
3 changes: 3 additions & 0 deletions src/project/release/ProjectReleaseInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { ReleaseDesiredState } from '../../domain/release/ReleaseDesiredState';

export type ProjectReleaseInput = Omit<ReleaseDesiredState, 'revision'>;
72 changes: 72 additions & 0 deletions src/project/release/projectReleaseAuthoring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return fs.mkdtemp(path.join(tmpdir(), 'ankh-deploy-release-authoring-'));
}
20 changes: 20 additions & 0 deletions src/project/release/writeProjectRelease.ts
Original file line number Diff line number Diff line change
@@ -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<ReleaseDesiredState> {
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 });
}
12 changes: 12 additions & 0 deletions src/project/storeListing/ProjectStoreListingAssetLocation.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface ResolvedProjectStoreListingAssetLocation {
readonly filePath: string;
readonly filename: string;
readonly locale?: string;
readonly cleanupDirectories: readonly string[];
}
30 changes: 30 additions & 0 deletions src/project/storeListing/assertStoreListingAssetBytes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/project/storeListing/assertStoreListingAssetLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { normalizeStoreListingLocale } from '../../domain/storeListing/normalizeStoreListingLocale';
import { readStoreListingLocales } from './readStoreListingLocales';

export async function assertStoreListingAssetLocale(
listingRoot: string,
localeValue: string,
): Promise<void> {
const locale = normalizeStoreListingLocale(localeValue);
const locales = await readStoreListingLocales(listingRoot);
if (!locales.some((candidate) => candidate.locale === locale)) {
throw new Error('STORE_LISTING_ASSET_LOCALE_UNKNOWN');
}
}
Loading
Loading