fresh-squeezy 0.1.19 → 0.2.0

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
@@ -2477,6 +2477,10 @@ var pickLauncherAction = async () => {
2477
2477
  name: "Run doctor now \u2014 pick stores interactively when needed",
2478
2478
  value: "doctor"
2479
2479
  },
2480
+ {
2481
+ name: "Manage resources \u2014 bulk delete / cancel / refund",
2482
+ value: "manage"
2483
+ },
2480
2484
  {
2481
2485
  name: "Show command examples \u2014 copy/paste friendly",
2482
2486
  value: "examples"
@@ -2712,6 +2716,29 @@ var isPromptCancel = (error) => {
2712
2716
  if (!(error instanceof Error)) return false;
2713
2717
  return error.name === "ExitPromptError" || error.message.includes("User force closed");
2714
2718
  };
2719
+ var pickManageOperation = async (ops) => {
2720
+ return select({
2721
+ message: "Which operation?",
2722
+ theme: PROMPT_THEME,
2723
+ choices: ops.map((op) => ({ name: `${op.verb} ${op.resource}`, value: op }))
2724
+ });
2725
+ };
2726
+ var askParentId = async (resource) => {
2727
+ const value = await input({
2728
+ message: `Parent id to list ${resource} items from:`,
2729
+ theme: PROMPT_THEME,
2730
+ validate: (entry) => entry.trim().length > 0 ? true : "A parent id is required."
2731
+ });
2732
+ return value.trim();
2733
+ };
2734
+ var pickBulkTargets = async (verb, items) => {
2735
+ return checkbox({
2736
+ message: `Pick items to ${verb} (space toggles, a selects all, enter confirms):`,
2737
+ theme: PROMPT_THEME,
2738
+ choices: items.map((item) => ({ name: `${item.label} \u2014 id ${item.id}`, value: item.id })),
2739
+ validate: (selected) => selected.length > 0 ? true : "Pick at least one item."
2740
+ });
2741
+ };
2715
2742
 
2716
2743
  // src/cli/resolveStores.ts
2717
2744
  var resolveStores = async (client, input2) => {
@@ -2947,6 +2974,11 @@ var renderCommandExamples = () => {
2947
2974
  " fresh-squeezy validate connection",
2948
2975
  " fresh-squeezy validate webhook --store-ids 12 --webhook-url https://app.example.com/api/webhooks/lemon-squeezy",
2949
2976
  "",
2977
+ `${chalk2.bold("Bulk ops (delete / cancel / refund)")}`,
2978
+ " fresh-squeezy delete discount --all --store-ids 12 --dry-run",
2979
+ " fresh-squeezy delete discount --all --store-ids 12 --match TEST --yes",
2980
+ " fresh-squeezy delete webhook --ids 1,2,3 --yes",
2981
+ "",
2950
2982
  `${chalk2.bold("Automation")}`,
2951
2983
  " fresh-squeezy doctor --all-stores --json",
2952
2984
  ""
@@ -3290,6 +3322,176 @@ var pathExists = async (filePath) => {
3290
3322
  }
3291
3323
  };
3292
3324
 
3325
+ // src/cli/commands/bulkOps.ts
3326
+ var supportsBulk = (spec) => Boolean(spec.destructive) && spec.idRole === "id";
3327
+ var describeBulkTarget = (item) => {
3328
+ const attrs = item.attributes ?? {};
3329
+ const id = String(item.id);
3330
+ for (const key of ["code", "name", "url", "identifier", "user_email", "status"]) {
3331
+ const value = attrs[key];
3332
+ if (typeof value === "string" && value.length > 0) return { id, label: value };
3333
+ }
3334
+ return { id, label: id };
3335
+ };
3336
+ var matchesText = (item, text) => {
3337
+ const needle = text.toLowerCase();
3338
+ return Object.values(item.attributes ?? {}).some(
3339
+ (value) => typeof value === "string" && value.toLowerCase().includes(needle)
3340
+ );
3341
+ };
3342
+ var collectBulkTargets = async (http, spec, selector) => {
3343
+ if (selector.ids?.length) {
3344
+ return selector.ids.map((id) => ({ id, label: id }));
3345
+ }
3346
+ const listSpec = findResourceVerb(spec.resource, "list");
3347
+ if (!listSpec) {
3348
+ throw new FreshSqueezyError({
3349
+ code: "UNKNOWN_OP",
3350
+ message: `Cannot use --all: ${spec.resource} has no list op. Pass --ids instead.`
3351
+ });
3352
+ }
3353
+ if (listSpec.idRole === "store" && !selector.storeId) {
3354
+ throw new FreshSqueezyError({
3355
+ code: "MISSING_ARG",
3356
+ message: "--all needs a store: pass --store-ids or set LEMON_SQUEEZY_STORE_ID."
3357
+ });
3358
+ }
3359
+ if (listSpec.idRole === "parent" && !selector.parentId) {
3360
+ throw new FreshSqueezyError({
3361
+ code: "MISSING_ARG",
3362
+ message: `--all needs --parent-id to list ${spec.resource} items.`
3363
+ });
3364
+ }
3365
+ const items = await invokeOp(http, spec.resource, "list", {
3366
+ storeId: selector.storeId,
3367
+ parentId: selector.parentId
3368
+ });
3369
+ return items.filter((item) => selector.match ? matchesText(item, selector.match) : true).map(describeBulkTarget);
3370
+ };
3371
+ var executeBulk = async (http, spec, targets, opts = {}) => {
3372
+ const dryRun = Boolean(opts.dryRun);
3373
+ const results = [];
3374
+ for (const target of targets) {
3375
+ if (dryRun) {
3376
+ results.push({ ...target, ok: true });
3377
+ continue;
3378
+ }
3379
+ try {
3380
+ await invokeOp(http, spec.resource, spec.verb, { id: target.id, body: opts.body });
3381
+ results.push({ ...target, ok: true });
3382
+ } catch (err) {
3383
+ results.push({
3384
+ ...target,
3385
+ ok: false,
3386
+ error: err instanceof Error ? err.message : String(err)
3387
+ });
3388
+ }
3389
+ }
3390
+ const failed = results.filter((r) => !r.ok).length;
3391
+ return { dryRun, total: results.length, succeeded: results.length - failed, failed, results };
3392
+ };
3393
+ var previewTargets = (targets, max = 5) => {
3394
+ const shown = targets.slice(0, max).map((t) => t.label);
3395
+ const more = targets.length - shown.length;
3396
+ return more > 0 ? `${shown.join(", ")}, \u2026 +${more} more` : shown.join(", ");
3397
+ };
3398
+ var renderBulkSummary = (spec, summary) => {
3399
+ if (summary.total === 0) return `No ${spec.resource} items matched \u2014 nothing to ${spec.verb}.
3400
+ `;
3401
+ const verb = summary.dryRun ? `would ${spec.verb}` : spec.verb;
3402
+ const lines = summary.results.map(
3403
+ (r) => r.ok ? ` \u2713 ${verb} ${spec.resource} ${r.id} (${r.label})` : ` \u2717 ${spec.verb} ${spec.resource} ${r.id} (${r.label}): ${r.error}`
3404
+ );
3405
+ 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` : ""}`;
3406
+ return `${lines.join("\n")}
3407
+ ${tail}
3408
+ `;
3409
+ };
3410
+
3411
+ // src/cli/commands/manage.ts
3412
+ var listManageOperations = () => resourceRegistry.filter((spec) => supportsBulk(spec) && findResourceVerb(spec.resource, "list")).map((spec) => ({ resource: spec.resource, verb: spec.verb }));
3413
+ var renderEquivalentCommand = (input2) => {
3414
+ const parts = ["fresh-squeezy", input2.verb, input2.resource];
3415
+ if (input2.picked.length === input2.total) {
3416
+ parts.push("--all");
3417
+ if (input2.storeId) parts.push("--store-ids", input2.storeId);
3418
+ if (input2.parentId) parts.push("--parent-id", input2.parentId);
3419
+ } else {
3420
+ parts.push("--ids", input2.picked.join(","));
3421
+ }
3422
+ parts.push("--yes");
3423
+ if (input2.mode === "live") parts.push("--mode", "live");
3424
+ return parts.join(" ");
3425
+ };
3426
+ var runManageCommand = async () => {
3427
+ try {
3428
+ const client = createFreshSqueezy();
3429
+ const http = new HttpClient(resolveConfig());
3430
+ const op = await pickManageOperation(listManageOperations());
3431
+ const spec = findResourceVerb(op.resource, op.verb);
3432
+ const listSpec = findResourceVerb(op.resource, "list");
3433
+ if (!spec || !listSpec) {
3434
+ throw new FreshSqueezyError({
3435
+ code: "UNKNOWN_OP",
3436
+ message: `${op.verb} ${op.resource} is not available.`
3437
+ });
3438
+ }
3439
+ process.stdout.write(`
3440
+ ${op.verb} ${op.resource} \u2014 mode=${client.mode}
3441
+ `);
3442
+ let scopes = [{}];
3443
+ if (listSpec.idRole === "store") {
3444
+ const { storeIds } = await resolveStores(client, { isInteractive: true });
3445
+ scopes = storeIds.map((storeId) => ({ storeId }));
3446
+ } else if (listSpec.idRole === "parent") {
3447
+ scopes = [{ parentId: await askParentId(op.resource) }];
3448
+ }
3449
+ let exitCode = 0;
3450
+ for (const scope of scopes) {
3451
+ const targets = await collectBulkTargets(http, spec, { all: true, ...scope });
3452
+ if (targets.length === 0) {
3453
+ process.stdout.write(
3454
+ `No ${op.resource} items found${scope.storeId ? ` in store ${scope.storeId}` : ""}.
3455
+ `
3456
+ );
3457
+ continue;
3458
+ }
3459
+ const pickedIds = await pickBulkTargets(op.verb, targets);
3460
+ const picked = targets.filter((t) => pickedIds.includes(t.id));
3461
+ const mark = client.mode === "live" ? "LIVE mode: " : "";
3462
+ const go = await confirmResourceOp(
3463
+ `${mark}${op.verb} ${picked.length} ${op.resource} item(s) (${previewTargets(picked)})?`
3464
+ );
3465
+ if (!go) {
3466
+ process.stdout.write("Skipped \u2014 nothing changed.\n");
3467
+ continue;
3468
+ }
3469
+ const summary = await executeBulk(http, spec, picked);
3470
+ process.stdout.write(renderBulkSummary(spec, summary));
3471
+ process.stdout.write(
3472
+ `
3473
+ Next time:
3474
+ ${renderEquivalentCommand({
3475
+ verb: op.verb,
3476
+ resource: op.resource,
3477
+ mode: client.mode,
3478
+ ...scope,
3479
+ picked: pickedIds,
3480
+ total: targets.length
3481
+ })}
3482
+ `
3483
+ );
3484
+ if (summary.failed > 0) exitCode = 1;
3485
+ }
3486
+ return exitCode;
3487
+ } catch (err) {
3488
+ if (isPromptCancel(err)) throw err;
3489
+ const message = err instanceof Error ? err.message : String(err);
3490
+ process.stderr.write(renderCliError(message, ["fresh-squeezy ops --list"]));
3491
+ return 2;
3492
+ }
3493
+ };
3494
+
3293
3495
  // src/cli/commands/launcher.ts
3294
3496
  var runLauncherCommand = async (options) => {
3295
3497
  if (!options.isInteractive) return 2;
@@ -3329,6 +3531,8 @@ var routeLauncherAction = async (action) => {
3329
3531
  return startGuidedSetup();
3330
3532
  case "doctor":
3331
3533
  return runDoctorCommand({ isInteractive: true });
3534
+ case "manage":
3535
+ return runManageCommand();
3332
3536
  case "examples":
3333
3537
  process.stdout.write(renderCommandExamples());
3334
3538
  return 0;
@@ -3380,6 +3584,14 @@ var runResourceOpCommand = async (options) => {
3380
3584
  );
3381
3585
  return 2;
3382
3586
  }
3587
+ const bulkError = validateBulkFlags(spec, options);
3588
+ if (bulkError) {
3589
+ writeFatal2(
3590
+ new FreshSqueezyError({ code: "INVALID_ARGS", message: bulkError }),
3591
+ options.json ?? false
3592
+ );
3593
+ return 2;
3594
+ }
3383
3595
  try {
3384
3596
  const body = readBody(options);
3385
3597
  if (spec.body === "required" && body === void 0) {
@@ -3389,13 +3601,17 @@ var runResourceOpCommand = async (options) => {
3389
3601
  });
3390
3602
  }
3391
3603
  const client = createFreshSqueezy({ mode: options.mode });
3392
- const allowed = await assertWriteSafety(spec, client.mode, options);
3393
- if (!allowed) return 2;
3394
3604
  const config = resolveConfig({ mode: options.mode });
3395
3605
  const http = new HttpClient(config);
3606
+ const storeId = options.storeIds?.[0] ?? config.storeId;
3607
+ if (options.all || options.ids) {
3608
+ return await runBulk(http, spec, client.mode, options, { storeId, body });
3609
+ }
3610
+ const allowed = await assertWriteSafety(spec, client.mode, options);
3611
+ if (!allowed) return 2;
3396
3612
  const opBody = await invokeOp(http, options.resource, options.verb, {
3397
3613
  id: options.id,
3398
- storeId: options.storeIds?.[0],
3614
+ storeId,
3399
3615
  parentId: options.parentId,
3400
3616
  body
3401
3617
  });
@@ -3453,22 +3669,74 @@ var writeCatalog = (asJson) => {
3453
3669
  "\nCatalog resources (product, variant, price, file, store, \u2026) are read-only in the API.\n"
3454
3670
  );
3455
3671
  };
3456
- var assertWriteSafety = async (spec, mode, options) => {
3672
+ var validateBulkFlags = (spec, options) => {
3673
+ const bulk = Boolean(options.all || options.ids);
3674
+ if (!bulk) {
3675
+ if (options.match) return "--match needs --all.";
3676
+ if (options.dryRun) return "--dry-run needs --all or --ids.";
3677
+ return void 0;
3678
+ }
3679
+ if (!supportsBulk(spec)) {
3680
+ return `--all/--ids only apply to delete, cancel, and refund ops (not ${spec.verb} ${spec.resource}).`;
3681
+ }
3682
+ if (options.all && options.ids) return "Use either --all or --ids, not both.";
3683
+ if (options.id) return "--id targets one resource; drop it when using --all or --ids.";
3684
+ if (options.match && !options.all) return "--match needs --all.";
3685
+ return void 0;
3686
+ };
3687
+ var runBulk = async (http, spec, mode, options, scope) => {
3688
+ const selector = {
3689
+ all: options.all,
3690
+ ids: options.ids,
3691
+ match: options.match,
3692
+ storeId: scope.storeId,
3693
+ parentId: options.parentId
3694
+ };
3695
+ const targets = await collectBulkTargets(http, spec, selector);
3696
+ if (!options.dryRun && targets.length > 0) {
3697
+ const allowed = await assertWriteSafety(spec, mode, options, {
3698
+ count: targets.length,
3699
+ preview: previewTargets(targets)
3700
+ });
3701
+ if (!allowed) return 2;
3702
+ }
3703
+ const summary = await executeBulk(http, spec, targets, {
3704
+ dryRun: options.dryRun,
3705
+ body: scope.body
3706
+ });
3707
+ if (options.json) {
3708
+ const envelope = {
3709
+ ok: summary.failed === 0,
3710
+ mode,
3711
+ resource: spec.resource,
3712
+ verb: spec.verb,
3713
+ docs: `https://docs.lemonsqueezy.com/api/${spec.docsPath}`,
3714
+ ...summary
3715
+ };
3716
+ process.stdout.write(`${JSON.stringify(envelope, null, 2)}
3717
+ `);
3718
+ } else {
3719
+ process.stdout.write(`${spec.verb} ${spec.resource} (mode=${mode})
3720
+ `);
3721
+ process.stdout.write(renderBulkSummary(spec, summary));
3722
+ }
3723
+ return summary.failed > 0 ? 1 : 0;
3724
+ };
3725
+ var assertWriteSafety = async (spec, mode, options, bulk) => {
3457
3726
  const isWrite = spec.verb !== "get" && spec.verb !== "list" && spec.verb !== "current-usage";
3458
3727
  if (!isWrite) return true;
3459
3728
  const needsGate = Boolean(spec.destructive) || mode === "live";
3460
3729
  if (!needsGate) return true;
3461
3730
  if (options.yes) return true;
3462
3731
  if (options.isInteractive) {
3463
- return confirmResourceOp(
3464
- mode === "live" ? `LIVE mode: allow ${spec.verb} ${spec.resource}?` : `Confirm ${spec.verb} ${spec.resource}?`
3465
- );
3732
+ const what = bulk ? `${spec.verb} ${bulk.count} ${spec.resource} item(s) (${bulk.preview})` : `${spec.verb} ${spec.resource}`;
3733
+ return confirmResourceOp(mode === "live" ? `LIVE mode: allow ${what}?` : `Confirm ${what}?`);
3466
3734
  }
3467
3735
  process.stderr.write(
3468
3736
  renderCliError(
3469
3737
  `${spec.verb} ${spec.resource} requires --yes in non-interactive ${mode} mode${spec.destructive ? " (destructive op)" : ""}`,
3470
3738
  [
3471
- `fresh-squeezy ${spec.verb} ${spec.resource} --yes \u2026`,
3739
+ `fresh-squeezy ${spec.verb} ${spec.resource}${bulk ? " --all" : ""} --yes \u2026`,
3472
3740
  "Use test mode for safer experimentation: --mode test"
3473
3741
  ]
3474
3742
  )
@@ -3840,6 +4108,28 @@ program.command("ops").description("List docs-backed resource ops (Lemon Squeezy
3840
4108
  })
3841
4109
  );
3842
4110
  });
4111
+ var BULK_VERBS = ["delete", "cancel", "refund"];
4112
+ var opHelpText = (verb) => {
4113
+ if (!BULK_VERBS.includes(verb)) {
4114
+ return `
4115
+ Example:
4116
+ fresh-squeezy ${verb} <resource> --id 42 --json
4117
+ `;
4118
+ }
4119
+ const r = verb === "delete" ? "discount" : verb === "cancel" ? "subscription" : "order";
4120
+ return `
4121
+ Examples:
4122
+ fresh-squeezy ${verb} ${r} --id 42 --yes
4123
+ fresh-squeezy ${verb} ${r} --ids 1,2,3 --yes
4124
+ fresh-squeezy ${verb} ${r} --all --store-ids 12 --dry-run
4125
+ fresh-squeezy ${verb} ${r} --all --store-ids 12 --match TEST --yes --json
4126
+
4127
+ Bulk notes:
4128
+ --all lists every page first, then asks once (TTY) or needs --yes (non-TTY).
4129
+ Store falls back to LEMON_SQUEEZY_STORE_ID when --store-ids is omitted.
4130
+ Exit codes: 0 ok, 1 some items failed, 2 usage error / declined.
4131
+ `;
4132
+ };
3843
4133
  for (const verb of OP_VERBS) {
3844
4134
  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
4135
  "--store-ids <ids>",
@@ -3848,7 +4138,7 @@ for (const verb of OP_VERBS) {
3848
4138
  ).option(
3849
4139
  "--parent-id <id>",
3850
4140
  "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) => {
4141
+ ).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
4142
  await exitWith(
3853
4143
  runResourceOpCommand({
3854
4144
  verb,
@@ -3861,7 +4151,11 @@ for (const verb of OP_VERBS) {
3861
4151
  bodyFile: opts.bodyFile,
3862
4152
  yes: Boolean(opts.yes),
3863
4153
  json: Boolean(opts.json),
3864
- isInteractive
4154
+ isInteractive,
4155
+ all: Boolean(opts.all),
4156
+ ids: opts.ids,
4157
+ match: opts.match,
4158
+ dryRun: Boolean(opts.dryRun)
3865
4159
  })
3866
4160
  );
3867
4161
  });