|
1 | | -import { Command } from 'commander'; |
| 1 | +import { Command, InvalidArgumentError } from 'commander'; |
2 | 2 | import kleur from 'kleur'; |
3 | 3 | import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; |
4 | 4 | import { homedir } from 'node:os'; |
@@ -121,6 +121,38 @@ export function getNextId(instances: FleetEntry[]): string { |
121 | 121 | return `inst-${String(max + 1).padStart(4, '0')}`; |
122 | 122 | } |
123 | 123 |
|
| 124 | +export function parsePositiveInteger(value: string): number { |
| 125 | + const parsed = Number(value); |
| 126 | + if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 1) { |
| 127 | + throw new InvalidArgumentError('must be a positive integer'); |
| 128 | + } |
| 129 | + return parsed; |
| 130 | +} |
| 131 | + |
| 132 | +export function parseNonNegativeInteger(value: string): number { |
| 133 | + const parsed = Number(value); |
| 134 | + if (value.trim() === '' || !Number.isSafeInteger(parsed) || parsed < 0) { |
| 135 | + throw new InvalidArgumentError('must be zero or a positive integer'); |
| 136 | + } |
| 137 | + return parsed; |
| 138 | +} |
| 139 | + |
| 140 | +export function parsePositiveNumber(value: string): number { |
| 141 | + const parsed = Number(value); |
| 142 | + if (value.trim() === '' || !Number.isFinite(parsed) || parsed <= 0) { |
| 143 | + throw new InvalidArgumentError('must be a positive finite number'); |
| 144 | + } |
| 145 | + return parsed; |
| 146 | +} |
| 147 | + |
| 148 | +export function parsePercentage(value: string): number { |
| 149 | + const parsed = parsePositiveInteger(value); |
| 150 | + if (parsed > 100) { |
| 151 | + throw new InvalidArgumentError('must be between 1 and 100'); |
| 152 | + } |
| 153 | + return parsed; |
| 154 | +} |
| 155 | + |
124 | 156 | function pickIps(count: number): string[] { |
125 | 157 | // Simulated IP allocation on RFC 1918 / 100.64.0.0/10 space |
126 | 158 | const base = 100 + Math.floor(Math.random() * 55); |
@@ -167,9 +199,9 @@ scaleCmd.addCommand(deployCmd); |
167 | 199 | scaleCmd |
168 | 200 | .command('up') |
169 | 201 | .description('Buy more instances of the current SKU (via sh1pt deploy under the hood)') |
170 | | - .option('--instances <n>', 'how many to add', Number, 1) |
| 202 | + .option('--instances <n>', 'how many to add', parsePositiveInteger, 1) |
171 | 203 | .option('--provider <id>', 'which cloud provider to add to (default: same as existing fleet, or first in pricing table)') |
172 | | - .option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', Number) |
| 204 | + .option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', parsePositiveNumber) |
173 | 205 | .option('--dry-run', 'show the plan without modifying state') |
174 | 206 | .action((opts: { |
175 | 207 | instances: number; |
@@ -264,7 +296,7 @@ scaleCmd |
264 | 296 | scaleCmd |
265 | 297 | .command('down') |
266 | 298 | .description('Tear down instances (cheapest / least-healthy first)') |
267 | | - .option('--instances <n>', 'number of instances to destroy', Number, 1) |
| 299 | + .option('--instances <n>', 'number of instances to destroy', parsePositiveInteger, 1) |
268 | 300 | .option('--provider <id>', 'only remove instances from this cloud provider') |
269 | 301 | .option('--dry-run', 'show the plan without modifying state') |
270 | 302 | .option('--json', 'machine-readable output') |
@@ -373,10 +405,10 @@ scaleCmd |
373 | 405 | scaleCmd |
374 | 406 | .command('auto') |
375 | 407 | .description('Set auto-scale rules (sh1pt cloud polls metrics and runs scale up/down on your behalf)') |
376 | | - .option('--min <n>', 'minimum instances', Number, 1) |
377 | | - .option('--max <n>', 'maximum instances', Number, 10) |
378 | | - .option('--target-cpu <percent>', 'target CPU utilization to maintain', Number, 70) |
379 | | - .option('--cooldown <seconds>', 'minimum time between scale events', Number, 300) |
| 408 | + .option('--min <n>', 'minimum instances', parseNonNegativeInteger, 1) |
| 409 | + .option('--max <n>', 'maximum instances', parsePositiveInteger, 10) |
| 410 | + .option('--target-cpu <percent>', 'target CPU utilization to maintain', parsePositiveInteger, 70) |
| 411 | + .option('--cooldown <seconds>', 'minimum time between scale events', parsePositiveInteger, 300) |
380 | 412 | .option('--status', 'show current auto-scale rules') |
381 | 413 | .option('--dry-run', 'show the rules without saving') |
382 | 414 | .option('--json', 'machine-readable output') |
@@ -499,7 +531,7 @@ scaleCmd |
499 | 531 | .description('Wire round-robin DNS so traffic spreads across the fleet') |
500 | 532 | .requiredOption('--provider <id>', 'dns-porkbun | dns-cloudflare') |
501 | 533 | .requiredOption('--domain <fqdn>', 'e.g. api.example.com') |
502 | | - .option('--ttl <seconds>', 'TTL for DNS records', Number, 60) |
| 534 | + .option('--ttl <seconds>', 'TTL for DNS records', parsePositiveInteger, 60) |
503 | 535 | .option('--proxied', 'cloudflare only — route through the CF edge (orange cloud)') |
504 | 536 | .option('--dry-run', 'show the DNS records that would be created/updated') |
505 | 537 | .option('--json', 'machine-readable output') |
@@ -611,7 +643,7 @@ scaleCmd |
611 | 643 | .description('Stage a new version across the fleet (canary / blue-green / rolling)') |
612 | 644 | .requiredOption('--version <id>', 'version identifier to deploy (e.g. v2.1.0)') |
613 | 645 | .option('--strategy <kind>', 'canary | blue-green | rolling', 'canary') |
614 | | - .option('--percent <n>', 'canary only — start at N% of traffic', Number, 5) |
| 646 | + .option('--percent <n>', 'canary only — start at N% of traffic', parsePercentage, 5) |
615 | 647 | .option('--dry-run', 'show the plan without modifying state') |
616 | 648 | .option('--status', 'show active rollouts and their state') |
617 | 649 | .option('--rollback <id>', 'roll back a previously completed rollout by ID') |
|
0 commit comments