fresh-squeezy 0.1.7 → 0.1.8

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/dist/cli.js CHANGED
@@ -4,6 +4,155 @@
4
4
  import { Command } from "commander";
5
5
  import dotenv from "dotenv";
6
6
 
7
+ // src/cli/commands/augment.ts
8
+ import { existsSync } from "fs";
9
+ import { mkdir, readFile, writeFile } from "fs/promises";
10
+ import { dirname, resolve } from "path";
11
+ async function runAugmentCommand(options = {}) {
12
+ const cwd = options.cwd ?? process.cwd();
13
+ const outPath = resolve(cwd, options.out ?? "lemonsqueezy.augment.d.ts");
14
+ try {
15
+ const flavour = await detectFlavour(cwd, options.force ?? false);
16
+ const contents = renderAugmentationFile(flavour);
17
+ await mkdir(dirname(outPath), { recursive: true });
18
+ await writeFile(outPath, contents, "utf8");
19
+ process.stdout.write(`fresh-squeezy: wrote ${outPath} (${flavour}).
20
+ `);
21
+ if (flavour === "with-sdk") {
22
+ process.stdout.write(
23
+ `Use it via: import type { LemonSqueezyAugmented } from "./lemonsqueezy.augment";
24
+ `
25
+ );
26
+ } else {
27
+ process.stdout.write(
28
+ `Use it via: import type { WithLatestLemonSqueezyFields } from "./lemonsqueezy.augment";
29
+ `
30
+ );
31
+ }
32
+ return 0;
33
+ } catch (err) {
34
+ const message = err instanceof Error ? err.message : String(err);
35
+ process.stderr.write(`fresh-squeezy: ${message}
36
+ `);
37
+ return 2;
38
+ }
39
+ }
40
+ async function detectFlavour(cwd, force) {
41
+ if (force) return "generic";
42
+ const pkgPath = resolve(cwd, "package.json");
43
+ if (!existsSync(pkgPath)) return "generic";
44
+ const raw = await readFile(pkgPath, "utf8");
45
+ const pkg = JSON.parse(raw);
46
+ const deps = {
47
+ ...pkg.dependencies ?? {},
48
+ ...pkg.devDependencies ?? {},
49
+ ...pkg.peerDependencies ?? {}
50
+ };
51
+ return "@lemonsqueezy/lemonsqueezy.js" in deps ? "with-sdk" : "generic";
52
+ }
53
+ function renderAugmentationFile(flavour) {
54
+ const header = `/**
55
+ * Generated by \`fresh-squeezy types:augment\` \u2014 re-run after upgrading
56
+ * fresh-squeezy to refresh the field set.
57
+ *
58
+ * Why this file exists: the live Lemon Squeezy API has acquired fields
59
+ * since the SDK / hand-rolled types in this repo were last updated
60
+ * (e.g. \`payment_processor\`, \`tax_inclusive\`, \`refunded_amount*\`,
61
+ * \`setup_fee*\`, \`unit_price_decimal\`). fresh-squeezy tracks them in its
62
+ * manifest; this file makes them appear on your existing resource types
63
+ * so you don't hand-roll the additions.
64
+ *
65
+ * To wire it in:
66
+ * 1. Make sure your tsconfig.json's "include" covers this file (most
67
+ * do by default if it's in the project root or under "src").
68
+ * 2. Import the augmented type from this file instead of the raw SDK
69
+ * type \u2014 see the example at the bottom of this file.
70
+ */
71
+
72
+ `;
73
+ if (flavour === "with-sdk") {
74
+ return header + `import type {
75
+ Subscription,
76
+ Order,
77
+ Variant,
78
+ Price,
79
+ } from "@lemonsqueezy/lemonsqueezy.js";
80
+ import type {
81
+ LatestSubscriptionFields,
82
+ LatestOrderFields,
83
+ LatestVariantFields,
84
+ LatestPriceFields,
85
+ } from "fresh-squeezy";
86
+
87
+ /**
88
+ * One-stop reference for resources fresh-squeezy has wider knowledge of
89
+ * than the official SDK. The shape mirrors the SDK's response envelope
90
+ * (\`{ data: { attributes: ... } }\`) \u2014 only the inner attributes are
91
+ * widened.
92
+ */
93
+ export interface LemonSqueezyAugmented {
94
+ subscription: AugmentResponse<Subscription, LatestSubscriptionFields>;
95
+ order: AugmentResponse<Order, LatestOrderFields>;
96
+ variant: AugmentResponse<Variant, LatestVariantFields>;
97
+ price: AugmentResponse<Price, LatestPriceFields>;
98
+ }
99
+
100
+ /**
101
+ * Helper that walks the official SDK's response envelope and intersects
102
+ * the inner \`attributes\` record with the supplied field set.
103
+ */
104
+ type AugmentResponse<T, Fields> = T extends {
105
+ data: infer Data;
106
+ }
107
+ ? Data extends { attributes: infer Attrs }
108
+ ? Omit<T, "data"> & {
109
+ data: Omit<Data, "attributes"> & { attributes: Attrs & Fields };
110
+ }
111
+ : T
112
+ : T;
113
+
114
+ /* Example:
115
+ *
116
+ * import type { LemonSqueezyAugmented } from "./lemonsqueezy.augment";
117
+ *
118
+ * function describe(sub: LemonSqueezyAugmented["subscription"]) {
119
+ * const attrs = sub.data.attributes;
120
+ * console.log(attrs.payment_processor); // \u2190 typed, no manual extension
121
+ * console.log(attrs.urls?.update_customer_portal);
122
+ * }
123
+ */
124
+ `;
125
+ }
126
+ return header + `import type {
127
+ WithLatestLemonSqueezyFields,
128
+ LatestSubscriptionFields,
129
+ LatestOrderFields,
130
+ LatestVariantFields,
131
+ LatestPriceFields,
132
+ } from "fresh-squeezy";
133
+
134
+ export type {
135
+ WithLatestLemonSqueezyFields,
136
+ LatestSubscriptionFields,
137
+ LatestOrderFields,
138
+ LatestVariantFields,
139
+ LatestPriceFields,
140
+ };
141
+
142
+ /* Example with a hand-rolled or third-party type:
143
+ *
144
+ * import type { WithLatestLemonSqueezyFields } from "./lemonsqueezy.augment";
145
+ *
146
+ * type MySubscription = WithLatestLemonSqueezyFields<MyExistingSubscription, "subscription">;
147
+ *
148
+ * function describe(sub: MySubscription) {
149
+ * console.log(sub.payment_processor); // typed
150
+ * console.log(sub.tax_inclusive); // typed
151
+ * }
152
+ */
153
+ `;
154
+ }
155
+
7
156
  // src/core/errors.ts
