diff --git a/.changeset/calm-deploy-cli.md b/.changeset/calm-deploy-cli.md new file mode 100644 index 0000000..3ee0c80 --- /dev/null +++ b/.changeset/calm-deploy-cli.md @@ -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. diff --git a/README.md b/README.md index 09468bb..885d6d5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock index 61df69d..40913eb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,11 @@ "": { "name": "@ankhorage/deploy", "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", @@ -19,14 +20,20 @@ }, }, "packages": { + "@ankhorage/ankh": ["@ankhorage/ankh@0.8.0", "", { "dependencies": { "@ankhorage/contracts": "^7.9.0", "@ankhorage/devtools": "^1.3.4", "@ankhorage/doctor": "^0.9.0", "yaml": "^2.8.1" }, "bin": { "ankh": "dist/bin.js" } }, "sha512-qoW5P7VWxVFPw7s+5ZlEkvUQgH4HfzHq/8Sp3NZypLIv1cpsrTeSnCs+a8Rv47Jg0qe4t7sQz1BRxiAHuswosQ=="], + "@ankhorage/color-theory": ["@ankhorage/color-theory@0.0.8", "", { "dependencies": { "culori": "^4.0.2" } }, "sha512-+JRLkvBUiPxFrRANyewRZxLv1sU5cKFOc3VBYB+b/eFngLFDIAqXf/a4e0A6gQE+lk6e3wb3FcGQYIyYB1Dqzg=="], - "@ankhorage/contracts": ["@ankhorage/contracts@7.5.0", "", { "dependencies": { "@ankhorage/color-theory": "^0.0.8" } }, "sha512-Rzastd2rCS37q3ywVDQd1ktFlfGy5Q2puaqwSaiNLLsdMB3t4rRnS1+1XBDFbfBgWu8tk6SFuAj3gF0VkSYbFQ=="], + "@ankhorage/contracts": ["@ankhorage/contracts@7.9.0", "", { "dependencies": { "@ankhorage/color-theory": "^0.0.8" } }, "sha512-a57wumzdovqSjdoFTEnh9g3S7BP41G+BxiUZq2RBQG6+ao07iEEXr/ysjeFPRAf1ftzlx0tZLAFh2XCSyd/2yg=="], "@ankhorage/devtools": ["@ankhorage/devtools@1.4.1", "", { "dependencies": { "@ankhorage/utility": "^0.1.1", "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.5.5", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-native": "^5.0.0", "eslint-plugin-security": "^4.0.1", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.4.1", "knip": "^6.12.2", "prettier": "^3.8.1", "typescript-eslint": "^8.24.0" }, "bin": { "ankhorage-eslint": "dist/cli/bin/eslint.js", "ankhorage-knip": "dist/cli/bin/knip.js", "ankhorage-prettier": "dist/cli/bin/prettier.js" } }, "sha512-8R651ho2ruNhQg7epaPD2iAUnx7332d8ucSShZDz+Kqz6+cwtJHE6Tn87fQhuTBPIt9vS0wAOy0sFKUxPIK+PQ=="], + "@ankhorage/doctor": ["@ankhorage/doctor@0.9.0", "", { "dependencies": { "@ankhorage/contracts": "^7.8.0", "@ankhorage/supabase-auth": "^1.2.0" }, "bin": { "ankhorage-doctor": "dist/cli/standalone.js" } }, "sha512-osfEV89W/VNvlsUFZm6/yc0mFtwjLT7O+7YPzKna46/IW6WXtEmNp8NzwKh/xurJ9jJ+xPixZa7hA2RUk3igWw=="], + "@ankhorage/paradox": ["@ankhorage/paradox@0.1.21", "", { "dependencies": { "ts-morph": "^24.0.0" }, "bin": { "paradox": "dist/cli/standalone.js" } }, "sha512-0M77MTVwAvJrpwcGoeRY+Ab8MhovZG5NGwSNFqYARkp8Qb1y35arrQ67AiwEoxN60cSYpskTrHV9D2x+0Q+JGA=="], + "@ankhorage/supabase-auth": ["@ankhorage/supabase-auth@1.2.0", "", { "dependencies": { "@ankhorage/contracts": "^7.8.0", "@supabase/supabase-js": "2.105.4" } }, "sha512-SwyrHUs7PM+IYxBo9X2SfzLWkqxV6XXMLQhvL2sUKC4Xl3rYhNwcxFV8m9atSm+94a4T+aG4SvY/h4p0jQxuQw=="], + "@ankhorage/utility": ["@ankhorage/utility@0.1.1", "", {}, "sha512-6EehBCB59HOibxq7YUYq54TjgAfktBFGrEXZSqn1DsnsoRSIRUNbroKvzw7oF3bWiJRXj7mtc3DClFm0kIW0Mg=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -237,6 +244,20 @@ "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + "@supabase/auth-js": ["@supabase/auth-js@2.105.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Ejfa37M5xoIwoxVebxRahnwubPo8g22qkXQ4p50+N9MIvU9UZoN+A8dwVPtczzGf8oV/YXN80ZPxK4aWXuSN/A=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.105.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-JVNKbBft3Qkja+WlGaE026AJ2AH9K0UTsxsfvEIHgd4zFrBor4BYRCrYFrv9IDsvVqkF72wKDsODJl5GY/C4tA=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.5", "", {}, "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.105.4", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-SppIyLo/kTwIlz1qpv2HN1EQqBg0GVktrDDFsXygYROha3MgVn4rT7p5EjFHFqXQm2rdRGb/BI7bc+jr10m91w=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.105.4", "", { "dependencies": { "@supabase/phoenix": "^0.4.2", "tslib": "2.8.1" } }, "sha512-6ov6c59+8D9h7q4M4Gy/uDJlC0Akxl9/714Y+6vJ+Sijuc16TS/p5DwhfRCLNcIhNiej1gEt+CQUwsjiPt4PxQ=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.105.4", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-Jx+pzMP1Whjof2PWHoVBUA75/p7PQE9CqKBzn1oXVyJDOggMLSH2OzVWwsXYaxEpdC1K/KltwmOX44nL3LHl9g=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.105.4", "", { "dependencies": { "@supabase/auth-js": "2.105.4", "@supabase/functions-js": "2.105.4", "@supabase/postgrest-js": "2.105.4", "@supabase/realtime-js": "2.105.4", "@supabase/storage-js": "2.105.4" } }, "sha512-cEnx+k49knU+qdIP7rXwR6fqEXPHZs+74xFK1R0S8MgQ7v9tbePVdGxvO03n3bPympMdJWVLadARBfU4TgNHCQ=="], + "@ts-morph/common": ["@ts-morph/common@0.25.0", "", { "dependencies": { "minimatch": "^9.0.4", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.9" } }, "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], @@ -539,6 +560,8 @@ "human-id": ["human-id@4.2.0", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/knip.config.ts b/knip.config.ts index 9242bab..592b88a 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -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', diff --git a/package.json b/package.json index 5c681c0..b7b1785 100644 --- a/package.json +++ b/package.json @@ -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" @@ -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", @@ -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", @@ -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", diff --git a/src/cli/DeployCliEnvironment.ts b/src/cli/DeployCliEnvironment.ts new file mode 100644 index 0000000..98c6114 --- /dev/null +++ b/src/cli/DeployCliEnvironment.ts @@ -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; diff --git a/src/cli/DeployCliInput.ts b/src/cli/DeployCliInput.ts new file mode 100644 index 0000000..aaba185 --- /dev/null +++ b/src/cli/DeployCliInput.ts @@ -0,0 +1,6 @@ +import type { AnkhCommandContext } from '@ankhorage/ankh'; + +export interface DeployCliInput { + readonly argv: readonly string[]; + readonly context: AnkhCommandContext; +} diff --git a/src/cli/DeployCliOptions.ts b/src/cli/DeployCliOptions.ts new file mode 100644 index 0000000..d0abd1d --- /dev/null +++ b/src/cli/DeployCliOptions.ts @@ -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; +} diff --git a/src/cli/DeployCliRuntime.ts b/src/cli/DeployCliRuntime.ts new file mode 100644 index 0000000..b419ee0 --- /dev/null +++ b/src/cli/DeployCliRuntime.ts @@ -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; + createProjectReleasePlan(inspection: ProjectReleaseInspection): ReleasePlan; + executeProjectRelease( + options: ExecuteProjectReleaseOptions, + ): Promise; + createExecutionId(): string; +} diff --git a/src/cli/cliPackage.test.ts b/src/cli/cliPackage.test.ts new file mode 100644 index 0000000..789f77b --- /dev/null +++ b/src/cli/cliPackage.test.ts @@ -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; + 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'], + }); +}); diff --git a/src/cli/createDeployCliAccess.ts b/src/cli/createDeployCliAccess.ts new file mode 100644 index 0000000..03adc50 --- /dev/null +++ b/src/cli/createDeployCliAccess.ts @@ -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>, +): ProjectReleaseAccess { + const credentials: DeploymentCredentialReference[] = []; + const secrets = new Map(); + 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, + env: Readonly>, + 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 { + if (options.androidTrack === undefined) return {}; + return { + android: { + track: options.androidTrack, + ...(options.androidBuildProfile === undefined + ? {} + : { buildProfile: options.androidBuildProfile }), + }, + }; +} + +function iosAccess(options: DeployCliOptions): Pick { + return options.iosBuildProfile === undefined + ? {} + : { ios: { buildProfile: options.iosBuildProfile } }; +} + +function webAccess(options: DeployCliOptions): Pick { + if (options.webAlias === undefined && options.webEnvironment === undefined) return {}; + return { + web: { + ...(options.webAlias === undefined ? {} : { alias: options.webAlias }), + ...(options.webEnvironment === undefined ? {} : { environment: options.webEnvironment }), + }, + }; +} diff --git a/src/cli/createDeployCliProvider.ts b/src/cli/createDeployCliProvider.ts new file mode 100644 index 0000000..09670d5 --- /dev/null +++ b/src/cli/createDeployCliProvider.ts @@ -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), + }, + ], + }; +} diff --git a/src/cli/defaultDeployCliRuntime.ts b/src/cli/defaultDeployCliRuntime.ts new file mode 100644 index 0000000..e87cacd --- /dev/null +++ b/src/cli/defaultDeployCliRuntime.ts @@ -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()}`; + }, +}; diff --git a/src/cli/deployCliProvider.test.ts b/src/cli/deployCliProvider.test.ts new file mode 100644 index 0000000..4207fdb --- /dev/null +++ b/src/cli/deployCliProvider.test.ts @@ -0,0 +1,186 @@ +import type { AnkhCommandContext } from '@ankhorage/ankh'; +import { expect, test } from 'bun:test'; + +import type { ReleasePlan } from '../index.js'; +import type { + ExecuteProjectReleaseOptions, + InspectProjectReleaseOptions, + ProjectReleaseInspection, +} from '../project/index.js'; +import { createDeployCliProvider } from './createDeployCliProvider.js'; +import type { DeployCliRuntime } from './DeployCliRuntime.js'; +import { handleDeployCliCommand } from './handleDeployCliCommand.js'; + +const inspection: ProjectReleaseInspection = { + projectRoot: '/repo', + desired: { + version: '1.2.3', + targets: ['web'], + notes: [], + rollout: {}, + revision: 'desired-revision', + }, + observed: { targets: [] }, + currentRevision: 'current-revision', + actions: [], +}; + +const changePlan: ReleasePlan = { + status: 'changes', + desiredRevision: 'desired-revision', + currentRevision: 'current-revision', + steps: [ + { + id: 'web:publish', + target: 'web', + operation: 'publish', + dependsOn: [], + irreversible: false, + retry: 'safe', + }, + ], + diagnostics: [], +}; + +test('provider exposes exactly one category-root deploy command', () => { + const runtime = createRuntime(changePlan); + const provider = createDeployCliProvider(runtime); + + expect(provider.category).toBe('deploy'); + expect(provider.commands).toHaveLength(1); + expect(provider.commands[0]?.path).toEqual([]); + expect(provider.handlers?.[0]?.path).toEqual([]); + expect(provider.planningHandlers?.[0]?.path).toEqual([]); +}); + +test('dry-run renders the owner plan and never invokes execution', async () => { + let executions = 0; + const runtime = createRuntime(changePlan, () => { + executions += 1; + }); + const memory = createContext('unavailable'); + + const result = await handleDeployCliCommand( + { argv: ['--dry-run'], context: memory.context }, + runtime, + ); + + expect(result).toEqual({ exitCode: 0 }); + expect(executions).toBe(0); + expect(memory.stdout.value).toContain('Release: 1.2.3'); + expect(memory.stdout.value).toContain('[web] publish'); + expect(memory.stdout.value).toContain('irreversible: no'); +}); + +test('non-interactive mutation fails without explicit approval', async () => { + let executions = 0; + const runtime = createRuntime(changePlan, () => { + executions += 1; + }); + const memory = createContext('unavailable'); + + const result = await handleDeployCliCommand({ argv: [], context: memory.context }, runtime); + + expect(result).toEqual({ exitCode: 1 }); + expect(executions).toBe(0); + expect(memory.stderr.value).toContain('Use --yes'); +}); + +test('--yes executes through the project owner without prompting', async () => { + let executions = 0; + let executedOptions: ExecuteProjectReleaseOptions | undefined; + const runtime = createRuntime(changePlan, (options) => { + executions += 1; + executedOptions = options; + }); + const memory = createContext('unavailable'); + + const result = await handleDeployCliCommand( + { + argv: ['--yes', '--execution-id', 'release-test', '--web-alias', 'production'], + context: memory.context, + }, + runtime, + ); + + expect(result).toEqual({ exitCode: 0 }); + expect(executions).toBe(1); + expect(executedOptions?.inspection).toBe(inspection); + expect(executedOptions?.plan).toBe(changePlan); + expect(executedOptions?.web).toEqual({ alias: 'production' }); + expect(memory.stdout.value).toContain('Execution: release-test'); + expect(memory.stdout.value).toContain('Result: completed'); +}); + +test('--yes cannot bypass a blocked owner plan', async () => { + let executions = 0; + const runtime = createRuntime({ ...changePlan, status: 'blocked', steps: [] }, () => { + executions += 1; + }); + const memory = createContext('confirmed'); + + const result = await handleDeployCliCommand( + { argv: ['--yes'], context: memory.context }, + runtime, + ); + + expect(result).toEqual({ exitCode: 1 }); + expect(executions).toBe(0); +}); + +function createRuntime( + plan: ReleasePlan, + onExecute: (options: ExecuteProjectReleaseOptions) => void = () => undefined, +): DeployCliRuntime { + return { + inspectProjectRelease: (options: InspectProjectReleaseOptions) => { + expect(options.projectRoot).toBe('/repo'); + return Promise.resolve({ ok: true, inspection }); + }, + createProjectReleasePlan: () => plan, + executeProjectRelease(options) { + onExecute(options); + return Promise.resolve({ + ok: true, + execution: { + result: { + status: 'completed', + plan: { ...plan, status: 'no-change', steps: [] }, + currentRevision: inspection.desired.revision, + executedStepIds: plan.steps.map((step) => step.id), + }, + historyRecorded: true, + }, + }); + }, + createExecutionId: () => 'release-generated', + }; +} + +function createContext(confirmation: 'confirmed' | 'declined' | 'unavailable'): { + readonly context: AnkhCommandContext; + readonly stdout: { value: string }; + readonly stderr: { value: string }; +} { + const stdout = { value: '' }; + const stderr = { value: '' }; + return { + context: { + cwd: '/repo', + env: {}, + version: 'test', + interaction: { + interactive: confirmation !== 'unavailable', + confirm: () => Promise.resolve(confirmation), + }, + writeStdout(text) { + stdout.value += text; + }, + writeStderr(text) { + stderr.value += text; + }, + }, + stdout, + stderr, + }; +} diff --git a/src/cli/handleDeployCliCommand.ts b/src/cli/handleDeployCliCommand.ts new file mode 100644 index 0000000..b3f2299 --- /dev/null +++ b/src/cli/handleDeployCliCommand.ts @@ -0,0 +1,78 @@ +import type { AnkhCliRunResult, AnkhConfirmationResult } from '@ankhorage/ankh'; + +import type { ReleasePlan } from '../index.js'; +import type { DeployCliInput } from './DeployCliInput.js'; +import type { DeployCliOptions } from './DeployCliOptions.js'; +import type { DeployCliRuntime } from './DeployCliRuntime.js'; +import { inspectDeployCliRelease } from './inspectDeployCliRelease.js'; +import { parseDeployCliOptions } from './parseDeployCliOptions.js'; +import { renderDeployCliExecution } from './renderDeployCliExecution.js'; +import { renderDeployCliFailure } from './renderDeployCliFailure.js'; +import { renderDeployCliPlan } from './renderDeployCliPlan.js'; + +export async function handleDeployCliCommand( + input: DeployCliInput, + runtime: DeployCliRuntime, +): Promise { + const parsed = parseDeployCliOptions(input.argv, input.context.cwd); + if (!parsed.ok) return fail(input, `Deploy CLI: ${parsed.message}\n`); + const inspected = await inspectDeployCliRelease(parsed.options, input.context.env, runtime); + if (!inspected.ok) return fail(input, renderDeployCliFailure(inspected.failure)); + + input.context.writeStdout( + renderDeployCliPlan(inspected.inspection, inspected.plan, parsed.options.format), + ); + const early = earlyResult(parsed.options, inspected.plan); + if (early !== null) return early; + + const confirmation = await confirmMutation(input, parsed.options, inspected.plan); + if (confirmation === 'declined') { + input.context.writeStderr('Deployment cancelled; no mutation was performed.\n'); + return { exitCode: 0 }; + } + if (confirmation !== 'confirmed') { + return fail( + input, + 'Deployment requires confirmation. Use --yes for explicit non-interactive approval or --dry-run to inspect only.\n', + ); + } + + const executionId = parsed.options.executionId ?? runtime.createExecutionId(); + const executed = await runtime.executeProjectRelease({ + ...inspected.access, + inspection: inspected.inspection, + plan: inspected.plan, + executionId, + }); + if (!executed.ok) return fail(input, renderDeployCliFailure(executed.failure)); + input.context.writeStdout( + renderDeployCliExecution(executed.execution, executionId, parsed.options.format), + ); + return { exitCode: executed.execution.result.status === 'completed' ? 0 : 1 }; +} + +function earlyResult(options: DeployCliOptions, plan: ReleasePlan): AnkhCliRunResult | null { + if (options.dryRun) return { exitCode: plan.status === 'blocked' ? 1 : 0 }; + if (plan.status === 'no-change') return { exitCode: 0 }; + if (plan.status === 'blocked') return { exitCode: 1 }; + if (plan.steps.length === 0) return { exitCode: plan.status === 'waiting' ? 1 : 0 }; + return null; +} + +async function confirmMutation( + input: DeployCliInput, + options: DeployCliOptions, + plan: ReleasePlan, +): Promise { + if (options.yes) return 'confirmed'; + const { interaction } = input.context; + if (interaction === undefined) return 'unavailable'; + const irreversible = plan.steps.filter((step) => step.irreversible).length; + const detail = irreversible === 0 ? '' : ` (${irreversible} irreversible)`; + return interaction.confirm(`Execute ${plan.steps.length} release step(s)${detail}?`); +} + +function fail(input: DeployCliInput, message: string): AnkhCliRunResult { + input.context.writeStderr(message); + return { exitCode: 1 }; +} diff --git a/src/cli/handleDeployCliPlan.ts b/src/cli/handleDeployCliPlan.ts new file mode 100644 index 0000000..3d3bf08 --- /dev/null +++ b/src/cli/handleDeployCliPlan.ts @@ -0,0 +1,28 @@ +import type { AnkhCommandPlan } from '@ankhorage/ankh'; + +import type { DeployCliInput } from './DeployCliInput.js'; +import type { DeployCliRuntime } from './DeployCliRuntime.js'; +import { inspectDeployCliRelease } from './inspectDeployCliRelease.js'; +import { mapDeployCliPlan } from './mapDeployCliPlan.js'; +import { parseDeployCliOptions } from './parseDeployCliOptions.js'; + +export async function handleDeployCliPlan( + input: DeployCliInput, + runtime: DeployCliRuntime, +): Promise { + const parsed = parseDeployCliOptions(input.argv, input.context.cwd); + if (!parsed.ok) return failedPlan('DEPLOY_CLI_INVALID_ARGUMENT', parsed.message); + const result = await inspectDeployCliRelease(parsed.options, input.context.env, runtime); + if (!result.ok) return failedPlan(result.failure.code, result.failure.message); + return mapDeployCliPlan(result.inspection, result.plan); +} + +function failedPlan(code: string, message: string): AnkhCommandPlan { + return { + kind: 'ankh-command-plan', + version: 1, + title: 'Deploy release', + steps: [], + diagnostics: [{ code, message, severity: 'error' }], + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..d49a92b --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,8 @@ +import { createDeployCliProvider } from './createDeployCliProvider.js'; +import { defaultDeployCliRuntime } from './defaultDeployCliRuntime.js'; + +export { DEPLOY_CLI_ENVIRONMENT } from './DeployCliEnvironment.js'; + +const provider = createDeployCliProvider(defaultDeployCliRuntime); + +export default provider; diff --git a/src/cli/inspectDeployCliRelease.ts b/src/cli/inspectDeployCliRelease.ts new file mode 100644 index 0000000..f854a89 --- /dev/null +++ b/src/cli/inspectDeployCliRelease.ts @@ -0,0 +1,33 @@ +import type { DeploymentFailure, ReleasePlan } from '../index.js'; +import type { ProjectReleaseAccess, ProjectReleaseInspection } from '../project/index.js'; +import { createDeployCliAccess } from './createDeployCliAccess.js'; +import type { DeployCliOptions } from './DeployCliOptions.js'; +import type { DeployCliRuntime } from './DeployCliRuntime.js'; + +type InspectionResult = + | { + readonly ok: true; + readonly access: ProjectReleaseAccess; + readonly inspection: ProjectReleaseInspection; + readonly plan: ReleasePlan; + } + | { readonly ok: false; readonly failure: DeploymentFailure }; + +export async function inspectDeployCliRelease( + options: DeployCliOptions, + env: Readonly>, + runtime: DeployCliRuntime, +): Promise { + const access = createDeployCliAccess(options, env); + const result = await runtime.inspectProjectRelease({ + projectRoot: options.projectRoot, + ...access, + }); + if (!result.ok) return result; + return { + ok: true, + access, + inspection: result.inspection, + plan: runtime.createProjectReleasePlan(result.inspection), + }; +} diff --git a/src/cli/mapDeployCliPlan.ts b/src/cli/mapDeployCliPlan.ts new file mode 100644 index 0000000..4490960 --- /dev/null +++ b/src/cli/mapDeployCliPlan.ts @@ -0,0 +1,36 @@ +import type { AnkhCommandPlan } from '@ankhorage/ankh'; + +import type { ReleasePlan } from '../index.js'; +import type { ProjectReleaseInspection } from '../project/index.js'; + +export function mapDeployCliPlan( + inspection: ProjectReleaseInspection, + plan: ReleasePlan, +): AnkhCommandPlan { + return { + kind: 'ankh-command-plan', + version: 1, + title: `Deploy release ${inspection.desired.version}`, + steps: plan.steps.map((step) => ({ + capability: 'deploy.execute', + dependsOn: step.dependsOn, + destructive: step.irreversible, + id: step.id, + label: `[${step.target}] ${step.operation}`, + providerId: '@ankhorage/deploy', + status: plan.status === 'blocked' ? 'blocked' : 'planned', + })), + diagnostics: [ + ...plan.diagnostics.map((diagnostic) => ({ + code: diagnostic.code, + message: diagnostic.message, + severity: diagnostic.severity, + })), + ...inspection.actions.map((action) => ({ + code: action.code, + message: action.message, + severity: 'warning' as const, + })), + ], + }; +} diff --git a/src/cli/parseDeployCliOptions.test.ts b/src/cli/parseDeployCliOptions.test.ts new file mode 100644 index 0000000..b3bfcbd --- /dev/null +++ b/src/cli/parseDeployCliOptions.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'bun:test'; + +import { createDeployCliAccess } from './createDeployCliAccess.js'; +import { DEPLOY_CLI_ENVIRONMENT } from './DeployCliEnvironment.js'; +import { parseDeployCliOptions } from './parseDeployCliOptions.js'; + +test('parses operational release context without defaulting Android track', () => { + const parsed = parseDeployCliOptions( + [ + '--dry-run', + '--android-track', + 'beta', + '--android-build-profile', + 'store', + '--ios-build-profile', + 'ios-store', + '--web-alias', + 'production', + '--web-environment', + 'production', + ], + '/repo', + ); + + expect(parsed.ok).toBeTrue(); + if (!parsed.ok) return; + expect(parsed.options.androidTrack).toBe('beta'); + expect(parsed.options.projectRoot).toBe('/repo'); + expect(parsed.options.dryRun).toBeTrue(); +}); + +test('rejects Android tracks outside the owner union', () => { + const parsed = parseDeployCliOptions(['--android-track', 'preview'], '/repo'); + + expect(parsed).toEqual({ + ok: false, + message: 'Invalid Android track: preview. Expected one of: internal, alpha, beta, production.', + }); +}); + +test('requires an explicit Android track when an Android build profile is supplied', () => { + const parsed = parseDeployCliOptions(['--android-build-profile', 'store'], '/repo'); + + expect(parsed).toEqual({ + ok: false, + message: '--android-build-profile requires --android-track.', + }); +}); + +test('maps transient environment secrets to references without serializing secret values', async () => { + const parsed = parseDeployCliOptions(['--android-track', 'internal'], '/repo'); + expect(parsed.ok).toBeTrue(); + if (!parsed.ok) return; + + const googleSecret = + '{"type":"service_account","client_email":"bot@example.test","private_key":"SECRET"}'; + const appleSecret = '{"keyId":"K","issuerId":"I","privateKey":"APPLE_SECRET"}'; + const access = createDeployCliAccess(parsed.options, { + [DEPLOY_CLI_ENVIRONMENT.googlePlayServiceAccountJson]: googleSecret, + [DEPLOY_CLI_ENVIRONMENT.appStoreConnectApiKeyJson]: appleSecret, + [DEPLOY_CLI_ENVIRONMENT.easToken]: 'EAS_SECRET', + }); + + expect(access.credentials).toHaveLength(3); + const google = access.credentials?.find((item) => item.provider === 'google-play'); + expect(google).toBeDefined(); + if (google === undefined || access.resolveSecret === undefined) return; + expect(await access.resolveSecret(google)).toBe(googleSecret); + expect(JSON.stringify(access)).not.toContain('SECRET'); + expect(JSON.stringify(access)).not.toContain('APPLE_SECRET'); + expect(JSON.stringify(access)).not.toContain('EAS_SECRET'); +}); diff --git a/src/cli/parseDeployCliOptions.ts b/src/cli/parseDeployCliOptions.ts new file mode 100644 index 0000000..34585dc --- /dev/null +++ b/src/cli/parseDeployCliOptions.ts @@ -0,0 +1,113 @@ +import path from 'node:path'; + +import { + ANDROID_DEPLOYMENT_TRACKS, + type AndroidDeploymentTrack, +} from '../domain/AndroidDeploymentIntent.js'; +import type { DeployCliOptions } from './DeployCliOptions.js'; + +const BOOLEAN_FLAGS = new Set(['--dry-run', '--yes', '--json']); +const VALUE_FLAGS = new Set([ + '--project-root', + '--execution-id', + '--android-track', + '--android-build-profile', + '--ios-build-profile', + '--web-alias', + '--web-environment', +]); + +type ParseResult = + | { readonly ok: true; readonly options: DeployCliOptions } + | { readonly ok: false; readonly message: string }; + +interface DraftOptions { + projectRoot: string; + dryRun: boolean; + yes: boolean; + format: 'human' | 'json'; + executionId?: string; + androidTrack?: AndroidDeploymentTrack; + androidBuildProfile?: string; + iosBuildProfile?: string; + webAlias?: string; + webEnvironment?: string; +} + +export function parseDeployCliOptions(argv: readonly string[], cwd: string): ParseResult { + const draft: DraftOptions = { + projectRoot: path.resolve(cwd), + dryRun: false, + yes: false, + format: 'human', + }; + const seen = new Set(); + + for (let index = 0; index < argv.length; index += 1) { + const token = argv.at(index); + if (token === undefined) continue; + const duplicate = markSeen(seen, token); + if (duplicate !== null) return failure(duplicate); + if (BOOLEAN_FLAGS.has(token)) { + applyBooleanFlag(draft, token); + continue; + } + if (!VALUE_FLAGS.has(token)) return failure(`Unknown deploy option: ${token}`); + const value = argv.at(index + 1); + if (value === undefined || value.startsWith('--')) { + return failure(`Deploy option ${token} requires a value.`); + } + const error = applyValueFlag(draft, token, value, cwd); + if (error !== null) return failure(error); + index += 1; + } + + if (draft.androidBuildProfile !== undefined && draft.androidTrack === undefined) { + return failure('--android-build-profile requires --android-track.'); + } + return { ok: true, options: draft }; +} + +function markSeen(seen: Set, token: string): string | null { + if (!token.startsWith('--')) return null; + if (seen.has(token)) return `Deploy option ${token} may only be provided once.`; + seen.add(token); + return null; +} + +function applyBooleanFlag(draft: DraftOptions, flag: string): void { + if (flag === '--dry-run') draft.dryRun = true; + if (flag === '--yes') draft.yes = true; + if (flag === '--json') draft.format = 'json'; +} + +function applyValueFlag( + draft: DraftOptions, + flag: string, + value: string, + cwd: string, +): string | null { + if (flag === '--project-root') draft.projectRoot = path.resolve(cwd, value); + if (flag === '--execution-id') draft.executionId = value; + if (flag === '--android-track') { + if (!isAndroidDeploymentTrack(value)) return invalidAndroidTrack(value); + draft.androidTrack = value; + } + if (flag === '--android-build-profile') draft.androidBuildProfile = value; + if (flag === '--ios-build-profile') draft.iosBuildProfile = value; + if (flag === '--web-alias') draft.webAlias = value; + if (flag === '--web-environment') draft.webEnvironment = value; + return null; +} + +function isAndroidDeploymentTrack(value: string): value is AndroidDeploymentTrack { + return ANDROID_DEPLOYMENT_TRACKS.some((track) => track === value); +} + +function invalidAndroidTrack(value: string): string { + return `Invalid Android track: ${value}. Expected one of: ${ANDROID_DEPLOYMENT_TRACKS.join(', ')}.`; +} + +function failure(message: string): { readonly ok: false; readonly message: string } { + return { ok: false, message }; +} diff --git a/src/cli/renderDeployCliExecution.ts b/src/cli/renderDeployCliExecution.ts new file mode 100644 index 0000000..59de42d --- /dev/null +++ b/src/cli/renderDeployCliExecution.ts @@ -0,0 +1,28 @@ +import type { ProjectReleaseExecution } from '../project/index.js'; + +export function renderDeployCliExecution( + execution: ProjectReleaseExecution, + executionId: string, + format: 'human' | 'json', +): string { + if (format === 'json') { + return `${JSON.stringify({ kind: 'deploy-release-execution', executionId, execution })}\n`; + } + const lines = [ + `Execution: ${executionId}`, + `Result: ${execution.result.status}`, + `Current revision: ${execution.result.currentRevision}`, + `History recorded: ${execution.historyRecorded ? 'yes' : 'no'}`, + ]; + if (execution.result.code !== undefined) lines.push(`Code: ${execution.result.code}`); + if (execution.result.executedStepIds.length > 0) { + lines.push(`Executed steps: ${execution.result.executedStepIds.join(', ')}`); + } + if (execution.historyFailure !== undefined) { + lines.push( + `History failure: ${execution.historyFailure.code}: ${execution.historyFailure.message}`, + ); + } + lines.push(''); + return lines.join('\n'); +} diff --git a/src/cli/renderDeployCliFailure.ts b/src/cli/renderDeployCliFailure.ts new file mode 100644 index 0000000..7e2886e --- /dev/null +++ b/src/cli/renderDeployCliFailure.ts @@ -0,0 +1,9 @@ +import type { DeploymentFailure } from '../index.js'; + +export function renderDeployCliFailure(failure: DeploymentFailure): string { + const scope = [failure.target, failure.provider].filter( + (value): value is string => value !== undefined, + ); + const suffix = scope.length === 0 ? '' : ` (${scope.join(' | ')})`; + return `Deploy failed: ${failure.code}: ${failure.message}${suffix}\n`; +} diff --git a/src/cli/renderDeployCliPlan.ts b/src/cli/renderDeployCliPlan.ts new file mode 100644 index 0000000..e573702 --- /dev/null +++ b/src/cli/renderDeployCliPlan.ts @@ -0,0 +1,55 @@ +import type { ReleasePlan, ReleasePlanStep } from '../index.js'; +import type { ProjectReleaseInspection } from '../project/index.js'; + +export function renderDeployCliPlan( + inspection: ProjectReleaseInspection, + plan: ReleasePlan, + format: 'human' | 'json', +): string { + if (format === 'json') { + return `${JSON.stringify({ + kind: 'deploy-release-plan', + desired: inspection.desired, + plan, + actions: inspection.actions, + })}\n`; + } + return renderHuman(inspection, plan); +} + +function renderHuman(inspection: ProjectReleaseInspection, plan: ReleasePlan): string { + const lines = [ + `Release: ${inspection.desired.version}`, + `Targets: ${inspection.desired.targets.join(', ')}`, + `Revision: ${inspection.desired.revision}`, + `Plan status: ${plan.status}`, + '', + 'Steps:', + ]; + if (plan.steps.length === 0) lines.push(' - none'); + plan.steps.forEach((step, index) => lines.push(...renderStep(step, index))); + lines.push('', 'Diagnostics:'); + if (plan.diagnostics.length === 0) lines.push(' - none'); + for (const diagnostic of plan.diagnostics) { + const target = diagnostic.target === undefined ? '' : ` [${diagnostic.target}]`; + lines.push(` [${diagnostic.severity}]${target} ${diagnostic.code}: ${diagnostic.message}`); + } + lines.push('', 'Required actions:'); + if (inspection.actions.length === 0) lines.push(' - none'); + for (const action of inspection.actions) { + const target = action.target === undefined ? '' : ` [${action.target}]`; + lines.push(` ${action.type}${target} ${action.code}: ${action.message}`); + } + lines.push(''); + return lines.join('\n'); +} + +function renderStep(step: ReleasePlanStep, index: number): string[] { + return [ + ` ${index + 1}. [${step.target}] ${step.operation}`, + ` id: ${step.id}`, + ` dependsOn: ${step.dependsOn.length === 0 ? 'none' : step.dependsOn.join(', ')}`, + ` irreversible: ${step.irreversible ? 'yes' : 'no'}`, + ` retry: ${step.retry}`, + ]; +} diff --git a/tsconfig.json b/tsconfig.json index 61279f4..cd45b6f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "skipLibCheck": true, "noEmit": true, "verbatimModuleSyntax": true, - "types": ["bun", "node"] + "types": ["bun", "node"], + "resolveJsonModule": true }, "include": ["src/**/*.ts"] }