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-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ankhorage/deploy': minor
---

Add the first-class `@ankhorage/deploy/cli` provider for category-root `ankh deploy` and `ankh plan deploy`, with dry-run planning, explicit runtime release context, host-owned confirmation, transient ENV credential mapping and offline safety coverage.
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.8.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.9.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
27 changes: 25 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion knip.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createKnipConfig } from '@ankhorage/devtools/knip';

export default createKnipConfig({
entry: ['src/index.ts', 'src/project/index.ts'],
entry: ['src/index.ts', 'src/project/index.ts', 'src/cli/index.ts'],
ignoreFiles: [
'.prettierrc.js',
'eslint.config.mjs',
Expand Down
20 changes: 15 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ankhorage/deploy",
"version": "0.9.0",
"description": "Declarative deployment engine for Expo apps across web, iOS, and Android build, provision, publish, and manage releases from one configuration.",
"description": "Declarative deployment engine for Expo apps across web, iOS, and Android \u2014 build, provision, publish, and manage releases from one configuration.",
"license": "MIT",
"publishConfig": {
"access": "public"
Expand Down Expand Up @@ -42,7 +42,12 @@
"import": "./dist/project/index.js",
"default": "./dist/project/index.js"
},
"./package.json": "./package.json"
"./package.json": "./package.json",
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js",
"default": "./dist/cli/index.js"
}
},
"files": [
"dist",
Expand All @@ -52,8 +57,12 @@
],
"ankh": {
"category": "deploy",
"provider": null,
"capabilities": []
"provider": "./dist/cli/index.js",
"capabilities": [
"deploy.inspect",
"deploy.plan",
"deploy.execute"
]
},
"scripts": {
"build": "rm -rf dist tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo && bun x tsc -p tsconfig.build.json",
Expand All @@ -71,10 +80,11 @@
"prepack": "bun run build"
},
"dependencies": {
"@ankhorage/contracts": "^7.5.0",
"@ankhorage/contracts": "^7.9.0",
"google-auth-library": "^10.9.1"
},
"devDependencies": {
"@ankhorage/ankh": "^0.8.0",
"@ankhorage/devtools": "^1.4.1",
"@ankhorage/paradox": "^0.1.21",
"@changesets/cli": "^2.31.0",
Expand Down
12 changes: 12 additions & 0 deletions src/cli/DeployCliEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Transient environment inputs understood by `ankh deploy`.
*
* Google Play expects the complete service-account JSON string.
* App Store Connect expects JSON containing keyId, issuerId and privateKey.
* EAS expects the raw Expo access token.
*/
export const DEPLOY_CLI_ENVIRONMENT = {
googlePlayServiceAccountJson: 'ANKH_DEPLOY_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON',
appStoreConnectApiKeyJson: 'ANKH_DEPLOY_APP_STORE_CONNECT_API_KEY_JSON',
easToken: 'ANKH_DEPLOY_EAS_TOKEN',
} as const;
6 changes: 6 additions & 0 deletions src/cli/DeployCliInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { AnkhCommandContext } from '@ankhorage/ankh';

export interface DeployCliInput {
readonly argv: readonly string[];
readonly context: AnkhCommandContext;
}
14 changes: 14 additions & 0 deletions src/cli/DeployCliOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { AndroidDeploymentTrack } from '../index.js';

export interface DeployCliOptions {
readonly projectRoot: string;
readonly dryRun: boolean;
readonly yes: boolean;
readonly format: 'human' | 'json';
readonly executionId?: string;
readonly androidTrack?: AndroidDeploymentTrack;
readonly androidBuildProfile?: string;
readonly iosBuildProfile?: string;
readonly webAlias?: string;
readonly webEnvironment?: string;
}
19 changes: 19 additions & 0 deletions src/cli/DeployCliRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReleasePlan } from '../index.js';
import type {
ExecuteProjectReleaseOptions,
InspectProjectReleaseOptions,
ProjectReleaseExecutionResult,
ProjectReleaseInspection,
ProjectReleaseInspectionResult,
} from '../project/index.js';

export interface DeployCliRuntime {
inspectProjectRelease(
options: InspectProjectReleaseOptions,
): Promise<ProjectReleaseInspectionResult>;
createProjectReleasePlan(inspection: ProjectReleaseInspection): ReleasePlan;
executeProjectRelease(
options: ExecuteProjectReleaseOptions,
): Promise<ProjectReleaseExecutionResult>;
createExecutionId(): string;
}
26 changes: 26 additions & 0 deletions src/cli/cliPackage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { promises as fs } from 'node:fs';

import { expect, test } from 'bun:test';

test('package exposes and registers the Deploy CLI provider', async () => {
const packageUrl = new URL('../../package.json', import.meta.url);
const parsed = JSON.parse(await fs.readFile(packageUrl, 'utf8')) as {
readonly exports?: Record<string, unknown>;
readonly ankh?: {
readonly category?: string;
readonly provider?: string | null;
readonly capabilities?: readonly string[];
};
};

expect(parsed.exports?.['./cli']).toEqual({
types: './dist/cli/index.d.ts',
import: './dist/cli/index.js',
default: './dist/cli/index.js',
});
expect(parsed.ankh).toEqual({
category: 'deploy',
provider: './dist/cli/index.js',
capabilities: ['deploy.inspect', 'deploy.plan', 'deploy.execute'],
});
});
85 changes: 85 additions & 0 deletions src/cli/createDeployCliAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { DeploymentCredentialReference } from '../index.js';
import type { ProjectReleaseAccess } from '../project/index.js';
import { DEPLOY_CLI_ENVIRONMENT } from './DeployCliEnvironment.js';
import type { DeployCliOptions } from './DeployCliOptions.js';

export function createDeployCliAccess(
options: DeployCliOptions,
env: Readonly<Record<string, string | undefined>>,
): ProjectReleaseAccess {
const credentials: DeploymentCredentialReference[] = [];
const secrets = new Map<string, string>();
addSecret(credentials, secrets, env, {
envName: DEPLOY_CLI_ENVIRONMENT.googlePlayServiceAccountJson,
id: 'env:google-play-service-account',
provider: 'google-play',
kind: 'service-account',
});
addSecret(credentials, secrets, env, {
envName: DEPLOY_CLI_ENVIRONMENT.appStoreConnectApiKeyJson,
id: 'env:app-store-connect-api-key',
provider: 'app-store-connect',
kind: 'api-key',
});
addSecret(credentials, secrets, env, {
envName: DEPLOY_CLI_ENVIRONMENT.easToken,
id: 'env:eas-token',
provider: 'eas',
kind: 'expo-token',
});

return {
credentials,
resolveSecret: (reference) => Promise.resolve(secrets.get(reference.id) ?? null),
...androidAccess(options),
...iosAccess(options),
...webAccess(options),
};
}

interface SecretInput {
readonly envName: string;
readonly id: string;
readonly provider: string;
readonly kind: string;
}

function addSecret(
credentials: DeploymentCredentialReference[],
secrets: Map<string, string>,
env: Readonly<Record<string, string | undefined>>,
input: SecretInput,
): void {
const value = env[input.envName]?.trim();
if (value === undefined || value.length === 0) return;
credentials.push({ id: input.id, provider: input.provider, kind: input.kind });
secrets.set(input.id, value);
}

function androidAccess(options: DeployCliOptions): Pick<ProjectReleaseAccess, 'android'> {
if (options.androidTrack === undefined) return {};
return {
android: {
track: options.androidTrack,
...(options.androidBuildProfile === undefined
? {}
: { buildProfile: options.androidBuildProfile }),
},
};
}

function iosAccess(options: DeployCliOptions): Pick<ProjectReleaseAccess, 'ios'> {
return options.iosBuildProfile === undefined
? {}
: { ios: { buildProfile: options.iosBuildProfile } };
}

function webAccess(options: DeployCliOptions): Pick<ProjectReleaseAccess, 'web'> {
if (options.webAlias === undefined && options.webEnvironment === undefined) return {};
return {
web: {
...(options.webAlias === undefined ? {} : { alias: options.webAlias }),
...(options.webEnvironment === undefined ? {} : { environment: options.webEnvironment }),
},
};
}
41 changes: 41 additions & 0 deletions src/cli/createDeployCliProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { AnkhRuntimeCommandProvider } from '@ankhorage/ankh';

import packageJson from '../../package.json';
import type { DeployCliRuntime } from './DeployCliRuntime.js';
import { handleDeployCliCommand } from './handleDeployCliCommand.js';
import { handleDeployCliPlan } from './handleDeployCliPlan.js';

export function createDeployCliProvider(runtime: DeployCliRuntime): AnkhRuntimeCommandProvider {
const command = {
path: [],
capability: 'deploy.execute',
summary: 'Inspect, plan and execute the authored project release',
examples: [
'ankh deploy',
'ankh deploy --dry-run',
'ankh deploy --yes --android-track production',
],
} as const;

return {
id: packageJson.name,
category: 'deploy',
version: packageJson.version,
capabilities: ['deploy.inspect', 'deploy.plan', 'deploy.execute'],
commands: [command],
handlers: [
{
path: [],
handler: (request) =>
handleDeployCliCommand({ argv: request.argv, context: request.context }, runtime),
},
],
planningHandlers: [
{
path: [],
handler: (request) =>
handleDeployCliPlan({ argv: request.argv, context: request.context }, runtime),
},
],
};
}
17 changes: 17 additions & 0 deletions src/cli/defaultDeployCliRuntime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { randomUUID } from 'node:crypto';

import {
createProjectReleasePlan,
executeProjectRelease,
inspectProjectRelease,
} from '../project/index.js';
import type { DeployCliRuntime } from './DeployCliRuntime.js';

export const defaultDeployCliRuntime: DeployCliRuntime = {
inspectProjectRelease,
createProjectReleasePlan,
executeProjectRelease,
createExecutionId() {
return `release-${randomUUID()}`;
},
};
Loading
Loading