8
157
  var FreshSqueezyError = class _FreshSqueezyError extends Error {
9
158
  code;
@@ -97,14 +246,41 @@ var HttpClient = class {
97
246
  return doc.data;
98
247
  }
99
248
  /**
100
- * Fetch a JSON:API collection and return its `data` array.
101
- * Pagination is the caller's responsibility use `meta.page` on the raw
102
- * request for multi-page traversal.
249
+ * Fetch a single page of a JSON:API collection. Most callers want
250
+ * `paginate()` instead this method is kept for one-shot lookups where
251
+ * the caller knows the result fits in a single page.
103
252
  */
104
253
  async getCollection(path2, query) {
105
254
  const doc = await this.request({ path: path2, query });
106
255
  return doc.data;
107
256
  }
257
+ /**
258
+ * Walk a JSON:API collection across every page and concatenate the
259
+ * results. Lemon Squeezy returns 25 items per page by default; without
260
+ * pagination, validators silently miss anything past the first page (e.g.
261
+ * `validateWebhook` on a store with 26+ webhooks would falsely report
262
+ * `WEBHOOK_NOT_FOUND` for any webhook on page 2).
263
+ *
264
+ * Stops as soon as `meta.page.lastPage` is reached. Falls back to a
265
+ * single-page fetch if the API does not surface page metadata.
266
+ *
267
+ * Caller-supplied `page[number]` is honored as the starting page; we
268
+ * advance from there. `page[size]` is left untouched so callers can tune
269
+ * batch size (max 100 per Lemon Squeezy docs).
270
+ */
271
+ async paginate(path2, query) {
272
+ const startPage = Number(query?.["page[number]"] ?? 1);
273
+ const all = [];
274
+ let pageNumber = startPage;
275
+ while (true) {
276
+ const pagedQuery = { ...query ?? {}, "page[number]": pageNumber };
277
+ const doc = await this.request({ path: path2, query: pagedQuery });
278
+ all.push(...doc.data);
279
+ const lastPage = doc.meta?.page?.lastPage;
280
+ if (lastPage === void 0 || pageNumber >= lastPage) return all;
281
+ pageNumber += 1;
282
+ }
283
+ }
108
284
  buildUrl(path2, query) {
109
285
  const url = new URL(path2, this.config.baseUrl);
110
286
  if (query) {
@@ -144,12 +320,19 @@ async function getAuthenticatedUser(http) {
144
320
  return http.request({ path: "/v1/users/me" });
145
321
  }
146
322
 
323
+ // src/core/mode.ts
324
+ function resolveActualMode(testMode) {
325
+ if (testMode === true) return "test";
326
+ if (testMode === false) return "live";
327
+ return void 0;
328
+ }
329
+
147
330
  // src/resources/stores.ts
148
331
  async function getStore(http, storeId) {
149
332
  return http.getResource(`/v1/stores/${storeId}`);
150
333
  }
151
334
  async function listStores(http) {
152
- return http.getCollection("/v1/stores");
335
+ return http.paginate("/v1/stores");
153
336
  }
154
337
 
155
338
  // src/validate/rules.ts
@@ -209,6 +392,72 @@ function buildResult(name, mode, issues, resource) {
209
392
  return result;
210
393
  }
211
394
 
395
+ // src/validate/probe.ts
396
+ function mapErrorToIssue(err, mapping = {}) {
397
+ if (err instanceof FreshSqueezyError) {
398
+ if (mapping.unauthorized && (err.status === 401 || err.code === "UNAUTHORIZED")) {
399
+ return issue(mapping.unauthorized.code, "error", mapping.unauthorized.message, {
400
+ ...mapping.unauthorized.suggestedFix !== void 0 ? { suggestedFix: mapping.unauthorized.suggestedFix } : {},
401
+ context: { status: err.status ?? null }
402
+ });
403
+ }
404
+ if (mapping.notFound && err.status === 404) {
405
+ return issue(mapping.notFound.code, "error", mapping.notFound.message, {
406
+ ...mapping.notFound.suggestedFix !== void 0 ? { suggestedFix: mapping.notFound.suggestedFix } : {},
407
+ ...mapping.notFound.context !== void 0 ? { context: mapping.notFound.context } : {}
408
+ });
409
+ }
410
+ if (err.code === "NETWORK_ERROR") {
411
+ return issue(
412
+ ISSUE_CODES.NETWORK_ERROR,
413
+ "error",
414
+ `Could not reach Lemon Squeezy: ${err.message}`
415
+ );
416
+ }
417
+ return issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
418
+ context: { status: err.status ?? null, code: err.code }
419
+ });
420
+ }
421
+ const message = err instanceof Error ? err.message : "Unknown error";
422
+ return issue(ISSUE_CODES.UNKNOWN, "error", message);
423
+ }
424
+ async function probeFetch(fetcher, options) {
425
+ try {
426
+ return { ok: true, resource: await fetcher() };
427
+ } catch (err) {
428
+ return {
429
+ ok: false,
430
+ issue: mapErrorToIssue(err, {
431
+ notFound: {
432
+ code: options.notFoundCode,
433
+ message: options.notFoundMessage,
434
+ ...options.notFoundFix !== void 0 ? { suggestedFix: options.notFoundFix } : {},
435
+ ...options.notFoundContext !== void 0 ? { context: options.notFoundContext } : {}
436
+ }
437
+ })
438
+ };
439
+ }
440
+ }
441
+ function checkStoreOwnership(check) {
442
+ const expected = String(check.expectedStoreId);
443
+ const actual = String(check.actualStoreId);
444
+ if (expected === actual) return null;
445
+ const context = {
446
+ expectedStoreId: expected,
447
+ actualStoreId: actual,
448
+ ...check.extraContext ?? {}
449
+ };
450
+ return issue(
451
+ check.code,
452
+ "error",
453
+ `${check.label} belongs to store ${actual}, expected ${expected}.`,
454
+ {
455
+ ...check.suggestedFix !== void 0 ? { suggestedFix: check.suggestedFix } : {},
456
+ context
457
+ }
458
+ );
459
+ }
460
+
212
461
  // src/validate/connection.ts
213
462
  async function validateConnection(http, mode) {
214
463
  const issues = [];
@@ -248,67 +497,33 @@ async function validateConnection(http, mode) {
248
497
  }
249
498
  return buildResult("connection", mode, issues, summary);
250
499
  } catch (err) {
251
- issues.push(toConnectionIssue(err));
500
+ issues.push(
501
+ mapErrorToIssue(err, {
502
+ unauthorized: {
503
+ code: ISSUE_CODES.AUTH_FAILED,
504
+ message: "API key rejected by Lemon Squeezy.",
505
+ suggestedFix: "Regenerate the key at https://app.lemonsqueezy.com/settings/api."
506
+ }
507
+ })
508
+ );
252
509
  return buildResult("connection", mode, issues);
253
510
  }
254
511
  }
