From bde1f749be8b9c66fb7b18a87a963b705b037e82 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Fri, 7 Aug 2026 02:17:51 +0300 Subject: [PATCH 1/2] refactor(credentials): lean credentials command surface Why: Dead CLI Promise facades and re-wrapped command failures hid the thin Commander/core boundary for `launch creds`. What: Drop unused chooseAccountInteractive/setupIos CLI facades and redundant Apple adapter provides; preserve CredentialsCommandFailure operations; DRY matched-account and unique-label checks; add pure validation tests; refresh docs test count. Impact: Behavior preserved; thinner CLI; actionable failure operations reach callers; gate green. --- src/cli/commands/creds.test.ts | 7 ++ src/cli/commands/creds.ts | 12 +-- src/core/credentials/command.test.ts | 116 +++++++++++++++++++++++++++ src/core/credentials/command.ts | 110 ++++++++++++++----------- 4 files changed, 188 insertions(+), 57 deletions(-) diff --git a/src/cli/commands/creds.test.ts b/src/cli/commands/creds.test.ts index 85d5786c..0f3f4e00 100644 --- a/src/cli/commands/creds.test.ts +++ b/src/cli/commands/creds.test.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { describe, expect, it } from 'vitest'; +import * as credsModule from './creds.js'; import { registerCredsCommand } from './creds.js'; const credsCommand = () => { @@ -21,3 +22,9 @@ describe('registerCredsCommand - non-interactive app selector (#261)', () => { expect(longs).toContain('--yes'); }); }); + +describe('registerCredsCommand - thin CLI boundary', () => { + it('exports only registration (no Promise facades for wizard helpers)', () => { + expect(Object.keys(credsModule).sort()).toEqual(['registerCredsCommand']); + }); +}); diff --git a/src/cli/commands/creds.ts b/src/cli/commands/creds.ts index f2c2abb8..fa86f0ab 100644 --- a/src/cli/commands/creds.ts +++ b/src/cli/commands/creds.ts @@ -1,22 +1,12 @@ import type { Command } from 'commander'; -import { Effect } from 'effect'; import { credentialsCommandProgram, type CredentialsCommandOptions, } from '@core/credentials/command.js'; -import { AppStoreIdentityLive } from '@core/services/appStoreIdentity.js'; -import { AppleCredentialsClientLive } from '@core/services/appleCredentialsClient.js'; import { runCliProgram } from '../runCliProgram.js'; export type CredsOptions = CredentialsCommandOptions; -/** Supply credential-specific Apple adapters to a core credentials command. */ -const provideCredentialAdapters = (commandInput: Parameters[0]) => - credentialsCommandProgram(commandInput).pipe( - Effect.provide(AppStoreIdentityLive), - Effect.provide(AppleCredentialsClientLive), - ); - /** Attach the credentials command and pass raw Commander input to the core schema boundary. */ export const registerCredsCommand = (program: Command): void => { program @@ -50,7 +40,7 @@ export const registerCredsCommand = (program: Command): void => { commandOptions: CredsOptions, ) => runCliProgram( - provideCredentialAdapters({ + credentialsCommandProgram({ action, firstArgument, secondArgument, diff --git a/src/core/credentials/command.test.ts b/src/core/credentials/command.test.ts index e161cea9..5345b80a 100644 --- a/src/core/credentials/command.test.ts +++ b/src/core/credentials/command.test.ts @@ -1,10 +1,25 @@ import { NodeContext } from '@effect/platform-node'; import { Effect, Schema } from 'effect'; import { describe, expect, it } from 'vitest'; +import { + AppStoreIdentityService, + type AppStoreIdentityService as AppStoreIdentity, +} from '../services/appStoreIdentity.js'; +import { + AppleCredentialsClientFactory, + type AppleCredentialsClientFactory as AppleCredentialsFactory, +} from '../services/appleCredentialsClient.js'; +import { LaunchEnvironmentTest } from '../services/environment.js'; +import { makeLaunchLoggerTest } from '../services/logger.js'; +import { makeLaunchPathsTest } from '../services/paths.js'; +import { makeLaunchPromptTest } from '../services/prompt.js'; +import { makeLaunchSecretStoreTest } from '../services/secretStore.js'; import { credentialSearchDirectories, + credentialsCommandProgram, CredentialsCommandInputSchema, isCredentialDiscoveryFile, + type CredentialsCommandFailure, } from './command.js'; const discoveryFixture = (filePath: string) => @@ -16,6 +31,39 @@ const discoveryFixture = (filePath: string) => return yield* isCredentialDiscoveryFile(filePath, searchDirectories); }).pipe(Effect.provide(NodeContext.layer)); +const unusedIdentity: AppStoreIdentity = { + verifyCredentials: () => + Effect.fail({ + _tag: 'AppleTransportFailure', + message: 'identity stub is not used by pure validation tests', + cause: 'unused', + status: 500, + }), + resolveIdentity: () => Effect.succeed({ teamId: null, apps: [] }), +}; + +const unusedCredentialsFactory: AppleCredentialsFactory = { + createClient: () => Effect.die('credentials client stub is not used by pure validation tests'), +}; + +/** Run a credentials command with testkit layers; pure validation fails before live Apple/Play. */ +const runCredentialsFailure = (commandInput: unknown): Promise => + Effect.runPromise( + credentialsCommandProgram(commandInput).pipe( + Effect.flip, + Effect.provide(NodeContext.layer), + Effect.provide(LaunchEnvironmentTest), + Effect.provide( + makeLaunchPathsTest('/tmp/launch-creds-command-test', '/tmp/launch-creds-command-test'), + ), + Effect.provide(makeLaunchLoggerTest([])), + Effect.provide(makeLaunchPromptTest()), + Effect.provide(makeLaunchSecretStoreTest()), + Effect.provideService(AppStoreIdentityService, unusedIdentity), + Effect.provideService(AppleCredentialsClientFactory, unusedCredentialsFactory), + ), + ); + describe('credential discovery directories', () => { it('matches a key directly inside Downloads', async () => { await expect( @@ -63,4 +111,72 @@ describe('CredentialsCommandInputSchema', () => { }), ).toThrow(); }); + + it('accepts logout as a remove alias at the schema boundary', () => { + expect( + Schema.decodeUnknownSync(CredentialsCommandInputSchema)({ + action: 'logout', + firstArgument: 'acme', + options: { yes: true }, + }), + ).toEqual({ + action: 'logout', + firstArgument: 'acme', + options: { yes: true }, + }); + }); +}); + +describe('credentialsCommandProgram validation', () => { + it('preserves the rename operation tag when arguments are missing', async () => { + const failure = await runCredentialsFailure({ + action: 'rename', + options: { yes: true }, + }); + expect(failure._tag).toBe('CredentialsCommandFailure'); + expect(failure.operation).toBe('rename Apple account'); + expect(failure.message).toContain('launch creds rename'); + }); + + it('preserves the remove operation tag when the account selector is missing', async () => { + const failure = await runCredentialsFailure({ + action: 'remove', + options: { yes: true }, + }); + expect(failure._tag).toBe('CredentialsCommandFailure'); + expect(failure.operation).toBe('remove Apple account'); + expect(failure.message).toContain('launch creds remove'); + }); + + it('requires an account selector for non-interactive use', async () => { + const failure = await runCredentialsFailure({ + action: 'use', + options: { yes: true }, + }); + expect(failure._tag).toBe('CredentialsCommandFailure'); + expect(failure.operation).toBe('use Apple account'); + expect(failure.message).toContain('account label or Key ID'); + }); + + it('rejects an unknown push-key subcommand without touching the vault', async () => { + const failure = await runCredentialsFailure({ + action: 'push-key', + firstArgument: 'rotate', + options: { yes: true }, + }); + expect(failure._tag).toBe('CredentialsCommandFailure'); + expect(failure.operation).toBe('run APNs command'); + expect(failure.message).toContain('import, status, or export'); + }); + + it('requires a Key ID for non-interactive APNs export', async () => { + const failure = await runCredentialsFailure({ + action: 'push-key', + firstArgument: 'export', + options: { yes: true }, + }); + expect(failure._tag).toBe('CredentialsCommandFailure'); + expect(failure.operation).toBe('export APNs key'); + expect(failure.message).toContain('push-key export'); + }); }); diff --git a/src/core/credentials/command.ts b/src/core/credentials/command.ts index 03115b1e..ce5a4a5d 100644 --- a/src/core/credentials/command.ts +++ b/src/core/credentials/command.ts @@ -12,7 +12,7 @@ import { LaunchPrompt, type LaunchPromptService } from '../services/prompt.js'; import type { LaunchSecretStoreService } from '../services/secretStore.js'; import { parsePlatform } from '../services/platform.js'; import type { AppDescriptor, Platform } from '../types/app.js'; -import type { AccountRecord, ApnsKeyRecord, AscKey } from '../types/credentials.js'; +import type { AccountRecord, AscKey } from '../types/credentials.js'; import { addAccount, formatAccountSummary, @@ -103,6 +103,14 @@ export const makeCredentialsCommandFailure = Data.tagged { + if (typeof cause !== 'object') return false; + if (cause === null) return false; + if (!('_tag' in cause)) return false; + return cause._tag === 'CredentialsCommandFailure'; +}; + /** Turn an underlying typed failure into the command family's public failure. */ const commandFailure = (operation: string, cause: unknown): CredentialsCommandFailure => makeCredentialsCommandFailure({ operation, message: errorMessage(cause), cause }); @@ -122,6 +130,40 @@ const firstDefinedText = (...candidates: readonly (string | undefined)[]): strin return undefined; }; +/** Resolve an account by label or Key ID, or fail with a shared missing-account message. */ +const requireMatchedAccount = ( + accounts: readonly AccountRecord[], + selector: string, + operation: string, +): Effect.Effect => { + const matchedAccount = matchAccount([...accounts], selector); + if (matchedAccount === undefined) { + return failCommand(operation, `No Apple account matching "${selector}".`); + } + return Effect.succeed(matchedAccount); +}; + +/** Reject a label that another Apple account already owns. */ +const requireUniqueAccountLabel = ( + accounts: readonly AccountRecord[], + accountLabel: string, + excludingKeyId: string, + operation: string, +): Effect.Effect => { + const conflictingAccount = accounts.find( + (account) => + account.label.toLowerCase() === accountLabel.toLowerCase() && + account.keyId !== excludingKeyId, + ); + if (conflictingAccount !== undefined) { + return failCommand( + operation, + `Label "${accountLabel}" is already used by key ${conflictingAccount.keyId}.`, + ); + } + return Effect.void; +}; + /** Build the ordered directories used for deliberate credential discovery. */ export const credentialSearchDirectories = ( homeDirectory: string, @@ -297,16 +339,7 @@ const selectAccountLabel = ( accountLabel = accountLabel.trim(); if (accountLabel.length === 0) accountLabel = keyId; const accounts = yield* listAccounts(); - const conflictingAccount = accounts.find( - (account) => - account.label.toLowerCase() === accountLabel.toLowerCase() && account.keyId !== keyId, - ); - if (conflictingAccount !== undefined) { - return yield* failCommand( - 'select account label', - `Label "${accountLabel}" is already used by key ${conflictingAccount.keyId}.`, - ); - } + yield* requireUniqueAccountLabel(accounts, accountLabel, keyId, 'select account label'); return accountLabel; }); @@ -489,9 +522,7 @@ const selectAppleAccount = ( const accounts = yield* listAccounts(); const selector = firstDefinedText(commandOptions.account, environment.values.appleAccount); if (selector !== undefined) { - const matchedAccount = matchAccount([...accounts], selector); - if (matchedAccount !== undefined) return matchedAccount; - return yield* failCommand('select Apple account', `No Apple account matching "${selector}".`); + return yield* requireMatchedAccount(accounts, selector, 'select Apple account'); } const activeAccount = yield* getActiveAccount(); if (activeAccount !== null) return activeAccount; @@ -694,10 +725,7 @@ const useAppleAccount = ( return; } const accounts = yield* listAccounts(); - const matchedAccount = matchAccount([...accounts], selector); - if (matchedAccount === undefined) { - return yield* failCommand('use Apple account', `No Apple account matching "${selector}".`); - } + const matchedAccount = yield* requireMatchedAccount(accounts, selector, 'use Apple account'); yield* setActiveKeyId(matchedAccount.keyId); const logger = yield* createLogger(false); yield* logger.ok( @@ -724,22 +752,14 @@ const renameAppleAccount = ( ); } const accounts = yield* listAccounts(); - const matchedAccount = matchAccount([...accounts], selector); - if (matchedAccount === undefined) { - return yield* failCommand('rename Apple account', `No Apple account matching "${selector}".`); - } + const matchedAccount = yield* requireMatchedAccount(accounts, selector, 'rename Apple account'); const accountLabel = enteredLabel.trim(); - const conflictingAccount = accounts.find( - (account) => - account.label.toLowerCase() === accountLabel.toLowerCase() && - account.keyId !== matchedAccount.keyId, + yield* requireUniqueAccountLabel( + accounts, + accountLabel, + matchedAccount.keyId, + 'rename Apple account', ); - if (conflictingAccount !== undefined) { - return yield* failCommand( - 'rename Apple account', - `Label "${accountLabel}" is already used by key ${conflictingAccount.keyId}.`, - ); - } yield* renameAccount(matchedAccount.keyId, accountLabel); const logger = yield* createLogger(false); yield* logger.ok(`Renamed account ${matchedAccount.keyId} to "${accountLabel}".`); @@ -755,10 +775,7 @@ const removeAppleAccount = ( return yield* failCommand('remove Apple account', 'Usage: launch creds remove .'); } const accounts = yield* listAccounts(); - const matchedAccount = matchAccount([...accounts], selector); - if (matchedAccount === undefined) { - return yield* failCommand('remove Apple account', `No Apple account matching "${selector}".`); - } + const matchedAccount = yield* requireMatchedAccount(accounts, selector, 'remove Apple account'); if (yield* commandCanPrompt(commandOptions)) { const prompt = yield* LaunchPrompt; const confirmed = yield* prompt.confirm( @@ -779,13 +796,11 @@ const refreshAppleAccounts = ( const accounts = yield* listAccounts(); let refreshTargets = [...accounts]; if (selector !== undefined) { - const matchedAccount = matchAccount([...accounts], selector); - if (matchedAccount === undefined) { - return yield* failCommand( - 'refresh Apple accounts', - `No Apple account matching "${selector}".`, - ); - } + const matchedAccount = yield* requireMatchedAccount( + accounts, + selector, + 'refresh Apple accounts', + ); refreshTargets = [matchedAccount]; } if (refreshTargets.length === 0) { @@ -987,6 +1002,9 @@ export const credentialsCommandProgram = ( decodedCommand.options, ); } - }).pipe(Effect.mapError((cause) => commandFailure('run credentials command', cause))); - -export type { ApnsKeyRecord }; + }).pipe( + Effect.mapError((cause) => { + if (isCredentialsCommandFailure(cause)) return cause; + return commandFailure('run credentials command', cause); + }), + ); From 086ea4a12be50867eecacc45c15d1fc3f6777639 Mon Sep 17 00:00:00 2001 From: YosefHayim Date: Fri, 7 Aug 2026 13:53:49 +0300 Subject: [PATCH 2/2] chore(docs): sync badges after creds-command rebase --- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- docs/commands.md | 2 +- llms.txt | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.de.md b/README.de.md index 97610037..01c03fcd 100644 --- a/README.de.md +++ b/README.de.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.es.md b/README.es.md index 11daca25..19aa2e42 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.fr.md b/README.fr.md index 3540e15b..7b08a912 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.ja.md b/README.ja.md index fad02677..e85e8a3f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.ko.md b/README.ko.md index 313cbac2..73bb8b80 100644 --- a/README.ko.md +++ b/README.ko.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.md b/README.md index f1ee79d4..61ef3fe3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.pt-BR.md b/README.pt-BR.md index 1fe1ef80..5040770a 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.ru.md b/README.ru.md index f3be87d2..7decf75b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/README.zh-CN.md b/README.zh-CN.md index a11c46cb..d5ec4266 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,7 @@

189 App Store Connect & Google Play API operations Full create / read / update / delete coverage across the store APIs - 2319 tests passing + 2326 tests passing

diff --git a/docs/commands.md b/docs/commands.md index 55bed077..1edeaffd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ # Launch command reference -> Launch wraps **189 App Store Connect & Google Play API operations** across **63 commands**, guarded by **2319 tests**. +> Launch wraps **189 App Store Connect & Google Play API operations** across **63 commands**, guarded by **2326 tests**. Generated from the `commander` definitions in `src/cli/` by `pnpm docs:gen` - edit the commands, then regenerate. For the curated overview, install, and configuration, see the [README](../README.md). diff --git a/llms.txt b/llms.txt index c4ac098b..e6bbd72b 100644 --- a/llms.txt +++ b/llms.txt @@ -142,7 +142,7 @@ Each is declared in `launch.config.ts` (or a `*.config.json` sidecar) and reconc ## Commands -All 63 `launch` commands (189 store-API operations underneath, 2319 tests): +All 63 `launch` commands (189 store-API operations underneath, 2326 tests): - `launch init` - scaffold launch.config.ts (and .env.example) into the current repo - `launch adopt` - onboard an app that already ships: import its App Store Connect setup into config