diff --git a/TARGETS.md b/TARGETS.md index 2b7f76d1..fefba8f2 100644 --- a/TARGETS.md +++ b/TARGETS.md @@ -41,7 +41,7 @@ Each row is a **target adapter** — one plugin in `packages/targets/*`. Status | `mobile-android` | Google Play + internal tracks | 🚧 | | `pkg-fdroid` | F-Droid (FOSS Android repo) | ✅ | | `mobile-huawei` | Huawei AppGallery | — | -| `mobile-amazon` | Amazon Appstore | — | +| `mobile-amazon` | Amazon Appstore (phones & tablets) | ✅ | ### Wearable | Target id | Channel | Status | diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index 6f78ff5d..9a6231a2 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -162,7 +162,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'targets', pkgPrefix: '@profullstack/sh1pt-target', description: 'Distribution targets — stores, registries, CDNs, deploy platforms', - adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], + adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-amazon', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], }, { id: 'vcs', diff --git a/packages/targets/mobile-amazon/README.md b/packages/targets/mobile-amazon/README.md new file mode 100644 index 00000000..0ac61826 --- /dev/null +++ b/packages/targets/mobile-amazon/README.md @@ -0,0 +1,43 @@ +# Amazon Appstore (Android phones & tablets) + +Provides the Amazon Appstore (Android phones & tablets) sh1pt target adapter for build, publish, or deployment workflows. + +## What it does + +- Registers a target surface that sh1pt can build, publish, or deploy to. +- Provides adapter metadata and lifecycle hooks used by the CLI. +- Includes setup guidance for required credentials or provider configuration. +- Implements build behavior used by sh1pt target workflows. + +## Package + +- Name: `@profullstack/sh1pt-target-mobile-amazon` +- Path: `packages/targets/mobile-amazon` +- Adapter ID: `mobile-amazon` +- Homepage: https://sh1pt.com + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-target-mobile-amazon +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-target-mobile-amazon typecheck +``` + +Run tests from the repository root when this module includes a test file: + +```bash +pnpm vitest run packages/targets/mobile-amazon/src/index.test.ts +``` + + diff --git a/packages/targets/mobile-amazon/package.json b/packages/targets/mobile-amazon/package.json new file mode 100644 index 00000000..85e17f70 --- /dev/null +++ b/packages/targets/mobile-amazon/package.json @@ -0,0 +1,25 @@ +{ + "name": "@profullstack/sh1pt-target-mobile-amazon", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/targets/mobile-amazon" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ] +} diff --git a/packages/targets/mobile-amazon/src/index.test.ts b/packages/targets/mobile-amazon/src/index.test.ts new file mode 100644 index 00000000..800f5b1d --- /dev/null +++ b/packages/targets/mobile-amazon/src/index.test.ts @@ -0,0 +1,141 @@ +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'mobile', requireKind: true }); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('Amazon Appstore (Android) package planning', () => { + it('writes an inspectable package plan with phone/tablet manifest requirements', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-amazon-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + version: '2.1.0', + channel: 'stable', + }) as any, { + packageName: 'com.acme.app', + appSku: 'ACMEANDROID', + }); + + const planFile = join(outDir, 'amazon-appstore-package-plan.json'); + expect(result.artifact).toBe(join(outDir, 'amazon', 'com.acme.app.apk')); + expect(result.meta?.planFile).toBe(planFile); + expect(result.meta?.deviceTargeting).toBe('phone-and-tablet'); + + // build() must create the artifact's directory so the downstream APK build + // can write to the reported path. + expect((await stat(join(outDir, 'amazon'))).isDirectory()).toBe(true); + + const plan = JSON.parse(await readFile(planFile, 'utf-8')) as { + appSku: string; + packageName: string; + version: string; + artifact: string; + deviceTargeting: string; + manifestChecks: Array<{ requirement: string; required: boolean }>; + commands: string[]; + }; + + expect(plan.appSku).toBe('ACMEANDROID'); + expect(plan.packageName).toBe('com.acme.app'); + expect(plan.version).toBe('2.1.0'); + expect(plan.artifact).toBe(result.artifact); + expect(plan.deviceTargeting).toBe('phone-and-tablet'); + expect(plan.manifestChecks).toEqual(expect.arrayContaining([ + expect.objectContaining({ + requirement: 'category android:name="android.intent.category.LAUNCHER"', + required: true, + }), + expect.objectContaining({ + requirement: 'uses-feature android:name="android.hardware.touchscreen" android:required="true"', + required: true, + }), + expect.objectContaining({ + requirement: 'no hard dependency on com.google.android.gms (Play Services)', + required: true, + }), + ])); + // Scope boundary: Fire TV / Firestick is tv-firetv's job, so no leanback check here. + expect(JSON.stringify(plan.manifestChecks)).not.toContain('leanback'); + expect(plan.commands).toContain('./gradlew :app:assembleRelease'); + }); + + it('honors explicit phone-only targeting in dry-run shipping', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-amazon-')); + tempDirs.push(outDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + version: '2.1.0', + }) as any, { + packageName: 'com.acme.app', + appSku: 'ACMEANDROID', + apkPath: 'dist/amazon.apk', + deviceTargeting: 'phone-only', + }); + + expect(result.artifact).toBe('dist/amazon.apk'); + expect(result.meta?.deviceTargeting).toBe('phone-only'); + + const ship = await adapter.ship(fakeShipContext({ + artifact: 'dist/amazon.apk', + dryRun: true, + }) as any, { + packageName: 'com.acme.app', + appSku: 'ACMEANDROID', + apkPath: 'dist/amazon.apk', + deviceTargeting: 'phone-only', + }); + + expect(ship).toEqual({ + id: 'dry-run', + meta: { + appSku: 'ACMEANDROID', + packageName: 'com.acme.app', + artifact: 'dist/amazon.apk', + deviceTargeting: 'phone-only', + commands: [ + 'amazon-appstore edits.create appSku=ACMEANDROID', + 'amazon-appstore apk.upload artifact=dist/amazon.apk', + 'amazon-appstore targeting.update device=phone-only', + 'amazon-appstore edits.submit', + ], + }, + }); + }); + + it('keeps the reported artifact and the upload command in sync without apkPath', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-amazon-')); + tempDirs.push(outDir); + + // No apkPath: the artifact is derived under outDir. ship() must report the + // artifact it was actually handed (ctx.artifact) AND reference that exact + // path in the upload command — they must not diverge. + const built = await adapter.build(fakeBuildContext({ outDir, version: '2.1.0' }) as any, { + packageName: 'com.acme.app', + appSku: 'ACMEANDROID', + }); + + const ship = await adapter.ship(fakeShipContext({ + outDir, + artifact: built.artifact, + dryRun: true, + }) as any, { + packageName: 'com.acme.app', + appSku: 'ACMEANDROID', + }); + + expect(ship.meta?.artifact).toBe(built.artifact); + expect(ship.meta?.commands).toContain(`amazon-appstore apk.upload artifact=${built.artifact}`); + }); +}); diff --git a/packages/targets/mobile-amazon/src/index.ts b/packages/targets/mobile-amazon/src/index.ts new file mode 100644 index 00000000..1e5ffdf3 --- /dev/null +++ b/packages/targets/mobile-amazon/src/index.ts @@ -0,0 +1,121 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; + +interface Config { + packageName: string; + appSku: string; + apkPath?: string; + deviceTargeting?: 'phone-only' | 'phone-and-tablet'; +} + +const PLAN_FILE = 'amazon-appstore-package-plan.json'; + +function artifactPath(ctx: { outDir: string }, config: Config): string { + return config.apkPath ?? join(ctx.outDir, 'amazon', `${config.packageName}.apk`); +} + +function targeting(config: Config): NonNullable { + return config.deviceTargeting ?? 'phone-and-tablet'; +} + +function buildPlan(ctx: { outDir: string; version: string; channel: string }, config: Config, artifactOverride?: string) { + const artifact = artifactOverride ?? artifactPath(ctx, config); + const deviceTargeting = targeting(config); + return { + packageName: config.packageName, + appSku: config.appSku, + version: ctx.version, + channel: ctx.channel, + artifact, + deviceTargeting, + planFile: join(ctx.outDir, PLAN_FILE), + manifestChecks: [ + { + path: 'AndroidManifest.xml', + requirement: 'category android:name="android.intent.category.LAUNCHER"', + required: true, + }, + { + path: 'AndroidManifest.xml', + requirement: 'uses-feature android:name="android.hardware.touchscreen" android:required="true"', + required: true, + }, + { + // Amazon rejects APKs that hard-require Google Play Services on the + // generic phone/tablet track — surface it as a check, not a silent fail. + path: 'AndroidManifest.xml', + requirement: 'no hard dependency on com.google.android.gms (Play Services)', + required: true, + }, + ], + commands: [ + './gradlew :app:assembleRelease', + `amazon-appstore edits.create appSku=${config.appSku}`, + `amazon-appstore apk.upload artifact=${artifact}`, + `amazon-appstore targeting.update device=${deviceTargeting}`, + 'amazon-appstore edits.submit', + ], + }; +} + +export default defineTarget({ + id: 'mobile-amazon', + kind: 'mobile', + label: 'Amazon Appstore (Android phones & tablets)', + async build(ctx, config) { + const plan = buildPlan(ctx, config); + ctx.log(`amazon plan ${config.appSku} -> ${plan.deviceTargeting}`); + await mkdir(ctx.outDir, { recursive: true }); + // Ensure the artifact's directory exists (e.g. /amazon/) so the + // downstream APK build can write to the reported artifact path. + await mkdir(dirname(plan.artifact), { recursive: true }); + await writeFile(plan.planFile, `${JSON.stringify(plan, null, 2)}\n`, 'utf-8'); + return { + artifact: plan.artifact, + meta: { + planFile: plan.planFile, + deviceTargeting: plan.deviceTargeting, + manifestChecks: plan.manifestChecks, + }, + }; + }, + async ship(ctx, config) { + // Build the plan against the artifact actually handed to ship() so the + // upload command and the reported artifact can't diverge when apkPath is + // omitted and ship's outDir differs from build's. + const plan = buildPlan(ctx, config, ctx.artifact); + ctx.log(`upload to Amazon Appstore sku=${config.appSku}`); + if (ctx.dryRun) { + return { + id: 'dry-run', + meta: { + appSku: config.appSku, + packageName: config.packageName, + artifact: plan.artifact, + deviceTargeting: plan.deviceTargeting, + commands: plan.commands.slice(1), + }, + }; + } + // TODO: Amazon App Submission API (create edit -> upload APK -> submit) + return { + id: `${config.appSku}@${ctx.version}`, + url: `https://www.amazon.com/gp/product/${config.appSku}`, + }; + }, + async status(id) { + return { state: 'in-review', version: id }; + }, + + setup: manualSetup({ + label: 'Amazon Appstore (Android) — App Submission API, shared with tv-firetv', + vendorDocUrl: 'https://developer.amazon.com/docs/app-submission-api/overview.html', + steps: [ + 'Open developer.amazon.com/apps-and-games and register for the Amazon Appstore.', + 'Generate App Submission API credentials in Account Settings -> Security.', + 'Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_ID (same credentials as tv-firetv)', + 'Run: sh1pt secret set AMAZON_APPSTORE_CLIENT_SECRET ', + ], + }), +}); diff --git a/packages/targets/mobile-amazon/tsconfig.json b/packages/targets/mobile-amazon/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/targets/mobile-amazon/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5378866..98474322 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1754,6 +1754,12 @@ importers: specifier: workspace:* version: link:../../core + packages/targets/mobile-amazon: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/targets/mobile-android: dependencies: '@profullstack/sh1pt-core':