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..dbea618 --- /dev/null +++ b/src/commands/integrate.ts @@ -0,0 +1,102 @@ +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, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' +import {billingLabel, detectBilling} from '../lib/project/billing.js' +import {confirm, isInteractive, select} from '../lib/ui/ask.js' + +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'}), + 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) { + 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['no-telemetry']) passthrough.push('--no-telemetry') + return this.config.runCommand('migrate', passthrough) + } + } 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 {driver, 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 (!flags.copy && 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.') + } + + // Real store IDs turn "defer everything to ADAPTY_SETUP.md" into a full dashboard setup. + const products = await collectStoreProducts(project.platform) + if (products === null) return this.log('Cancelled.') + const promptCtx = await preparePromptContext(setup, approach, products) + + if (flags.copy) { + return emitCopyPrompt(this, integrateAction, promptCtx) + } + + const result = await runActionWithFollowUp(this, { + action: integrateAction, + 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..25ee3da --- /dev/null +++ b/src/commands/migrate.ts @@ -0,0 +1,172 @@ +import {Command, Flags} from '@oclif/core' +import {execFile} from 'node:child_process' +import {resolve} from 'node:path' +import {promisify} from 'node:util' + +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, reportActionFailure, runActionWithFollowUp} from '../lib/agent/run.js' +import {loadMigrationReference} from '../lib/agent/skill-source.js' +import {preparePromptContext, prepareWizard} from '../lib/agent/wizard.js' +import {BILLING_LABELS, type BillingId, billingLabel, detectBilling} from '../lib/project/billing.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'}), + 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()) + + const setup = await prepareWizard(this, {...flags, path}) + if (!setup) return + const {driver, 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 (!flags.copy) { + // A migration rewrites many files - a clean tree makes it reviewable and revertable. + if (await hasDirtyWorkingTree(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.') + } + } + + // Without the RC catalog the user's own store IDs are the only ground truth available. + const products = rcCatalog ? [] : 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) + const action = buildMigrateAction(providerLabel, rcCatalog) + + if (flags.copy) { + return emitCopyPrompt(this, action, promptCtx) + } + + const result = await runActionWithFollowUp(this, { + action, + ctx: promptCtx, + driver: driver!, + env: token ? {ADAPTY_TOKEN: token} : undefined, + interactive, + noTelemetry: flags['no-telemetry'], + }) + if (!result.ok) reportActionFailure(this, driver!, result) + } +} + +const execFileAsync = promisify(execFile) + +/** Uncommitted changes present? Outside a git repo -> false (nothing to protect). */ +async function hasDirtyWorkingTree(dir: string): Promise { + try { + const {stdout} = await execFileAsync('git', ['-C', dir, 'status', '--porcelain'], {timeout: 5000}) + return stdout.trim().length > 0 + } catch { + return false + } +} 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..a8e0377 --- /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 flows are dashboard-only and can NOT be created from the CLI. Create the placement(s) only (`placements create --app --title "Main" --developer-id "main" --audiences \'[]\'`), and add to ADAPTY_SETUP.md: create a flow at https://app.adapty.io/flows and attach it to the placement(s).' + : '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 creating the placement. 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-sdk-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..c2598d5 --- /dev/null +++ b/src/lib/agent/actions/migrate.ts @@ -0,0 +1,63 @@ +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 + 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. 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' + ? ' Mention that re-running `adapty migrate --rc-key ` automates that comparison.' + : '' + }\n` + } +--- MIGRATION PLAYBOOK (from the adapty-sdk-integration skill) --- + +${migrationReference || '(not available - state this in ADAPTY_SETUP.md and map conservatively: create nothing you cannot verify.)'} + +--- PLATFORM PLAYBOOK (from the adapty-sdk-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..6a7a82d --- /dev/null +++ b/src/lib/agent/prompt.ts @@ -0,0 +1,120 @@ +import {dirname, join} from 'node:path' + +import type {DetectedProject} from '../project/scan.js' + +/** + * 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 + /** Rendered migration playbook (spine + source file) - set only by `migrate`. */ + migrationReference?: string + paywallApproach: 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)'} + ${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.` + : '' + + 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. + 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..dca7436 --- /dev/null +++ b/src/lib/agent/run.ts @@ -0,0 +1,138 @@ +import type {Command} from '@oclif/core' + +import {existsSync} from 'node:fs' +import {join} from 'node:path' + +import {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 +} + +/** The --copy exit shared by every agent-driven command: put the prompt on the clipboard (or print it). */ +export async function emitCopyPrompt(command: Command, action: AgentAction, ctx: PromptContext): 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.)`) +} + +/** 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.`, + ) +} + +export interface RunActionOptions { + action: AgentAction + 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, 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) + + // Leave the skill installed in the user's agents so future sessions know Adapty. + const skillSpin = spinner() + skillSpin.start('Installing the Adapty skill into your coding agent') + const installed = await installAgentSkills() + skillSpin.stop( + installed + ? 'Adapty skill installed - your agent can now handle Adapty tasks in any session.' + : 'Skill install skipped - run `npx skills add adaptyteam/adapty-sdk-integration-skill` to add it manually.', + ) + + command.log(`\nDone. Review the changes with \`git diff\`, 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..8d25e20 --- /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-sdk-integration-skill 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-sdk-integration-skill/main/skills/adapty-sdk-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..4df1ca2 --- /dev/null +++ b/src/lib/agent/skills-install.ts @@ -0,0 +1,20 @@ +import {spawn} from 'node:child_process' + +/** + * Install the adapty-sdk-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-sdk-integration-skill' + +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..0a49867 --- /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-sdk-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..bdd89ec --- /dev/null +++ b/src/lib/agent/wizard.ts @@ -0,0 +1,274 @@ +import type {Command} from '@oclif/core' + +import type {ApiClient} from '../api-client.js' +import type {AppDetailDTO, AppSummaryDTO} 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 PromptContext, resolveCliCommand} from './prompt.js' +import {loadPlatformReference} from './skill-source.js' +import {telemetryDisabled} from './telemetry.js' + +export interface WizardFlags { + app?: string + copy?: boolean + driver?: string + 'no-telemetry'?: boolean + path: string +} + +export interface WizardSetup { + appId: string + /** null only in --copy mode. */ + driver: AgentDriver | null + interactive: boolean + /** 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. Which agent will do the work? + const interactive = isInteractive() + let driver: AgentDriver | null = null + if (!flags.copy) { + driver = await resolveDriver(command, commandName, interactive, flags.driver) + if (!driver) return null + command.log(`Using ${driver.displayName} as the coding agent`) + } + + // 3. Auth - needed to pick/create the app and for the agent's `adapty` CLI calls. + 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) + } + } + + if (!token && !flags.copy) { + 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. --copy sends nothing. + if (!flags.copy && !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. With --copy this + // is best-effort: the prompt is still useful with the key left blank. + let appId = '' + let sdkKey = '' + if (token) { + try { + const picked = await resolveApp(command, project, interactive, flags.app) + if (!picked) { + command.log('Cancelled.') + return null + } + + appId = picked.appId + sdkKey = picked.sdkKey + if (!sdkKey) + command.warn('This app has no public SDK key yet - the agent will need one to call Adapty.activate().') + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!flags.copy) { + 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, driver, interactive, 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[], + migrationReference?: 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(), + migrationReference, + paywallApproach, + platformReference: playbook.reference, + project: setup.project, + sdkKey: setup.sdkKey, + storeProducts: storeProducts && storeProducts.length > 0 ? renderStoreProducts(storeProducts) : undefined, + } +} + +/** + * 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). Returns null when nothing is + * installed or the user cancels (already logged). + */ +async function resolveDriver( + command: Command, + commandName: string, + 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) { + const longestName = Math.max(...DRIVERS.map((d) => d.displayName.length)) + command.log(`\nNo coding agent found (looked for ${DRIVERS.map((d) => d.displayName).join(', ')}).`) + command.log(`Install one and re-run, or use \`adapty ${commandName} --copy\` to get a prompt for any agent:`) + for (const d of DRIVERS) command.log(` ${`${d.displayName}:`.padEnd(longestName + 1)} ${d.installHint}`) + return null + } + + 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)! +} + +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 ?? ''} +} + +async function resolveApp( + command: Command, + project: DetectedProject, + interactive: boolean, + appFlag?: string, +): Promise { + const client = await createAuthenticatedClient(command.config) + + 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/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..85259bc --- /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-sdk-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/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