@creem_io/cli 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -0
- package/dist/commands/checkouts.d.ts +3 -0
- package/dist/commands/checkouts.d.ts.map +1 -0
- package/dist/commands/checkouts.js +175 -0
- package/dist/commands/checkouts.js.map +1 -0
- package/dist/commands/config.d.ts +3 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +184 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/customers.d.ts +3 -0
- package/dist/commands/customers.d.ts.map +1 -0
- package/dist/commands/customers.js +246 -0
- package/dist/commands/customers.js.map +1 -0
- package/dist/commands/discounts.d.ts +3 -0
- package/dist/commands/discounts.d.ts.map +1 -0
- package/dist/commands/discounts.js +419 -0
- package/dist/commands/discounts.js.map +1 -0
- package/dist/commands/index.d.ts +12 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +26 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +115 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +61 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/migrate.d.ts +3 -0
- package/dist/commands/migrate.d.ts.map +1 -0
- package/dist/commands/migrate.js +1073 -0
- package/dist/commands/migrate.js.map +1 -0
- package/dist/commands/products.d.ts +3 -0
- package/dist/commands/products.d.ts.map +1 -0
- package/dist/commands/products.js +400 -0
- package/dist/commands/products.js.map +1 -0
- package/dist/commands/subscriptions.d.ts +3 -0
- package/dist/commands/subscriptions.d.ts.map +1 -0
- package/dist/commands/subscriptions.js +476 -0
- package/dist/commands/subscriptions.js.map +1 -0
- package/dist/commands/transactions.d.ts +3 -0
- package/dist/commands/transactions.d.ts.map +1 -0
- package/dist/commands/transactions.js +326 -0
- package/dist/commands/transactions.js.map +1 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +76 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +109 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api.d.ts +25 -0
- package/dist/lib/api.d.ts.map +1 -0
- package/dist/lib/api.js +77 -0
- package/dist/lib/api.js.map +1 -0
- package/dist/lib/auth.d.ts +28 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +113 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +144 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/env-cache.d.ts +10 -0
- package/dist/lib/env-cache.d.ts.map +1 -0
- package/dist/lib/env-cache.js +27 -0
- package/dist/lib/env-cache.js.map +1 -0
- package/dist/tui/command-bar.d.ts +10 -0
- package/dist/tui/command-bar.d.ts.map +1 -0
- package/dist/tui/command-bar.js +35 -0
- package/dist/tui/command-bar.js.map +1 -0
- package/dist/tui/engine.d.ts +3 -0
- package/dist/tui/engine.d.ts.map +1 -0
- package/dist/tui/engine.js +454 -0
- package/dist/tui/engine.js.map +1 -0
- package/dist/tui/index.d.ts +3 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +6 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/keymap.d.ts +29 -0
- package/dist/tui/keymap.d.ts.map +1 -0
- package/dist/tui/keymap.js +143 -0
- package/dist/tui/keymap.js.map +1 -0
- package/dist/tui/renderer.d.ts +12 -0
- package/dist/tui/renderer.d.ts.map +1 -0
- package/dist/tui/renderer.js +379 -0
- package/dist/tui/renderer.js.map +1 -0
- package/dist/tui/types.d.ts +53 -0
- package/dist/tui/types.d.ts.map +1 -0
- package/dist/tui/types.js +24 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/utils/output.d.ts +67 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/output.js +245 -0
- package/dist/utils/output.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.createMigrateCommand = createMigrateCommand;
|
|
40
|
+
const commander_1 = require("commander");
|
|
41
|
+
const ora_1 = __importDefault(require("ora"));
|
|
42
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
43
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
44
|
+
const api_1 = require("../lib/api");
|
|
45
|
+
const output = __importStar(require("../utils/output"));
|
|
46
|
+
function isRetryableError(error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
const message = error.message.toLowerCase();
|
|
49
|
+
// Retry on network errors, timeouts, and rate limits only
|
|
50
|
+
if (message.includes("econnreset") ||
|
|
51
|
+
message.includes("econnrefused") ||
|
|
52
|
+
message.includes("etimedout") ||
|
|
53
|
+
message.includes("socket hang up") ||
|
|
54
|
+
message.includes("network") ||
|
|
55
|
+
message.includes("timeout") ||
|
|
56
|
+
message.includes("fetch failed")) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Retry on 429 (rate limit) and 5xx (transient server errors)
|
|
61
|
+
if (error && typeof error === "object" && "status" in error) {
|
|
62
|
+
const status = error.status;
|
|
63
|
+
if (status === 429 || (status >= 500 && status <= 599))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// Also check error message for HTTP status codes (e.g. LemonSqueezyClient throws
|
|
67
|
+
// `new Error(...)` with status embedded in the message, not as a property)
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
const msg = error.message;
|
|
70
|
+
if (/\b429\b/.test(msg) || /rate.?limit/i.test(msg))
|
|
71
|
+
return true;
|
|
72
|
+
if (/\b5\d{2}\b/.test(msg))
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
async function withRetry(fn, label, maxAttempts = 3, baseDelayMs = 1000) {
|
|
78
|
+
if (maxAttempts < 1) {
|
|
79
|
+
throw new Error("maxAttempts must be at least 1");
|
|
80
|
+
}
|
|
81
|
+
let lastError;
|
|
82
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
return await fn();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
88
|
+
if (attempt < maxAttempts && isRetryableError(error)) {
|
|
89
|
+
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
|
|
90
|
+
console.error(chalk_1.default.dim(` ↻ Attempt ${attempt + 1}/${maxAttempts} for ${label} in ${delayMs}ms...`));
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw lastError;
|
|
99
|
+
}
|
|
100
|
+
// ============================================================================
|
|
101
|
+
// Lemon Squeezy API Client
|
|
102
|
+
// ============================================================================
|
|
103
|
+
class LemonSqueezyClient {
|
|
104
|
+
apiKey;
|
|
105
|
+
baseUrl = "https://api.lemonsqueezy.com/v1";
|
|
106
|
+
storeId;
|
|
107
|
+
constructor(apiKey) {
|
|
108
|
+
this.apiKey = apiKey;
|
|
109
|
+
}
|
|
110
|
+
setStoreId(storeId) {
|
|
111
|
+
this.storeId = storeId;
|
|
112
|
+
}
|
|
113
|
+
async request(endpoint, page = 1, perPage = 100, filterByStore = false) {
|
|
114
|
+
return withRetry(async () => {
|
|
115
|
+
const url = new URL(`${this.baseUrl}${endpoint}`);
|
|
116
|
+
url.searchParams.set("page[number]", page.toString());
|
|
117
|
+
url.searchParams.set("page[size]", perPage.toString());
|
|
118
|
+
// Filter by store_id to only fetch data from the validated store
|
|
119
|
+
if (filterByStore && this.storeId) {
|
|
120
|
+
url.searchParams.set("filter[store_id]", this.storeId.toString());
|
|
121
|
+
}
|
|
122
|
+
const response = await fetch(url.toString(), {
|
|
123
|
+
headers: {
|
|
124
|
+
Accept: "application/vnd.api+json",
|
|
125
|
+
"Content-Type": "application/vnd.api+json",
|
|
126
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const errorText = await response.text();
|
|
131
|
+
throw new Error(`Lemon Squeezy API error (${response.status}): ${errorText}`);
|
|
132
|
+
}
|
|
133
|
+
return response.json();
|
|
134
|
+
}, `${endpoint} (page ${page})`);
|
|
135
|
+
}
|
|
136
|
+
async *paginate(endpoint, filterByStore = false) {
|
|
137
|
+
let page = 1;
|
|
138
|
+
let hasMore = true;
|
|
139
|
+
while (hasMore) {
|
|
140
|
+
const response = await this.request(endpoint, page, 100, filterByStore);
|
|
141
|
+
yield response.data;
|
|
142
|
+
// Check for both undefined and null - JSON:API spec allows null for unavailable links
|
|
143
|
+
hasMore = response.links.next != null;
|
|
144
|
+
page++;
|
|
145
|
+
// Rate limit: LS allows 300 requests per minute (5 req/sec)
|
|
146
|
+
// Using 200ms delay for safety margin
|
|
147
|
+
if (hasMore) {
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async fetchAll(endpoint, filterByStore = false) {
|
|
153
|
+
const items = [];
|
|
154
|
+
for await (const batch of this.paginate(endpoint, filterByStore)) {
|
|
155
|
+
items.push(...batch);
|
|
156
|
+
}
|
|
157
|
+
return items;
|
|
158
|
+
}
|
|
159
|
+
async getProducts() {
|
|
160
|
+
return this.fetchAll("/products", true);
|
|
161
|
+
}
|
|
162
|
+
async getVariants() {
|
|
163
|
+
// Variants endpoint doesn't support store_id filter - filter client-side via product_id
|
|
164
|
+
return this.fetchAll("/variants", false);
|
|
165
|
+
}
|
|
166
|
+
async getDiscounts() {
|
|
167
|
+
return this.fetchAll("/discounts", true);
|
|
168
|
+
}
|
|
169
|
+
async getCustomers() {
|
|
170
|
+
return this.fetchAll("/customers", true);
|
|
171
|
+
}
|
|
172
|
+
async getFiles() {
|
|
173
|
+
// Files endpoint doesn't support store_id filter - filtered in buildMigrationPlan via productMap
|
|
174
|
+
return this.fetchAll("/files", false);
|
|
175
|
+
}
|
|
176
|
+
async getStores() {
|
|
177
|
+
return this.fetchAll("/stores", false);
|
|
178
|
+
}
|
|
179
|
+
async validateKey() {
|
|
180
|
+
try {
|
|
181
|
+
const stores = await this.getStores();
|
|
182
|
+
if (stores.length === 0) {
|
|
183
|
+
return { valid: false, error: "No store found for this API key" };
|
|
184
|
+
}
|
|
185
|
+
return { valid: true, stores };
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return {
|
|
189
|
+
valid: false,
|
|
190
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function mapBillingPeriod(interval, intervalCount) {
|
|
196
|
+
// Missing interval for subscriptions should be skipped - can happen with deprecated LS variant fields
|
|
197
|
+
// This function is only called when is_subscription is true, so missing interval is invalid
|
|
198
|
+
if (!interval) {
|
|
199
|
+
return {
|
|
200
|
+
period: undefined,
|
|
201
|
+
warning: "Subscription variant missing billing interval (possibly deprecated LS field)",
|
|
202
|
+
skip: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Map LS intervals to CREEM billing periods
|
|
206
|
+
if (interval === "month") {
|
|
207
|
+
// Handle undefined intervalCount as default monthly (same pattern as year branch)
|
|
208
|
+
if (intervalCount === 1 || intervalCount === undefined)
|
|
209
|
+
return { period: "every-month" };
|
|
210
|
+
if (intervalCount === 3)
|
|
211
|
+
return { period: "every-three-months" };
|
|
212
|
+
if (intervalCount === 6)
|
|
213
|
+
return { period: "every-six-months" };
|
|
214
|
+
// Unsupported month intervals (2, 4, 5, etc.) - skip with warning
|
|
215
|
+
return {
|
|
216
|
+
period: undefined,
|
|
217
|
+
warning: `${intervalCount}-month billing not supported in CREEM`,
|
|
218
|
+
skip: true,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (interval === "year") {
|
|
222
|
+
if (intervalCount === 1 || intervalCount === undefined) {
|
|
223
|
+
return { period: "every-year" };
|
|
224
|
+
}
|
|
225
|
+
// Multi-year intervals not supported - skip to avoid pricing errors
|
|
226
|
+
return {
|
|
227
|
+
period: undefined,
|
|
228
|
+
warning: `${intervalCount}-year billing not supported in CREEM`,
|
|
229
|
+
skip: true,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
// Day/week intervals not supported in CREEM - MUST skip to avoid wrong pricing
|
|
233
|
+
// A $5/week product silently becoming $5/month would be a 4x pricing error
|
|
234
|
+
return {
|
|
235
|
+
period: undefined,
|
|
236
|
+
warning: `${interval} billing interval not supported in CREEM (would cause incorrect pricing)`,
|
|
237
|
+
skip: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function mapDiscountType(amountType) {
|
|
241
|
+
return amountType === "percent" ? "percentage" : "fixed";
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Map a Lemon Squeezy discount to CREEM discount format.
|
|
245
|
+
* Centralized helper to avoid duplication across skipped and active discount handling.
|
|
246
|
+
*/
|
|
247
|
+
function mapLsDiscountToCreemDiscount(discount, storeCurrency) {
|
|
248
|
+
const creemDiscount = {
|
|
249
|
+
name: discount.attributes.name,
|
|
250
|
+
code: discount.attributes.code,
|
|
251
|
+
type: mapDiscountType(discount.attributes.amount_type),
|
|
252
|
+
duration: discount.attributes.duration,
|
|
253
|
+
durationInMonths: discount.attributes.duration_in_months,
|
|
254
|
+
maxRedemptions: discount.attributes.is_limited_redemptions
|
|
255
|
+
? discount.attributes.max_redemptions
|
|
256
|
+
: undefined,
|
|
257
|
+
expiryDate: discount.attributes.expires_at
|
|
258
|
+
? new Date(discount.attributes.expires_at)
|
|
259
|
+
: undefined,
|
|
260
|
+
};
|
|
261
|
+
// Set amount/percentage based on discount type
|
|
262
|
+
if (discount.attributes.amount_type === "percent") {
|
|
263
|
+
creemDiscount.percentage = discount.attributes.amount;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Fixed amount - LS stores in cents, CREEM also expects cents
|
|
267
|
+
creemDiscount.amount = discount.attributes.amount;
|
|
268
|
+
creemDiscount.currency = storeCurrency;
|
|
269
|
+
}
|
|
270
|
+
return creemDiscount;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get the effective price for a variant.
|
|
274
|
+
* For PWYW (Pay What You Want) variants, the `price` field is deprecated and may be 0.
|
|
275
|
+
* Instead, use min_price (minimum acceptable) or suggested_price (recommended) or 0 if none set.
|
|
276
|
+
*/
|
|
277
|
+
function getVariantPrice(variant) {
|
|
278
|
+
if (variant.attributes.pay_what_you_want) {
|
|
279
|
+
// For PWYW, prefer min_price (sets a floor), fall back to suggested_price, then 0
|
|
280
|
+
// Use || instead of ?? because min_price: 0 means "no minimum" in LS (should fall through)
|
|
281
|
+
return variant.attributes.min_price || variant.attributes.suggested_price || 0;
|
|
282
|
+
}
|
|
283
|
+
return variant.attributes.price;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Check if a variant is eligible for migration.
|
|
287
|
+
* Must match the same criteria used in the main migration loop.
|
|
288
|
+
*/
|
|
289
|
+
function isVariantMigrateable(variant, product) {
|
|
290
|
+
if (!product)
|
|
291
|
+
return false;
|
|
292
|
+
// Import all products regardless of status (draft, pending, published)
|
|
293
|
+
// Users may want to migrate their entire catalog including work-in-progress
|
|
294
|
+
// Skip variants with no price (unless PWYW)
|
|
295
|
+
if (variant.attributes.price === 0 && !variant.attributes.pay_what_you_want) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
// For recurring variants, check if billing interval is supported
|
|
299
|
+
if (variant.attributes.is_subscription) {
|
|
300
|
+
const billingResult = mapBillingPeriod(variant.attributes.interval, variant.attributes.interval_count);
|
|
301
|
+
if (billingResult.skip) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
function buildMigrationPlan(products, variants, discounts, customers, files, storeCurrency) {
|
|
308
|
+
const productMap = new Map(products.map((p) => [parseInt(p.id, 10), p]));
|
|
309
|
+
// Pre-compute migrateable variant counts per product to avoid O(N²) complexity
|
|
310
|
+
const migrateableVariantCounts = new Map();
|
|
311
|
+
for (const variant of variants) {
|
|
312
|
+
const productId = variant.attributes.product_id;
|
|
313
|
+
const product = productMap.get(productId);
|
|
314
|
+
if (isVariantMigrateable(variant, product)) {
|
|
315
|
+
migrateableVariantCounts.set(productId, (migrateableVariantCounts.get(productId) || 0) + 1);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Build products from variants
|
|
319
|
+
// Uses isVariantMigrateable as the single source of truth for eligibility checks
|
|
320
|
+
const productPlan = [];
|
|
321
|
+
for (const variant of variants) {
|
|
322
|
+
const product = productMap.get(variant.attributes.product_id);
|
|
323
|
+
if (!product)
|
|
324
|
+
continue;
|
|
325
|
+
const isRecurring = variant.attributes.is_subscription;
|
|
326
|
+
const billingResult = isRecurring
|
|
327
|
+
? mapBillingPeriod(variant.attributes.interval, variant.attributes.interval_count)
|
|
328
|
+
: { period: undefined };
|
|
329
|
+
// Use pre-computed count for naming decision
|
|
330
|
+
const productId = parseInt(product.id, 10);
|
|
331
|
+
const migrateableVariantCount = migrateableVariantCounts.get(productId) || 0;
|
|
332
|
+
const productName = migrateableVariantCount > 1
|
|
333
|
+
? `${product.attributes.name} - ${variant.attributes.name}`
|
|
334
|
+
: product.attributes.name;
|
|
335
|
+
// Use isVariantMigrateable for all eligibility checks
|
|
336
|
+
if (!isVariantMigrateable(variant, product)) {
|
|
337
|
+
// Determine the specific reason for skipping - ALWAYS track and report
|
|
338
|
+
let skipReason;
|
|
339
|
+
const hasPrice = variant.attributes.price > 0 || variant.attributes.pay_what_you_want;
|
|
340
|
+
if (!hasPrice) {
|
|
341
|
+
skipReason = "Variant has no price set";
|
|
342
|
+
}
|
|
343
|
+
else if (billingResult.skip && billingResult.warning) {
|
|
344
|
+
skipReason = billingResult.warning;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
skipReason = "Unknown eligibility issue";
|
|
348
|
+
}
|
|
349
|
+
productPlan.push({
|
|
350
|
+
lsProduct: product,
|
|
351
|
+
lsVariant: variant,
|
|
352
|
+
creemProduct: {
|
|
353
|
+
name: productName,
|
|
354
|
+
description: product.attributes.description || "",
|
|
355
|
+
price: getVariantPrice(variant),
|
|
356
|
+
currency: storeCurrency,
|
|
357
|
+
billingType: isRecurring ? "recurring" : "onetime",
|
|
358
|
+
billingPeriod: billingResult.period,
|
|
359
|
+
taxCategory: "saas",
|
|
360
|
+
},
|
|
361
|
+
skipped: true,
|
|
362
|
+
skipReason,
|
|
363
|
+
});
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
productPlan.push({
|
|
367
|
+
lsProduct: product,
|
|
368
|
+
lsVariant: variant,
|
|
369
|
+
creemProduct: {
|
|
370
|
+
name: productName,
|
|
371
|
+
description: product.attributes.description || "",
|
|
372
|
+
price: getVariantPrice(variant), // Use min_price/suggested_price for PWYW variants
|
|
373
|
+
currency: storeCurrency, // Use store's configured currency (LS supports 150+ currencies)
|
|
374
|
+
billingType: isRecurring ? "recurring" : "onetime",
|
|
375
|
+
billingPeriod: billingResult.period,
|
|
376
|
+
taxCategory: "saas",
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
// Build discounts
|
|
381
|
+
const discountPlan = [];
|
|
382
|
+
for (const discount of discounts) {
|
|
383
|
+
// Skip expired or inactive discounts
|
|
384
|
+
if (discount.attributes.status !== "published") {
|
|
385
|
+
discountPlan.push({
|
|
386
|
+
lsDiscount: discount,
|
|
387
|
+
creemDiscount: mapLsDiscountToCreemDiscount(discount, storeCurrency),
|
|
388
|
+
skipped: true,
|
|
389
|
+
skipReason: `Discount status is "${discount.attributes.status}" (not published)`,
|
|
390
|
+
});
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
// Skip product-scoped discounts - LS API doesn't expose which products they apply to
|
|
394
|
+
// Migrating them would apply to ALL products, potentially causing revenue loss
|
|
395
|
+
if (discount.attributes.is_limited_to_products) {
|
|
396
|
+
discountPlan.push({
|
|
397
|
+
lsDiscount: discount,
|
|
398
|
+
creemDiscount: mapLsDiscountToCreemDiscount(discount, storeCurrency),
|
|
399
|
+
skipped: true,
|
|
400
|
+
skipReason: "Product-scoped discount (requires manual setup in CREEM to avoid applying to wrong products)",
|
|
401
|
+
});
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// Map LS discount to CREEM format using centralized helper
|
|
405
|
+
const creemDiscount = mapLsDiscountToCreemDiscount(discount, storeCurrency);
|
|
406
|
+
discountPlan.push({
|
|
407
|
+
lsDiscount: discount,
|
|
408
|
+
creemDiscount,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// Build customers
|
|
412
|
+
const customerPlan = [];
|
|
413
|
+
for (const customer of customers) {
|
|
414
|
+
customerPlan.push({
|
|
415
|
+
lsCustomer: customer,
|
|
416
|
+
creemCustomer: {
|
|
417
|
+
email: customer.attributes.email,
|
|
418
|
+
name: customer.attributes.name,
|
|
419
|
+
billingAddress: customer.attributes.city || customer.attributes.country
|
|
420
|
+
? {
|
|
421
|
+
city: customer.attributes.city,
|
|
422
|
+
country: customer.attributes.country,
|
|
423
|
+
}
|
|
424
|
+
: undefined,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// Build files (for later reference)
|
|
429
|
+
// Filter to only include files belonging to products in our store (via productMap)
|
|
430
|
+
const filePlan = [];
|
|
431
|
+
for (const file of files) {
|
|
432
|
+
const variant = variants.find((v) => parseInt(v.id, 10) === file.attributes.variant_id);
|
|
433
|
+
const product = variant ? productMap.get(variant.attributes.product_id) : undefined;
|
|
434
|
+
// Skip files that don't belong to products in our store
|
|
435
|
+
if (!product)
|
|
436
|
+
continue;
|
|
437
|
+
filePlan.push({
|
|
438
|
+
lsFile: file,
|
|
439
|
+
variantId: file.attributes.variant_id.toString(),
|
|
440
|
+
productName: product.attributes.name,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const migrateableProducts = productPlan.filter((p) => !p.skipped);
|
|
444
|
+
const skippedProducts = productPlan.filter((p) => p.skipped);
|
|
445
|
+
const migrateableDiscounts = discountPlan.filter((d) => !d.skipped);
|
|
446
|
+
const skippedDiscounts = discountPlan.filter((d) => d.skipped);
|
|
447
|
+
return {
|
|
448
|
+
products: productPlan,
|
|
449
|
+
discounts: discountPlan,
|
|
450
|
+
customers: customerPlan,
|
|
451
|
+
files: filePlan,
|
|
452
|
+
summary: {
|
|
453
|
+
totalProducts: migrateableProducts.length,
|
|
454
|
+
totalDiscounts: migrateableDiscounts.length,
|
|
455
|
+
totalCustomers: customerPlan.length,
|
|
456
|
+
totalFiles: filePlan.length,
|
|
457
|
+
skippedProducts: skippedProducts.length,
|
|
458
|
+
skippedDiscounts: skippedDiscounts.length,
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
// ============================================================================
|
|
463
|
+
// Migration Execution
|
|
464
|
+
// ============================================================================
|
|
465
|
+
async function executeMigration(plan, options) {
|
|
466
|
+
const result = {
|
|
467
|
+
success: true,
|
|
468
|
+
created: { products: [], discounts: [], customers: [], files: [] },
|
|
469
|
+
failed: { products: [], discounts: [], customers: [], files: [] },
|
|
470
|
+
skipped: { products: [], discounts: [], customers: [] },
|
|
471
|
+
};
|
|
472
|
+
if (options.dryRun) {
|
|
473
|
+
console.log();
|
|
474
|
+
console.log(chalk_1.default.yellow("DRY RUN MODE - No changes will be made"));
|
|
475
|
+
console.log();
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
const client = (0, api_1.getClient)();
|
|
479
|
+
// Migrate Products (skip products with unsupported billing intervals)
|
|
480
|
+
const migrateableProducts = plan.products.filter((p) => !p.skipped);
|
|
481
|
+
const skippedProducts = plan.products.filter((p) => p.skipped);
|
|
482
|
+
console.log();
|
|
483
|
+
console.log(chalk_1.default.bold("Migrating Products..."));
|
|
484
|
+
// Record skipped products first
|
|
485
|
+
for (const skipped of skippedProducts) {
|
|
486
|
+
result.skipped.products.push({
|
|
487
|
+
name: skipped.creemProduct.name,
|
|
488
|
+
reason: skipped.skipReason || "Unsupported billing interval",
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
for (let i = 0; i < migrateableProducts.length; i++) {
|
|
492
|
+
const item = migrateableProducts[i];
|
|
493
|
+
const progress = `[${i + 1}/${migrateableProducts.length}]`;
|
|
494
|
+
const spinner = (0, ora_1.default)(`${progress} Creating: ${item.creemProduct.name}`).start();
|
|
495
|
+
try {
|
|
496
|
+
const params = {
|
|
497
|
+
name: item.creemProduct.name,
|
|
498
|
+
// CREEM API requires description to be a non-empty string
|
|
499
|
+
// Use product name as fallback if no description is provided
|
|
500
|
+
description: item.creemProduct.description || item.creemProduct.name,
|
|
501
|
+
price: item.creemProduct.price,
|
|
502
|
+
currency: item.creemProduct.currency,
|
|
503
|
+
billingType: item.creemProduct.billingType,
|
|
504
|
+
taxCategory: item.creemProduct.taxCategory,
|
|
505
|
+
};
|
|
506
|
+
if (item.creemProduct.billingPeriod) {
|
|
507
|
+
params.billingPeriod = item.creemProduct.billingPeriod;
|
|
508
|
+
}
|
|
509
|
+
// NOTE: Retrying a POST create is not idempotent — a transient failure after the server
|
|
510
|
+
// processes the request could lead to a duplicate product. This is an acceptable tradeoff:
|
|
511
|
+
// duplicates can be cleaned up manually, whereas failing the entire migration is worse.
|
|
512
|
+
const created = (await withRetry(() => client.products.create(params), `product "${item.creemProduct.name}"`));
|
|
513
|
+
result.created.products.push(created.id);
|
|
514
|
+
spinner.succeed(`${progress} Created: ${item.creemProduct.name}`);
|
|
515
|
+
}
|
|
516
|
+
catch (error) {
|
|
517
|
+
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
518
|
+
result.failed.products.push({
|
|
519
|
+
name: item.creemProduct.name,
|
|
520
|
+
error: errorMsg,
|
|
521
|
+
});
|
|
522
|
+
spinner.fail(`${progress} Failed: ${item.creemProduct.name} - ${errorMsg}`);
|
|
523
|
+
result.success = false;
|
|
524
|
+
}
|
|
525
|
+
// Small delay to avoid rate limiting
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
527
|
+
}
|
|
528
|
+
// Migrate Discounts (after products so we can apply to all created products)
|
|
529
|
+
const migrateableDiscounts = plan.discounts.filter((d) => !d.skipped);
|
|
530
|
+
const skippedDiscounts = plan.discounts.filter((d) => d.skipped);
|
|
531
|
+
if (plan.discounts.length > 0) {
|
|
532
|
+
console.log();
|
|
533
|
+
console.log(chalk_1.default.bold("Migrating Discounts..."));
|
|
534
|
+
// Record skipped discounts first
|
|
535
|
+
for (const skipped of skippedDiscounts) {
|
|
536
|
+
result.skipped.discounts.push({
|
|
537
|
+
code: skipped.creemDiscount.code,
|
|
538
|
+
reason: skipped.skipReason || "Discount not active",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
// Skip discount creation if no products were successfully created
|
|
542
|
+
// CREEM API requires at least one product for discounts to apply to
|
|
543
|
+
if (result.created.products.length === 0 && migrateableDiscounts.length > 0) {
|
|
544
|
+
console.log(chalk_1.default.yellow(` ⚠ Skipping ${migrateableDiscounts.length} discount(s): No products were created to apply them to`));
|
|
545
|
+
for (const item of migrateableDiscounts) {
|
|
546
|
+
result.skipped.discounts.push({
|
|
547
|
+
code: item.creemDiscount.code,
|
|
548
|
+
reason: "No products available to apply discount to",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
// Create discounts - apply to all created products
|
|
553
|
+
for (let i = 0; i < (result.created.products.length > 0 ? migrateableDiscounts.length : 0); i++) {
|
|
554
|
+
const item = migrateableDiscounts[i];
|
|
555
|
+
const progress = `[${i + 1}/${migrateableDiscounts.length}]`;
|
|
556
|
+
const spinner = (0, ora_1.default)(`${progress} Creating: ${item.creemDiscount.code}`).start();
|
|
557
|
+
try {
|
|
558
|
+
// Build discount creation params
|
|
559
|
+
const params = {
|
|
560
|
+
name: item.creemDiscount.name,
|
|
561
|
+
code: item.creemDiscount.code,
|
|
562
|
+
type: item.creemDiscount.type,
|
|
563
|
+
duration: item.creemDiscount.duration,
|
|
564
|
+
// Apply to all created products from this migration
|
|
565
|
+
appliesToProducts: result.created.products,
|
|
566
|
+
};
|
|
567
|
+
// Set amount/percentage based on type
|
|
568
|
+
if (item.creemDiscount.type === "percentage" &&
|
|
569
|
+
item.creemDiscount.percentage !== undefined) {
|
|
570
|
+
params.percentage = item.creemDiscount.percentage;
|
|
571
|
+
}
|
|
572
|
+
else if (item.creemDiscount.type === "fixed" && item.creemDiscount.amount !== undefined) {
|
|
573
|
+
params.amount = item.creemDiscount.amount;
|
|
574
|
+
params.currency = item.creemDiscount.currency;
|
|
575
|
+
}
|
|
576
|
+
// Optional fields
|
|
577
|
+
if (item.creemDiscount.expiryDate) {
|
|
578
|
+
params.expiryDate = item.creemDiscount.expiryDate;
|
|
579
|
+
}
|
|
580
|
+
if (item.creemDiscount.maxRedemptions !== undefined) {
|
|
581
|
+
params.maxRedemptions = item.creemDiscount.maxRedemptions;
|
|
582
|
+
}
|
|
583
|
+
if (item.creemDiscount.duration === "repeating" &&
|
|
584
|
+
item.creemDiscount.durationInMonths !== undefined) {
|
|
585
|
+
params.durationInMonths = item.creemDiscount.durationInMonths;
|
|
586
|
+
}
|
|
587
|
+
// NOTE: Retrying a POST create is not idempotent — see product create comment above.
|
|
588
|
+
// Discount codes are unique, so duplicates will fail with a conflict error rather than
|
|
589
|
+
// silently creating duplicates.
|
|
590
|
+
const created = (await withRetry(() => client.discounts.create(params), `discount "${item.creemDiscount.code}"`));
|
|
591
|
+
result.created.discounts.push(created.id);
|
|
592
|
+
spinner.succeed(`${progress} Created: ${item.creemDiscount.code}`);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
const errorMsg = error instanceof Error ? error.message : "Unknown error";
|
|
596
|
+
result.failed.discounts.push({
|
|
597
|
+
code: item.creemDiscount.code,
|
|
598
|
+
error: errorMsg,
|
|
599
|
+
});
|
|
600
|
+
spinner.fail(`${progress} Failed: ${item.creemDiscount.code} - ${errorMsg}`);
|
|
601
|
+
result.success = false;
|
|
602
|
+
}
|
|
603
|
+
// Small delay to avoid rate limiting
|
|
604
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// Note: Customer and File migration require additional CREEM API endpoints
|
|
608
|
+
// that are not yet available in the SDK. Logging for manual follow-up.
|
|
609
|
+
if (plan.customers.length > 0) {
|
|
610
|
+
console.log();
|
|
611
|
+
console.log(chalk_1.default.yellow(`⚠ ${plan.customers.length} customers found - will be created automatically on first purchase`));
|
|
612
|
+
console.log(chalk_1.default.dim(" Customers are auto-created in CREEM when they make their first purchase"));
|
|
613
|
+
}
|
|
614
|
+
if (plan.files.length > 0) {
|
|
615
|
+
console.log();
|
|
616
|
+
console.log(chalk_1.default.yellow(`⚠ ${plan.files.length} files found - manual upload required`));
|
|
617
|
+
console.log(chalk_1.default.dim(" Upload downloadable files in the CREEM dashboard"));
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
}
|
|
621
|
+
// ============================================================================
|
|
622
|
+
// CLI Commands
|
|
623
|
+
// ============================================================================
|
|
624
|
+
function createLemonSqueezyCommand() {
|
|
625
|
+
const command = new commander_1.Command("lemon-squeezy")
|
|
626
|
+
.description("Migrate from Lemon Squeezy to CREEM")
|
|
627
|
+
.option("--ls-api-key <key>", "Lemon Squeezy API key")
|
|
628
|
+
.option("--ls-store-id <id>", "Lemon Squeezy store ID to migrate from")
|
|
629
|
+
.option("--dry-run", "Preview migration without making changes")
|
|
630
|
+
// Note: --log-file is not yet implemented - will be added in future version
|
|
631
|
+
.option("--json", "Output migration plan as JSON (implies --dry-run)")
|
|
632
|
+
.option("--exclude-discounts", "Skip migrating discounts entirely")
|
|
633
|
+
.action(async (options) => {
|
|
634
|
+
// Skip banner when outputting JSON to avoid corrupting stdout
|
|
635
|
+
if (!options.json) {
|
|
636
|
+
console.log();
|
|
637
|
+
console.log(chalk_1.default.bold.cyan("🍋 Lemon Squeezy → CREEM Migration"));
|
|
638
|
+
console.log(chalk_1.default.dim("Migrate your products, customers, and discounts"));
|
|
639
|
+
console.log();
|
|
640
|
+
}
|
|
641
|
+
// Verify CREEM authentication
|
|
642
|
+
try {
|
|
643
|
+
(0, api_1.getClient)();
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
output.error("Not authenticated with CREEM. Run `creem login` first.");
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
// Get Lemon Squeezy API key
|
|
650
|
+
let lsApiKey = options.lsApiKey;
|
|
651
|
+
if (!lsApiKey) {
|
|
652
|
+
try {
|
|
653
|
+
lsApiKey = await (0, prompts_1.password)({
|
|
654
|
+
message: "Enter your Lemon Squeezy API key:",
|
|
655
|
+
validate: (value) => (value.length > 0 ? true : "API key is required"),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
console.log(chalk_1.default.dim("\nMigration cancelled."));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
if (!lsApiKey) {
|
|
663
|
+
console.log(chalk_1.default.dim("\nMigration cancelled."));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Validate Lemon Squeezy API key
|
|
668
|
+
const lsClient = new LemonSqueezyClient(lsApiKey);
|
|
669
|
+
const validateSpinner = (0, ora_1.default)("Validating Lemon Squeezy API key...").start();
|
|
670
|
+
const validation = await lsClient.validateKey();
|
|
671
|
+
if (!validation.valid) {
|
|
672
|
+
validateSpinner.fail("Invalid Lemon Squeezy API key");
|
|
673
|
+
output.error(validation.error || "Could not validate API key");
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
const stores = validation.stores || [];
|
|
677
|
+
validateSpinner.succeed(`Connected to Lemon Squeezy (${stores.length} store${stores.length === 1 ? "" : "s"} found)`);
|
|
678
|
+
let selectedStore;
|
|
679
|
+
if (options.lsStoreId) {
|
|
680
|
+
if (!/^\d+$/.test(options.lsStoreId)) {
|
|
681
|
+
output.error(`Invalid Lemon Squeezy store ID: ${options.lsStoreId}`);
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
const requestedStoreId = parseInt(options.lsStoreId, 10);
|
|
685
|
+
if (!Number.isInteger(requestedStoreId) || requestedStoreId <= 0) {
|
|
686
|
+
output.error(`Invalid Lemon Squeezy store ID: ${options.lsStoreId}`);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
selectedStore = stores.find((store) => parseInt(store.id, 10) === requestedStoreId);
|
|
690
|
+
if (!selectedStore) {
|
|
691
|
+
output.error(`Lemon Squeezy store ID ${options.lsStoreId} was not found for this API key.`);
|
|
692
|
+
if (stores.length > 0) {
|
|
693
|
+
output.dim(`Available store IDs: ${stores.map((store) => store.id).join(", ")}`);
|
|
694
|
+
}
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
else if (stores.length === 1) {
|
|
699
|
+
selectedStore = stores[0];
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
try {
|
|
703
|
+
selectedStore = await (0, prompts_1.select)({
|
|
704
|
+
message: "Which Lemon Squeezy store do you want to migrate?",
|
|
705
|
+
choices: stores.map((store) => {
|
|
706
|
+
const parts = [
|
|
707
|
+
store.attributes.name,
|
|
708
|
+
store.attributes.domain || store.attributes.slug,
|
|
709
|
+
`ID: ${store.id}`,
|
|
710
|
+
store.attributes.currency,
|
|
711
|
+
].filter(Boolean);
|
|
712
|
+
return {
|
|
713
|
+
name: parts.join(" · "),
|
|
714
|
+
value: store,
|
|
715
|
+
};
|
|
716
|
+
}),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
console.log(chalk_1.default.dim("\nMigration cancelled."));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (!selectedStore) {
|
|
725
|
+
output.error("No Lemon Squeezy store selected. Cannot proceed with migration.");
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
const selectedStoreId = parseInt(selectedStore.id, 10);
|
|
729
|
+
if (!Number.isInteger(selectedStoreId) || selectedStoreId <= 0) {
|
|
730
|
+
output.error(`Invalid Lemon Squeezy store ID: ${selectedStore.id}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
const selectedStoreCurrency = selectedStore.attributes?.currency;
|
|
734
|
+
if (!selectedStoreCurrency) {
|
|
735
|
+
output.error("Store currency not found. Cannot proceed with migration.");
|
|
736
|
+
process.exit(1);
|
|
737
|
+
}
|
|
738
|
+
if (!options.json) {
|
|
739
|
+
console.log(chalk_1.default.dim(`Using Lemon Squeezy store: ${selectedStore.attributes.name} (ID: ${selectedStore.id})`));
|
|
740
|
+
}
|
|
741
|
+
// Set store ID for filtering subsequent API requests
|
|
742
|
+
lsClient.setStoreId(selectedStoreId);
|
|
743
|
+
// Validate store currency - CREEM only supports USD and EUR
|
|
744
|
+
const rawCurrency = selectedStoreCurrency.toUpperCase();
|
|
745
|
+
if (rawCurrency !== "USD" && rawCurrency !== "EUR") {
|
|
746
|
+
output.error(`Your Lemon Squeezy store uses ${rawCurrency}, but CREEM currently only supports USD and EUR.\n` +
|
|
747
|
+
`To migrate, please either:\n` +
|
|
748
|
+
` 1. Change your Lemon Squeezy store currency to USD or EUR before migration\n` +
|
|
749
|
+
` 2. Contact CREEM support for assistance with currency conversion\n` +
|
|
750
|
+
`\nNote: Migrating with incorrect currency would result in wrong pricing in CREEM.`);
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
const storeCurrency = rawCurrency;
|
|
754
|
+
// Fetch data from Lemon Squeezy
|
|
755
|
+
const fetchSpinner = (0, ora_1.default)("Fetching data from Lemon Squeezy...").start();
|
|
756
|
+
let products = [];
|
|
757
|
+
let variants = [];
|
|
758
|
+
let discounts = [];
|
|
759
|
+
let customers = [];
|
|
760
|
+
let files = [];
|
|
761
|
+
const fetchFailures = [];
|
|
762
|
+
fetchSpinner.text = "Fetching products...";
|
|
763
|
+
try {
|
|
764
|
+
products = await lsClient.getProducts();
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
768
|
+
fetchFailures.push({
|
|
769
|
+
type: "product",
|
|
770
|
+
label: "all products",
|
|
771
|
+
error: msg,
|
|
772
|
+
});
|
|
773
|
+
fetchSpinner.text = "Products fetch failed, continuing...";
|
|
774
|
+
}
|
|
775
|
+
fetchSpinner.text = "Fetching variants...";
|
|
776
|
+
try {
|
|
777
|
+
variants = await lsClient.getVariants();
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
781
|
+
fetchFailures.push({
|
|
782
|
+
type: "variant",
|
|
783
|
+
label: "all variants",
|
|
784
|
+
error: msg,
|
|
785
|
+
});
|
|
786
|
+
fetchSpinner.text = "Variants fetch failed, continuing...";
|
|
787
|
+
}
|
|
788
|
+
if (options.excludeDiscounts) {
|
|
789
|
+
discounts = [];
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
fetchSpinner.text = "Fetching discounts...";
|
|
793
|
+
try {
|
|
794
|
+
discounts = await lsClient.getDiscounts();
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
798
|
+
fetchFailures.push({
|
|
799
|
+
type: "discount",
|
|
800
|
+
label: "all discounts",
|
|
801
|
+
error: msg,
|
|
802
|
+
});
|
|
803
|
+
fetchSpinner.text = "Discounts fetch failed, continuing...";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
fetchSpinner.text = "Fetching customers...";
|
|
807
|
+
try {
|
|
808
|
+
customers = await lsClient.getCustomers();
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
812
|
+
fetchFailures.push({
|
|
813
|
+
type: "customer",
|
|
814
|
+
label: "all customers",
|
|
815
|
+
error: msg,
|
|
816
|
+
});
|
|
817
|
+
fetchSpinner.text = "Customers fetch failed, continuing...";
|
|
818
|
+
}
|
|
819
|
+
fetchSpinner.text = "Fetching files...";
|
|
820
|
+
try {
|
|
821
|
+
files = await lsClient.getFiles();
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
825
|
+
fetchFailures.push({ type: "file", label: "all files", error: msg });
|
|
826
|
+
fetchSpinner.text = "Files fetch failed, continuing...";
|
|
827
|
+
}
|
|
828
|
+
{
|
|
829
|
+
const discountsLabel = options.excludeDiscounts
|
|
830
|
+
? "discounts excluded"
|
|
831
|
+
: `${discounts.length} discounts`;
|
|
832
|
+
const failLabel = fetchFailures.length > 0
|
|
833
|
+
? chalk_1.default.yellow(` (${fetchFailures.length} fetch failures)`)
|
|
834
|
+
: "";
|
|
835
|
+
fetchSpinner.succeed(`Fetched: ${products.length} products, ${variants.length} variants, ${discountsLabel}, ${customers.length} customers, ${files.length} files${failLabel}`);
|
|
836
|
+
}
|
|
837
|
+
if (fetchFailures.length > 0 && !options.json) {
|
|
838
|
+
console.error();
|
|
839
|
+
console.error(chalk_1.default.yellow("⚠ Some data could not be fetched after retries:"));
|
|
840
|
+
for (const f of fetchFailures) {
|
|
841
|
+
console.error(` • ${f.type}: ${f.label} - ${f.error}`);
|
|
842
|
+
}
|
|
843
|
+
console.error(chalk_1.default.dim(" Migration will continue with the data that was successfully fetched."));
|
|
844
|
+
}
|
|
845
|
+
// Build migration plan (storeCurrency already validated as USD or EUR above)
|
|
846
|
+
const planSpinner = (0, ora_1.default)("Building migration plan...").start();
|
|
847
|
+
const plan = buildMigrationPlan(products, variants, discounts, customers, files, storeCurrency);
|
|
848
|
+
planSpinner.succeed("Migration plan ready");
|
|
849
|
+
// If JSON output requested, print and exit
|
|
850
|
+
if (options.json) {
|
|
851
|
+
const jsonOutput = fetchFailures.length > 0 ? { ...plan, fetchFailures } : plan;
|
|
852
|
+
output.outputJson(jsonOutput);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// Display migration summary
|
|
856
|
+
console.log();
|
|
857
|
+
console.log(chalk_1.default.bold("Migration Summary"));
|
|
858
|
+
console.log(chalk_1.default.dim("─".repeat(50)));
|
|
859
|
+
console.log(` Products: ${chalk_1.default.cyan(plan.summary.totalProducts)} CREEM products from ${variants.length} LS variants`);
|
|
860
|
+
if (plan.summary.skippedProducts > 0) {
|
|
861
|
+
console.log(` Skipped: ${chalk_1.default.yellow(plan.summary.skippedProducts)} variants (see details below)`);
|
|
862
|
+
}
|
|
863
|
+
if (options.excludeDiscounts) {
|
|
864
|
+
console.log(` Discounts: ${chalk_1.default.yellow("excluded")} (--exclude-discounts)`);
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
console.log(` Discounts: ${chalk_1.default.cyan(plan.summary.totalDiscounts)}${plan.summary.skippedDiscounts > 0 ? chalk_1.default.yellow(` (${plan.summary.skippedDiscounts} skipped)`) : ""}`);
|
|
868
|
+
}
|
|
869
|
+
console.log(` Customers: ${chalk_1.default.cyan(plan.summary.totalCustomers)}`);
|
|
870
|
+
console.log(` Files: ${chalk_1.default.cyan(plan.summary.totalFiles)} (manual upload required)`);
|
|
871
|
+
console.log(chalk_1.default.dim("─".repeat(50)));
|
|
872
|
+
if (plan.summary.totalProducts === 0 && plan.summary.totalCustomers === 0) {
|
|
873
|
+
console.log();
|
|
874
|
+
if (plan.summary.skippedProducts > 0) {
|
|
875
|
+
// All products were skipped - show why
|
|
876
|
+
console.log(chalk_1.default.yellow(`No products can be migrated. ${plan.summary.skippedProducts} variant(s) were skipped:`));
|
|
877
|
+
console.log();
|
|
878
|
+
const skippedProductsList = plan.products.filter((p) => p.skipped);
|
|
879
|
+
// Group by skip reason for cleaner output
|
|
880
|
+
const reasonCounts = new Map();
|
|
881
|
+
for (const p of skippedProductsList) {
|
|
882
|
+
const reason = p.skipReason || "Unknown reason";
|
|
883
|
+
reasonCounts.set(reason, (reasonCounts.get(reason) || 0) + 1);
|
|
884
|
+
}
|
|
885
|
+
console.log(chalk_1.default.dim("Skip reasons:"));
|
|
886
|
+
for (const [reason, count] of reasonCounts) {
|
|
887
|
+
console.log(` • ${chalk_1.default.yellow(count)} variant(s): ${reason}`);
|
|
888
|
+
}
|
|
889
|
+
console.log();
|
|
890
|
+
console.log(chalk_1.default.dim("Products may be skipped due to unsupported billing intervals or pricing."));
|
|
891
|
+
console.log(chalk_1.default.dim("CREEM supports one-time, monthly, and yearly billing cycles."));
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
console.log(chalk_1.default.yellow("Nothing to migrate. Your Lemon Squeezy account appears empty."));
|
|
895
|
+
}
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
// Show sample products (only migrateable ones)
|
|
899
|
+
const migrateableProducts = plan.products.filter((p) => !p.skipped);
|
|
900
|
+
const skippedProductsList = plan.products.filter((p) => p.skipped);
|
|
901
|
+
if (migrateableProducts.length > 0) {
|
|
902
|
+
console.log();
|
|
903
|
+
console.log(chalk_1.default.dim("Sample products to be created:"));
|
|
904
|
+
const sample = migrateableProducts.slice(0, 5);
|
|
905
|
+
for (const p of sample) {
|
|
906
|
+
const price = output.formatCurrency(p.creemProduct.price, p.creemProduct.currency);
|
|
907
|
+
const billing = p.creemProduct.billingType === "recurring"
|
|
908
|
+
? `Recurring / ${p.creemProduct.billingPeriod?.replace("every-", "")}`
|
|
909
|
+
: "One-time";
|
|
910
|
+
console.log(` • ${p.creemProduct.name} - ${price} (${billing})`);
|
|
911
|
+
}
|
|
912
|
+
if (migrateableProducts.length > 5) {
|
|
913
|
+
console.log(chalk_1.default.dim(` ... and ${migrateableProducts.length - 5} more`));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Show skipped products with reasons
|
|
917
|
+
if (skippedProductsList.length > 0) {
|
|
918
|
+
console.log();
|
|
919
|
+
console.log(chalk_1.default.yellow(`⚠ ${skippedProductsList.length} variant(s) cannot be migrated:`));
|
|
920
|
+
// Group by skip reason for cleaner output
|
|
921
|
+
const reasonCounts = new Map();
|
|
922
|
+
for (const p of skippedProductsList) {
|
|
923
|
+
const reason = p.skipReason || "Unknown reason";
|
|
924
|
+
if (!reasonCounts.has(reason)) {
|
|
925
|
+
reasonCounts.set(reason, []);
|
|
926
|
+
}
|
|
927
|
+
reasonCounts.get(reason).push(p.creemProduct.name);
|
|
928
|
+
}
|
|
929
|
+
for (const [reason, names] of reasonCounts) {
|
|
930
|
+
console.log(` • ${reason}: ${chalk_1.default.dim(`${names.length} variant(s)`)}`);
|
|
931
|
+
// Show first 2 names as examples
|
|
932
|
+
for (const name of names.slice(0, 2)) {
|
|
933
|
+
console.log(chalk_1.default.dim(` - ${name}`));
|
|
934
|
+
}
|
|
935
|
+
if (names.length > 2) {
|
|
936
|
+
console.log(chalk_1.default.dim(` ... and ${names.length - 2} more`));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// Show skipped discounts with reasons
|
|
941
|
+
const skippedDiscountsList = plan.discounts.filter((d) => d.skipped);
|
|
942
|
+
if (skippedDiscountsList.length > 0) {
|
|
943
|
+
console.log();
|
|
944
|
+
console.log(chalk_1.default.yellow(`⚠ ${skippedDiscountsList.length} discount(s) require manual setup:`));
|
|
945
|
+
// Group by skip reason for cleaner output
|
|
946
|
+
const discountReasonCounts = new Map();
|
|
947
|
+
for (const d of skippedDiscountsList) {
|
|
948
|
+
const reason = d.skipReason || "Unknown reason";
|
|
949
|
+
if (!discountReasonCounts.has(reason)) {
|
|
950
|
+
discountReasonCounts.set(reason, []);
|
|
951
|
+
}
|
|
952
|
+
discountReasonCounts.get(reason).push(d.creemDiscount.code);
|
|
953
|
+
}
|
|
954
|
+
for (const [reason, codes] of discountReasonCounts) {
|
|
955
|
+
console.log(` • ${reason}: ${chalk_1.default.dim(`${codes.length} discount(s)`)}`);
|
|
956
|
+
// Show first 3 codes as examples
|
|
957
|
+
for (const code of codes.slice(0, 3)) {
|
|
958
|
+
console.log(chalk_1.default.dim(` - ${code}`));
|
|
959
|
+
}
|
|
960
|
+
if (codes.length > 3) {
|
|
961
|
+
console.log(chalk_1.default.dim(` ... and ${codes.length - 3} more`));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Dry run mode
|
|
966
|
+
if (options.dryRun) {
|
|
967
|
+
console.log();
|
|
968
|
+
console.log(chalk_1.default.yellow.bold("DRY RUN MODE"));
|
|
969
|
+
console.log(chalk_1.default.dim("No changes were made. Remove --dry-run to execute migration."));
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
// Confirmation prompt
|
|
973
|
+
console.log();
|
|
974
|
+
let proceed;
|
|
975
|
+
try {
|
|
976
|
+
proceed = await (0, prompts_1.confirm)({
|
|
977
|
+
message: "Proceed with migration?",
|
|
978
|
+
default: false,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
console.log(chalk_1.default.dim("\nMigration cancelled."));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (!proceed) {
|
|
986
|
+
console.log(chalk_1.default.dim("\nMigration cancelled."));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
// Execute migration
|
|
990
|
+
console.log();
|
|
991
|
+
console.log(chalk_1.default.bold.green("Starting migration..."));
|
|
992
|
+
const result = await executeMigration(plan, {
|
|
993
|
+
dryRun: false,
|
|
994
|
+
});
|
|
995
|
+
// Display results
|
|
996
|
+
console.log();
|
|
997
|
+
console.log(chalk_1.default.bold("Migration Complete"));
|
|
998
|
+
console.log(chalk_1.default.dim("─".repeat(50)));
|
|
999
|
+
console.log(` Products created: ${chalk_1.default.green(result.created.products.length)}`);
|
|
1000
|
+
console.log(` Discounts created: ${chalk_1.default.green(result.created.discounts.length)}`);
|
|
1001
|
+
if (result.failed.products.length > 0 || result.failed.discounts.length > 0) {
|
|
1002
|
+
console.log();
|
|
1003
|
+
console.log(chalk_1.default.red("Failed items:"));
|
|
1004
|
+
for (const f of result.failed.products) {
|
|
1005
|
+
console.log(` • Product: ${f.name} - ${f.error}`);
|
|
1006
|
+
}
|
|
1007
|
+
for (const f of result.failed.discounts) {
|
|
1008
|
+
console.log(` • Discount: ${f.code} - ${f.error}`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (result.skipped.discounts.length > 0) {
|
|
1012
|
+
console.log();
|
|
1013
|
+
console.log(chalk_1.default.yellow("Skipped discounts:"));
|
|
1014
|
+
for (const s of result.skipped.discounts.slice(0, 5)) {
|
|
1015
|
+
console.log(` • ${s.code}: ${s.reason}`);
|
|
1016
|
+
}
|
|
1017
|
+
if (result.skipped.discounts.length > 5) {
|
|
1018
|
+
console.log(chalk_1.default.dim(` ... and ${result.skipped.discounts.length - 5} more`));
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
console.log(chalk_1.default.dim("─".repeat(50)));
|
|
1022
|
+
if (fetchFailures.length > 0) {
|
|
1023
|
+
console.error();
|
|
1024
|
+
console.error(chalk_1.default.red("Failed to fetch (after retries):"));
|
|
1025
|
+
for (const f of fetchFailures) {
|
|
1026
|
+
console.error(` • ${f.type}: ${f.label} - ${f.error}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
if (result.success && fetchFailures.length === 0) {
|
|
1030
|
+
console.log();
|
|
1031
|
+
console.log(chalk_1.default.green.bold("✓ Migration completed successfully!"));
|
|
1032
|
+
}
|
|
1033
|
+
else {
|
|
1034
|
+
console.log();
|
|
1035
|
+
console.log(chalk_1.default.yellow.bold("⚠ Migration completed with some errors."));
|
|
1036
|
+
if (fetchFailures.length > 0) {
|
|
1037
|
+
console.log(chalk_1.default.dim(" Re-run the migration to retry failed fetches."));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
console.log();
|
|
1041
|
+
console.log(chalk_1.default.dim("Next steps:"));
|
|
1042
|
+
console.log(chalk_1.default.dim(" 1. Review your products at https://creem.io/dashboard/products"));
|
|
1043
|
+
console.log(chalk_1.default.dim(" 2. Review your discounts at https://creem.io/dashboard/discounts"));
|
|
1044
|
+
console.log(chalk_1.default.dim(" 3. Upload downloadable files in the dashboard if needed"));
|
|
1045
|
+
console.log(chalk_1.default.dim(" 4. Update your checkout links to use CREEM"));
|
|
1046
|
+
});
|
|
1047
|
+
return command;
|
|
1048
|
+
}
|
|
1049
|
+
function createMigrateCommand() {
|
|
1050
|
+
const command = new commander_1.Command("migrate")
|
|
1051
|
+
.description("Migrate from other platforms to CREEM")
|
|
1052
|
+
.addHelpText("after", `
|
|
1053
|
+
${chalk_1.default.dim("Available migrations:")}
|
|
1054
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy")} Migrate from Lemon Squeezy
|
|
1055
|
+
|
|
1056
|
+
${chalk_1.default.dim("Options:")}
|
|
1057
|
+
--dry-run Preview what would be migrated without making changes
|
|
1058
|
+
--ls-api-key <key> Provide API key via flag (skips interactive prompt)
|
|
1059
|
+
--ls-store-id <id> Lemon Squeezy store ID to migrate from
|
|
1060
|
+
--json Output migration plan as JSON
|
|
1061
|
+
--exclude-discounts Skip migrating discounts entirely
|
|
1062
|
+
|
|
1063
|
+
${chalk_1.default.dim("Examples:")}
|
|
1064
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy")} Interactive migration wizard
|
|
1065
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy --dry-run")} Preview migration plan
|
|
1066
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy --ls-store-id 123")} Skip store selection prompt
|
|
1067
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy --ls-api-key ls_xxx --ls-store-id 123")} Non-interactive migration
|
|
1068
|
+
${chalk_1.default.cyan("creem migrate lemon-squeezy --json > plan.json")} Export migration plan
|
|
1069
|
+
`);
|
|
1070
|
+
command.addCommand(createLemonSqueezyCommand());
|
|
1071
|
+
return command;
|
|
1072
|
+
}
|
|
1073
|
+
//# sourceMappingURL=migrate.js.map
|