Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/cli/src/commands/scale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
saveFleet,
loadRollouts,
saveRollouts,
parsePositiveSafeInteger,
parseNonNegativeSafeInteger,
parsePositiveFiniteNumber,
} from './scale.js';

// Helper to create a temp dir and override CREDS_FILE path
Expand All @@ -28,6 +31,30 @@ afterEach(() => {
}
});

describe('scale numeric option parsers', () => {
it('accepts safe integer counts and rejects invalid count values', () => {
expect(parsePositiveSafeInteger('3')).toBe(3);
for (const invalid of ['nope', '1.5', '0', '-1', 'Infinity', '9007199254740992']) {
expect(() => parsePositiveSafeInteger(invalid)).toThrow('positive safe integer');
}
});

it('allows zero only for non-negative safe integer options', () => {
expect(parseNonNegativeSafeInteger('0')).toBe(0);
expect(parseNonNegativeSafeInteger('4')).toBe(4);
Comment on lines +39 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing unsafe-integer test for parseNonNegativeSafeInteger

The parsePositiveSafeInteger suite explicitly tests '9007199254740992' (2^53, the first unsafe integer), but the parseNonNegativeSafeInteger suite omits that boundary case. Adding it would ensure the upper bound of Number.isSafeInteger is uniformly exercised across all three parsers.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

for (const invalid of ['-1', '1.5', 'NaN', 'Infinity']) {
expect(() => parseNonNegativeSafeInteger(invalid)).toThrow('non-negative safe integer');
}
});

it('accepts positive finite prices and rejects non-finite or non-positive values', () => {
expect(parsePositiveFiniteNumber('1.25')).toBe(1.25);
for (const invalid of ['nope', '0', '-1', 'Infinity', '-Infinity']) {
expect(() => parsePositiveFiniteNumber(invalid)).toThrow('positive finite number');
}
});
});

// ---------------------------------------------------------------------------
// getNextId
// ---------------------------------------------------------------------------
Expand Down
44 changes: 34 additions & 10 deletions packages/cli/src/commands/scale.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +12,30 @@ import { deployCmd } from './deploy.js';
const CREDS_FILE = join(homedir(), '.sh1pt', 'credentials.json');
const ROLLOUTS_FILE = join(homedir(), '.sh1pt', 'rollouts.json');

export function parsePositiveSafeInteger(value: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new InvalidArgumentError('must be a positive safe integer');
}
return parsed;
}

export function parseNonNegativeSafeInteger(value: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 0) {
throw new InvalidArgumentError('must be a non-negative safe integer');
}
return parsed;
}

export function parsePositiveFiniteNumber(value: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new InvalidArgumentError('must be a positive finite number');
}
return parsed;
}

export interface FleetEntry {
id: string;
provider: string;
Expand Down Expand Up @@ -165,9 +189,9 @@ scaleCmd.addCommand(deployCmd);
scaleCmd
.command('up')
.description('Buy more instances of the current SKU (via sh1pt deploy under the hood)')
.option('--instances <n>', 'how many to add', Number, 1)
.option('--instances <n>', 'how many to add', parsePositiveSafeInteger, 1)
.option('--provider <id>', 'which cloud provider to add to (default: same as existing fleet, or first in pricing table)')
.option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', Number)
.option('--max-hourly-price <usd>', 'abort if the new instances would push above this total/hr', parsePositiveFiniteNumber)
.option('--dry-run', 'show the plan without modifying state')
.action((opts: {
instances: number;
Expand Down Expand Up @@ -262,7 +286,7 @@ scaleCmd
scaleCmd
.command('down')
.description('Tear down instances (cheapest / least-healthy first)')
.option('--instances <n>', 'number of instances to destroy', Number, 1)
.option('--instances <n>', 'number of instances to destroy', parsePositiveSafeInteger, 1)
.option('--provider <id>', 'only remove instances from this cloud provider')
.option('--dry-run', 'show the plan without modifying state')
.option('--json', 'machine-readable output')
Expand Down Expand Up @@ -371,10 +395,10 @@ scaleCmd
scaleCmd
.command('auto')
.description('Set auto-scale rules (sh1pt cloud polls metrics and runs scale up/down on your behalf)')
.option('--min <n>', 'minimum instances', Number, 1)
.option('--max <n>', 'maximum instances', Number, 10)
.option('--target-cpu <percent>', 'target CPU utilization to maintain', Number, 70)
.option('--cooldown <seconds>', 'minimum time between scale events', Number, 300)
.option('--min <n>', 'minimum instances', parseNonNegativeSafeInteger, 1)
.option('--max <n>', 'maximum instances', parsePositiveSafeInteger, 10)
.option('--target-cpu <percent>', 'target CPU utilization to maintain', parsePositiveSafeInteger, 70)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 --target-cpu upper-bound check inconsistency

parsePositiveSafeInteger accepts any integer >= 1, so values like --target-cpu 200 pass parse-time validation and are only caught in the action handler (line 478) with a process.exit(1) message. All other options in this PR now surface validation errors early via Commander's InvalidArgumentError. Consider using a dedicated parser for percent values (e.g. one that rejects anything outside 1–100) for consistent user experience.

.option('--cooldown <seconds>', 'minimum time between scale events', parsePositiveSafeInteger, 300)
.option('--status', 'show current auto-scale rules')
.option('--dry-run', 'show the rules without saving')
.option('--json', 'machine-readable output')
Expand Down Expand Up @@ -497,7 +521,7 @@ scaleCmd
.description('Wire round-robin DNS so traffic spreads across the fleet')
.requiredOption('--provider <id>', 'dns-porkbun | dns-cloudflare')
.requiredOption('--domain <fqdn>', 'e.g. api.example.com')
.option('--ttl <seconds>', 'TTL for DNS records', Number, 60)
.option('--ttl <seconds>', 'TTL for DNS records', parsePositiveSafeInteger, 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')
Expand Down Expand Up @@ -609,7 +633,7 @@ scaleCmd
.description('Stage a new version across the fleet (canary / blue-green / rolling)')
.requiredOption('--version <id>', 'version identifier to deploy (e.g. v2.1.0)')
.option('--strategy <kind>', 'canary | blue-green | rolling', 'canary')
.option('--percent <n>', 'canary only — start at N% of traffic', Number, 5)
.option('--percent <n>', 'canary only — start at N% of traffic', parsePositiveSafeInteger, 5)
.option('--dry-run', 'show the plan without modifying state')
.option('--status', 'show active rollouts and their state')
.option('--rollback <id>', 'roll back a previously completed rollout by ID')
Expand Down
Loading