diff --git a/package.json b/package.json index 85cd208..fa1c749 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "url": "https://github.com/adaptyteam/adapty-cli/issues" }, "dependencies": { + "@clack/prompts": "^1.7.0", "@oclif/core": "^4", "@oclif/plugin-help": "^6", "open": "^11.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4d7c7..d9a4811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@clack/prompts': + specifier: ^1.7.0 + version: 1.7.0 '@oclif/core': specifier: ^4 version: 4.8.0 @@ -247,6 +250,14 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1652,6 +1663,15 @@ packages: fast-levenshtein@3.0.0: resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-parser@5.3.6: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true @@ -2594,6 +2614,9 @@ packages: sinon@21.0.2: resolution: {integrity: sha512-VHV4UaoxIe5jrMd89Y9duI76T5g3Lp+ET+ctLhLDaZtSznDPah1KKpRElbdBV4RwqWSw2vadFiVs9Del7MbVeQ==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -3443,6 +3466,18 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -5218,6 +5253,16 @@ snapshots: dependencies: fastest-levenshtein: 1.0.16 + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -6186,6 +6231,8 @@ snapshots: diff: 8.0.3 supports-color: 7.2.0 + sisteransi@1.0.5: {} + snake-case@3.0.4: dependencies: dot-case: 3.0.4 diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 6d37f8b..fb6111a 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -19,7 +19,9 @@ interface TokenSuccessResponse { access_token: string expires_in: number token_type: string - user: {email: string; name: string} + // The response has carried different user shapes; treat every field as optional + // so a missing one degrades to a plain "Authenticated" instead of "undefined". + user?: {email?: string; name?: string} } interface TokenErrorResponse { @@ -137,7 +139,8 @@ static examples = ['<%= config.bin %> auth login'] this.config.configDir, ) - this.log(`\nAuthenticated as ${result.user.email}`) + const who = result.user?.email ?? result.user?.name + this.log(who ? `\nAuthenticated as ${who}` : '\nAuthenticated') this.log(`Token saved to ${this.config.configDir}/config.json`) return } diff --git a/src/commands/integrate.ts b/src/commands/integrate.ts new file mode 100644 index 0000000..2ba2547 --- /dev/null +++ b/src/commands/integrate.ts @@ -0,0 +1,125 @@ +import {Command, Flags} from '@oclif/core' +import {resolve} from 'node:path' + +import {integrateAction} from '../lib/agent/actions/integrate.js' +import {DRIVER_IDS, DRIVERS} from '../lib/agent/drivers/index.js' +import {collectStoreProducts} from '../lib/agent/products.js' +import {emitCopyPrompt, prepareWorkBranch, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {preparePromptContext, prepareWizard, resolvePlacementDeveloperId} from '../lib/agent/wizard.js' +import {billingLabel, detectBilling} from '../lib/project/billing.js' +import {confirm, isInteractive, select} from '../lib/ui/ask.js' + +/** Every integrate flag re-expressed as migrate argv, so the switch loses nothing the user typed. */ +function migratePassthrough( + path: string, + flags: {app?: string; 'code-only'?: boolean; copy?: boolean; driver?: string; 'no-telemetry'?: boolean}, +): string[] { + const passthrough = ['--path', path] + if (flags.app) passthrough.push('--app', flags.app) + if (flags.driver) passthrough.push('--driver', flags.driver) + if (flags.copy) passthrough.push('--copy') + if (flags['code-only'] !== undefined) passthrough.push(flags['code-only'] ? '--code-only' : '--no-code-only') + if (flags['no-telemetry']) passthrough.push('--no-telemetry') + return passthrough +} + +export default class Integrate extends Command { + static description = `Set up the Adapty SDK in your app using your coding agent (${DRIVERS.map( + (d) => d.displayName, + ).join(', ')})` +static examples = [ + '<%= config.bin %> integrate', + '<%= config.bin %> integrate --path ./apps/mobile', + '<%= config.bin %> integrate --copy', + ] +static flags = { + app: Flags.string({description: 'Adapty app ID (UUID) to connect; skips the app picker'}), + 'code-only': Flags.boolean({ + allowNo: true, + description: + 'The dashboard is already set up - wire the code to the existing entities and create nothing (--no-code-only forces entity creation; without either, the CLI asks when the app is not empty)', + }), + copy: Flags.boolean({ + description: 'Print the integration prompt instead of running an agent (paste it into any coding agent)', + }), + driver: Flags.string({description: 'Force a specific coding agent', options: DRIVER_IDS}), + 'no-telemetry': Flags.boolean({ + description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', + }), + path: Flags.string({description: 'App directory (defaults to the current directory)'}), + } + + async run(): Promise { + const {flags} = await this.parse(Integrate) + const path = resolve(flags.path ?? process.cwd()) + + // A project that already has a billing SDK is a migration, not a fresh + // integration - offer the switch BEFORE the wizard so no question runs twice. + const billing = await detectBilling(path) + if (billing) { + if (isInteractive()) { + const wantsMigrate = await confirm( + `Found ${billingLabel(billing)} in this project - \`adapty migrate\` replaces it with Adapty end-to-end. Switch to migrate?`, + ) + if (wantsMigrate === null) return this.log('Cancelled.') + if (wantsMigrate) return this.config.runCommand('migrate', migratePassthrough(path, flags)) + } else { + this.log( + `Found ${billingLabel(billing)} in this project - \`adapty migrate\` is built for replacing it. Continuing with a fresh integration.`, + ) + } + } + + const setup = await prepareWizard(this, {...flags, path}) + if (!setup) return + const {copyOnly, driver, installSkill, interactive, project, token} = setup + + // Paywall approach - the one product question the skill needs answered upfront. + const approach = await select( + 'How do you want to build paywalls?', + [ + {hint: 'no-code visual editor, recommended', label: 'Flow Builder', value: 'flow_builder'}, + {hint: 'you build the UI, Adapty handles products & purchases', label: 'Custom paywall', value: 'custom'}, + {hint: 'keep existing purchase code, Adapty only tracks', label: 'Observer mode', value: 'observer'}, + ], + 'flow_builder', + ) + if (!approach) return this.log('Cancelled.') + + // The go/no-go gate comes BEFORE the product interview - never collect + // answers that a declined confirm would throw away. + if (!copyOnly && interactive && !(await confirm(`Integrate the Adapty SDK into "${project.name}" now?`))) { + return this.log('No problem - run `adapty integrate` again anytime, or use --copy to drive your own agent.') + } + + // In code-only mode the placement's developer ID is settled here, by the + // user, not later by the agent - a wrong guess fails silently at runtime. + const placementDeveloperId = await resolvePlacementDeveloperId(this, setup, approach) + if (placementDeveloperId === null) return this.log('Cancelled.') + + // Real store IDs turn "defer everything to ADAPTY_SETUP.md" into a full dashboard + // setup - pointless in code-only mode, where the products already exist in Adapty. + const products = setup.dashboardMode === 'code-only' ? [] : await collectStoreProducts(project.platform) + if (products === null) return this.log('Cancelled.') + const promptCtx = await preparePromptContext(setup, approach, products, {placementDeveloperId}) + + if (copyOnly) { + return emitCopyPrompt(this, integrateAction, promptCtx, {installSkill}) + } + + // Every run gets its own branch; no git at all is the one case that stops us. + const branch = await prepareWorkBranch(this, project.path, 'integrate', interactive) + if (branch === null) return this.log('Run `git init` and commit what you have, then re-run `adapty integrate`.') + + const result = await runActionWithFollowUp(this, { + action: integrateAction, + branch, + ctx: promptCtx, + driver: driver!, + env: token ? {ADAPTY_TOKEN: token} : undefined, + interactive, + noTelemetry: flags['no-telemetry'], + }) + if (!result.ok) reportActionFailure(this, driver!, result) + } +} diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts new file mode 100644 index 0000000..4b12896 --- /dev/null +++ b/src/commands/migrate.ts @@ -0,0 +1,184 @@ +import {Command, Flags} from '@oclif/core' +import {resolve} from 'node:path' + +import {buildMigrateAction} from '../lib/agent/actions/migrate.js' +import {DRIVER_IDS} from '../lib/agent/drivers/index.js' +import {collectStoreProducts} from '../lib/agent/products.js' +import {emitCopyPrompt, prepareWorkBranch, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {loadMigrationReference} from '../lib/agent/skill-source.js' +import {preparePromptContext, prepareWizard, resolvePlacementDeveloperId} from '../lib/agent/wizard.js' +import {BILLING_LABELS, type BillingId, billingLabel, detectBilling} from '../lib/project/billing.js' +import {hasUncommittedChanges} from '../lib/project/git.js' +import {fetchRcCatalog, renderRcCatalog} from '../lib/project/revenuecat.js' +import {confirm, select, spinner} from '../lib/ui/ask.js' + +export default class Migrate extends Command { + static description = 'Migrate your app from RevenueCat, Superwall, or Qonversion to Adapty using your coding agent' +static examples = [ + '<%= config.bin %> migrate', + '<%= config.bin %> migrate --path ./apps/mobile', + '<%= config.bin %> migrate --copy', + ] +static flags = { + app: Flags.string({description: 'Adapty app ID (UUID) to connect; skips the app picker'}), + 'code-only': Flags.boolean({ + allowNo: true, + description: + 'The dashboard is already set up - wire the code to the existing entities and create nothing (--no-code-only forces entity creation; without either, the CLI asks when the app is not empty)', + }), + copy: Flags.boolean({ + description: 'Print the migration prompt instead of running an agent (paste it into any coding agent)', + }), + driver: Flags.string({description: 'Force a specific coding agent', options: DRIVER_IDS}), + 'no-telemetry': Flags.boolean({ + description: 'Do not send anonymous usage stats (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1)', + }), + path: Flags.string({description: 'App directory (defaults to the current directory)'}), + 'rc-key': Flags.string({ + description: + 'RevenueCat v2 secret API key - pulls your RC catalog (entitlements, products, offerings) so entities are recreated exactly instead of guessed from code', + }), + } + + async run(): Promise { + const {flags} = await this.parse(Migrate) + const path = resolve(flags.path ?? process.cwd()) + + // --rc-key exists to recreate RC entities in Adapty; --code-only creates nothing. + if (flags['rc-key'] && flags['code-only']) { + this.error( + '--rc-key recreates your RevenueCat entities in Adapty, but --code-only creates nothing - pass one or the other.', + ) + } + + const setup = await prepareWizard(this, {...flags, path}) + if (!setup) return + const {copyOnly, driver, installSkill, interactive, project, token} = setup + + // With an RC key the source is a given; otherwise auto-detect and let the user correct. + let providerLabel = 'RevenueCat' + // The label is for humans; the id picks references/migration-.md. + let sourceId: BillingId | undefined = 'revenuecat' + let rcCatalog: string | undefined + if (flags['rc-key']) { + const rcSpin = spinner() + rcSpin.start('Pulling your RevenueCat catalog') + const catalog = await fetchRcCatalog(flags['rc-key']) + if (catalog) { + rcSpin.stop( + `RevenueCat catalog loaded: "${catalog.projectName}" - ${catalog.entitlements.length} entitlement(s), ${catalog.products.length} product(s), ${catalog.offerings.length} offering(s)`, + ) + if (!catalog.complete) { + this.warn( + 'Some RevenueCat requests failed or were truncated - the catalog may be incomplete. The agent is told to flag this in ADAPTY_SETUP.md.', + ) + } + + rcCatalog = renderRcCatalog(catalog) + } else { + rcSpin.stop('Could not read the RevenueCat catalog') + this.error( + 'The RevenueCat API rejected the key or returned no project. Use a v2 SECRET key (sk_...) with read scopes for projects, apps, entitlements, products, and offerings.', + ) + } + } else { + const detected = await detectBilling(path) + if (detected) this.log(`Detected ${billingLabel(detected)} in this project`) + if (interactive) { + const choice = await select( + 'What are you migrating from?', + [ + {label: 'RevenueCat', value: 'revenuecat'}, + {label: 'Superwall', value: 'superwall'}, + {label: 'Qonversion', value: 'qonversion'}, + {hint: 'in_app_purchase, react-native-iap, Unity IAP, ...', label: 'A store plugin', value: 'store-plugin'}, + {hint: 'hand-rolled StoreKit / Play Billing', label: 'Custom store code', value: 'native-store'}, + ], + detected?.id, + ) + if (!choice) return this.log('Cancelled.') + // Prefer the detected detail ("the in_app_purchase plugin") when the user confirms the detected kind. + providerLabel = detected && detected.id === choice ? billingLabel(detected) : BILLING_LABELS[choice as BillingId] + sourceId = choice as BillingId + if (choice === 'revenuecat') { + this.log( + 'Tip: pass --rc-key to recreate your RC entitlements, products, and offerings exactly.', + ) + } + } else { + // Headless: never dead-end on a missing answer - fall back to a generic label. + providerLabel = detected ? billingLabel(detected) : 'the current billing SDK' + sourceId = detected?.id + } + } + + const approach = await select( + 'How do you want to build paywalls in Adapty?', + [ + {hint: 'no-code visual editor, recommended', label: 'Flow Builder', value: 'flow_builder'}, + {hint: 'you build the UI, Adapty handles products & purchases', label: 'Custom paywall', value: 'custom'}, + ], + 'flow_builder', + ) + if (!approach) return this.log('Cancelled.') + + // Cheap, deterministic gates come BEFORE the product interview - never + // collect answers that a declined confirm would throw away. + if (!copyOnly) { + // A migration rewrites many files - a clean tree makes it reviewable and revertable. + if (await hasUncommittedChanges(path)) { + this.warn('This project has uncommitted changes. Commit or stash them so `git diff` shows only the migration.') + if (interactive && !(await confirm('Proceed on the dirty working tree anyway?', false))) { + return this.log('Commit your changes and re-run `adapty migrate`.') + } + } + + if (interactive && !(await confirm(`Migrate "${project.name}" from ${providerLabel} to Adapty now?`))) { + return this.log('No problem - run `adapty migrate` again anytime, or use --copy to drive your own agent.') + } + } + + // In code-only mode the placement's developer ID is settled here, by the + // user, not later by the agent - a wrong guess fails silently at runtime. + const placementDeveloperId = await resolvePlacementDeveloperId(this, setup, approach) + if (placementDeveloperId === null) return this.log('Cancelled.') + + // Without the RC catalog the user's own store IDs are the only ground truth + // available - except in code-only mode, where Adapty already has better ones. + const products = + rcCatalog || setup.dashboardMode === 'code-only' ? [] : await collectStoreProducts(project.platform) + if (products === null) return this.log('Cancelled.') + // The spine carries the mapping rules and the ADAPTY_SETUP.md contract the + // prompt no longer inlines, so a failure here must stop the run, not warn. + const mSpin = spinner() + mSpin.start('Fetching the migration playbook') + const migrationReference = await loadMigrationReference(sourceId).catch((error: unknown) => { + mSpin.stop('Could not fetch the migration playbook') + return this.error(error instanceof Error ? error.message : String(error)) + }) + mSpin.stop('Migration playbook ready') + + const promptCtx = await preparePromptContext(setup, approach, products, {migrationReference, placementDeveloperId}) + const action = buildMigrateAction(providerLabel, rcCatalog) + + if (copyOnly) { + return emitCopyPrompt(this, action, promptCtx, {installSkill}) + } + + // Every run gets its own branch; no git at all is the one case that stops us. + const branch = await prepareWorkBranch(this, project.path, 'migrate', interactive) + if (branch === null) return this.log('Run `git init` and commit what you have, then re-run `adapty migrate`.') + + const result = await runActionWithFollowUp(this, { + action, + branch, + ctx: promptCtx, + driver: driver!, + env: token ? {ADAPTY_TOKEN: token} : undefined, + interactive, + noTelemetry: flags['no-telemetry'], + }) + if (!result.ok) reportActionFailure(this, driver!, result) + } +} + diff --git a/src/commands/products/create.ts b/src/commands/products/create.ts index d913105..33534d4 100644 --- a/src/commands/products/create.ts +++ b/src/commands/products/create.ts @@ -1,21 +1,10 @@ import {Command, Flags} from '@oclif/core' -import type {ProductCreateRequestDTO, ProductDTO, ProductPeriod} from '../../lib/api-schemas.js' - +import {type ProductCreateRequestDTO, type ProductDTO, type ProductPeriod, VALID_PERIODS} from '../../lib/api-schemas.js' import {createAuthenticatedClient} from '../../lib/client-from-config.js' import {appFlag} from '../../lib/flags.js' import {printResponse} from '../../lib/output.js' -const VALID_PERIODS = [ - 'weekly', - 'monthly', - 'two_months', - 'trimonthly', - 'semiannual', - 'annual', - 'lifetime', -] as const satisfies readonly ProductPeriod[] - const xor = (a?: string, b?: string) => (a === undefined) !== (b === undefined) export default class ProductsCreate extends Command { @@ -33,7 +22,7 @@ static flags = { 'paddle-price-id': Flags.string({description: 'Paddle price ID (requires --paddle-product-id)', helpGroup: 'STORE BINDINGS'}), 'paddle-product-id': Flags.string({description: 'Paddle product ID (requires --paddle-price-id)', helpGroup: 'STORE BINDINGS'}), period: Flags.string({ - description: 'Subscription period (weekly, monthly, two_months, trimonthly, semiannual, annual, lifetime)', + description: `Subscription period (${VALID_PERIODS.join(', ')})`, required: true, }), 'stripe-price-id': Flags.string({description: 'Stripe price ID (requires --stripe-product-id)', helpGroup: 'STORE BINDINGS'}), diff --git a/src/lib/agent/actions/integrate.ts b/src/lib/agent/actions/integrate.ts new file mode 100644 index 0000000..947429d --- /dev/null +++ b/src/lib/agent/actions/integrate.ts @@ -0,0 +1,30 @@ +import type {AgentAction, PromptContext} from '../prompt.js' + +export const integrateAction: AgentAction = { + id: 'integrate', + task(ctx: PromptContext): string { + const {appId, cliCommand, paywallApproach, platformReference, storeProducts} = ctx + return `You are integrating the Adapty SDK into the user's app, end-to-end: + +1. Dashboard setup via \`${cliCommand}\` (app already exists - see context): + a. Get the access level ID: \`access-levels list --app ${appId || ''} --json\` (default is usually "premium"). + b. ${ + storeProducts + ? `Products: run \`products list\` first (skip ones that already exist), then create each product below with \`products create\` (Android subscriptions need --android-base-plan-id). If any of these products do not exist in the stores yet, add that store-side work to ADAPTY_SETUP.md: create them in App Store Connect / Google Play Console with these EXACT IDs (Google Play only allows creating products after an AAB with the billing permission has been uploaded).\n\n\n${storeProducts}\n\n` + : 'Products: run `products list` first. Create products ONLY when you know their real store product IDs (from the code, a .storekit file, or store config found in the project) - store IDs are immutable after creation, so a guessed ID is unfixable junk. Real IDs unknown -> create no products; put the ready-to-run `products create` commands in ADAPTY_SETUP.md instead (Android subscriptions also need --android-base-plan-id, and Google Play product IDs can only exist after an AAB with billing permission is uploaded).' + } + c. ONLY if step (b) actually created products - without them a paywall/placement is an empty shell, so skip creation and put the whole command sequence in ADAPTY_SETUP.md right after the products create commands: ${ + paywallApproach === 'flow_builder' + ? 'Flow Builder needs a FLOW placement, and this CLI can create neither the flow nor that placement - both are dashboard-only. Create NOTHING here, not even the placement: a placement carries a type (flow / paywall / onboarding) fixed at creation, this CLI only creates paywall placements, and a developer ID can never be changed or reused - so a placement created now would permanently burn the ID your code uses and force the user to rename it everywhere. Instead put the dashboard steps in ADAPTY_SETUP.md: create a FLOW placement at https://app.adapty.io/placements with the EXACT developer ID your code uses, build a flow at https://app.adapty.io/flows with the products above, and attach it to that placement. The same holds when you defer everything because the store IDs are unknown: the deferred sequence carries the `products create` commands only - no `paywalls create`, no `placements create` - and the flow placement stays a dashboard step.' + : 'Create a paywall and a placement: `paywalls create --app --title "Main Paywall" --json`, then `placements create --app --title "Main" --developer-id "main" --audiences \'[{"segment_ids":[],"paywall_id":"","priority":0}]\'`.' + } +2. Decide where the paywall belongs in THIS app before you create the placement (or hand it off in ADAPTY_SETUP.md). Read the project and look for the natural spots: onboarding, entry points of premium features, a settings/upgrade screen, locked content. Pick the one that fits the app best, name the placement after it (\`--developer-id\` like "onboarding" or "premium_feature", not a generic "main"), and show the paywall from that spot in the code. In ADAPTY_SETUP.md, say which spot you chose and list the other candidates you found, so the user can move it without hunting. +3. Implement the SDK following the platform playbook below, stage by stage, fetching the listed docs pages before writing each stage's code. +4. If a build command exists for this project, build to verify it compiles. Fix what you broke; do not chase pre-existing failures. + +--- PLATFORM PLAYBOOK (from the adapty-integration skill) --- + +${platformReference}` + }, + title: 'integration', +} diff --git a/src/lib/agent/actions/migrate.ts b/src/lib/agent/actions/migrate.ts new file mode 100644 index 0000000..d53f814 --- /dev/null +++ b/src/lib/agent/actions/migrate.ts @@ -0,0 +1,68 @@ +import type {AgentAction, PromptContext} from '../prompt.js' + +/** + * Migration reuses the integration playbook (same skill) - what differs is + * the mission: map every call site of the current billing system to its + * Adapty equivalent instead of integrating from scratch. `label` names what + * is being replaced ("RevenueCat", "the in_app_purchase plugin", ...); + * `rcCatalog` is a rendered snapshot of the RevenueCat project (from + * --rc-key) that upgrades placeholder guessing into exact recreation. + * + * The mapping rules are NOT inlined here. They live in the skill's + * references/migration.md (plus references/migration-.md where one + * exists) and arrive as ctx.migrationReference, so this prompt and the skill + * cannot drift apart. Only knowledge this CLI has and the skill cannot - + * which source the user picked, whether a catalog was actually fetched - + * stays here. + */ +export function buildMigrateAction(label: string, rcCatalog?: string): AgentAction { + return { + id: 'migrate', + task(ctx: PromptContext): string { + const {appId, cliCommand, migrationReference, platformReference} = ctx + const codeOnly = ctx.dashboardMode === 'code-only' + return `You are migrating the user's app from ${label} to Adapty - Adapty fully replaces ${label}. + +1. Map the existing setup. Find every call site of ${label}: SDK init, user identification, paywall/product fetching, purchase & restore, entitlement/subscription checks, event listeners.${ + rcCatalog + ? ' The full RevenueCat catalog is provided below - treat it as ground truth; use the code only to learn which entities the app actually calls.' + : ' Extract the REAL store product IDs and entitlement names from the code and config - use them below instead of placeholders wherever they exist.' + } + +2. ${ + codeOnly + ? `The user has ALREADY set up this app's dashboard - map ${label}'s concepts onto the entities that exist (list them via \`${cliCommand}\` with --app ${appId || ''}). Create nothing; the playbook's mapping rules below tell you how a ${label} concept matches an existing Adapty entity.` + : `Dashboard setup via \`${cliCommand}\` (app already exists - see context; scope every command with --app ${appId || ''}).` + } +${ + !rcCatalog && ctx.storeProducts + ? `\nThe user also typed in their store product IDs below. Identifiers you find in the code are trustworthy as-is - do not second-guess or replace them. Treat the user's list as a COMPLEMENT: create every product from it that the code does not already cover, and when the same product appears in both with a different identifier, keep the code's identifier and flag the mismatch in ADAPTY_SETUP.md for the user to double-check.\n\n\n${ctx.storeProducts}\n\n` + : '' +} +The MIGRATION PLAYBOOK below governs which entities to create, which to skip, and what the ADAPTY_SETUP.md handoff must contain. Follow it - do not improvise a mapping. Its rules on never creating an entity that does not map cleanly, and on never writing a guessed store identifier, are the ones that cause unfixable damage when ignored.${ + rcCatalog ? `\n\n\n${rcCatalog}\n` : '' + } + +3. Replace the code, call site by call site, using the platform playbook below for every Adapty API (init/activate, identify, getPaywall/getFlow, makePurchase, restorePurchases, access level checks). Then remove ${label} from the dependencies and delete all now-dead code that used it. + +4. If a build command exists for this project, build to verify it compiles. Fix what you broke; do not chase pre-existing failures. +${ + rcCatalog + ? '' + : `\nYou worked WITHOUT access to the ${label} account, so the code was your only source and the account almost certainly holds entities you could not see. The migration playbook's "verify against your source's dashboard" checklist is mandatory for this run.${ + label === 'RevenueCat' && !codeOnly + ? ' Mention that re-running `adapty migrate --rc-key ` automates that comparison.' + : '' + }\n` + } +--- MIGRATION PLAYBOOK (from the adapty-integration skill) --- + +${migrationReference || '(not available - state this in ADAPTY_SETUP.md and map conservatively: create nothing you cannot verify.)'} + +--- PLATFORM PLAYBOOK (from the adapty-integration skill) --- + +${platformReference}` + }, + title: 'migration', + } +} diff --git a/src/lib/agent/drivers/claude.ts b/src/lib/agent/drivers/claude.ts new file mode 100644 index 0000000..a917ecc --- /dev/null +++ b/src/lib/agent/drivers/claude.ts @@ -0,0 +1,33 @@ +import {type AgentDriver, OAUTH_AUTH_ERROR, runStreamJson} from './shared.js' + +const ALLOWED_TOOLS = ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash', 'WebFetch', 'WebSearch'] + +export const claudeDriver: AgentDriver = { + authErrorPattern: OAUTH_AUTH_ERROR, + bin: 'claude', + displayName: 'Claude Code', + id: 'claude', + installHint: 'npm install --global @anthropic-ai/claude-code', + loginHint: 'run `claude` and complete /login', + resumeHint: 'claude "work through ADAPTY_SETUP.md"', + run: (opts) => + runStreamJson( + { + args: [ + '-p', + opts.prompt, + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'acceptEdits', + '--add-dir', + opts.cwd, + '--allowedTools', + ALLOWED_TOOLS.join(','), + ], + bin: 'claude', + }, + opts, + ), +} diff --git a/src/lib/agent/drivers/codex.ts b/src/lib/agent/drivers/codex.ts new file mode 100644 index 0000000..6f426bb --- /dev/null +++ b/src/lib/agent/drivers/codex.ts @@ -0,0 +1,83 @@ +import {readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import { + type AgentDriver, + type AgentResult, + type AgentRunOptions, + extractStatuses, + OAUTH_AUTH_ERROR, + spawnStream, + STDERR_TAIL_LINES, + tailCollector, +} from './shared.js' + +async function run(opts: AgentRunOptions): Promise { + const lastMsgFile = join(tmpdir(), `adapty-codex-${process.pid}-${Date.now()}.txt`) + const args = [ + 'exec', + opts.prompt, + '--json', + '--sandbox', + 'workspace-write', + '-c', + 'sandbox_workspace_write.network_access=true', + '-C', + opts.cwd, + '--skip-git-repo-check', + '--output-last-message', + lastMsgFile, + ] + + const stderr = tailCollector(STDERR_TAIL_LINES) + const code = await spawnStream({ + args, + bin: 'codex', + cwd: opts.cwd, + env: opts.env, + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + // Codex's event schema shifts between versions; pull message text defensively. + const node = (msg.msg ?? msg.item ?? msg) as Record + const type = String(node.type ?? msg.type ?? '') + if (type.includes('agent_message') || type.includes('assistant')) { + const textValue = node.message ?? node.text ?? node.delta + if (typeof textValue === 'string') { + for (const status of extractStatuses(textValue)) opts.onStatus?.(status) + } + } + }, + onStderrLine: stderr.push, + }) + + let finalText: string | undefined + try { + finalText = (await readFile(lastMsgFile, 'utf8')).trim() || undefined + } catch { + // no final message file - fine + } finally { + await rm(lastMsgFile, {force: true}).catch(() => {}) + } + + // Auth errors often land only on stderr with no last-message file. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return {finalText, ok: code === 0} +} + +export const codexDriver: AgentDriver = { + authErrorPattern: OAUTH_AUTH_ERROR, + bin: 'codex', + displayName: 'Codex', + id: 'codex', + installHint: 'npm install --global @openai/codex', + loginHint: 'run `codex login`', + resumeHint: 'codex "work through ADAPTY_SETUP.md"', + run, +} diff --git a/src/lib/agent/drivers/copilot.ts b/src/lib/agent/drivers/copilot.ts new file mode 100644 index 0000000..df85799 --- /dev/null +++ b/src/lib/agent/drivers/copilot.ts @@ -0,0 +1,15 @@ +import {type AgentDriver, runPlainText} from './shared.js' + +export const copilotDriver: AgentDriver = { + authErrorPattern: /not (logged in|authenticated)|authentication (required|failed)|use \/login|gh auth login/i, + bin: 'copilot', + displayName: 'Copilot CLI', + id: 'copilot', + installHint: 'npm install --global @github/copilot', + loginHint: 'run `copilot` and complete /login', + resumeHint: 'copilot -p "work through ADAPTY_SETUP.md"', + // --allow-all-tools skips per-tool approval prompts (headless runs would + // otherwise stall); --add-dir grants write access to the project. + run: (opts) => + runPlainText({args: ['-p', opts.prompt, '--allow-all-tools', '--add-dir', opts.cwd], bin: 'copilot'}, opts), +} diff --git a/src/lib/agent/drivers/cursor.ts b/src/lib/agent/drivers/cursor.ts new file mode 100644 index 0000000..02abfed --- /dev/null +++ b/src/lib/agent/drivers/cursor.ts @@ -0,0 +1,24 @@ +import {type AgentDriver, runStreamJson} from './shared.js' + +export const cursorDriver: AgentDriver = { + authErrorPattern: /not (logged in|authenticated)|cursor-agent login|unauthorized|sign in/i, + bin: 'cursor-agent', + displayName: 'Cursor CLI', + id: 'cursor', + installHint: 'curl https://cursor.com/install -fsS | bash', + loginHint: 'run `cursor-agent login`', + resumeHint: 'cursor-agent "work through ADAPTY_SETUP.md"', + run: (opts) => + runStreamJson( + { + // --force allows file edits and commands in print mode - the same posture as the Claude driver. + args: ['-p', opts.prompt, '--output-format', 'stream-json', '--force'], + bin: 'cursor-agent', + // Cursor's stream-json protocol varies between versions; don't fail a clean exit just because no result event arrived. + exitCodeFallback: true, + // Cursor's result event doesn't always carry a subtype; is_error is the reliable signal. + okFromResult: (msg) => msg.is_error !== true, + }, + opts, + ), +} diff --git a/src/lib/agent/drivers/gemini.ts b/src/lib/agent/drivers/gemini.ts new file mode 100644 index 0000000..36ab26f --- /dev/null +++ b/src/lib/agent/drivers/gemini.ts @@ -0,0 +1,15 @@ +import {type AgentDriver, runPlainText} from './shared.js' + +export const geminiDriver: AgentDriver = { + authErrorPattern: /set an auth method|not authenticated|login required|oauth.*(expired|invalid)|reauthenticate/i, + bin: 'gemini', + displayName: 'Gemini CLI', + id: 'gemini', + installHint: 'npm install --global @google/gemini-cli', + loginHint: 'run `gemini` and pick an auth method', + resumeHint: 'gemini -i "work through ADAPTY_SETUP.md"', + // --yolo auto-approves edits and shell commands - the same posture as the + // Claude driver (acceptEdits + Bash in allowedTools); headless runs would + // otherwise stall on approval prompts. + run: (opts) => runPlainText({args: ['-p', opts.prompt, '--yolo'], bin: 'gemini'}, opts), +} diff --git a/src/lib/agent/drivers/index.ts b/src/lib/agent/drivers/index.ts new file mode 100644 index 0000000..6214e3d --- /dev/null +++ b/src/lib/agent/drivers/index.ts @@ -0,0 +1,37 @@ +import {execFile} from 'node:child_process' +import {promisify} from 'node:util' + +import {claudeDriver} from './claude.js' +import {codexDriver} from './codex.js' +import {copilotDriver} from './copilot.js' +import {cursorDriver} from './cursor.js' +import {geminiDriver} from './gemini.js' +import {type AgentDriver, type AgentResult, type AgentRunOptions, withAuthCheck} from './shared.js' + +export type {AgentDriver, AgentResult, AgentRunOptions, DriverId} from './shared.js' + +const execFileAsync = promisify(execFile) + +/** All supported agents; array order is the preference order when several are installed. */ +export const DRIVERS: AgentDriver[] = [claudeDriver, codexDriver, geminiDriver, cursorDriver, copilotDriver] + +/** For the --driver flag's `options` list. */ +export const DRIVER_IDS = DRIVERS.map((d) => d.id) + +async function onPath(bin: string): Promise { + try { + await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [bin], {timeout: 5000}) + return true + } catch { + return false + } +} + +export async function detectDrivers(): Promise { + const found = await Promise.all(DRIVERS.map(async (d) => ((await onPath(d.bin)) ? d : null))) + return found.filter((d): d is AgentDriver => d !== null) +} + +export async function runAgent(driver: AgentDriver, opts: AgentRunOptions): Promise { + return withAuthCheck(await driver.run(opts), driver.authErrorPattern) +} diff --git a/src/lib/agent/drivers/shared.ts b/src/lib/agent/drivers/shared.ts new file mode 100644 index 0000000..434ffa8 --- /dev/null +++ b/src/lib/agent/drivers/shared.ts @@ -0,0 +1,206 @@ +import {spawn} from 'node:child_process' +import {createInterface} from 'node:readline' + +export type DriverId = 'claude' | 'codex' | 'copilot' | 'cursor' | 'gemini' + +export interface AgentDriver { + /** Matches when a failure is recognizably this agent's own expired/missing login - drives the loginHint messaging. */ + authErrorPattern: RegExp + bin: string + displayName: string + id: DriverId + /** Shown when no agent is found on PATH. */ + installHint: string + /** How the user re-authenticates this agent when its own session expires. */ + loginHint: string + /** Command that hands ADAPTY_SETUP.md to this agent after the run. */ + resumeHint: string + run(opts: AgentRunOptions): Promise +} + +export interface AgentRunOptions { + cwd: string + /** Extra env vars for the agent process only (e.g. ADAPTY_TOKEN) - never mutate process.env. */ + env?: Record + onStatus?: (text: string) => void + prompt: string +} + +export interface AgentResult { + /** Set when the failure is recognizably the agent's own expired/missing login. */ + failureReason?: 'auth' + finalText?: string + ok: boolean +} + +/** Claude Code and Codex share OAuth-style login wording; kept in one place so a wording change can't drift between them. */ +export const OAUTH_AUTH_ERROR = + /OAuth (access|refresh) token (has )?expired|failed to authenticate|please run \/login|invalid api key|re-authenticate|not logged in/i + +export function withAuthCheck(result: AgentResult, authErrorPattern: RegExp): AgentResult { + if (!result.ok && result.finalText && authErrorPattern.test(result.finalText)) { + return {...result, failureReason: 'auth'} + } + + return result +} + +/** [STATUS] must start its line - agents also mention the token mid-sentence when describing their own work. */ +export function extractStatuses(textBlock: string): string[] { + return [...textBlock.matchAll(/^\[STATUS\]\s*(.+?)\s*$/gm)].map((m) => m[1].trim()) +} + +export interface SpawnStreamOptions { + args: string[] + bin: string + cwd: string + env?: Record + onLine: (line: string) => void + onStderrLine?: (line: string) => void +} + +/** Kill agents that stop producing output - a headless CLI stuck on an interactive prompt would otherwise hang the spinner forever. */ +const IDLE_TIMEOUT_MS = 15 * 60_000 + +export function spawnStream({args, bin, cwd, env, onLine, onStderrLine}: SpawnStreamOptions): Promise { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { + cwd, + // NO_COLOR keeps ANSI escapes out of everything surfaced to the user (stderr tails included). + env: {...process.env, NO_COLOR: '1', ...env}, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const idleTimer = setTimeout(() => child.kill('SIGTERM'), IDLE_TIMEOUT_MS) + createInterface({input: child.stdout}).on('line', (line) => { + idleTimer.refresh() + onLine(line) + }) + createInterface({input: child.stderr}).on('line', (line) => { + idleTimer.refresh() + onStderrLine?.(line) + }) + child.on('error', (error) => { + clearTimeout(idleTimer) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(idleTimer) + resolve(code) + }) + }) +} + +export const STDERR_TAIL_LINES = 30 +/** Enough for the closing summary the prompt asks for; bounded so a chatty transcript can't grow without limit. */ +const STDOUT_TAIL_LINES = 100 + +/** Agents print auth errors to stderr without producing a result payload, and chatty stdout is unbounded - keep tails so both stay diagnosable in constant memory. */ +export function tailCollector(maxLines: number): {push: (line: string) => void; tail: () => string} { + const lines: string[] = [] + return { + push(line) { + lines.push(line) + if (lines.length > maxLines) lines.shift() + }, + tail: () => lines.join('\n').trim(), + } +} + +export interface StreamJsonOptions { + args: string[] + bin: string + /** Fall back to the exit code when the stream never emits a result event - for third-party CLIs whose protocol varies between versions. */ + exitCodeFallback?: boolean + /** Whether a `result` event means success; default requires subtype "success" and no is_error. */ + okFromResult?: (msg: Record) => boolean +} + +/** + * Runner for agents that speak the Claude Code stream-json protocol + * (Claude Code, Cursor): `assistant` events carry [STATUS] text blocks, + * a final `result` event carries the outcome and summary. + */ +export async function runStreamJson( + {args, bin, exitCodeFallback, okFromResult}: StreamJsonOptions, + opts: AgentRunOptions, +): Promise { + const isOk = okFromResult ?? ((msg) => msg.subtype === 'success' && msg.is_error !== true) + let finalText: string | undefined + let ok = false + let sawResult = false + const stderr = tailCollector(STDERR_TAIL_LINES) + + const code = await spawnStream({ + args, + bin, + cwd: opts.cwd, + env: opts.env, + onLine(line) { + let msg: Record + try { + msg = JSON.parse(line) as Record + } catch { + return + } + + if (msg.type === 'assistant') { + const content = (msg.message as {content?: unknown})?.content + if (!Array.isArray(content)) return + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + for (const status of extractStatuses(block.text)) opts.onStatus?.(status) + } + } + } else if (msg.type === 'result') { + sawResult = true + if (typeof msg.result === 'string') finalText = msg.result + ok = isOk(msg) + } + }, + onStderrLine: stderr.push, + }) + + if (!sawResult && exitCodeFallback) ok = code === 0 + // Auth errors often land only on stderr with no result payload. + if (!finalText && code !== 0) finalText = stderr.tail() || undefined + return {finalText, ok: ok && code === 0} +} + +/** + * Runner for agents that stream plain text with no event protocol + * (Gemini, Copilot): [STATUS] lines drive the spinner and everything else + * accumulates into finalText. + */ +export async function runPlainText( + {args, bin}: {args: string[]; bin: string}, + opts: AgentRunOptions, +): Promise { + const output = tailCollector(STDOUT_TAIL_LINES) + const stderr = tailCollector(STDERR_TAIL_LINES) + let sawStdout = false + + const code = await spawnStream({ + args, + bin, + cwd: opts.cwd, + env: opts.env, + onLine(line) { + sawStdout = true + const [status] = extractStatuses(line) + if (status) { + opts.onStatus?.(status) + return + } + + output.push(line) + }, + onStderrLine: stderr.push, + }) + + // An agent that exits 0 without a single stdout line almost certainly stalled + // (auth refusals often land only on stderr) - don't report that as success. + const ok = code === 0 && sawStdout + let finalText = output.tail() || undefined + if (!finalText && !ok) finalText = stderr.tail() || undefined + return {finalText, ok} +} diff --git a/src/lib/agent/products.ts b/src/lib/agent/products.ts new file mode 100644 index 0000000..e2f7cd9 --- /dev/null +++ b/src/lib/agent/products.ts @@ -0,0 +1,177 @@ +import {type ProductPeriod, VALID_PERIODS} from '../api-schemas.js' +import {type Platform} from '../project/scan.js' +import {confirm, isInteractive, notice, select, text} from '../ui/ask.js' + +/** + * Interactive collection of real store product IDs. Store IDs are immutable + * in Adapty, so user-provided IDs are the difference between the agent + * creating the full dashboard setup (products + paywall + placement) and + * deferring all of it to ADAPTY_SETUP.md. + * + * Contracts: + * - Skippable at every step: the store question has an explicit skip option, + * and an empty answer to the first ID prompt of a product abandons + * collection ("I'll do it later" is always one Enter away). + * - Cancel (Esc/Ctrl-C) anywhere returns null - the caller aborts the whole + * command, same as cancelling any other wizard question. + * - "-" for a store ID means "this product is not in that store". + */ + +export interface StoreProduct { + appStoreId?: string + /** Only for Google Play subscriptions (never set when period is 'lifetime'). */ + googleBasePlanId?: string + googlePlayId?: string + period: ProductPeriod +} + +type StorePick = 'app_store' | 'both' | 'google_play' | 'skip' + +/** Exhaustive by construction: adding a Platform member forces a decision here. */ +const PLATFORM_STORES: Record = { + android: {appStore: false, googlePlay: true}, + capacitor: {appStore: true, googlePlay: true}, + flutter: {appStore: true, googlePlay: true}, + ios: {appStore: true, googlePlay: false}, + kmp: {appStore: true, googlePlay: true}, + 'react-native': {appStore: true, googlePlay: true}, + unity: {appStore: true, googlePlay: true}, +} + +/** + * The store question is a single select of mutually exclusive combinations - + * never a multi-select competing with a "No" option. Irrelevant stores are + * not shown at all. null = cancelled. + */ +async function askWhichStores(platform: Platform): Promise { + const {appStore, googlePlay} = PLATFORM_STORES[platform] + const options = [ + ...(appStore && googlePlay ? [{label: 'Yes, in both stores', value: 'both'}] : []), + ...(appStore + ? [{label: googlePlay ? 'Yes, in the App Store only' : 'Yes, in the App Store', value: 'app_store'}] + : []), + ...(googlePlay + ? [{label: appStore ? 'Yes, in Google Play only' : 'Yes, in Google Play', value: 'google_play'}] + : []), + {hint: 'the agent will leave ready-to-run commands in ADAPTY_SETUP.md', label: 'Not yet - skip', value: 'skip'}, + ] + const answer = await select('Do your products already exist in the stores?', options, 'skip') + return answer as null | StorePick +} + +/** + * One store-ID answer: null = cancelled, '' = skip/finish, '-' = not in this + * store. Rejects IDs with whitespace (immutable once created - a typo is + * unfixable) and warns about uppercase in Google Play IDs. + */ +async function askProductId( + message: string, + defaultValue: string | undefined, + googlePlay: boolean, +): Promise { + for (;;) { + const raw = await text(message, defaultValue) + if (raw === null) return null + const id = raw.trim() + if (!id || id === '-') return id + if (/\s/.test(id)) { + notice('Store product IDs cannot contain spaces - store IDs are immutable in Adapty, so typos are unfixable.') + continue + } + + if (googlePlay && /[A-Z]/.test(id)) { + notice('Google Play product IDs are lowercase by convention - double-check this one before continuing.') + } + + return id + } +} + +/** + * Returns the collected products, [] when skipped, or null when the user + * cancelled (the caller should abort the command). Headless runs always + * return [] without prompting. + */ +export async function collectStoreProducts(platform: Platform): Promise { + if (!isInteractive()) return [] + + const pick = await askWhichStores(platform) + if (pick === null) return null + if (pick === 'skip') return [] + + const askAppStore = pick === 'app_store' || pick === 'both' + const askGooglePlay = pick === 'google_play' || pick === 'both' + const products: StoreProduct[] = [] + + for (;;) { + const n = products.length + 1 + const exitHint = n === 1 ? 'Enter to skip products for now' : 'Enter to finish' + + let appStoreId: string | undefined + if (askAppStore) { + const notInStore = askGooglePlay ? ', "-" if not on the App Store' : '' + const id = await askProductId(`Product ${n} - App Store product ID (${exitHint}${notInStore})`, undefined, false) + if (id === null) return null + if (!id) break + if (id !== '-') appStoreId = id + } + + let googlePlayId: string | undefined + if (askGooglePlay) { + // Cross-store products usually share the identifier - Enter reuses it. + const message = appStoreId + ? `Product ${n} - Google Play product ID (Enter to reuse the App Store ID, "-" if not on Google Play)` + : `Product ${n} - Google Play product ID (${exitHint})` + const id = await askProductId(message, appStoreId, true) + if (id === null) return null + if (!id && !appStoreId) break + if (id && id !== '-') googlePlayId = id + } + + // Both stores answered "-": nothing to create for this entry. + if (!appStoreId && !googlePlayId) break + + const period = await select( + `Product ${n} - subscription period`, + VALID_PERIODS.map((value) => ({ + hint: value === 'lifetime' ? 'one-time purchase, not a subscription' : undefined, + label: value, + value, + })), + 'monthly', + ) + if (period === null) return null + + // Google Play subscriptions carry a base plan; lifetime products never do. + let googleBasePlanId: string | undefined + if (googlePlayId && period !== 'lifetime') { + const basePlan = await text(`Product ${n} - Google Play base plan ID (e.g. "monthly-base")`) + if (basePlan === null) return null + if (basePlan.trim()) googleBasePlanId = basePlan.trim() + } + + products.push({appStoreId, googleBasePlanId, googlePlayId, period: period as ProductPeriod}) + + const more = await confirm(`Product ${n} saved. Add another?`, false) + if (more === null) return null + if (!more) break + } + + return products +} + +/** Prompt block: user-provided ground truth, same contract as the RC catalog. */ +export function renderStoreProducts(products: StoreProduct[]): string { + const lines = products.map((p) => { + const parts = [ + `period: ${p.period}`, + ...(p.appStoreId ? [`App Store: ${p.appStoreId}`] : []), + ...(p.googlePlayId + ? [`Google Play: ${p.googlePlayId}${p.googleBasePlanId ? ` (base plan: ${p.googleBasePlanId})` : ''}`] + : []), + ] + return `- ${parts.join(' | ')}` + }) + return `The user provided their EXACT store product IDs and periods - use them verbatim (store IDs are immutable, never "normalize" them; pass the period to \`products create --period\`). One line = one Adapty product; a line with both stores = ONE product carrying both store IDs. +${lines.join('\n')}` +} diff --git a/src/lib/agent/prompt.ts b/src/lib/agent/prompt.ts new file mode 100644 index 0000000..f13d5b4 --- /dev/null +++ b/src/lib/agent/prompt.ts @@ -0,0 +1,136 @@ +import {dirname, join} from 'node:path' + +import type {DetectedProject} from '../project/scan.js' + +/** + * 'code-only' = the app's dashboard entities already exist, the agent creates + * nothing; 'create' = today's behavior. Declared here (not in wizard.ts, which + * resolves it) because wizard.ts already imports from this module. + */ +export type DashboardMode = 'code-only' | 'create' + +/** + * Shared context every agent-driven command provides. Action-specific data + * (e.g. a migration's source provider) travels inside the action's task body. + */ +export interface PromptContext { + appId: string + /** How the agent invokes this exact CLI build (never `npx adapty@latest` - version skew). */ + cliCommand: string + /** 'code-only' = the app's entities already exist, the agent must create nothing. Set by the wizard. */ + dashboardMode?: DashboardMode + /** Rendered migration playbook (spine + source file) - set only by `migrate`. */ + migrationReference?: string + paywallApproach: string + /** The placement developer ID the code must fetch - resolved by the CLI so the agent never chooses it. */ + placementDeveloperId?: string + platformReference: string + project: DetectedProject + sdkKey: string + /** Rendered user-provided store product IDs (see products.ts) - ground truth when present. */ + storeProducts?: string +} + +/** + * One agent-driven task (integrate, migrate, ...). The shared wrapper below + * supplies the context block, the common rules, the [STATUS] protocol, and + * the ADAPTY_SETUP.md finish conventions - an action only defines its + * mission-specific task body and human labels. + */ +export interface AgentAction { + /** Command id, used in telemetry tags (e.g. 'integrate'). */ + id: string + /** The task body. Opens with the mission sentence; conditional content lives here. */ + task(ctx: PromptContext): string + /** Human noun for messages like "How was the integration?" */ + title: string +} + +/** 'headless' = run by this CLI, no user available; 'copy' = pasted into the user's own interactive agent. */ +export type PromptMode = 'copy' | 'headless' + +/** + * The command the agent must use to invoke THIS build of the CLI. Never + * `npx adapty@latest` (the published package can lag behind the commands the + * prompt relies on). bin/dev.js only works through its shebang (ts-node + * loader flags), so a bare `node dev.js` child would crash on .ts imports - + * point at its sibling run.js instead. + */ +export function resolveCliCommand(): string { + const entry = process.argv[1] + if (!entry) return 'npx -y adapty@latest' + const resolved = entry.endsWith('dev.js') ? join(dirname(entry), 'run.js') : entry + return `node "${resolved}"` +} + +function contextBlock(ctx: PromptContext): string { + const {appId, paywallApproach, project, sdkKey} = ctx + return ` + ${project.platformLabel} + ${project.path} + ${appId || '(not provided)'} + ${sdkKey || '(not provided - ask the user to paste it from the Adapty dashboard before running)'} + ${ctx.placementDeveloperId ? `${ctx.placementDeveloperId}\n ` : ''}${paywallApproach ? `${paywallApproach}\n ` : ''}https://adapty.io/docs/llms.txt lists all pages; fetch any page as https://adapty.io/docs/{slug}.md +` +} + +function rulesBlock(ctx: PromptContext, actionId: string, mode: PromptMode): string { + const interactionRule = + mode === 'headless' + ? `This is a HEADLESS run - there is no user to ask. Never ask questions. Where a playbook says to ask the user, pick the stated default (or the most conservative option), continue, and record the decision in ADAPTY_SETUP.md (see the finish instructions).` + : `When a decision genuinely needs the user (store product IDs, existing dashboard setup), ask - otherwise pick the stated default and record it in ADAPTY_SETUP.md (see the finish instructions).` + const statusRule = + mode === 'headless' + ? `\n Emit a one-line progress update prefixed with '[STATUS] ' before EVERY step, including before running any shell command (e.g. '[STATUS] Installing the Adapty package', '[STATUS] Creating the placement'). The user only sees these [STATUS] lines - never go more than one tool call without one.` + : '' + const dashboardRule = + ctx.dashboardMode === 'code-only' + ? `The user has ALREADY set up this app's dashboard entities - create NO dashboard entities of any kind (no access levels, products, paywalls, or placements). List what exists (\`${ctx.cliCommand} access-levels list --json\`, \`products list --json\`, \`paywalls list --json\`, \`placements list --json\`) and wire the code to exactly those strings. Anything genuinely absent goes into ADAPTY_SETUP.md as a ready-to-run command - never created in this run.` + : `Before creating ANY entity, list what already exists (\`${ctx.cliCommand} list --json\`) and never recreate something already there - reuse it. This also makes a re-run after a half-finished session safe.` + + return ` + Make the smallest set of edits that achieves a working, verifiable result. Do not refactor unrelated code or reformat files. + ${interactionRule} + Never invent an Adapty API key. Use the exact public SDK key from the context. Never write a secret key into source. + Before editing any file, read it first. Match the file's existing style and conventions. + Detect the package manager / build system from the project (lockfiles, Podfile, gradle files, pubspec.yaml) rather than assuming. + Dashboard entities (access levels, products, paywalls, placements) are managed with the Adapty CLI. Invoke it EXACTLY as \`${ctx.cliCommand} --json\` and scope every command with --app ${ctx.appId || ''}. It authenticates via the ADAPTY_TOKEN environment variable, which is already set for you. Never ask the user for IDs the CLI can return. + ${dashboardRule} + NEVER run the CLI's auth commands (auth login / logout / revoke) - login needs a browser and logout would destroy the user's session. If a CLI call fails with an auth error, do NOT retry or troubleshoot auth: record the exact remaining commands in ADAPTY_SETUP.md and continue with the code stages. + Store product IDs are IMMUTABLE in Adapty - once a product is created its store IDs can never be changed, only the whole product deleted and recreated. So NEVER create a product with a guessed or placeholder store ID. Create products only with real IDs you found in the code, config, or provided data. When you do not know the real IDs, create NO products - and no paywall or placement either (they would be empty shells): put the full ready-to-run command sequence (products create -> paywalls create -> placements create, with slots) in ADAPTY_SETUP.md instead, and make sure the placement ID used in code matches the one in those commands. + Never invent an SDK API. Every symbol you write must come from the playbook below or a docs page you fetched. Where a docs URL in the playbook carries a ?ref=skill- tag, use ref=cli-${actionId} instead.${statusRule} +` +} + +function finishBlock(mode: PromptMode): string { + return ` +When the task is complete: + +1. Write a checklist file \`ADAPTY_SETUP.md\` in the app directory root covering everything that still needs a human (or another agent session). Use GitHub checkboxes (\`- [ ]\`), be specific (exact IDs, file paths, dashboard URLs). Where a step genuinely needs a guide - connecting App Store / Google Play credentials, server notifications, creating flows - add the Adapty docs link (find the exact slug via https://adapty.io/docs/llms.txt; the .md variant of a page URL is agent-friendly). Do not force a link onto every item. Group into these sections, skipping any that are empty: + - **Replace placeholders** - every placeholder or default you chose, with where it lives (Adapty dashboard and/or code) and what to put there instead. + - **Dashboard steps** - what must be done at https://app.adapty.io. + - **Verify on device** - run the app, see the paywall at the placement(s), complete a sandbox purchase. + - **Before release** - fetch https://adapty.io/docs/release-checklist.md and include the few items relevant to this app (e.g. server notifications, switching placeholder products to real ones). + Start the file with one line stating what was done and when to delete the file, and end it with: "Tip: you can hand this file to a coding agent - e.g. \`claude "work through ADAPTY_SETUP.md"\` - to finish these steps." +2. End with a 3-5 line summary (what was changed and configured, the key IDs, and that the remaining steps are in ADAPTY_SETUP.md).${ + mode === 'headless' ? `\n Then a final line '[STATUS] Done'.` : '' + } +` +} + +export function buildActionPrompt(action: AgentAction, ctx: PromptContext, mode: PromptMode = 'headless'): string { + return `${contextBlock(ctx)} + +${rulesBlock(ctx, action.id, mode)} + + +${action.task(ctx)} + + +${finishBlock(mode)}` +} + +/** Same prompt for pasting into the user's own interactive agent: no [STATUS] protocol, and questions are allowed. */ +export function buildCopyPrompt(action: AgentAction, ctx: PromptContext): string { + return buildActionPrompt(action, ctx, 'copy') +} diff --git a/src/lib/agent/run.ts b/src/lib/agent/run.ts new file mode 100644 index 0000000..122a11a --- /dev/null +++ b/src/lib/agent/run.ts @@ -0,0 +1,198 @@ +import type {Command} from '@oclif/core' + +import {existsSync} from 'node:fs' +import {join} from 'node:path' + +import {availableBranchName, createBranch, isGitRepo} from '../project/git.js' +import {confirm, spinner, text} from '../ui/ask.js' +import {copyToClipboard} from '../ui/clipboard.js' +import {type AgentDriver, type AgentResult, runAgent} from './drivers/index.js' +import {type AgentAction, buildActionPrompt, buildCopyPrompt, type PromptContext} from './prompt.js' +import {installAgentSkills} from './skills-install.js' +import {telemetryDisabled, trackAgentRun} from './telemetry.js' + +const DASHBOARD_URL = 'https://app.adapty.io' + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1) +} + +export interface RunActionResult { + failureReason?: 'auth' + finalText?: string + ok: boolean +} + +/** Leave the skill installed in the user's agents so future sessions know Adapty. */ +async function installSkill(): Promise { + const spin = spinner() + spin.start('Installing the Adapty skill into your coding agent') + const installed = await installAgentSkills() + spin.stop( + installed + ? 'Adapty skill installed - your agent can now handle Adapty tasks in any session.' + : 'Skill install skipped - run `npx skills add adaptyteam/adapty-skills` to add it manually.', + ) +} + +/** + * The copy-only exit shared by every agent-driven command: put the prompt on + * the clipboard (or print it). `installSkill` comes from the no-agent path, + * where the user asked for the skill in the agent they actually use. + */ +export async function emitCopyPrompt( + command: Command, + action: AgentAction, + ctx: PromptContext, + opts: {installSkill?: boolean} = {}, +): Promise { + const prompt = buildCopyPrompt(action, ctx) + const copied = await copyToClipboard(prompt) + if (copied) command.log(`\n${capitalize(action.title)} prompt copied to clipboard - paste it into any coding agent.`) + else command.log(`\n${prompt}\n\n(Copy the prompt above into any coding agent.)`) + + // After the prompt, never before: the install can take a minute, and the + // clipboard is what the user is waiting for. + if (opts.installSkill) await installSkill() +} + +/** The failure exit shared by every agent-driven command; never returns. */ +export function reportActionFailure(command: Command, driver: AgentDriver, result: RunActionResult): never { + const name = command.id ?? 'integrate' + command.error( + result.failureReason === 'auth' + ? `${driver.displayName} isn't logged in (its own session expired - not your Adapty login). ` + + `To fix: ${driver.loginHint}, then re-run \`adapty ${name}\`. Or use --copy to drive your own agent.` + : `The agent stopped before finishing. Re-run \`adapty ${name}\`, or use --copy to drive your own agent.`, + ) +} + +/** + * Put the run on a branch of its own. An agent rewrites files all over the + * project; on its own branch that is one `git switch -` away from undone, and + * the diff reviews like any other PR. Not a question - the branch costs the + * user nothing and its absence costs them a lot, so it just happens. + * + * No git at all is the case worth stopping for: there is then no way to see + * what changed or to undo it, and that is the user's call to make. + * + * Returns the branch name, undefined when there is none (no repo, or git + * refused), or null when the user chose not to continue without git. + */ +export async function prepareWorkBranch( + command: Command, + dir: string, + actionId: string, + interactive: boolean, +): Promise { + if (!(await isGitRepo(dir))) { + command.warn( + 'This project is not a git repository - there will be no way to review the agent\'s changes with `git diff` or undo them.', + ) + // Headless has nobody to ask; warning them is all we can do. + if (!interactive) return undefined + const proceed = await confirm('Continue anyway?', false) + return proceed ? undefined : null + } + + const name = await availableBranchName(dir, `adapty-${actionId}`) + if (await createBranch(dir, name)) { + command.log(`Working on branch ${name}`) + return name + } + + command.warn(`Could not create branch ${name} - continuing on the current branch.`) + return undefined +} + +export interface RunActionOptions { + action: AgentAction + /** Branch created for this run, if any - named in the closing message. */ + branch?: string + ctx: PromptContext + driver: AgentDriver + /** Extra env for the agent process only (e.g. ADAPTY_TOKEN) - keeps secrets out of the global process.env. */ + env?: Record + interactive: boolean + /** --no-telemetry: skip the usage event entirely (also honored: ADAPTY_TELEMETRY_DISABLED=1, DO_NOT_TRACK=1). */ + noTelemetry?: boolean +} + +/** + * The full post-question sequence shared by every agent-driven command: + * run the agent headless → surface [STATUS] lines → rating → telemetry → + * install the Adapty skill → point at ADAPTY_SETUP.md. Returns the result; + * the caller decides how to fail. + */ +export async function runActionWithFollowUp( + command: Command, + {action, branch, ctx, driver, env, interactive, noTelemetry}: RunActionOptions, +): Promise { + // Disclosed once during setup (see prepareWizard) - nothing to print here. + const sendTelemetry = !noTelemetry && !telemetryDisabled() + + const spin = spinner() + spin.start(`Running ${driver.displayName} - this can take a few minutes`) + const started = Date.now() + let result: AgentResult + try { + result = await runAgent(driver, { + cwd: ctx.project.path, + env, + onStatus: (statusText) => spin.message(statusText), + prompt: buildActionPrompt(action, ctx), + }) + } catch (error) { + // spawn itself failed (binary vanished, unspawnable) - fail with the crafted path, not a raw stack. + result = {finalText: error instanceof Error ? error.message : String(error), ok: false} + } + + spin.stop(result.ok ? `${capitalize(action.title)} complete` : 'The agent stopped before finishing.') + + const track = async (rating: null | number) => { + if (!sendTelemetry) return + await trackAgentRun({ + appId: ctx.appId, + command: action.id, + driver: driver.id, + durationS: Math.round((Date.now() - started) / 1000), + isDev: existsSync(join(command.config.root, '.git')), + ok: result.ok, + paywallApproach: ctx.paywallApproach, + platform: ctx.project.platform, + rating, + version: command.config.version, + }) + } + + if (!result.ok) { + if (result.finalText) command.log(`\n${result.finalText.slice(0, 1500)}`) + await track(null) + return {failureReason: result.failureReason, finalText: result.finalText, ok: false} + } + + if (result.finalText) command.log(`\n${result.finalText}`) + + let rating: null | number = null + if (interactive) { + const answer = (await text(`How was the ${action.title}? Rate 1-5 (enter to skip)`)) ?? '' + const parsed = Number.parseInt(answer, 10) + if (parsed >= 1 && parsed <= 5) rating = parsed + } + + await track(rating) + + await installSkill() + + command.log( + `\nDone. Review the changes with \`git diff\`${branch ? ` on branch ${branch}` : ''}, then finish up in the dashboard: ${DASHBOARD_URL}`, + ) + if (existsSync(join(ctx.project.path, 'ADAPTY_SETUP.md'))) { + command.log( + 'The remaining steps are in ADAPTY_SETUP.md - work through it yourself, or hand it to your agent:\n' + + ` ${driver.resumeHint}`, + ) + } + + return {finalText: result.finalText, ok: true} +} diff --git a/src/lib/agent/skill-source.ts b/src/lib/agent/skill-source.ts new file mode 100644 index 0000000..428f91e --- /dev/null +++ b/src/lib/agent/skill-source.ts @@ -0,0 +1,77 @@ +import {readFile} from 'node:fs/promises' +import {join} from 'node:path' + +import type {Platform} from '../project/scan.js' + +/** + * Skill content is NOT vendored into this package - the single source of + * truth is the adapty-skills repo. Files are fetched from + * GitHub raw at run time; set ADAPTY_SKILL_DIR to a local checkout of the + * skill directory (the one containing SKILL.md) to develop against local + * edits. + */ +const RAW_BASE = + 'https://raw.githubusercontent.com/adaptyteam/adapty-skills/main/skills/adapty-integration' + +function stripFrontmatter(md: string): string { + const lines = md.split('\n') + if (lines[0]?.trim() === '---') { + const end = lines.indexOf('---', 1) + if (end > 0) return lines.slice(end + 1).join('\n').trim() + } + + return md.trim() +} + +async function loadSkillFile(rel: string): Promise { + let content: string + const localDir = process.env.ADAPTY_SKILL_DIR + if (localDir) { + content = await readFile(join(localDir, rel), 'utf8') + } else { + const response = await fetch(`${RAW_BASE}/${rel}`, {signal: AbortSignal.timeout(15_000)}) + if (!response.ok) { + throw new Error( + `Could not fetch skill file "${rel}" (HTTP ${response.status}). Check your connection and retry.`, + ) + } + + content = await response.text() + } + + // Normalize CRLF (e.g. a Windows checkout via ADAPTY_SKILL_DIR) so frontmatter stripping works. + return stripFrontmatter(content.replaceAll('\r\n', '\n')) +} + +/** + * A skill file that may legitimately not exist. Only some migration sources + * have a dedicated references/migration-.md; a missing one means "use + * the spine's unknown-source path", not an error. Every other failure still + * throws, so a network problem is never mistaken for an absent file. + */ +async function loadOptionalSkillFile(rel: string): Promise { + try { + return await loadSkillFile(rel) + } catch (error) { + if (error instanceof Error && error.message.includes('HTTP 404')) return undefined + if ((error as {code?: string}).code === 'ENOENT') return undefined + throw error + } +} + +/** The platform-specific integration playbook (references/.md). */ +export async function loadPlatformReference(platform: Platform): Promise { + return loadSkillFile(`references/${platform}.md`) +} + +/** + * The migration playbook: the source-agnostic spine, plus the source-specific + * file when the skill ships one. The spine is REQUIRED - it carries the + * mapping rules and the ADAPTY_SETUP.md contract that the migrate prompt no + * longer inlines, so a run without it would silently lose them. + */ +export async function loadMigrationReference(source?: string): Promise { + const spine = await loadSkillFile('references/migration.md') + const specific = source ? await loadOptionalSkillFile(`references/migration-${source}.md`) : undefined + return specific ? `${spine}\n\n---\n\n${specific}` : spine +} diff --git a/src/lib/agent/skills-install.ts b/src/lib/agent/skills-install.ts new file mode 100644 index 0000000..539a94e --- /dev/null +++ b/src/lib/agent/skills-install.ts @@ -0,0 +1,20 @@ +import {spawn} from 'node:child_process' + +/** + * Install the adapty-integration skill into the user's coding agents + * (Claude Code, Codex, Cursor, ...) via the `skills` CLI, so the agent knows + * Adapty in every future session - not just this run. Same source repo the + * integrate prompt is built from. + */ +const SKILL_SOURCE = 'adaptyteam/adapty-skills' + +export function installAgentSkills(): Promise { + return new Promise((resolve) => { + const child = spawn('npx', ['-y', 'skills@latest', 'add', SKILL_SOURCE, '--global', '--yes'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 120_000, + }) + child.on('error', () => resolve(false)) + child.on('close', (code) => resolve(code === 0)) + }) +} diff --git a/src/lib/agent/telemetry.ts b/src/lib/agent/telemetry.ts new file mode 100644 index 0000000..c1d32b5 --- /dev/null +++ b/src/lib/agent/telemetry.ts @@ -0,0 +1,59 @@ +/** + * One event per agent-driven command run (integrate, migrate, ...), sent to + * the same feedback endpoint the adapty-integration skill uses (Slack + + * Airtable behind it), so CLI and skill sessions land in one funnel. + * Fire-and-forget: never blocks the command for more than 3s, never surfaces + * an error. + * + * Opt out with ADAPTY_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1. + * Override the endpoint with ADAPTY_FEEDBACK_URL (used by tests). + */ +const DEFAULT_ENDPOINT = 'https://feedback-endpoint-eandreeva-twrs-projects.vercel.app/api/sdk-integration-feedback' + +export interface AgentRunEvent { + appId: string + /** Which agent-driven command ran, e.g. 'integrate'. */ + command: string + driver: string + durationS: number + /** Running from a source checkout (has .git) rather than an npm install. */ + isDev: boolean + ok: boolean + paywallApproach: string + platform: string + rating: null | number + version: string +} + +export function telemetryDisabled(): boolean { + return process.env.ADAPTY_TELEMETRY_DISABLED === '1' || process.env.DO_NOT_TRACK === '1' +} + +export async function trackAgentRun(event: AgentRunEvent): Promise { + if (telemetryDisabled()) return + + const tags = `source:cli-${event.command} v${event.version} · driver:${event.driver}${event.isDev ? ' · dev' : ''}` + const slackText = + `[${event.platform} · ${event.paywallApproach}] CLI ${event.command} ${event.ok ? '✓' : '✗'} in ${event.durationS}s · ${tags}` + + (event.rating ? ` · Rating: ${event.rating}/5` : '') + + (event.appId ? ` · App: ${event.appId}` : '') + + try { + await fetch(process.env.ADAPTY_FEEDBACK_URL ?? DEFAULT_ENDPOINT, { + body: JSON.stringify({ + app_id: event.appId || null, + integrations: tags, + paywall_approach: event.paywallApproach, + phases_completed: event.ok ? 4 : 0, + platform: event.platform, + rating: event.rating, + slack_text: slackText, + }), + headers: {'Content-Type': 'application/json'}, + method: 'POST', + signal: AbortSignal.timeout(3000), + }) + } catch { + // telemetry must never break the workflow + } +} diff --git a/src/lib/agent/wizard.ts b/src/lib/agent/wizard.ts new file mode 100644 index 0000000..ab0d71b --- /dev/null +++ b/src/lib/agent/wizard.ts @@ -0,0 +1,620 @@ +import type {Command} from '@oclif/core' + +import type {ApiClient} from '../api-client.js' +import type { + AccessLevelDTO, + AppDetailDTO, + AppSummaryDTO, + PaywallDTO, + PlacementSummaryDTO, + ProductDTO, +} from '../api-schemas.js' + +import {resolveToken} from '../auth.js' +import {createAuthenticatedClient} from '../client-from-config.js' +import {isValidUuid, type PaginatedResponse, paginationParams} from '../flags.js' +import {type DetectedProject, scanProject} from '../project/scan.js' +import {confirm, isInteractive, select, spinner, text} from '../ui/ask.js' +import {type AgentDriver, detectDrivers, DRIVERS} from './drivers/index.js' +import {renderStoreProducts, type StoreProduct} from './products.js' +import {type DashboardMode, type PromptContext, resolveCliCommand} from './prompt.js' +import {loadPlatformReference} from './skill-source.js' +import {telemetryDisabled} from './telemetry.js' + +/** Everything already in the bound Adapty app - the basis for mode detection and the placement picker. */ +export interface DashboardSnapshot { + accessLevels: AccessLevelDTO[] + paywalls: PaywallDTO[] + placements: PlacementSummaryDTO[] + products: ProductDTO[] + /** Server-side totals per kind - the lists above hold only the first page (100). */ + totals: {accessLevels: number; paywalls: number; placements: number; products: number} +} + +// One page is plenty here: the snapshot exists to detect a populated app and +// to offer a placement picker, not to mirror the account. +const SNAPSHOT_PAGE = paginationParams({page: 1, 'page-size': 100}) + +/** Four parallel GETs against endpoints that already back the CLI's own `list` commands. */ +export async function fetchDashboardSnapshot(client: ApiClient, appId: string): Promise { + const [accessLevels, products, paywalls, placements] = await Promise.all([ + client.get>(`/apps/${appId}/access-levels`, SNAPSHOT_PAGE), + client.get>(`/apps/${appId}/products`, SNAPSHOT_PAGE), + client.get>(`/apps/${appId}/paywalls`, SNAPSHOT_PAGE), + client.get>(`/apps/${appId}/placements`, SNAPSHOT_PAGE), + ]) + return { + accessLevels: accessLevels.data, + paywalls: paywalls.data, + placements: placements.data, + products: products.data, + totals: { + accessLevels: accessLevels.meta.pagination.count, + paywalls: paywalls.meta.pagination.count, + placements: placements.meta.pagination.count, + products: products.meta.pagination.count, + }, + } +} + +export function snapshotIsEmpty(snapshot: DashboardSnapshot): boolean { + return ( + snapshot.accessLevels.length === 0 && + snapshot.products.length === 0 && + snapshot.paywalls.length === 0 && + snapshot.placements.length === 0 + ) +} + +/** "Products Monthly, Annual, Lifetime + 2 more" - names, because counts don't let the user recognize their own setup. */ +function nameRow(label: string, names: string[], total: number): string[] { + if (names.length === 0) return [] + // The list is one page; the server total keeps "+ N more" honest past it. + const count = Math.max(total, names.length) + const shown = names.slice(0, 3).join(', ') + const more = count > 3 ? ` + ${count - 3} more` : '' + return [`${label.padEnd(14)} ${shown}${more}`] +} + +/** One line per non-empty entity kind, named by the field that identifies it to a human. */ +export function renderSnapshotLines(snapshot: DashboardSnapshot): string[] { + const {totals} = snapshot + return [ + // sdk_id / developer_id are the strings that end up in code; titles are what the dashboard shows. + ...nameRow('Access levels', snapshot.accessLevels.map((a) => a.sdk_id), totals.accessLevels), + ...nameRow('Products', snapshot.products.map((p) => p.title), totals.products), + ...nameRow('Paywalls', snapshot.paywalls.map((p) => p.title), totals.paywalls), + ...nameRow('Placements', snapshot.placements.map((p) => p.developer_id), totals.placements), + ] +} + +export type {DashboardMode} from './prompt.js' + +/** + * The CLI decides the mode BEFORE the agent launches - the prompt never asks + * the agent to work out which mode it is in. 'ask' sends the question to the + * user; 'headless-needs-flag' makes the command error: headless is scripting, + * and a wrong guess against a populated app is silent and unrecoverable + * (immutable store IDs, placements that block flow IDs), while a refused run + * is cheap to restart. + */ +export function decideDashboardMode(opts: { + codeOnlyFlag: boolean | undefined + interactive: boolean + snapshot: DashboardSnapshot | null +}): 'ask' | 'headless-needs-flag' | DashboardMode { + if (opts.codeOnlyFlag !== undefined) return opts.codeOnlyFlag ? 'code-only' : 'create' + if (opts.snapshot === null) return opts.interactive ? 'ask' : 'create' // fetch failed: never fatal + if (snapshotIsEmpty(opts.snapshot)) return 'create' + return opts.interactive ? 'ask' : 'headless-needs-flag' +} + +/** + * The one snapshot value the agent must not choose for itself: several + * placements and nothing in the code says which one this app fetches - that + * information was lost when the setup happened in the dashboard - and a wrong + * developer_id compiles, ships, and silently returns nothing at runtime. + * + * Called by the command AFTER its paywall-approach question (which is why this + * cannot run inside prepareWizard); reuses the placements the wizard already + * fetched. flow_builder always gets the text prompt: the placements endpoint + * returns paywall placements only, so an empty list is ambiguous there. Both + * text prompts are Enter-to-skip - skipping falls through to the playbook's + * inferred-ID rule, same contract as collectStoreProducts. + * + * null = cancelled; undefined = skipped or not applicable; string = resolved. + */ +export async function resolvePlacementDeveloperId( + command: Command, + setup: WizardSetup, + approach: string, +): Promise { + if (setup.dashboardMode !== 'code-only') return undefined + if (approach === 'observer') return undefined + + const {interactive, placements} = setup + + if (approach === 'custom' && placements.length === 1) { + command.log(`Using placement "${placements[0].developer_id}" from your dashboard.`) + return placements[0].developer_id + } + + if (!interactive) return undefined // never prompt without a TTY + + if (approach === 'custom' && placements.length > 1) { + const choice = await select( + 'Which placement should the code fetch?', + placements.map((p) => ({hint: p.title, label: p.developer_id, value: p.developer_id})), + placements[0].developer_id, + ) + return choice // null = cancelled, passed straight through + } + + // flow_builder (flows aren't in the endpoint yet), or custom with zero placements. + const answer = await text( + approach === 'flow_builder' + ? "Your flow's placement ID from the dashboard (Enter to skip - the agent will pick one and flag it)" + : 'Placement ID the code should fetch (Enter to skip - the agent will pick one and flag it)', + ) + if (answer === null) return null + return answer.trim() || undefined +} + +export interface WizardFlags { + app?: string + 'code-only'?: boolean + copy?: boolean + driver?: string + 'no-telemetry'?: boolean + path: string +} + +export interface WizardSetup { + appId: string + /** Nothing will be run for the user: --copy, or no agent was found and they took the prompt instead. */ + copyOnly: boolean + /** Resolved BEFORE the agent launches: 'code-only' = wire code to existing entities, create nothing. */ + dashboardMode: DashboardMode + /** null whenever copyOnly is true. */ + driver: AgentDriver | null + /** The user asked for the Adapty skill in the agent they actually use (only offered when none was found). */ + installSkill: boolean + interactive: boolean + /** Paywall placements already in the app (flow placements are not returned by the API yet). */ + placements: PlacementSummaryDTO[] + /** Playbook fetch started during the wizard so its latency hides behind the user's answers. */ + playbook: Promise<{error: unknown; ok: false} | {ok: true; reference: string}> + project: DetectedProject + sdkKey: string + /** Adapty session token, for scoping into the agent's environment. Empty in keyless --copy runs. */ + token: string +} + +/** + * The gathering steps shared by every agent-driven command (integrate, + * migrate, ...): detect the project, find a coding agent, ensure an Adapty + * session, and bind an Adapty app + SDK key. Returns null when the user + * cancels (already logged); throws command.error on fatals. + */ +export async function prepareWizard(command: Command, flags: WizardFlags): Promise { + const commandName = command.id ?? 'integrate' + + // 1. What app is this? + const project = await scanProject(flags.path) + if (!project) { + command.error( + 'No supported mobile app found here. Supported: iOS, Android, Flutter, React Native, Capacitor, Unity, Kotlin Multiplatform.\n' + + 'Run from the app directory or pass --path.', + {exit: 2}, + ) + } + + command.log(`Detected ${project.platformLabel} app "${project.name}"`) + + // Kick off the GitHub fetch now; the .catch keeps a failure from becoming + // an unhandled rejection while the user is still answering questions. + const playbook = loadPlatformReference(project.platform).then( + (reference) => ({ok: true as const, reference}), + (error: unknown) => ({error, ok: false as const}), + ) + + // 2. Who does the work - an agent we can run, or the user's own agent? + const interactive = isInteractive() + const execution = await resolveExecution(command, commandName, interactive, flags) + if (!execution) return null + const {copyOnly, driver, installSkill} = execution + + // 3. Auth - needed to pick/create the app and for the agent's `adapty` CLI calls. + const token = await ensureToken(command, interactive) + if (token === null) return null // cancelled (already logged) + + if (!token && !copyOnly) { + command.error('This command needs an authenticated session. Run `adapty auth login` and try again.') + } + + // Disclose telemetry here, right after sign-in: said once among the other + // setup lines it scrolls away, whereas saying it last would leave it pinned + // above the run spinner for the whole integration. A copy-only run sends nothing. + if (!copyOnly && !flags['no-telemetry'] && !telemetryDisabled()) { + command.log( + 'Anonymous usage stats are shared with Adapty (platform, outcome, duration - never your code or keys). Disable with --no-telemetry or ADAPTY_TELEMETRY_DISABLED=1.', + ) + } + + // 4. Connect to an Adapty app and get its public SDK key. Without an agent + // to run this is best-effort: the prompt is still useful with the key blank. + let appId = '' + let sdkKey = '' + // The explicit flag must win even when no app gets bound (keyless --copy + // runs skip the whole block below); with an app, resolveDashboardMode + // re-applies the same flag-first precedence. + let dashboardMode: DashboardMode = flags['code-only'] ? 'code-only' : 'create' + let placements: PlacementSummaryDTO[] = [] + if (token) { + const bound = await bindApp(command, copyOnly, {appFlag: flags.app, interactive, project}) + if (!bound) return null // cancelled (already logged) + appId = bound.appId + sdkKey = bound.sdkKey + + // OUTSIDE bindApp's try/catch: that catch downgrades binding failures to a + // warning on --copy runs, and the mode refusal must never be downgraded - a + // copy prompt generated in the wrong mode tells an agent to create entities + // in an app that already has them. + if (appId && bound.client) { + const resolved = await resolveDashboardMode(command, bound.client, { + appId, + codeOnlyFlag: flags['code-only'], + interactive, + }) + if (!resolved) return null // cancelled (already logged) + dashboardMode = resolved.mode + placements = resolved.placements + } + } + + return { + appId, + copyOnly, + dashboardMode, + driver, + installSkill, + interactive, + placements, + playbook, + project, + sdkKey, + token: token ?? '', + } +} + +/** Await the prefetched playbook and assemble the PromptContext - identical for every agent-driven command. */ +export async function preparePromptContext( + setup: WizardSetup, + paywallApproach: string, + storeProducts?: StoreProduct[], + opts: {migrationReference?: string; placementDeveloperId?: string} = {}, +): Promise { + const spin = spinner() + spin.start('Fetching the integration playbook') + const playbook = await setup.playbook + if (!playbook.ok) { + spin.stop('Could not fetch the integration playbook') + throw playbook.error instanceof Error ? playbook.error : new Error(String(playbook.error)) + } + + spin.stop('Integration playbook ready') + + return { + appId: setup.appId, + cliCommand: resolveCliCommand(), + dashboardMode: setup.dashboardMode, + migrationReference: opts.migrationReference, + paywallApproach, + placementDeveloperId: opts.placementDeveloperId, + platformReference: playbook.reference, + project: setup.project, + sdkKey: setup.sdkKey, + storeProducts: storeProducts && storeProducts.length > 0 ? renderStoreProducts(storeProducts) : undefined, + } +} + +/** 'none-installed' = no agent on PATH; null = the user cancelled (already logged). */ +type DriverResolution = 'none-installed' | AgentDriver | null + +/** How this run will be carried out: by an agent we spawn, or by the user's own agent via the clipboard. */ +interface Execution { + copyOnly: boolean + driver: AgentDriver | null + installSkill: boolean +} + +/** + * Decide who does the work. --copy skips agent detection entirely; otherwise + * we look for an agent on PATH and, finding none, offer the agentless path + * instead of dead-ending. Returns null when the user backs out (already logged). + */ +async function resolveExecution( + command: Command, + commandName: string, + interactive: boolean, + flags: WizardFlags, +): Promise { + if (flags.copy) return {copyOnly: true, driver: null, installSkill: false} + + const resolved = await resolveDriver(command, interactive, flags.driver) + if (resolved === null) return null + + if (resolved === 'none-installed') { + const agentless = await offerAgentlessPath(command, commandName, interactive) + if (!agentless) return null + return {copyOnly: true, driver: null, installSkill: agentless.installSkill} + } + + command.log(`Using ${resolved.displayName} as the coding agent`) + return {copyOnly: false, driver: resolved, installSkill: false} +} + +/** + * Pick the coding agent: --driver wins, a single detected agent is used + * as-is, several detected agents become an interactive choice (first = + * default; headless runs also take the first). + */ +async function resolveDriver(command: Command, interactive: boolean, driverFlag?: string): Promise { + const drivers = await detectDrivers() + + if (driverFlag) { + const driver = drivers.find((d) => d.id === driverFlag) + if (!driver) { + command.error(`Agent "${driverFlag}" not found on PATH. Detected: ${drivers.map((d) => d.id).join(', ') || 'none'}.`) + } + + return driver + } + + if (drivers.length === 0) return 'none-installed' + + if (drivers.length === 1 || !interactive) return drivers[0] + + const choice = await select( + 'Which coding agent should do the work?', + drivers.map((d) => ({label: d.displayName, value: d.id})), + drivers[0].id, + ) + if (!choice) { + command.log('Cancelled.') + return null + } + + // choice comes from options built from this same drivers list, so the lookup always succeeds. + return drivers.find((d) => d.id === choice)! +} + +function logInstallHints(command: Command): void { + const longestName = Math.max(...DRIVERS.map((d) => d.displayName.length)) + command.log('\nTo have the CLI do the work itself, install one of these and re-run:') + for (const d of DRIVERS) command.log(` ${`${d.displayName}:`.padEnd(longestName + 1)} ${d.installHint}`) +} + +/** + * No agent on PATH is not a dead end. Most people run their agent inside an + * editor (Cursor, Copilot in VS Code, Windsurf, ...), where this CLI cannot + * invoke it - but the two things that actually help still work: the prompt on + * their clipboard, and the Adapty skill installed into whatever agent they do + * use. Returns null when the user declines or cancels (already logged). + */ +async function offerAgentlessPath( + command: Command, + commandName: string, + interactive: boolean, +): Promise { + command.log(`\nNo coding agent found in your terminal (looked for ${DRIVERS.map((d) => d.displayName).join(', ')}).`) + + // Headless: nobody is here to take a clipboard or answer a question. + if (!interactive) { + command.log(`Run \`adapty ${commandName} --copy\` to get a prompt for any agent.`) + logInstallHints(command) + return null + } + + command.log('If you use an agent inside your editor, this CLI still has two things for you.') + + const wantsPrompt = await confirm(`Put the ${commandName} prompt on your clipboard, ready to paste into it?`) + if (wantsPrompt === null) { + command.log('Cancelled.') + return null + } + + if (!wantsPrompt) { + logInstallHints(command) + return null + } + + const wantsSkill = await confirm( + 'Also install the Adapty skill into your agent, so it knows Adapty in every future session?', + ) + if (wantsSkill === null) { + command.log('Cancelled.') + return null + } + + // The rest of the wizard still runs: the prompt is only worth pasting with + // the app ID and SDK key already in it. + return {installSkill: wantsSkill} +} + +async function createApp( + command: Command, + client: ApiClient, + project: DetectedProject, +): Promise { + const title = await text('Name for the new app', project.name) + if (!title) return null + const app = await client.post('/apps', {title}) + command.log(`Created Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} +} + +/** + * Bind the Adapty app and its public SDK key. Best-effort on --copy runs: a + * failure there degrades to an empty binding (the prompt is still useful with + * the key blank) instead of aborting. null = the user cancelled the picker. + */ +async function bindApp( + command: Command, + copyOnly: boolean, + opts: {appFlag?: string; interactive: boolean; project: DetectedProject}, +): Promise { + try { + const client = await createAuthenticatedClient(command.config) + const picked = await resolveApp(command, client, opts) + if (!picked) { + command.log('Cancelled.') + return null + } + + if (!picked.sdkKey) + command.warn('This app has no public SDK key yet - the agent will need one to call Adapty.activate().') + return {appId: picked.appId, client, sdkKey: picked.sdkKey} + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!copyOnly) { + command.error(`Couldn't bind an Adapty app (${message}). Fix that and try again.`) + } + + command.warn(`Couldn't bind an Adapty app (${message}) - the prompt will leave the SDK key blank.`) + return {appId: '', sdkKey: ''} + } +} + +/** Resolve or interactively acquire a session token. null = user cancelled (already logged); undefined = still no token. */ +async function ensureToken(command: Command, interactive: boolean): Promise { + let token = await resolveToken(command.config.configDir) + if (!token && interactive) { + const wantsLogin = await confirm('You are not logged in to Adapty. Log in now?') + if (wantsLogin === null) { + command.log('Cancelled.') + return null + } + + if (wantsLogin) { + await command.config.runCommand('auth:login') + token = await resolveToken(command.config.configDir) + } + } + + return token ?? undefined +} + +/** + * Fetch what's already in the app and settle the mode BEFORE any agent runs. + * Returns null only when the user cancels the question. A failed fetch is + * never fatal: interactive asks without the names, headless proceeds as today. + */ +async function resolveDashboardMode( + command: Command, + client: ApiClient, + opts: {appId: string; codeOnlyFlag: boolean | undefined; interactive: boolean}, +): Promise { + const {appId, codeOnlyFlag, interactive} = opts + let snapshot: DashboardSnapshot | null = null + try { + snapshot = await fetchDashboardSnapshot(client, appId) + } catch { + command.warn("Couldn't read this app's current dashboard setup - continuing without it.") + } + + const placements = snapshot?.placements ?? [] + const decision = decideDashboardMode({codeOnlyFlag, interactive, snapshot}) + + if (decision === 'headless-needs-flag') { + command.error( + 'This app already has dashboard entities. Pass --code-only to wire the code to them (nothing is created), or --no-code-only to also create what is missing.', + ) + } + + if (decision !== 'ask') { + if (decision === 'code-only') + command.log('Code-only run: the agent will use the existing dashboard entities and create nothing.') + return {mode: decision, placements} + } + + // Names, not counts - the user has to recognize their own setup. + if (snapshot) { + command.log('\nThis app already has:') + for (const line of renderSnapshotLines(snapshot)) command.log(` ${line}`) + } + + const answer = await select( + snapshot + ? 'Use these entities, or create what is missing?' + : "Couldn't read this app's setup - have you already created your entities in the dashboard?", + [ + {hint: 'wires code to them, creates nothing', label: 'Use these - I set them up already', value: 'code-only'}, + {hint: 'the agent creates whatever the app is missing', label: "Create what's missing", value: 'create'}, + ], + // Two defaults on purpose: a KNOWN populated app most likely means the user + // set it up (code-only); an UNREADABLE app is an unknown, and unknowns + // default to today's behavior (create). + snapshot ? 'code-only' : 'create', + ) + if (!answer) { + command.log('Cancelled.') + return null + } + + return {mode: answer as DashboardMode, placements} +} + +async function resolveApp( + command: Command, + client: ApiClient, + opts: {appFlag?: string; interactive: boolean; project: DetectedProject}, +): Promise { + const {appFlag, interactive, project} = opts + if (appFlag) { + if (!isValidUuid(appFlag)) command.error('Invalid app ID format. Run `adapty apps list` to find your app ID.') + const app = await client.get(`/apps/${appFlag}`) + command.log(`Using Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} + } + + const {data: apps} = await client.get>( + '/apps', + paginationParams({page: 1, 'page-size': 100}), + ) + + // Never create or guess an app without a user: creating is a real, visible + // side effect. Headless runs must pass --app (or have exactly one app). + if (!interactive) { + if (apps.length === 1) { + const app = apps[0] + command.log(`Using Adapty app "${app.title}"`) + return {appId: app.id, sdkKey: app.sdk_key ?? ''} + } + + throw new Error( + apps.length === 0 + ? 'no apps in this account - run interactively to create one, or create it in the dashboard' + : `${apps.length} apps in this account - pass --app to choose (see \`adapty apps list\`)`, + ) + } + + if (apps.length === 0) { + command.log('No apps in your Adapty account yet.') + return createApp(command, client, project) + } + + const CREATE = '__create__' + const choice = await select( + 'Which Adapty app is this project?', + [ + {hint: 'a new app with its own SDK key', label: '+ Create a new app', value: CREATE}, + ...apps.map((app) => ({label: app.title, value: app.id})), + ], + CREATE, + ) + if (!choice) return null + if (choice === CREATE) return createApp(command, client, project) + const app = apps.find((a) => a.id === choice)! + return {appId: app.id, sdkKey: app.sdk_key ?? ''} +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index cf0df04..3850a36 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -17,6 +17,17 @@ export type ProductPeriod = | 'uncategorised' | 'weekly' +/** The user-creatable subset of ProductPeriod accepted by `products create --period`. */ +export const VALID_PERIODS = [ + 'weekly', + 'monthly', + 'two_months', + 'trimonthly', + 'semiannual', + 'annual', + 'lifetime', +] as const satisfies readonly ProductPeriod[] + export interface VendorProductDTO { base_plan_id: null | string id: null | string diff --git a/src/lib/config.ts b/src/lib/config.ts index a1ab6cf..b9799af 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -4,7 +4,9 @@ import {dirname, join} from 'node:path' export interface AdaptyConfig { access_token?: string - user?: {email: string; name: string} + // Optional fields: the API has not always returned both, and a missing one + // must not surface as "undefined" in the CLI output. + user?: {email?: string; name?: string} } const CONFIG_DIR = join(homedir(), '.config', 'adapty') diff --git a/src/lib/project/billing.ts b/src/lib/project/billing.ts new file mode 100644 index 0000000..3ba06fc --- /dev/null +++ b/src/lib/project/billing.ts @@ -0,0 +1,83 @@ +import {readFile} from 'node:fs/promises' +import {join} from 'node:path' + +/** + * What currently handles purchases in the project: + * - competitors (RevenueCat, Superwall, Qonversion) + * - official/community store plugins (in_app_purchase, react-native-iap, ...) + * - hand-rolled StoreKit / Google Play Billing code + */ +export type BillingId = 'native-store' | 'qonversion' | 'revenuecat' | 'store-plugin' | 'superwall' + +export interface DetectedBilling { + /** The specific tech matched, e.g. 'in_app_purchase', 'Google Play Billing'. */ + detail?: string + id: BillingId +} + +export const BILLING_LABELS: Record = { + 'native-store': 'custom StoreKit / Play Billing code', + qonversion: 'Qonversion', + revenuecat: 'RevenueCat', + 'store-plugin': 'the existing store plugin', + superwall: 'Superwall', +} + +/** Human label, using the matched tech when we have it: "the in_app_purchase plugin". */ +export function billingLabel(billing: DetectedBilling): string { + if (billing.id === 'store-plugin' && billing.detail) return `the ${billing.detail} plugin` + if (billing.id === 'native-store' && billing.detail) return `custom ${billing.detail} code` + return BILLING_LABELS[billing.id] +} + +/** + * Dependency manifests worth grepping, across all supported platforms. + * Deliberately NOT pubspec.lock: lockfiles list transitive dependencies at + * the same indentation as direct ones, which false-triggers plugin detection + * (e.g. in_app_purchase pulled in by another plugin). Direct deps always + * appear in pubspec.yaml. + */ +const MANIFESTS = [ + 'package.json', + 'pubspec.yaml', + 'Podfile', + 'ios/Podfile', + 'Package.swift', + 'build.gradle', + 'build.gradle.kts', + 'app/build.gradle', + 'app/build.gradle.kts', + 'android/app/build.gradle', + 'android/app/build.gradle.kts', + 'Packages/manifest.json', +] + +/** Xcode project files - the only manifest-level trace of a hand-rolled StoreKit integration. */ +const PBXPROJ_HINTS = ['ios/Runner.xcodeproj/project.pbxproj', 'ios/App/App.xcodeproj/project.pbxproj'] + +/** Ordered: competitors first, then store plugins, then native store code. First match wins. */ +const MATCHERS: Array = [ + {id: 'revenuecat', re: /revenuecat|react-native-purchases|purchases_flutter|purchases-capacitor|purchases-hybrid/i}, + {id: 'superwall', re: /superwall/i}, + {id: 'qonversion', re: /qonversion/i}, + {detail: 'in_app_purchase', id: 'store-plugin', re: /^\s+in_app_purchase\s*:/m}, + {detail: 'flutter_inapp_purchase', id: 'store-plugin', re: /flutter_inapp_purchase/}, + {detail: 'react-native-iap', id: 'store-plugin', re: /react-native-iap/}, + {detail: 'expo-in-app-purchases', id: 'store-plugin', re: /expo-in-app-purchases/}, + {detail: 'cordova-plugin-purchase', id: 'store-plugin', re: /cordova-plugin-purchase/}, + {detail: 'Unity IAP', id: 'store-plugin', re: /com\.unity\.purchasing/}, + {detail: 'Google Play Billing', id: 'native-store', re: /com\.android\.billingclient/}, + {detail: 'StoreKit', id: 'native-store', re: /StoreKit\.framework|import StoreKit/}, +] + +export async function detectBilling(dir: string): Promise { + const contents = await Promise.all( + [...MANIFESTS, ...PBXPROJ_HINTS].map((rel) => readFile(join(dir, rel), 'utf8').catch(() => '')), + ) + const blob = contents.join('\n') + for (const {re, ...billing} of MATCHERS) { + if (re.test(blob)) return billing + } + + return null +} diff --git a/src/lib/project/git.ts b/src/lib/project/git.ts new file mode 100644 index 0000000..952f11f --- /dev/null +++ b/src/lib/project/git.ts @@ -0,0 +1,46 @@ +import {execFile} from 'node:child_process' +import {promisify} from 'node:util' + +const execFileAsync = promisify(execFile) + +/** Run git in `dir`. null = git failed (not a repo, git missing, ref absent) - every caller treats that as "no". */ +async function git(dir: string, args: string[]): Promise { + try { + const {stdout} = await execFileAsync('git', ['-C', dir, ...args], {timeout: 5000}) + return stdout.trim() + } catch { + return null + } +} + +export async function isGitRepo(dir: string): Promise { + return (await git(dir, ['rev-parse', '--is-inside-work-tree'])) === 'true' +} + +/** Uncommitted changes present? Outside a git repo -> false (nothing to protect). */ +export async function hasUncommittedChanges(dir: string): Promise { + const status = await git(dir, ['status', '--porcelain']) + return status !== null && status.length > 0 +} + +async function branchExists(dir: string, name: string): Promise { + return (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`])) !== null +} + +/** `base`, or `base-2`, `base-3`, ... when taken - a second run must never collide with the first one's branch. */ +export async function availableBranchName(dir: string, base: string): Promise { + if (!(await branchExists(dir, base))) return base + + for (let n = 2; n <= 20; n++) { + const candidate = `${base}-${n}` + // Sequential on purpose: names are tried in order and the first free one wins. + if (!(await branchExists(dir, candidate))) return candidate + } + + return `${base}-${Date.now()}` +} + +/** Create and switch to `name`. false = git refused; the caller stays on the current branch. */ +export async function createBranch(dir: string, name: string): Promise { + return (await git(dir, ['checkout', '-b', name])) !== null +} diff --git a/src/lib/project/revenuecat.ts b/src/lib/project/revenuecat.ts new file mode 100644 index 0000000..228f30f --- /dev/null +++ b/src/lib/project/revenuecat.ts @@ -0,0 +1,251 @@ +/** + * Read-only RevenueCat v2 API client for `adapty migrate --rc-key`. + * One RC v2 secret key = one RC project (the wizard model from the dashboard + * import design). Everything is fetched once, up front; the agent receives a + * rendered snapshot and never sees the key itself. + * + * Every sub-fetch is best-effort, but failure is DISTINGUISHED from empty: + * a throttled or failed fetch must never masquerade as "this offering has no + * packages" or "no builder paywall" - the downstream mapping rules treat + * those states very differently (a misread builder paywall permanently burns + * a placement ID). + */ + +const rcApiBase = () => process.env.ADAPTY_RC_API_URL ?? 'https://api.revenuecat.com/v2' + +/** Max simultaneous RC requests - large accounts must not trip rate limiting. */ +const RC_CONCURRENCY = 5 +/** Pagination safety valve: 20 pages x 100 items per list. */ +const MAX_PAGES = 20 + +export interface RcEntitlement { + displayName: string + lookupKey: string + /** Store identifiers of the products attached to this entitlement. */ + productStoreIds: string[] +} + +export interface RcProduct { + displayName: string + entitlementLookupKeys: string[] + storeIdentifier: string + storeType: string +} + +export interface RcOffering { + displayName: string + /** true = published RC Paywall Builder paywall; 'unknown' = the API didn't say OR the check failed. */ + hasPublishedBuilderPaywall: 'unknown' | boolean + isCurrent: boolean + lookupKey: string + metadata: null | Record + /** "package lookup_key -> store identifiers" in position order; null = the fetch FAILED (not "no packages"). */ + packages: Array<{lookupKey: string; productStoreIds: string[]}> | null +} + +export interface RcCatalog { + apps: Array<{name: string; type: string}> + /** false when any list was truncated or a sub-fetch failed - the catalog may be incomplete. */ + complete: boolean + entitlements: RcEntitlement[] + offerings: RcOffering[] + products: RcProduct[] + projectName: string +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +/** Run fn over items with bounded concurrency, preserving order. */ +async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results: R[] = Array.from({length: items.length}) + let next = 0 + const workers = Array.from({length: Math.min(limit, items.length)}, async () => { + for (;;) { + const index = next++ + if (index >= items.length) return + results[index] = await fn(items[index]) + } + }) + await Promise.all(workers) + return results +} + +async function rcGet(key: string, path: string): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`${rcApiBase()}${path}`, { + headers: {accept: 'application/json', authorization: `Bearer ${key}`}, + signal: AbortSignal.timeout(15_000), + }) + if (res.status === 429) { + const retryAfter = Number.parseFloat(res.headers.get('retry-after') ?? '1') + await sleep(Math.min(Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000, 10_000)) + continue + } + + if (!res.ok) return null + return (await res.json()) as T + } catch { + return null + } + } + + return null +} + +// RC response shapes shift and are only partially documented; access is defensive throughout. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Raw = Record + +interface RcPage { + items?: Raw[] + next_page?: null | string +} + +/** Fetch every page of a list endpoint. null = the FIRST page failed; `complete` = no truncation/failure. */ +async function rcGetAll(key: string, path: string): Promise { + const items: Raw[] = [] + let next: null | string = `${path}${path.includes('?') ? '&' : '?'}limit=100` + for (let page = 0; page < MAX_PAGES && next; page++) { + const data: null | RcPage = await rcGet(key, next) + if (!data) return page === 0 ? null : {complete: false, items} + items.push(...(data.items ?? [])) + // next_page comes back as an absolute API path including the /v2 prefix our base already has. + next = data.next_page ? data.next_page.replace(/^\/v2/, '') : null + } + + return {complete: !next, items} +} + +export async function fetchRcCatalog(key: string): Promise { + const projects = await rcGet(key, '/projects?limit=100') + const project = projects?.items?.[0] + if (!project) return null + const pid = String(project.id) + + const [apps, entitlementsRaw, productsRaw, offeringsRaw] = await Promise.all([ + rcGetAll(key, `/projects/${pid}/apps`), + rcGetAll(key, `/projects/${pid}/entitlements`), + rcGetAll(key, `/projects/${pid}/products`), + rcGetAll(key, `/projects/${pid}/offerings`), + ]) + let complete = [apps, entitlementsRaw, productsRaw, offeringsRaw].every((r) => r !== null && r.complete) + + // product <-> entitlement association is only exposed from the entitlement side. + const entitlementProducts = new Map() + await mapLimit(entitlementsRaw?.items ?? [], RC_CONCURRENCY, async (ent) => { + const products = await rcGetAll(key, `/projects/${pid}/entitlements/${ent.id}/products`) + if (!products?.complete) complete = false + entitlementProducts.set(String(ent.id), products?.items ?? []) + }) + + const entitlements: RcEntitlement[] = (entitlementsRaw?.items ?? []).map((ent) => ({ + displayName: String(ent.display_name ?? ent.lookup_key ?? ent.id), + lookupKey: String(ent.lookup_key ?? ent.id), + productStoreIds: (entitlementProducts.get(String(ent.id)) ?? []).map((p) => String(p.store_identifier ?? p.id)), + })) + + const products: RcProduct[] = (productsRaw?.items ?? []).map((product) => { + const storeId = String(product.store_identifier ?? product.id) + return { + displayName: String(product.display_name ?? storeId), + entitlementLookupKeys: entitlements.filter((e) => e.productStoreIds.includes(storeId)).map((e) => e.lookupKey), + storeIdentifier: storeId, + storeType: String(product.app?.type ?? product.store ?? product.type ?? ''), + } + }) + + const offerings: RcOffering[] = await mapLimit(offeringsRaw?.items ?? [], RC_CONCURRENCY, async (off) => { + const packages = await rcGetAll(key, `/projects/${pid}/offerings/${off.id}/packages?expand=items.product`) + if (!packages?.complete) complete = false + + // Published builder paywall = non-null paywall id whose paywall has published_at. + // A FAILED check stays 'unknown' - downstream must not create anything for it. + let hasPublishedBuilderPaywall: 'unknown' | boolean = 'unknown' + if ('paywall_id' in off || 'paywall' in off) { + const paywallId = off.paywall_id ?? off.paywall?.id + if (paywallId) { + const paywall = await rcGet(key, `/projects/${pid}/paywalls/${paywallId}`) + hasPublishedBuilderPaywall = paywall ? Boolean(paywall.published_at) : 'unknown' + if (!paywall) complete = false + } else { + hasPublishedBuilderPaywall = false + } + } + + return { + displayName: String(off.display_name ?? off.lookup_key ?? off.id), + hasPublishedBuilderPaywall, + isCurrent: Boolean(off.is_current), + lookupKey: String(off.lookup_key ?? off.id), + metadata: off.metadata && typeof off.metadata === 'object' ? (off.metadata as Record) : null, + packages: packages + ? packages.items.map((pkg) => ({ + lookupKey: String(pkg.lookup_key ?? pkg.id), + productStoreIds: ((pkg.products?.items ?? pkg.products ?? []) as Raw[]).map((p) => + String(p.product?.store_identifier ?? p.store_identifier ?? p.id), + ), + })) + : null, + } + }) + + return { + apps: (apps?.items ?? []).map((a) => ({name: String(a.name ?? a.id), type: String(a.type ?? '')})), + complete, + entitlements, + offerings, + products, + projectName: String(project.name ?? pid), + } +} + +/** Compact markdown snapshot for the agent prompt. */ +export function renderRcCatalog(catalog: RcCatalog): string { + const lines: string[] = [ + `RevenueCat project: ${catalog.projectName}`, + `Stores: ${catalog.apps.map((a) => `${a.name} (${a.type})`).join(', ') || '(none listed)'}`, + ...(catalog.complete + ? [] + : [ + '', + 'WARNING: some RevenueCat requests failed or were truncated - this catalog may be INCOMPLETE. Add to ADAPTY_SETUP.md that the user must compare it against the RC dashboard before trusting it.', + ]), + '', + 'Entitlements (one Adapty access level each; access level ID = lookup_key):', + ...(catalog.entitlements.length > 0 + ? catalog.entitlements.map( + (e) => `- ${e.lookupKey} ("${e.displayName}") - products: ${e.productStoreIds.join(', ') || '(none)'}`, + ) + : ['- (none - use the default "premium" access level and note that in ADAPTY_SETUP.md)']), + '', + 'Products (use these EXACT store identifiers):', + ...catalog.products.map( + (p) => + `- ${p.storeIdentifier}${p.storeType ? ` [${p.storeType}]` : ''} ("${p.displayName}") - entitlements: ${p.entitlementLookupKeys.join(', ') || '(none - flag the access-level choice)'}`, + ), + '', + 'Offerings (placement ID = lookup_key):', + ...catalog.offerings.map((o) => { + const packages = + o.packages === null + ? 'FETCH FAILED - do not guess; list this offering in ADAPTY_SETUP.md for manual verification' + : o.packages.map((pkg) => `${pkg.lookupKey}: ${pkg.productStoreIds.join('+')}`).join('; ') || '(none)' + const builder = + o.hasPublishedBuilderPaywall === true + ? 'PUBLISHED BUILDER PAYWALL - create NOTHING for this offering, reserve the placement ID for a flow (see rules)' + : o.hasPublishedBuilderPaywall === 'unknown' + ? 'builder paywall status UNKNOWN (API did not confirm; RC v1 paywalls are also invisible to it) - create NOTHING for this offering now; add to ADAPTY_SETUP.md: check in the RC dashboard whether it has a builder paywall, then either create the placement + paywall or rebuild it as a flow' + : 'no builder paywall - create placement + paywall' + return ( + `- ${o.lookupKey} ("${o.displayName}")${o.isCurrent ? ' [CURRENT]' : ''} - packages: ${packages} - ${builder}` + + (o.metadata ? `\n metadata (-> paywall remote config): ${JSON.stringify(o.metadata)}` : '') + ) + }), + ] + return lines.join('\n') +} diff --git a/src/lib/project/scan.ts b/src/lib/project/scan.ts new file mode 100644 index 0000000..55fa2d2 --- /dev/null +++ b/src/lib/project/scan.ts @@ -0,0 +1,89 @@ +import {readdir, readFile} from 'node:fs/promises' +import {basename, join} from 'node:path' + +export type Platform = 'android' | 'capacitor' | 'flutter' | 'ios' | 'kmp' | 'react-native' | 'unity' + +export interface DetectedProject { + name: string + path: string + platform: Platform + platformLabel: string +} + +export const PLATFORM_LABELS: Record = { + android: 'Android', + capacitor: 'Capacitor', + flutter: 'Flutter', + ios: 'iOS', + kmp: 'Kotlin Multiplatform', + 'react-native': 'React Native', + unity: 'Unity', +} + +async function readJson(path: string): Promise> { + try { + return JSON.parse(await readFile(path, 'utf8')) as Record + } catch { + return null + } +} + +async function entries(dir: string): Promise { + try { + return await readdir(dir) + } catch { + return [] + } +} + +async function hasKmpModule(dir: string, topLevel: string[]): Promise { + const checks = await Promise.all( + topLevel.map(async (entry) => (await entries(join(dir, entry, 'src'))).includes('commonMain')), + ) + return checks.includes(true) +} + +/** + * Detect the mobile framework of the project at `dir`. Signals mirror the + * adapty-integration skill's Phase 1 table. Only the top level is + * inspected - monorepos should pass the app directory explicitly. + */ +export async function scanProject(dir: string): Promise { + const topLevel = await entries(dir) + const has = (name: string): boolean => topLevel.includes(name) + const pkg = has('package.json') ? await readJson(join(dir, 'package.json')) : null + const deps = { + ...(pkg?.dependencies as Record | undefined), + ...(pkg?.devDependencies as Record | undefined), + } + + let platform: null | Platform = null + let name: null | string = null + + if (deps['@capacitor/core']) { + platform = 'capacitor' + } else if (deps['react-native']) { + platform = 'react-native' + } else if (has('pubspec.yaml')) { + platform = 'flutter' + const pubspec = await readFile(join(dir, 'pubspec.yaml'), 'utf8').catch(() => '') + name = pubspec.match(/^name:\s*(\S+)/m)?.[1] ?? null + } else if (has('Assets') && has('ProjectSettings')) { + platform = 'unity' + } else if (topLevel.some((f) => f === 'build.gradle' || f === 'build.gradle.kts' || f.startsWith('settings.gradle'))) { + platform = (await hasKmpModule(dir, topLevel)) ? 'kmp' : 'android' + } else { + const xcodeproj = topLevel.find((f) => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace')) + if (xcodeproj || has('Package.swift') || topLevel.some((f) => f.endsWith('.swift'))) { + platform = 'ios' + if (xcodeproj) name = xcodeproj.replace(/\.(xcodeproj|xcworkspace)$/, '') + } + } + + if (!platform) return null + + if (!name && typeof pkg?.name === 'string') name = pkg.name + if (!name) name = basename(dir) + + return {name, path: dir, platform, platformLabel: PLATFORM_LABELS[platform]} +} diff --git a/src/lib/ui/ask.ts b/src/lib/ui/ask.ts new file mode 100644 index 0000000..305b377 --- /dev/null +++ b/src/lib/ui/ask.ts @@ -0,0 +1,109 @@ +import * as clack from '@clack/prompts' + +/** + * Thin wrapper over @clack/prompts so callers stay library-agnostic. + * Non-interactive runs (piped stdin OR stdout, CI) never render prompts - + * every function resolves to its default immediately, so headless flows keep + * working. + */ + +export interface SelectOption { + hint?: string + label: string + value: string +} + +/** + * The single definition of "can we ask the user something": both streams + * must be terminals (clack renders on stdout and reads stdin). Commands must + * use this - not process.stdin.isTTY alone - or they will believe a question + * was asked when it silently auto-resolved. + */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY) +} + +/** Arrow-key select. Returns the chosen value, or null when cancelled (Esc/Ctrl-C). */ +export async function select(message: string, options: SelectOption[], defaultValue?: string): Promise { + if (!isInteractive()) return defaultValue ?? null + const result = await clack.select({ + initialValue: defaultValue, + message, + options: options.map((o) => ({hint: o.hint, label: o.label, value: o.value})), + }) + return clack.isCancel(result) ? null : result +} + +/** Yes/no. Returns null when cancelled (Esc/Ctrl-C) so callers can distinguish "No" from "get me out". */ +export async function confirm(message: string, defaultYes = true): Promise { + if (!isInteractive()) return defaultYes + const result = await clack.confirm({initialValue: defaultYes, message}) + return clack.isCancel(result) ? null : result +} + +/** Free-text input. Returns null on cancel (Esc/Ctrl-C) - distinct from an empty answer. */ +export async function text(message: string, defaultValue?: string): Promise { + if (!isInteractive()) return defaultValue ?? '' + const result = await clack.text({defaultValue: defaultValue ?? '', message, placeholder: defaultValue}) + return clack.isCancel(result) ? null : (result ?? '') +} + +/** One-line warning in the prompt flow (clack-styled in TTY, stderr otherwise). */ +export function notice(message: string): void { + if (isInteractive()) clack.log.warn(message) + else console.error(message) +} + +export interface Spinner { + message(text: string): void + start(text: string): void + stop(text: string): void +} + +/** East-Asian wide chars and emoji occupy ~2 terminal columns. */ +const WIDE_CHAR = + + /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|\p{Extended_Pictographic}/u + +/** + * A spinner line that wraps past the terminal edge breaks clack's in-place + * redraw - every frame then prints a NEW line and the spinner looks stuck in + * a loop. Clip by DISPLAY width (code points, wide chars counted double) so + * it can never wrap; clack's own prefix + frame glyph eat a few columns. + */ +function clipToWidth(text: string): string { + const budget = (process.stdout.columns ?? 80) - 8 + let width = 0 + let out = '' + for (const ch of text) { + width += WIDE_CHAR.test(ch) ? 2 : 1 + if (width > budget - 1) return `${out}…` + out += ch + } + + return out +} + +/** In-place spinner; in non-interactive runs it degrades to plain log lines so CI output stays readable. */ +export function spinner(): Spinner { + if (!isInteractive()) { + return { + message: (t) => console.log(` • ${t}`), + start: (t) => console.log(t), + stop: (t) => console.log(t), + } + } + + const s = clack.spinner() + let lastMessage = '' + return { + message(t) { + const clipped = clipToWidth(t) + if (clipped === lastMessage) return + lastMessage = clipped + s.message(clipped) + }, + start: (t) => s.start(clipToWidth(t)), + stop: (t) => s.stop(t), + } +} diff --git a/src/lib/ui/clipboard.ts b/src/lib/ui/clipboard.ts new file mode 100644 index 0000000..4ce1ef3 --- /dev/null +++ b/src/lib/ui/clipboard.ts @@ -0,0 +1,17 @@ +import {execFile} from 'node:child_process' + +export async function copyToClipboard(content: string): Promise { + const cmd = + process.platform === 'darwin' ? 'pbcopy' : process.platform === 'win32' ? 'clip' : 'xclip -selection clipboard' + try { + const [bin, ...args] = cmd.split(' ') + await new Promise((resolvePromise, rejectPromise) => { + const child = execFile(bin, args, (error) => (error ? rejectPromise(error) : resolvePromise())) + child.stdin?.write(content) + child.stdin?.end() + }) + return true + } catch { + return false + } +} diff --git a/test/commands/migrate.test.ts b/test/commands/migrate.test.ts new file mode 100644 index 0000000..735b326 --- /dev/null +++ b/test/commands/migrate.test.ts @@ -0,0 +1,54 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {writeFile} from 'node:fs/promises' +import {type SinonStub, stub} from 'sinon' + +import {EMPTY_LIST_RESPONSE, TEST_APP_ID} from '../helpers/mock-fetch.js' +import {useTmpDir} from '../helpers/tmp-dir.js' + +const LIST_WITH_PLACEMENT = { + data: [{developer_id: 'onboarding', id: 'pl-1', title: 'Onboarding'}], + meta: {pagination: {count: 1, page: 1, pages: 1}}, +} + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), {headers: {'Content-Type': 'application/json'}, status: 200}) +} + +/** Routed by URL, not call order - the playbook fetch races the API calls. */ +function stubFetchByUrl(): SinonStub { + return stub(globalThis, 'fetch').callsFake(async (input) => { + const url = String(input) + if (url.includes('raw.githubusercontent.com')) return new Response('# playbook', {status: 200}) + if (url.includes('/placements')) return json(LIST_WITH_PLACEMENT) + if (url.includes('/access-levels') || url.includes('/products') || url.includes('/paywalls')) + return json(EMPTY_LIST_RESPONSE) + if (url.includes(`/apps/${TEST_APP_ID}/`)) + return json({id: TEST_APP_ID, platforms: [], sdk_key: 'public_live_x', secret_key: null, title: 'My App'}) + return json(EMPTY_LIST_RESPONSE) + }) +} + +describe('migrate', () => { + const tmpDir = useTmpDir('adapty-migrate-test-') + let fetchStub: SinonStub + + afterEach(() => { + fetchStub.restore() + delete process.env.ADAPTY_TOKEN + }) + + // Pins the fix for the swallowed refusal: the mode decision runs OUTSIDE the + // app-binding try/catch, so a headless run against a populated app must abort + // even on --copy - never fall through and emit a create-mode prompt. + it('headless run against a populated app refuses without --code-only, even on --copy', async () => { + process.env.ADAPTY_TOKEN = 'test-token' + fetchStub = stubFetchByUrl() + await writeFile(`${tmpDir()}/pubspec.yaml`, 'name: demo_app\ndependencies:\n flutter:\n sdk: flutter\n') + + const {error} = await runCommand(`migrate --copy --app ${TEST_APP_ID} --path ${tmpDir()}`) + + expect(error, 'expected the command to error').to.exist + expect(error?.message).to.include('--code-only') + }) +}) diff --git a/test/helpers/tmp-dir.ts b/test/helpers/tmp-dir.ts new file mode 100644 index 0000000..339f87a --- /dev/null +++ b/test/helpers/tmp-dir.ts @@ -0,0 +1,23 @@ +/* eslint-disable mocha/no-exports -- shared fixture helper (registers hooks for its callers), not a test file */ +import {mkdtemp, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +/** + * Fresh temp directory per test. Registers the mocha hooks once; call the + * returned getter inside tests. One place owns the cleanup contract for + * every fs-fixture suite. + */ +export function useTmpDir(prefix: string): () => string { + let dir = '' + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), prefix)) + }) + + afterEach(async () => { + await rm(dir, {force: true, recursive: true}) + }) + + return () => dir +} diff --git a/test/lib/agent/drivers.test.ts b/test/lib/agent/drivers.test.ts new file mode 100644 index 0000000..f107c7a --- /dev/null +++ b/test/lib/agent/drivers.test.ts @@ -0,0 +1,141 @@ +import {expect} from 'chai' + +import {DRIVER_IDS, DRIVERS} from '../../../src/lib/agent/drivers/index.js' +import {type AgentRunOptions, runPlainText, runStreamJson, withAuthCheck} from '../../../src/lib/agent/drivers/shared.js' + +/** Run a runner against `node -e