Skip to content
Merged
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
"segments": {
"description": "List segments"
},
"migrations": {
"description": "Migrate from RevenueCat: catalog, transactions and store events"
},
"asa": {
"description": "Apple Search Ads: campaigns, keywords, metrics and automations (scoped by the token's company, no --app)"
},
Expand Down
39 changes: 39 additions & 0 deletions src/cli/commands/migrations/close/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Flags } from '@oclif/core';

import { AdaptyCommand } from '../../../base/adapty/index.js';
import { migrationFlags } from '../../../flags.js';

import type { Envelope } from '../../../../sdk/adapty/index.js';

export default class Close extends AdaptyCommand {
static override description = 'Finish a migration or cancel it for good';

static override examples = [
'<%= config.bin %> migrations close --outcome finish --yes',
'<%= config.bin %> migrations close --outcome cancel --yes -m mig_7x2',
];

static override flags = {
...migrationFlags,
outcome: Flags.option({
description: 'Finish — mark the migration as completed; Cancel — abandon the migration.',
options: ['finish', 'cancel'] as const,
required: true,
})(),
// Closing is final and never appears in next_actions, so there is nothing to preview:
// the agreement is the flag itself, required even on a TTY.
yes: Flags.boolean({
char: 'y',
description: 'Confirm closing: it is final and never asked for again',
required: true,
}),
};

async run(): Promise<Envelope> {
await this.parse(Close);

// TODO: resolve the migration id, POST close with the outcome and expected_revision from a
// fresh envelope, then render what came back.
throw new Error('`adapty migrations close` is not implemented yet');
}
}
56 changes: 56 additions & 0 deletions src/cli/commands/migrations/create/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Flags } from '@oclif/core';

import { validateCreateMigration } from '../../../../sdk/adapty/migrations/index.js';
import { assertValid } from '../../../../sdk/core/validation.js';
import { AdaptyCommand } from '../../../base/adapty/index.js';
import { renderEnvelope } from '../../../views/envelope.js';

import type { CreateMigrationInput, Envelope } from '../../../../sdk/adapty/index.js';

export default class Create extends AdaptyCommand {
static override description = 'Start a migration from RevenueCat';

static override examples = [
'<%= config.bin %> migrations create --name "Acme Fitness"',
'<%= config.bin %> migrations create --flow transactions --app 3f2ab1c4-0000-4000-8000-000000000000',
];

// One endpoint, two shapes: --name starts the main flow and names the Adapty app it will
// create along the way; --flow starts an optional flow for an app main has already created.
// The pairing is input shape, so it is declared here; the rule behind it — exactly one of the
// two — lives in sdk/adapty/migrations/create.ts, where an MCP server obeys it too.
static override flags = {
name: Flags.string({
description: 'Name of the Adapty app to create (starts the main flow: RevenueCat catalog)',
exclusive: ['app', 'flow'],
}),
flow: Flags.string({
dependsOn: ['app'],
description: 'Optional flow to start for an existing app, e.g. transactions (see `adapty migrations list`)',
}),
app: Flags.string({
dependsOn: ['flow'],
description: 'App ID (UUID) the optional flow runs for',
}),
};

async run(): Promise<Envelope> {
const { flags } = await this.parse(Create);

const input: CreateMigrationInput = {
appId: flags.app,
appName: flags.name,
flow: flags.flow,
};

assertValid(validateCreateMigration(input));

const envelope = await this.adapty.migrations.create(input);

this.log('Migration created.');
this.render(envelope, renderEnvelope);
this.log(`\nContinue with \`${this.config.bin} migrations status -m ${envelope.migration.id}\`.`);

return envelope;
}
}
19 changes: 19 additions & 0 deletions src/cli/commands/migrations/list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { AdaptyCommand } from '../../../base/adapty/index.js';

import { renderMigrationList } from './lib/render.js';

import type { MigrationList } from '../../../../sdk/adapty/index.js';

