diff --git a/packages/cli/src/commands/scale.test.ts b/packages/cli/src/commands/scale.test.ts index e4c3b6bb..7e6f5295 100644 --- a/packages/cli/src/commands/scale.test.ts +++ b/packages/cli/src/commands/scale.test.ts @@ -11,6 +11,10 @@ import { saveFleet, loadRollouts, saveRollouts, + parsePositiveInteger, + parseNonNegativeInteger, + parsePositiveNumber, + parsePercentage, } from './scale.js'; // Helper to create a temp dir and override CREDS_FILE path @@ -62,6 +66,43 @@ describe('getNextId', () => { }); }); +describe('scale numeric option parsers', () => { + it('accepts valid integer counts and finite prices', () => { + expect(parsePositiveInteger('3')).toBe(3); + expect(parseNonNegativeInteger('0')).toBe(0); + expect(parsePositiveNumber('0.25')).toBe(0.25); + expect(parsePercentage('100')).toBe(100); + }); + + it.each(['nope', '1.5', '0', '-1', 'Infinity', 'NaN', ''])( + 'rejects invalid positive integers: %s', + (value) => { + expect(() => parsePositiveInteger(value)).toThrow(); + }, + ); + + it.each(['nope', '1.5', '-1', 'Infinity', 'NaN', ''])( + 'rejects invalid non-negative integers: %s', + (value) => { + expect(() => parseNonNegativeInteger(value)).toThrow(); + }, + ); + + it.each(['nope', '0', '-1', 'Infinity', 'NaN', ''])( + 'rejects invalid positive finite numbers: %s', + (value) => { + expect(() => parsePositiveNumber(value)).toThrow(); + }, + ); + + it.each(['0', '101', '1.5', 'Infinity', ''])( + 'rejects invalid rollout percentages: %s', + (value) => { + expect(() => parsePercentage(value)).toThrow(); + }, + ); +}); + // --------------------------------------------------------------------------- // loadFleet / saveFleet // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/scale.ts b/packages/cli/src/commands/scale.ts index 5b433a10..ec0c77f1 100644 --- a/packages/cli/src/commands/scale.ts +++ b/packages/cli/src/commands/scale.ts @@ -1,4 +1,4 @@ -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; import kleur from 'kleur'; import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; @@ -120,6 +120,38 @@ export function getNextId(instances: FleetEntry[]): string { return `inst-${String(max + 1).padStart(4, '0')}`; } +export function parsePositiveInteger(value: string): number { + const parsed = Number(value); + if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 1) { + throw new InvalidArgumentError('must be a positive integer'); + } + return parsed; +} + +export function parseNonNegativeInteger(value: string): number { + const parsed = Number(value); + if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError('must be zero or a positive integer'); + } + return parsed; +} + +export function parsePositiveNumber(value: string): number { + const parsed = Number(value); + if (value.trim() === '' || !Number.isFinite(parsed) || parsed <= 0) { + throw new InvalidArgumentError('must be a positive finite number'); + } + return parsed; +} + +export function parsePercentage(value: string): number { + const parsed = parsePositiveInteger(value); + if (parsed > 100) { + throw new InvalidArgumentError('must be between 1 and 100'); + } + return parsed; +} + function pickIps(count: number): string[] { // Simulated IP allocation on RFC 1918 / 100.64.0.0/10 space const base = 100 + Math.floor(Math.random() * 55); @@ -165,9 +197,9 @@ scaleCmd.addCommand(deployCmd); scaleCmd .command('up') .description('Buy more instances of the current SKU (via sh1pt deploy under the hood)') - .option('--instances ', 'how many to add', Number, 1) + .option('--instances ', 'how many to add', parsePositiveInteger, 1) .option('--provider ', 'which cloud provider to add to (default: same as existing fleet, or first in pricing table)') - .option('--max-hourly-price ', 'abort if the new instances would push above this total/hr', Number) + .option('--max-hourly-price ', 'abort if the new instances would push above this total/hr', parsePositiveNumber) .option('--dry-run', 'show the plan without modifying state') .action((opts: { instances: number; @@ -262,7 +294,7 @@ scaleCmd scaleCmd .command('down') .description('Tear down instances (cheapest / least-healthy first)') - .option('--instances ', 'number of instances to destroy', Number, 1) + .option('--instances ', 'number of instances to destroy', parsePositiveInteger, 1) .option('--provider ', 'only remove instances from this cloud provider') .option('--dry-run', 'show the plan without modifying state') .option('--json', 'machine-readable output') @@ -371,10 +403,10 @@ scaleCmd scaleCmd .command('auto') .description('Set auto-scale rules (sh1pt cloud polls metrics and runs scale up/down on your behalf)') - .option('--min ', 'minimum instances', Number, 1) - .option('--max ', 'maximum instances', Number, 10) - .option('--target-cpu ', 'target CPU utilization to maintain', Number, 70) - .option('--cooldown ', 'minimum time between scale events', Number, 300) + .option('--min ', 'minimum instances', parseNonNegativeInteger, 1) + .option('--max ', 'maximum instances', parsePositiveInteger, 10) + .option('--target-cpu ', 'target CPU utilization to maintain', parsePositiveInteger, 70) + .option('--cooldown ', 'minimum time between scale events', parsePositiveInteger, 300) .option('--status', 'show current auto-scale rules') .option('--dry-run', 'show the rules without saving') .option('--json', 'machine-readable output') @@ -497,7 +529,7 @@ scaleCmd .description('Wire round-robin DNS so traffic spreads across the fleet') .requiredOption('--provider ', 'dns-porkbun | dns-cloudflare') .requiredOption('--domain ', 'e.g. api.example.com') - .option('--ttl ', 'TTL for DNS records', Number, 60) + .option('--ttl ', 'TTL for DNS records', parsePositiveInteger, 60) .option('--proxied', 'cloudflare only — route through the CF edge (orange cloud)') .option('--dry-run', 'show the DNS records that would be created/updated') .option('--json', 'machine-readable output') @@ -609,7 +641,7 @@ scaleCmd .description('Stage a new version across the fleet (canary / blue-green / rolling)') .requiredOption('--version ', 'version identifier to deploy (e.g. v2.1.0)') .option('--strategy ', 'canary | blue-green | rolling', 'canary') - .option('--percent ', 'canary only — start at N% of traffic', Number, 5) + .option('--percent ', 'canary only — start at N% of traffic', parsePercentage, 5) .option('--dry-run', 'show the plan without modifying state') .option('--status', 'show active rollouts and their state') .option('--rollback ', 'roll back a previously completed rollout by ID')