255
- function resolveActualMode(testMode) {
256
- if (testMode === true) return "test";
257
- if (testMode === false) return "live";
258
- return void 0;
259
- }
260
- function toConnectionIssue(err) {
261
- if (err instanceof FreshSqueezyError) {
262
- if (err.code === "UNAUTHORIZED") {
263
- return issue(ISSUE_CODES.AUTH_FAILED, "error", "API key rejected by Lemon Squeezy.", {
264
- suggestedFix: "Regenerate the key at https://app.lemonsqueezy.com/settings/api.",
265
- context: { status: err.status ?? null }
266
- });
267
- }
268
- if (err.code === "NETWORK_ERROR") {
269
- return issue(ISSUE_CODES.NETWORK_ERROR, "error", `Could not reach Lemon Squeezy: ${err.message}`);
270
- }
271
- return issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
272
- context: { status: err.status ?? null, code: err.code }
273
- });
274
- }
275
- const message = err instanceof Error ? err.message : "Unknown error";
276
- return issue(ISSUE_CODES.UNKNOWN, "error", message);
277
- }
278
512
 
279
513
  // src/validate/store.ts
280
514
  async function validateStore(http, mode, storeId) {
281
515
  const issues = [];
282
- try {
283
- const store = await getStore(http, storeId);
284
- return buildResult("store", mode, issues, store.attributes);
285
- } catch (err) {
286
- if (err instanceof FreshSqueezyError && err.status === 404) {
287
- issues.push(
288
- issue(
289
- ISSUE_CODES.STORE_NOT_FOUND,
290
- "error",
291
- `Store ${storeId} not found with the current API key.`,
292
- {
293
- suggestedFix: "Check the store ID and confirm the API key belongs to the account that owns it.",
294
- context: { storeId: String(storeId) }
295
- }
296
- )
297
- );
298
- return buildResult("store", mode, issues);
299
- }
300
- if (err instanceof FreshSqueezyError) {
301
- issues.push(
302
- issue(ISSUE_CODES.UNKNOWN, "error", err.message, {
303
- context: { status: err.status ?? null, code: err.code }
304
- })
305
- );
306
- return buildResult("store", mode, issues);
307
- }
308
- const message = err instanceof Error ? err.message : "Unknown error";
309
- issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
516
+ const fetched = await probeFetch(() => getStore(http, storeId), {
517
+ notFoundCode: ISSUE_CODES.STORE_NOT_FOUND,
518
+ notFoundMessage: `Store ${storeId} not found with the current API key.`,
519
+ notFoundFix: "Check the store ID and confirm the API key belongs to the account that owns it.",
520
+ notFoundContext: { storeId: String(storeId) }
521
+ });
522
+ if (!fetched.ok) {
523
+ issues.push(fetched.issue);
310
524
  return buildResult("store", mode, issues);
311
525
  }
526
+ return buildResult("store", mode, issues, fetched.resource.attributes);
312
527
  }
313
528
 
314
529
  // src/resources/products.ts
@@ -321,7 +536,7 @@ async function getVariant(http, variantId) {
321
536
  return http.getResource(`/v1/variants/${variantId}`);
322
537
  }
323
538
  async function listVariantsForProduct(http, productId) {
324
- return http.getCollection("/v1/variants", {
539
+ return http.paginate("/v1/variants", {
325
540
  "filter[product_id]": String(productId)
326
541
  });
327
542
  }
@@ -329,45 +544,26 @@ async function listVariantsForProduct(http, productId) {
329
544
  // src/validate/product.ts
330
545
  async function validateProduct(http, mode, options) {
331
546
  const issues = [];
332
- let product;
333
- try {
334
- product = await getProduct(http, options.productId);
335
- } catch (err) {
336
- if (err instanceof FreshSqueezyError && err.status === 404) {
337
- issues.push(
338
- issue(
339
- ISSUE_CODES.PRODUCT_NOT_FOUND,
340
- "error",
341
- `Product ${options.productId} not found.`,
342
- {
343
- suggestedFix: "Verify the product ID in the Lemon Squeezy dashboard.",
344
- context: { productId: String(options.productId) }
345
- }
346
- )
347
- );
348
- return buildResult("product", mode, issues);
349
- }
350
- const message = err instanceof Error ? err.message : "Unknown error";
351
- issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
547
+ const fetched = await probeFetch(() => getProduct(http, options.productId), {
548
+ notFoundCode: ISSUE_CODES.PRODUCT_NOT_FOUND,
549
+ notFoundMessage: `Product ${options.productId} not found.`,
550
+ notFoundFix: "Verify the product ID in the Lemon Squeezy dashboard.",
551
+ notFoundContext: { productId: String(options.productId) }
552
+ });
553
+ if (!fetched.ok) {
554
+ issues.push(fetched.issue);
352
555
  return buildResult("product", mode, issues);
353
556
  }
354
- const attrs = product.attributes;
557
+ const attrs = fetched.resource.attributes;
355
558
  if (options.expectedStoreId !== void 0) {
356
- const expected = String(options.expectedStoreId);
357
- const actual = String(attrs.store_id);
358
- if (expected !== actual) {
359
- issues.push(
360
- issue(
361
- ISSUE_CODES.PRODUCT_WRONG_STORE,
362
- "error",
363
- `Product belongs to store ${actual}, expected ${expected}.`,
364
- {
365
- suggestedFix: "Either use the correct store ID or the correct product ID \u2014 IDs should not cross stores.",
366
- context: { expectedStoreId: expected, actualStoreId: actual }
367
- }
368
- )
369
- );
370
- }
559
+ const mismatch = checkStoreOwnership({
560
+ expectedStoreId: options.expectedStoreId,
561
+ actualStoreId: attrs.store_id,
562
+ code: ISSUE_CODES.PRODUCT_WRONG_STORE,
563
+ label: "Product",
564
+ suggestedFix: "Either use the correct store ID or the correct product ID \u2014 IDs should not cross stores."
565
+ });
566
+ if (mismatch) issues.push(mismatch);
371
567
  }
372
568
  if (attrs.status !== "published") {
373
569
  issues.push(
@@ -420,9 +616,17 @@ async function validateProduct(http, mode, options) {
420
616
  return buildResult("product", mode, issues, attrs);
421
617
  }
422
618
 
619
+ // src/core/equality.ts
620
+ function sameWebhookUrl(a, b) {
621
+ return normalizeWebhookUrl(a) === normalizeWebhookUrl(b);
622
+ }
623
+ function normalizeWebhookUrl(raw) {
624
+ return raw.replace(/\/+$/, "").toLowerCase();
625
+ }
626
+
423
627
  // src/resources/webhooks.ts
424
628
  async function listWebhooksForStore(http, storeId) {
425
- return http.getCollection("/v1/webhooks", {
629
+ return http.paginate("/v1/webhooks", {
426
630
  "filter[store_id]": String(storeId)
427
631
  });
428
632
  }
@@ -465,7 +669,7 @@ async function validateWebhook(http, mode, options) {
465
669
  issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
466
670
  return buildResult("webhook", mode, issues);
467
671
  }
468
- const match = webhooks.find((webhook) => normalizeUrl(webhook.attributes.url) === normalizeUrl(options.url));
672
+ const match = webhooks.find((webhook) => sameWebhookUrl(webhook.attributes.url, options.url));
469
673
  if (!match) {
470
674
  issues.push(
471
675
  issue(
@@ -508,9 +712,6 @@ async function validateWebhook(http, mode, options) {
508
712
  }
509
713
  return buildResult("webhook", mode, issues, match.attributes);
510
714
  }
511
- function normalizeUrl(raw) {
512
- return raw.replace(/\/+$/, "").toLowerCase();
513
- }
514
715
 
515
716
  // src/resources/discounts.ts
516
717
  async function getDiscount(http, discountId) {
@@ -520,39 +721,25 @@ async function getDiscount(http, discountId) {
520
721
  // src/validate/discount.ts
521
722
  async function validateDiscount(http, mode, options) {
522
723
  const issues = [];
523
- let discount;
524
- try {
525
- discount = await getDiscount(http, options.discountId);
526
- } catch (err) {
527
- if (err instanceof FreshSqueezyError && err.status === 404) {
528
- issues.push(
529
- issue(ISSUE_CODES.DISCOUNT_NOT_FOUND, "error", `Discount ${options.discountId} not found.`, {
530
- suggestedFix: "Verify the discount ID in the Lemon Squeezy dashboard.",
531
- context: { discountId: String(options.discountId) }
532
- })
533
- );
534
- return buildResult("discount", mode, issues);
535
- }
536
- const message = err instanceof Error ? err.message : "Unknown error";
537
- issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
724
+ const fetched = await probeFetch(() => getDiscount(http, options.discountId), {
725
+ notFoundCode: ISSUE_CODES.DISCOUNT_NOT_FOUND,
726
+ notFoundMessage: `Discount ${options.discountId} not found.`,
727
+ notFoundFix: "Verify the discount ID in the Lemon Squeezy dashboard.",
728
+ notFoundContext: { discountId: String(options.discountId) }
729
+ });
730
+ if (!fetched.ok) {
731
+ issues.push(fetched.issue);
538
732
  return buildResult("discount", mode, issues);
539
733
  }
540
- const attrs = discount.attributes;
541
- const expectedStore = String(options.storeId);
542
- const actualStore = String(attrs.store_id);
543
- if (expectedStore !== actualStore) {
544
- issues.push(
545
- issue(
546
- ISSUE_CODES.DISCOUNT_STORE_MISMATCH,
547
- "error",
548
- `Discount belongs to store ${actualStore}, expected ${expectedStore}.`,
549
- {
550
- suggestedFix: "Use the correct store ID or discount ID \u2014 discounts should not cross stores.",
551
- context: { expectedStoreId: expectedStore, actualStoreId: actualStore }
552
- }
553
- )
554
- );
555
- }
734
+ const attrs = fetched.resource.attributes;
735
+ const mismatch = checkStoreOwnership({
736
+ expectedStoreId: options.storeId,
737
+ actualStoreId: attrs.store_id,
738
+ code: ISSUE_CODES.DISCOUNT_STORE_MISMATCH,
739
+ label: "Discount",
740
+ suggestedFix: "Use the correct store ID or discount ID \u2014 discounts should not cross stores."
741
+ });
742
+ if (mismatch) issues.push(mismatch);
556
743
  if (attrs.status === "draft") {
557
744
  issues.push(
558
745
  issue(
@@ -642,44 +829,25 @@ async function getLicenseKey(http, licenseKeyId) {
642
829
  // src/validate/licenseKey.ts
643
830
  async function validateLicenseKey(http, mode, options) {
644
831
  const issues = [];
645
- let licenseKey;
646
- try {
647
- licenseKey = await getLicenseKey(http, options.licenseKeyId);
648
- } catch (err) {
649
- if (err instanceof FreshSqueezyError && err.status === 404) {
650
- issues.push(
651
- issue(
652
- ISSUE_CODES.LICENSE_KEY_NOT_FOUND,
653
- "error",
654
- `License key ${options.licenseKeyId} not found.`,
655
- {
656
- suggestedFix: "Verify the license key ID in the Lemon Squeezy dashboard.",
657
- context: { licenseKeyId: String(options.licenseKeyId) }
658
- }
659
- )
660
- );
661
- return buildResult("licenseKey", mode, issues);
662
- }
663
- const message = err instanceof Error ? err.message : "Unknown error";
664
- issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
832
+ const fetched = await probeFetch(() => getLicenseKey(http, options.licenseKeyId), {
833
+ notFoundCode: ISSUE_CODES.LICENSE_KEY_NOT_FOUND,
834
+ notFoundMessage: `License key ${options.licenseKeyId} not found.`,
835
+ notFoundFix: "Verify the license key ID in the Lemon Squeezy dashboard.",
836
+ notFoundContext: { licenseKeyId: String(options.licenseKeyId) }
837
+ });
838
+ if (!fetched.ok) {
839
+ issues.push(fetched.issue);
665
840
  return buildResult("licenseKey", mode, issues);
666
841
  }
667
- const attrs = licenseKey.attributes;
668
- const expectedStore = String(options.storeId);
669
- const actualStore = String(attrs.store_id);
670
- if (expectedStore !== actualStore) {
671
- issues.push(
672
- issue(
673
- ISSUE_CODES.LICENSE_KEY_STORE_MISMATCH,
674
- "error",
675
- `License key belongs to store ${actualStore}, expected ${expectedStore}.`,
676
- {
677
- suggestedFix: "Use the correct store ID or license key ID \u2014 keys should not cross stores.",
678
- context: { expectedStoreId: expectedStore, actualStoreId: actualStore }
679
- }
680
- )
681
- );
682
- }
842
+ const attrs = fetched.resource.attributes;
843
+ const mismatch = checkStoreOwnership({
844
+ expectedStoreId: options.storeId,
845
+ actualStoreId: attrs.store_id,
846
+ code: ISSUE_CODES.LICENSE_KEY_STORE_MISMATCH,
847
+ label: "License key",
848
+ suggestedFix: "Use the correct store ID or license key ID \u2014 keys should not cross stores."
849
+ });
850
+ if (mismatch) issues.push(mismatch);
683
851
  if (attrs.disabled) {
684
852
  issues.push(
685
853
  issue(
@@ -730,28 +898,20 @@ async function validateLicenseKey(http, mode, options) {
730
898
  var VALID_INTERVALS = /* @__PURE__ */ new Set(["day", "week", "month", "year"]);
731
899
  async function validateSubscriptionPlan(http, mode, options) {
732
900
  const issues = [];
733
- let variant;
734
- try {
735
- variant = await getVariant(http, options.variantId);
736
- } catch (err) {
737
- if (err instanceof FreshSqueezyError && err.status === 404) {
738
- issues.push(
739
- issue(
740
- ISSUE_CODES.PLAN_VARIANT_NOT_FOUND,
741
- "error",
742
- `Variant ${options.variantId} not found.`,
743
- {
744
- suggestedFix: "Verify the variant ID in the Lemon Squeezy dashboard.",
745
- context: { variantId: String(options.variantId) }
746
- }
747
- )
748
- );
749
- return buildResult("subscriptionPlan", mode, issues);
901
+ const fetched = await probeFetch(
902
+ () => getVariant(http, options.variantId),
903
+ {
904
+ notFoundCode: ISSUE_CODES.PLAN_VARIANT_NOT_FOUND,
905
+ notFoundMessage: `Variant ${options.variantId} not found.`,
906
+ notFoundFix: "Verify the variant ID in the Lemon Squeezy dashboard.",
907
+ notFoundContext: { variantId: String(options.variantId) }
750
908
  }
751
- const message = err instanceof Error ? err.message : "Unknown error";
752
- issues.push(issue(ISSUE_CODES.UNKNOWN, "error", message));
909
+ );
910
+ if (!fetched.ok) {
911
+ issues.push(fetched.issue);
753
912
  return buildResult("subscriptionPlan", mode, issues);
754
913
  }
914
+ const variant = fetched.resource;
755
915
  const attrs = variant.attributes;
756
916
  if (!attrs.is_subscription) {
757
917
  issues.push(
@@ -836,21 +996,15 @@ async function validateSubscriptionPlan(http, mode, options) {
836
996
  }
837
997
  try {
838
998
  const product = await getProduct(http, attrs.product_id);
839
- const expectedStore = String(options.storeId);
840
- const actualStore = String(product.attributes.store_id);
841
- if (expectedStore !== actualStore) {
842
- issues.push(
843
- issue(
844
- ISSUE_CODES.PLAN_STORE_MISMATCH,
845
- "error",
846
- `Subscription variant belongs to store ${actualStore} (via product ${attrs.product_id}), expected ${expectedStore}.`,
847
- {
848
- suggestedFix: "Use the correct store ID or variant ID \u2014 plans should not cross stores.",
849
- context: { expectedStoreId: expectedStore, actualStoreId: actualStore, productId: String(attrs.product_id) }
850
- }
851
- )
852
- );
853
- }
999
+ const mismatch = checkStoreOwnership({
1000
+ expectedStoreId: options.storeId,
1001
+ actualStoreId: product.attributes.store_id,
1002
+ code: ISSUE_CODES.PLAN_STORE_MISMATCH,
1003
+ label: `Subscription variant (via product ${attrs.product_id})`,
1004
+ suggestedFix: "Use the correct store ID or variant ID \u2014 plans should not cross stores.",
1005
+ extraContext: { productId: String(attrs.product_id) }
1006
+ });
1007
+ if (mismatch) issues.push(mismatch);
854
1008
  } catch {
855
1009
  }
856
1010
  const summary = {
@@ -865,19 +1019,29 @@ async function validateSubscriptionPlan(http, mode, options) {
865
1019
  }
866
1020
 
867
1021
  // src/validate/doctor.ts
868
- async function doctor(http, mode, options = {}) {
1022
+ var DEFAULT_VALIDATORS = {
1023
+ connection: validateConnection,
1024
+ store: validateStore,
1025
+ product: validateProduct,
1026
+ webhook: validateWebhook,
1027
+ discount: validateDiscount,
1028
+ licenseKey: validateLicenseKey,
1029
+ subscriptionPlan: validateSubscriptionPlan
1030
+ };
1031
+ async function doctor(http, mode, options = {}, validators = DEFAULT_VALIDATORS) {
1032
+ assertOptionsCoherent(options);
869
1033
  const results = [];
870
- const connection = await validateConnection(http, mode);
1034
+ const connection = await validators.connection(http, mode);
871
1035
  results.push(connection);
872
1036
  if (!connection.ok) {
873
1037
  return { ok: false, mode, results };
874
1038
  }
875
1039
  if (options.storeId !== void 0) {
876
- results.push(await validateStore(http, mode, options.storeId));
1040
+ results.push(await validators.store(http, mode, options.storeId));
877
1041
  }
878
1042
  if (options.productId !== void 0) {
879
1043
  results.push(
880
- await validateProduct(http, mode, {
1044
+ await validators.product(http, mode, {
881
1045
  productId: options.productId,
882
1046
  expectedStoreId: options.storeId
883
1047
  })
@@ -885,7 +1049,7 @@ async function doctor(http, mode, options = {}) {
885
1049
  }
886
1050
  if (options.storeId !== void 0 && options.webhookUrl !== void 0) {
887
1051
  results.push(
888
- await validateWebhook(http, mode, {
1052
+ await validators.webhook(http, mode, {
889
1053
  storeId: options.storeId,
890
1054
  url: options.webhookUrl
891
1055
  })
@@ -893,7 +1057,7 @@ async function doctor(http, mode, options = {}) {
893
1057
  }
894
1058
  if (options.storeId !== void 0 && options.discountId !== void 0) {
895
1059
  results.push(
896
- await validateDiscount(http, mode, {
1060
+ await validators.discount(http, mode, {
897
1061
  storeId: options.storeId,
898
1062
  discountId: options.discountId
899
1063
  })
@@ -901,7 +1065,7 @@ async function doctor(http, mode, options = {}) {
901
1065
  }
902
1066
  if (options.storeId !== void 0 && options.licenseKeyId !== void 0) {
903
1067
  results.push(
904
- await validateLicenseKey(http, mode, {
1068
+ await validators.licenseKey(http, mode, {
905
1069
  storeId: options.storeId,
906
1070
  licenseKeyId: options.licenseKeyId
907
1071
  })
@@ -909,7 +1073,7 @@ async function doctor(http, mode, options = {}) {
909
1073
  }
910
1074
  if (options.storeId !== void 0 && options.variantId !== void 0) {
911
1075
  results.push(
912
- await validateSubscriptionPlan(http, mode, {
1076
+ await validators.subscriptionPlan(http, mode, {
913
1077
  storeId: options.storeId,
914
1078
  variantId: options.variantId
915
1079
  })
@@ -918,6 +1082,19 @@ async function doctor(http, mode, options = {}) {
918
1082
  const ok = results.every((result) => result.ok);
919
1083
  return { ok, mode, results };
920
1084
  }
1085
+ function assertOptionsCoherent(options) {
1086
+ if (options.storeId !== void 0) return;
1087
+ const dependent = [];
1088
+ if (options.webhookUrl !== void 0) dependent.push("webhookUrl");
1089
+ if (options.discountId !== void 0) dependent.push("discountId");
1090
+ if (options.licenseKeyId !== void 0) dependent.push("licenseKeyId");
1091
+ if (options.variantId !== void 0) dependent.push("variantId");
1092
+ if (dependent.length === 0) return;
1093
+ throw new FreshSqueezyError({
1094
+ code: "DOCTOR_OPTIONS_INVALID",
1095
+ message: `doctor() received ${dependent.join(", ")} without storeId. These validators cannot run without a store; pass \`storeId\` or remove the dependent options.`
1096
+ });
1097
+ }
921
1098
 
922
1099
  // src/createFreshSqueezy.ts
923
1100
  function createFreshSqueezy(config = {}) {
@@ -1363,6 +1540,13 @@ program.command("init").description("Interactive setup: ask for credentials, pic
1363
1540
  const code = await runInitCommand({ envFile: opts.envFile });
1364
1541
  process.exit(code);
1365
1542
  });
1543
+ var types = program.command("types").description("Type-augmentation utilities for the official Lemon Squeezy SDK and hand-rolled types");
1544
+ types.command("augment").description(
1545
+ "Generate a .d.ts that intersects your Lemon Squeezy resource types with fields fresh-squeezy already tracks (e.g. payment_processor, tax_inclusive, refunded_amount*)."
1546
+ ).option("--out <path>", "Output path (default: lemonsqueezy.augment.d.ts in CWD)").option("--force", "Emit the generic file even when @lemonsqueezy/lemonsqueezy.js is detected").action(async (opts) => {
1547
+ const code = await runAugmentCommand({ out: opts.out, force: Boolean(opts.force) });
1548
+ process.exit(code);
1549
+ });
1366
1550
  program.parseAsync(process.argv).catch((err) => {
1367
1551
  const message = err instanceof Error ? err.message : String(err);
1368
1552
  process.stderr.write(`fresh-squeezy: ${message}