export default class List extends AdaptyCommand {
static override description = 'List migrations and the flows you can start';
static override examples = ['<%= config.bin %> migrations list'];

async run(): Promise<MigrationList> {
await this.parse(List);

const list = await this.adapty.migrations.list();
this.render(list, renderMigrationList);

return list;
}
}
113 changes: 113 additions & 0 deletions src/cli/commands/migrations/list/lib/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { AvailableFlow, Migration, MigrationList } from '../../../../../sdk/adapty/index.js';

type App = Migration['app'];

/** One block per Adapty App: its migrations, then the optional flows WS says can start for it. */
type Group = {
app: App;
available: AvailableFlow[];
migrations: Migration[];
};

const stateOrder = [
'action_required',
'running',
'failed',
'completed',
'canceled',
] as const satisfies readonly Migration['state'][];

const stateRank = (state: string): number => {
const index = stateOrder.findIndex(knownState => knownState === state);

return index === -1 ? stateOrder.length : index;
};

const appLabel = (app: App): string => (app === null ? 'App not created yet' : `${app.name} (${app.id})`);

const maxWidth = (values: readonly string[]): number => {
return values.reduce((max, value) => Math.max(max, value.length), 0);
};

const groupByApp = (list: MigrationList): Group[] => {
const groups = new Map<string | null, Group>();

const groupFor = (app: App): Group => {
const key = app === null ? null : app.id;
const existing = groups.get(key);

if (existing !== undefined) {
return existing;
}

const group: Group = { app, available: [], migrations: [] };

groups.set(key, group);

return group;
};

for (const migration of list.items) {
groupFor(migration.app).migrations.push(migration);
}

for (const flow of list.available) {
groupFor(flow.app).available.push(flow);
}

return [...groups.values()];
};

const renderMigrations = (migrations: readonly Migration[]): string[] => {
const sorted = [...migrations].sort((a, b) => stateRank(a.state) - stateRank(b.state));
const idWidth = maxWidth(sorted.map(migration => migration.id));
const flowWidth = maxWidth(sorted.map(migration => migration.flow));
const lines: string[] = [];
let heading: string | undefined;

for (const migration of sorted) {
if (migration.state !== heading) {
heading = migration.state;
lines.push(` ${heading}`);
}

const id = migration.id.padEnd(idWidth);
const flow = migration.flow.padEnd(flowWidth);

lines.push(` ${id} ${flow} ${migration.updated_at} ${migration.summary}`);
}

return lines;
};

const renderAvailable = (available: readonly AvailableFlow[]): string[] => {
if (available.length === 0) {
return [];
}

const flowWidth = maxWidth(available.map(flow => flow.flow));

return [
' Available to start:',
...available.map((flow) => {
const detail = flow.detail === null ? '' : ` ${flow.detail}`;

return ` ${flow.flow.padEnd(flowWidth)} ${flow.title}${detail}`;
}),
];
};

const renderGroup = (group: Group): string => [
appLabel(group.app),
...renderMigrations(group.migrations),
...renderAvailable(group.available),
].join('\n');

/** Grouped by app and, inside an app, by state. */
export const renderMigrationList = (list: MigrationList): string => {
if (list.items.length === 0 && list.available.length === 0) {
return 'No migrations yet. Start one: `adapty migration create --name <app name>`';
}

return groupByApp(list).map(group => renderGroup(group)).join('\n\n');
};
62 changes: 62 additions & 0 deletions src/cli/commands/migrations/run/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Args, Flags } from '@oclif/core';

import { AdaptyCommand } from '../../../base/adapty/index.js';
import { migrationFlags } from '../../../flags.js';

import type { Envelope } from '../../../../sdk/adapty/index.js';

