Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions prompts/actions/migrate-plan.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<task>
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.

<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.
</report>

Emit a [STATUS] before each step.
</task>
10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -190,6 +193,11 @@ export async function run(): Promise<void> {
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) => {
Expand Down
73 changes: 72 additions & 1 deletion src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,13 +13,15 @@ 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({
name: "migrate",
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();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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"),
Expand All @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion src/commands/print-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 31 additions & 15 deletions src/core/agent/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export type ActionId =
| "placements-review"
| "placements-implement"
| "dashboard-setup"
| "migrate";
| "migrate"
| "migrate-plan";

export interface ActionContext {
app: DetectedApp;
Expand Down Expand Up @@ -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<string, string> {
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<ActionId, WizardAction>;