From a7f77e985b168b7fa903c4768b015b523e20d12b Mon Sep 17 00:00:00 2001 From: aiirvizionz Date: Sat, 18 Jul 2026 17:50:47 -0600 Subject: [PATCH] fix(merch): reject non-decimal numeric options --- packages/cli/src/commands/merch.test.ts | 29 ++++++++++++++++++++++ packages/cli/src/commands/merch.ts | 32 +++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/merch.test.ts diff --git a/packages/cli/src/commands/merch.test.ts b/packages/cli/src/commands/merch.test.ts new file mode 100644 index 00000000..36f8f632 --- /dev/null +++ b/packages/cli/src/commands/merch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { parsePositiveInteger, parsePositiveNumber } from './merch.js'; + +describe('merch numeric option parsers', () => { + it('accepts decimal positive integers', () => { + expect(parsePositiveInteger('1')).toBe(1); + expect(parsePositiveInteger('25')).toBe(25); + }); + + it.each(['0', '-1', '1.5', '1e2', '0x10', 'Infinity', 'NaN', 'many'])( + 'rejects invalid positive integer %s', + (value) => { + expect(() => parsePositiveInteger(value)).toThrow('positive integer'); + }, + ); + + it('accepts decimal positive money values and percentages', () => { + expect(parsePositiveNumber('0.5')).toBe(0.5); + expect(parsePositiveNumber('19.99')).toBe(19.99); + expect(parsePositiveNumber('40')).toBe(40); + }); + + it.each(['0', '-1', '1e2', '0x10', 'Infinity', 'NaN', 'free'])( + 'rejects invalid positive number %s', + (value) => { + expect(() => parsePositiveNumber(value)).toThrow('positive finite number'); + }, + ); +}); diff --git a/packages/cli/src/commands/merch.ts b/packages/cli/src/commands/merch.ts index 5c1443d1..578c0025 100644 --- a/packages/cli/src/commands/merch.ts +++ b/packages/cli/src/commands/merch.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import kleur from 'kleur'; // Merch = swag. Shirts, stickers, hoodies, mugs, pens, notebooks, tote @@ -8,6 +8,28 @@ export const merchCmd = new Command('merch') .description('Print & ship swag via Printful / Printify — for sale or for free conference giveaways') .action(() => { merchCmd.help(); }); +export function parsePositiveInteger(value: string): number { + if (!/^\d+$/.test(value)) { + throw new InvalidArgumentError('must be a positive integer'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new InvalidArgumentError('must be a positive integer'); + } + return parsed; +} + +export function parsePositiveNumber(value: string): number { + if (!/^\d+(?:\.\d+)?$/.test(value)) { + throw new InvalidArgumentError('must be a positive finite number'); + } + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new InvalidArgumentError('must be a positive finite number'); + } + return parsed; +} + merchCmd .command('setup') .description('Connect a POD provider (Printful, Printify) and optionally a storefront (Shopify, Etsy, Gumroad)') @@ -23,8 +45,8 @@ merchCmd .requiredOption('--products ', 'tshirt hoodie sticker mug pen notebook etc.') .option('--colors ', 'comma-separated colors', 'black,white') .option('--sizes ', 'comma-separated sizes (apparel only)', 'S,M,L,XL,XXL') - .option('--price ', 'retail price in USD; omit to use provider suggestion', Number) - .option('--markup ', 'margin over base cost if --price is omitted', Number, 40) + .option('--price ', 'retail price in USD; omit to use provider suggestion', parsePositiveNumber) + .option('--markup ', 'margin over base cost if --price is omitted', parsePositiveNumber, 40) .option('--provider ', 'default: first configured') .option('--dry-run') .action((opts) => { @@ -55,8 +77,8 @@ merchCmd .description('Bulk ship swag for free (conference, hackathon, community giveaway — no storefront)') .requiredOption('--sku ', 'which SKU(s) to ship') .requiredOption('--addresses ', 'CSV with name,email,address1,city,region,zip,country columns') - .option('--quantity ', 'items per recipient', Number, 1) - .option('--budget-cap ', 'abort if total exceeds this (strongly recommended)', Number) + .option('--quantity ', 'items per recipient', parsePositiveInteger, 1) + .option('--budget-cap ', 'abort if total exceeds this (strongly recommended)', parsePositiveNumber) .option('--dry-run') .action((opts) => { console.log(kleur.yellow(`[stub] merch giveaway ${JSON.stringify(opts)}`));