export default class Run extends AdaptyCommand {
static override description = 'Do one of the actions the migration offers';

static override examples = [
'<%= config.bin %> migrations run resolve_app_mapping --input \'{"rc_app_ids":["app_ios"]}\'',
'<%= config.bin %> migrations run resolve_mapping --input-file ./decisions.json --yes',
'<%= config.bin %> migrations run upload_file --file ./rc-export.csv.gz',
];

static override args = {
action_id: Args.string({
description: 'Action id, as listed by `adapty migrations status`',
required: true,
}),
};

// One command per action kind: input goes with --input/--input-file, upload with --file, and
// an external action only prints its link. Which one applies is the server's answer, so the
// flags cannot be split into three commands — the checks belong in run().
static override flags = {
...migrationFlags,
'input': Flags.string({
description: 'Action input as JSON',
exclusive: ['input-file'],
}),
'input-file': Flags.string({
description: 'Read the action input from a file, or from stdin with -',
exclusive: ['input'],
}),
'file': Flags.string({
description: 'File to upload for an upload action',
}),
'yes': Flags.boolean({
char: 'y',
description: 'Agree to an action that changes production data, without the prompt',
}),
'open': Flags.boolean({
description: 'Open the link of an external action, even with --json',
exclusive: ['no-browser'],
}),
'no-browser': Flags.boolean({
description: 'Never open a browser; print the link only',
exclusive: ['open'],
}),
};

async run(): Promise<Envelope> {
await this.parse(Run);

// TODO: read the envelope, find the action in next_actions ∪ available_actions (exit 2 when
// it is not there), then branch on kind: external prints and opens the href, upload streams
// the file first, input POSTs. A confirm without --yes prints the text and exits 6.
throw new Error('`adapty migrations run` is not implemented yet');
}
}
32 changes: 32 additions & 0 deletions src/cli/commands/migrations/show/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Args } from '@oclif/core';

import { AdaptyCommand } from '../../../base/adapty/index.js';
import { migrationFlags } from '../../../flags.js';

import type { Envelope } from '../../../../sdk/adapty/index.js';

export default class Show extends AdaptyCommand {
static override description = 'Read the data behind a migration: apps, mapping, report';

static override examples = [
'<%= config.bin %> migrations show',
'<%= config.bin %> migrations show mapping',
'<%= config.bin %> migrations show report -m mig_7x2',
];

static override args = {
resource: Args.string({
description: 'Resource name; omit to list what can be read now',
}),
};

static override flags = { ...migrationFlags };

async run(): Promise<Envelope> {
await this.parse(Show);

// TODO: without the arg render resources[] from the envelope; with it GET the resource and
// render result — a table for a collection, text for { markdown }, JSON for anything else.
throw new Error('`adapty migrations show` is not implemented yet');
}
}
34 changes: 34 additions & 0 deletions src/cli/commands/migrations/status/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Flags } from '@oclif/core';

import { AdaptyCommand } from '../../../base/adapty/index.js';
import { migrationFlags } from '../../../flags.js';

import type { Envelope } from '../../../../sdk/adapty/index.js';

export default class Status extends AdaptyCommand {
static override description = 'Show where a migration is and what it needs from you';

static override examples = [
'<%= config.bin %> migrations status',
'<%= config.bin %> migrations status -m mig_7x2',
'<%= config.bin %> migrations status --wait 300s',
];

static override flags = {
...migrationFlags,
// oclif has no optional-value flag, so the contract's bare `--wait` cannot be declared as
// it is written: a string flag always demands a value. Either the duration stays required
// here, or run() reads the default (120s, max 600s) for a bare `--wait` on its own.
wait: Flags.string({
description: 'Wait until the migration changes, e.g. 300s (default 120s, max 600s)',
}),
};

async run(): Promise<Envelope> {
await this.parse(Status);

// TODO: resolve the migration id, GET the envelope (polling every poll_after_seconds while
// --wait is on, progress to stderr) and render it. A failed migration is still exit 0.
throw new Error('`adapty migrations status` is not implemented yet');
}
}
22 changes: 22 additions & 0 deletions src/cli/commands/migrations/steps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AdaptyCommand } from '../../../base/adapty/index.js';
import { migrationFlags } from '../../../flags.js';

import type { Envelope } from '../../../../sdk/adapty/index.js';

export default class Steps extends AdaptyCommand {
static override description = 'Show the migration checklist: done, current and locked steps';

static override examples = [
'<%= config.bin %> migrations steps',
'<%= config.bin %> migrations steps -m mig_7x2',
];

static override flags = { ...migrationFlags };

async run(): Promise<Envelope> {
await this.parse(Steps);

// TODO: resolve the migration id, GET the envelope and render steps[] as a checklist.
throw new Error('`adapty migrations steps` is not implemented yet');
}
}
Loading
Loading