fresh-squeezy 0.1.19 → 0.1.20

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 CHANGED
@@ -137,6 +137,11 @@ npx fresh-squeezy create webhook --body-file webhook.json --mode test
137
137
  npx fresh-squeezy cancel subscription --id 9 --yes
138
138
  npx fresh-squeezy refund order --id 100 --yes --mode live
139
139
  npx fresh-squeezy generate-invoice order --id 100 --yes
140
+
141
+ # Bulk delete / cancel / refund: --all, --ids, --match, --dry-run
142
+ npx fresh-squeezy delete discount --all --store-ids 12 --dry-run
143
+ npx fresh-squeezy delete discount --all --store-ids 12 --match TEST --yes
144
+ npx fresh-squeezy delete webhook --ids 1,2,3 --yes
140
145
  ```
141
146
 
142
147
  **Safety:** `delete` / `cancel` / `refund` always require `--yes` or a TTY confirm. Live-mode writes need `--yes` or TTY confirm too. Bodies are JSON:API documents via `--body` or `--body-file` (not flat flags).
@@ -206,7 +211,7 @@ The CLI default is `--mode test`. Override with `--mode live`. Guided setup asks
206
211
  | `LEMON_SQUEEZY_MODE` | no | `test` (default) or `live` |
207
212
  | `LEMON_SQUEEZY_STORE_ID` | no | Convenience default for `client.doctor()` — library only |
208
213
 
209
- The CLI does not read `LEMON_SQUEEZY_STORE_ID`; use `--store-ids` or `--all-stores` so store selection stays explicit per command.
214
+ `doctor` and `validate` do not read `LEMON_SQUEEZY_STORE_ID`; use `--store-ids` or `--all-stores` there. Only the `ops` verbs (`list`, `delete --all`, …) fall back to it when `--store-ids` is omitted.
210
215
 
211
216
  ## Issue codes
212
217
 
package/dist/cli.js CHANGED
@@ -2947,6 +2947,11 @@ var renderCommandExamples = () => {
2947
2947
  " fresh-squeezy validate connection",
2948
2948
  " fresh-squeezy validate webhook --store-ids 12 --webhook-url https://app.example.com/api/webhooks/lemon-squeezy",
2949
2949
  "",
2950
+ `${chalk2.bold("Bulk ops (delete / cancel / refund)")}`,
2951
+ " fresh-squeezy delete discount --all --store-ids 12 --dry-run",
2952
+ " fresh-squeezy delete discount --all --store-ids 12 --match TEST --yes",
2953
+ " fresh-squeezy delete webhook --ids 1,2,3 --yes",
2954
+ "",
2950
2955
  `${chalk2.bold("Automation")}`,
2951
2956
  " fresh-squeezy doctor --all-stores --json",
2952
2957
  ""
@@ -3364,6 +3369,94 @@ var renderProjectInstallResult = (result) => {
3364
3369
 
3365
3370
  // src/cli/commands/resourceOps.ts
3366
3371
  import { readFileSync } from "fs";
3372
+
3373
+ // src/cli/commands/bulkOps.ts
3374
+ var supportsBulk = (spec) => Boolean(spec.destructive) && spec.idRole === "id";
3375
+ var describeBulkTarget = (item) => {
3376
+ const attrs = item.attributes ?? {};
3377
+ const id = String(item.id);
3378
+ for (const key of ["code", "name", "url", "identifier", "user_email", "status"]) {
3379
+ const value = attrs[key];
3380
+ if (typeof value === "string" && value.length > 0) return { id, label: value };
3381
+ }
3382
+ return { id, label: id };
3383
+ };
3384
+ var matchesText = (item, text) => {
3385
+ const needle = text.toLowerCase();
3386
+ return Object.values(item.attributes ?? {}).some(
3387
+ (value) => typeof value === "string" && value.toLowerCase().includes(needle)
3388
+ );
3389
+ };
3390
+ var collectBulkTargets = async (http, spec, selector) => {
3391
+ if (selector.ids?.length) {
3392
+ return selector.ids.map((id) => ({ id, label: id }));
3393
+ }
3394
+ const listSpec = findResourceVerb(spec.resource, "list");
3395
+ if (!listSpec) {
3396
+ throw new FreshSqueezyError({
3397
+ code: "UNKNOWN_OP",
3398
+ message: `Cannot use --all: ${spec.resource} has no list op. Pass --ids instead.`
3399
+ });
3400
+ }
3401
+ if (listSpec.idRole === "store" && !selector.storeId) {
3402
+ throw new FreshSqueezyError({
3403
+ code: "MISSING_ARG",
3404
+ message: "--all needs a store: pass --store-ids or set LEMON_SQUEEZY_STORE_ID."
3405
+ });
3406
+ }
3407
+ if (listSpec.idRole === "parent" && !selector.parentId) {
3408
+ throw new FreshSqueezyError({
3409
+ code: "MISSING_ARG",
3410
+ message: `--all needs --parent-id to list ${spec.resource} items.`
3411
+ });
3412
+ }
3413
+ const items = await invokeOp(http, spec.resource, "list", {
3414
+ storeId: selector.storeId,
3415
+ parentId: selector.parentId
3416
+ });
3417
+ return items.filter((item) => selector.match ? matchesText(item, selector.match) : true).map(describeBulkTarget);
3418
+ };
3419
+ var executeBulk = async (http, spec, targets, opts = {}) => {
3420
+ const dryRun = Boolean(opts.dryRun);
3421
+ const results = [];
3422
+ for (const target of targets) {
3423
+ if (dryRun) {
3424
+ results.push({ ...target, ok: true });
3425
+ continue;
3426
+ }
3427
+ try {
3428
+ await invokeOp(http, spec.resource, spec.verb, { id: target.id, body: opts.body });
3429
+ results.push({ ...target, ok: true });
3430
+ } catch (err) {
3431
+ results.push({
3432
+ ...target,
3433
+ ok: false,
3434
+ error: err instanceof Error ? err.message : String(err)
3435
+ });
3436
+ }
3437
+ }
3438
+ const failed = results.filter((r) => !r.ok).length;
3439
+ return { dryRun, total: results.length, succeeded: results.length - failed, failed, results };
3440
+ };
3441
+ var previewTargets = (targets, max = 5) => {
3442
+ const shown = targets.slice(0, max).map((t) => t.label);
3443
+ const more = targets.length - shown.length;
3444
+ return more > 0 ? `${shown.join(", ")}, \u2026 +${more} more` : shown.join(", ");
3445
+ };
3446
+ var renderBulkSummary = (spec, summary) => {
3447
+ if (summary.total === 0) return `No ${spec.resource} items matched \u2014 nothing to ${spec.verb}.
3448
+ `;
3449
+ const verb = summary.dryRun ? `would ${spec.verb}` : spec.verb;
3450
+ const lines = summary.results.map(
3451
+ (r) => r.ok ? ` \u2713 ${verb} ${spec.resource} ${r.id} (${r.label})` : ` \u2717 ${spec.verb} ${spec.resource} ${r.id} (${r.label}): ${r.error}`
3452
+ );
3453
+ const tail = summary.dryRun ? `dry run \u2014 ${summary.total} ${spec.resource} item(s) would be affected, nothing sent` : `${summary.succeeded}/${summary.total} succeeded${summary.failed ? `, ${summary.failed} failed` : ""}`;
3454
+ return `${lines.join("\n")}
3455
+ ${tail}
3456
+ `;
3457
+ };
3458
+
3459
+ // src/cli/commands/resourceOps.ts
3367
3460
  var runResourceOpCommand = async (options) => {
3368
3461
  if (options.listCatalog) {
3369
3462
  writeCatalog(options.json ?? false);
@@ -3380,6 +3473,14 @@ var runResourceOpCommand = async (options) => {
3380
3473
  );
3381
3474
  return 2;
3382
3475
  }
3476
+ const bulkError = validateBulkFlags(spec, options);
3477
+ if (bulkError) {
3478
+ writeFatal2(
3479
+ new FreshSqueezyError({ code: "INVALID_ARGS", message: bulkError }),
3480
+ options.json ?? false
3481
+ );
3482
+ return 2;
3483
+ }
3383
3484
  try {
3384
3485
  const body = readBody(options);
3385
3486
  if (spec.body === "required" && body === void 0) {
@@ -3389,13 +3490,17 @@ var runResourceOpCommand = async (options) => {
3389
3490
  });
3390
3491
  }
3391
3492
  const client = createFreshSqueezy({ mode: options.mode });
3392
- const allowed = await assertWriteSafety(spec, client.mode, options);
3393
- if (!allowed) return 2;
3394
3493
  const config = resolveConfig({ mode: options.mode });
3395
3494
  const http = new HttpClient(config);
3495
+ const storeId = options.storeIds?.[0] ?? config.storeId;
3496
+ if (options.all || options.ids) {
3497
+ return await runBulk(http, spec, client.mode, options, { storeId, body });
3498
+ }
3499
+ const allowed = await assertWriteSafety(spec, client.mode, options);
3500
+ if (!allowed) return 2;
3396
3501
  const opBody = await invokeOp(http, options.resource, options.verb, {
3397
3502
  id: options.id,
3398
- storeId: options.storeIds?.[0],
3503
+ storeId,
3399
3504
  parentId: options.parentId,
3400
3505
  body
3401
3506
  });
@@ -3453,22 +3558,74 @@ var writeCatalog = (asJson) => {
3453
3558
  "\nCatalog resources (product, variant, price, file, store, \u2026) are read-only in the API.\n"
3454
3559
  );
3455
3560
  };
3456
- var assertWriteSafety = async (spec, mode, options) => {
3561
+ var validateBulkFlags = (spec, options) => {
3562
+ const bulk = Boolean(options.all || options.ids);
3563
+ if (!bulk) {
3564
+ if (options.match) return "--match needs --all.";
3565
+ if (options.dryRun) return "--dry-run needs --all or --ids.";
3566
+ return void 0;
3567
+ }
3568
+ if (!supportsBulk(spec)) {
3569
+ return `--all/--ids only apply to delete, cancel, and refund ops (not ${spec.verb} ${spec.resource}).`;
3570
+ }
3571
+ if (options.all && options.ids) return "Use either --all or --ids, not both.";
3572
+ if (options.id) return "--id targets one resource; drop it when using --all or --ids.";
3573
+ if (options.match && !options.all) return "--match needs --all.";
3574
+ return void 0;
3575
+ };
3576
+ var runBulk = async (http, spec, mode, options, scope) => {
3577
+ const selector = {
3578
+ all: options.all,
3579
+ ids: options.ids,
3580
+ match: options.match,
3581
+ storeId: scope.storeId,
3582
+ parentId: options.parentId
3583
+ };
3584
+ const targets = await collectBulkTargets(http, spec, selector);
3585
+ if (!options.dryRun && targets.length > 0) {
3586
+ const allowed = await assertWriteSafety(spec, mode, options, {
3587
+ count: targets.length,
3588
+ preview: previewTargets(targets)
3589
+ });
3590
+ if (!allowed) return 2;
3591
+ }
3592
+ const summary = await executeBulk(http, spec, targets, {
3593
+ dryRun: options.dryRun,
3594
+ body: scope.body
3595
+ });
3596
+ if (options.json) {
3597
+ const envelope = {
3598
+ ok: summary.failed === 0,
3599
+ mode,
3600
+ resource: spec.resource,
3601
+ verb: spec.verb,
3602
+ docs: `https://docs.lemonsqueezy.com/api/${spec.docsPath}`,
3603
+ ...summary
3604
+ };
3605
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}
3606
+ `);
3607
+ } else {
3608
+ process.stdout.write(`${spec.verb} ${spec.resource} (mode=${mode})
3609
+ `);
3610
+ process.stdout.write(renderBulkSummary(spec, summary));
3611
+ }
3612
+ return summary.failed > 0 ? 1 : 0;
3613
+ };
3614
+ var assertWriteSafety = async (spec, mode, options, bulk) => {
3457
3615
  const isWrite = spec.verb !== "get" && spec.verb !== "list" && spec.verb !== "current-usage";
3458
3616
  if (!isWrite) return true;
3459
3617
  const needsGate = Boolean(spec.destructive) || mode === "live";
3460
3618
  if (!needsGate) return true;
3461
3619
  if (options.yes) return true;
3462
3620
  if (options.isInteractive) {
3463
- return confirmResourceOp(
3464
- mode === "live" ? `LIVE mode: allow ${spec.verb} ${spec.resource}?` : `Confirm ${spec.verb} ${spec.resource}?`
3465
- );
3621
+ const what = bulk ? `${spec.verb} ${bulk.count} ${spec.resource} item(s) (${bulk.preview})` : `${spec.verb} ${spec.resource}`;
3622
+ return confirmResourceOp(mode === "live" ? `LIVE mode: allow ${what}?` : `Confirm ${what}?`);
3466
3623
  }
3467
3624
  process.stderr.write(
3468
3625
  renderCliError(
3469
3626
  `${spec.verb} ${spec.resource} requires --yes in non-interactive ${mode} mode${spec.destructive ? " (destructive op)" : ""}`,
3470
3627
  [
3471
- `fresh-squeezy ${spec.verb} ${spec.resource} --yes \u2026`,
3628
+ `fresh-squeezy ${spec.verb} ${spec.resource}${bulk ? " --all" : ""} --yes \u2026`,
3472
3629
  "Use test mode for safer experimentation: --mode test"
3473
3630
  ]
3474
3631
  )
@@ -3840,6 +3997,28 @@ program.command("ops").description("List docs-backed resource ops (Lemon Squeezy
3840
3997
  })
3841
3998
  );
3842
3999
  });
4000
+ var BULK_VERBS = ["delete", "cancel", "refund"];
4001
+ var opHelpText = (verb) => {
4002
+ if (!BULK_VERBS.includes(verb)) {
4003
+ return `
4004
+ Example:
4005
+ fresh-squeezy ${verb} <resource> --id 42 --json
4006
+ `;
4007
+ }
4008
+ const r = verb === "delete" ? "discount" : verb === "cancel" ? "subscription" : "order";
4009
+ return `
4010
+ Examples:
4011
+ fresh-squeezy ${verb} ${r} --id 42 --yes
4012
+ fresh-squeezy ${verb} ${r} --ids 1,2,3 --yes
4013
+ fresh-squeezy ${verb} ${r} --all --store-ids 12 --dry-run
4014
+ fresh-squeezy ${verb} ${r} --all --store-ids 12 --match TEST --yes --json
4015
+
4016
+ Bulk notes:
4017
+ --all lists every page first, then asks once (TTY) or needs --yes (non-TTY).
4018
+ Store falls back to LEMON_SQUEEZY_STORE_ID when --store-ids is omitted.
4019
+ Exit codes: 0 ok, 1 some items failed, 2 usage error / declined.
4020
+ `;
4021
+ };
3843
4022
  for (const verb of OP_VERBS) {
3844
4023
  program.command(`${verb} <resource>`).description(`Docs-backed ${verb} for a Lemon Squeezy resource`).option("-m, --mode <mode>", "test or live", parseMode).option("--id <id>", "Resource id").option(
3845
4024
  "--store-ids <ids>",
@@ -3848,7 +4027,7 @@ for (const verb of OP_VERBS) {
3848
4027
  ).option(
3849
4028
  "--parent-id <id>",
3850
4029
  "Parent resource id for nested lists (product, order, subscription, \u2026)"
3851
- ).option("--body <json>", "JSON:API body string").option("--body-file <path>", "Path to JSON:API body file").option("--yes", "Skip confirm; required for live writes and destructive ops when non-TTY").option("--json", "Emit machine-readable JSON").action(async (resource, opts) => {
4030
+ ).option("--body <json>", "JSON:API body string").option("--body-file <path>", "Path to JSON:API body file").option("--yes", "Skip confirm; required for live writes and destructive ops when non-TTY").option("--json", "Emit machine-readable JSON").option("--all", "Bulk: apply to every item from `list` (delete, cancel, refund)").option("--ids <ids>", "Bulk: comma-separated ids (delete, cancel, refund)", parseCsv).option("--match <text>", "Bulk: with --all, only items whose name/code/url contain <text>").option("--dry-run", "Bulk: show what would be affected; send nothing").addHelpText("after", opHelpText(verb)).action(async (resource, opts) => {
3852
4031
  await exitWith(
3853
4032
  runResourceOpCommand({
3854
4033
  verb,
@@ -3861,7 +4040,11 @@ for (const verb of OP_VERBS) {
3861
4040
  bodyFile: opts.bodyFile,
3862
4041
  yes: Boolean(opts.yes),
3863
4042
  json: Boolean(opts.json),
3864
- isInteractive
4043
+ isInteractive,
4044
+ all: Boolean(opts.all),
4045
+ ids: opts.ids,
4046
+ match: opts.match,
4047
+ dryRun: Boolean(opts.dryRun)
3865
4048
  })
3866
4049
  );
3867
4050
  });