diff --git a/README.md b/README.md index b6ebe65..fa2c834 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ Prefer to drive the agent yourself? Add `--skill` to `integrate`, `migrate`, or `review` and the CLI prints the full playbook for your project's framework instead of running it — paste it into any agent. +`migrate` starts by asking whether to **plan first** or **migrate now**. Plan +first is a read-only pass: it writes a reviewable `sw-migration.md` to your +project root and copies `implement the plan at sw-migration.md` to your +clipboard, so you can read the plan and then run it through your own agent. +Migrate now hands the whole migration to the agent end to end. Skip the prompt +with `superwall migrate --plan` (plan) or leave it off (migrate now). + ## Manage your account Everything in the dashboard, from the terminal: diff --git a/prompts/actions/migrate-plan.xml b/prompts/actions/migrate-plan.xml new file mode 100644 index 0000000..6c3011c --- /dev/null +++ b/prompts/actions/migrate-plan.xml @@ -0,0 +1,13 @@ + +Produce a reviewable plan to migrate this app from {{provider}} to Superwall - {{goal}}. This is plan-only: DO NOT modify, create, or delete any files. Read the project, then return the plan as your report output - the CLI writes it to disk, not you. + +{{skill}} + +Analyze how this app integrates {{provider}}, then return a detailed, step-by-step Superwall migration plan. Make it specific to this codebase: the {{provider}}→Superwall mapping, the exact files and call sites to change, the products / entitlements / campaigns / placements to create, and any manual steps left for a human. Do not touch a single file - the plan is your report. + + +A complete step-by-step Superwall migration plan in markdown: the {{provider}}→Superwall mapping, per-file changes with call sites, the products / entitlements / campaigns / placements to create, and remaining manual steps. This report becomes `sw-migration.md`, so write it as the document a human will follow. + + +Emit a [STATUS] before each step. + diff --git a/src/cli.ts b/src/cli.ts index 2a11224..44196a8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -58,7 +58,10 @@ function printHelp(): void { "", head("Workflow commands"), row("integrate", "Set up Superwall in your app (agent-driven)"), - row("migrate", "Migrate from RevenueCat / Adapty / Qonversion"), + row( + "migrate [--plan]", + "Migrate from RevenueCat / Adapty / Qonversion; --plan writes a plan first", + ), row( "review [--fix]", "Audit an existing Superwall setup; --fix repairs issues", @@ -190,6 +193,11 @@ export async function run(): Promise { y.options({ ...installDirOption, "dry-run": { type: "boolean", describe: "Plan without changes" }, + plan: { + type: "boolean", + describe: + "Write a reviewable migration plan (sw-migration.md) instead of migrating now", + }, ...skillOption, }), (argv) => { diff --git a/src/commands/migrate.ts b/src/commands/migrate.ts index 9a0f13c..e2e546d 100644 --- a/src/commands/migrate.ts +++ b/src/commands/migrate.ts @@ -1,3 +1,5 @@ +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { DASHBOARD_URL, VERSION } from "@/constants"; import { ui } from "@/ui"; import { defineCommand } from "@/command"; @@ -11,6 +13,7 @@ import { loadSession } from "@/core/auth/storage"; import { performLoginWithUi } from "./integrate/app"; import { runAction } from "@/core/agent/run"; import { saveReport } from "@/core/state/reports"; +import { copyToClipboard } from "@/util/clipboard"; import { actionById, type ActionContext } from "@/core/agent/actions"; export const runMigrate = defineCommand({ @@ -18,6 +21,7 @@ export const runMigrate = defineCommand({ auth: "optional", async run(ctx) { const { installDir, dryRun } = ctx; + const plan = Boolean(ctx.argv.plan); ui.intro(`Superwall migrate v${VERSION}`); const drivers = await detectDrivers(); @@ -56,6 +60,28 @@ export const runMigrate = defineCommand({ } ui.info(`Found \`${provider}\``); + let mode = plan ? "plan" : "migrate"; + if (ctx.interactive && !dryRun) { + const sel = await ui.select({ + message: `How do you want to migrate from ${provider}?`, + options: [ + { + value: "plan", + label: "Plan first", + hint: "write a reviewable plan you run yourself", + }, + { + value: "migrate", + label: "Migrate now", + hint: "let the agent do it end-to-end", + }, + ], + initialValue: "plan", + }); + if (sel === null) return void ui.warn("Cancelled."); + mode = sel; + } + let intent = "full"; if (ctx.interactive && !dryRun) { const sel = await ui.select({ @@ -102,7 +128,8 @@ export const runMigrate = defineCommand({ ui.note( [ `Provider: ${provider}`, - `Mode: ${intent}`, + `Mode: ${mode === "plan" ? "plan first" : "migrate now"}`, + `Scope: ${intent}`, `App: ${app.name} (${app.framework.name})`, `Path: ${app.path}`, ].join("\n"), @@ -112,6 +139,50 @@ export const runMigrate = defineCommand({ return; } + if (mode === "plan") { + const spin = ui.spinner(); + spin.start(`Planning migration from ${provider}`); + const result = await runAction({ + driver, + fallbackDrivers, + action: actionById["migrate-plan"], + ctx: actionCtx, + onEvent: (e) => { + if (e.type === "status") spin.message(e.text); + }, + }); + if (!result.ok || !result.finalText) { + spin.stop("Planning stopped early"); + if (result.finalText) + ui.report(stripReport(result.finalText).slice(0, 1500)); + throw new Error("Migration plan did not complete."); + } + spin.stop("Migration plan ready"); + const planText = stripReport(result.finalText); + writeFileSync(join(app.path, "sw-migration.md"), planText, "utf8"); + ui.report(planText); + + const runPrompt = "implement the plan at sw-migration.md"; + const copied = await copyToClipboard(runPrompt); + if (copied) { + ui.note( + `\`${runPrompt}\` - paste it into Claude Code, Codex, Cursor, or another coding agent.`, + "Copied to clipboard", + ); + } else { + ui.note(runPrompt, "Copy this into your agent to run the migration"); + } + + ctx.output.render( + blocks.nextSteps([ + "Review `sw-migration.md`.", + "Paste `implement the plan at sw-migration.md` into your agent to execute.", + ]), + blocks.outro(`Done. ${DASHBOARD_URL}`), + ); + return; + } + const spin = ui.spinner(); spin.start(`Migrating from ${provider}`); const result = await runAction({ diff --git a/src/commands/print-skill.ts b/src/commands/print-skill.ts index cb0812d..d716323 100644 --- a/src/commands/print-skill.ts +++ b/src/commands/print-skill.ts @@ -64,7 +64,8 @@ export function runPrintSkill( case "dashboard-setup": skill = "superwall-dashboard"; break; - case "migrate": { + case "migrate": + case "migrate-plan": { skill = "superwall-migrate"; const billing = app ? detectBilling(app) : null; reference = billing?.revenueCat diff --git a/src/core/agent/actions.ts b/src/core/agent/actions.ts index 3141860..64b5313 100644 --- a/src/core/agent/actions.ts +++ b/src/core/agent/actions.ts @@ -25,7 +25,8 @@ export type ActionId = | "placements-review" | "placements-implement" | "dashboard-setup" - | "migrate"; + | "migrate" + | "migrate-plan"; export interface ActionContext { app: DetectedApp; @@ -183,23 +184,38 @@ export const ACTIONS: WizardAction[] = [ requiresOrgKey: false, outputMode: "report", capabilities: WRITE_CAPABILITIES, - vars: (ctx) => { - const provider = ctx.billingProvider ?? "the current billing provider"; - return { - provider, - goal: - ctx.migrateIntent === "paywalls" - ? `paywalls only, keep ${provider} for billing` - : "full migration", - skill: loadBundledSkill( - "superwall-migrate", - PROVIDER_REF[provider.toLowerCase()], - ), - }; - }, + vars: migrateVars, + }, + { + id: "migrate-plan", + title: "Plan the migration", + hint: "Write a reviewable migration plan you run yourself", + runningLabel: "Planning the migration", + successLabel: "Migration plan ready", + requiresIntegration: false, + requiresOrgKey: false, + outputMode: "report", + capabilities: READ_CAPABILITIES, + vars: migrateVars, }, ]; +/** Provider + scope + skill vars shared by the migrate and migrate-plan actions. */ +function migrateVars(ctx: ActionContext): Record { + const provider = ctx.billingProvider ?? "the current billing provider"; + return { + provider, + goal: + ctx.migrateIntent === "paywalls" + ? `paywalls only, keep ${provider} for billing` + : "full migration", + skill: loadBundledSkill( + "superwall-migrate", + PROVIDER_REF[provider.toLowerCase()], + ), + }; +} + export const actionById = Object.fromEntries( ACTIONS.map((a) => [a.id, a]), ) as Record;