diff --git a/packages/cli/src/commands/scale.test.ts b/packages/cli/src/commands/scale.test.ts index 6a3065b8..05dba0d1 100644 --- a/packages/cli/src/commands/scale.test.ts +++ b/packages/cli/src/commands/scale.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -263,6 +264,45 @@ describe('scale command registration', () => { }); }); +describe('scale down JSON mode', () => { + it('updates fleet state when --json is used without --dry-run', () => { + const dir = makeTempDir(); + const stateDir = join(dir, '.sh1pt'); + mkdirSync(stateDir, { recursive: true }); + const statePath = join(stateDir, 'credentials.json'); + writeFileSync(statePath, JSON.stringify({ + apiKey: 'preserve-me', + instances: [ + { id: 'inst-0001', provider: 'aws', status: 'running', createdAt: '', hourlyRate: 0.1 }, + { id: 'inst-0002', provider: 'aws', status: 'running', createdAt: '', hourlyRate: 0.2 }, + ], + lastUpdated: '', + })); + + const scaleModule = new URL('./scale.ts', import.meta.url).href; + const runScaleDown = [ + `import { scaleCmd } from ${JSON.stringify(scaleModule)};`, + `await scaleCmd.parseAsync(['node', 'sh1pt', 'down', '--instances', '1', '--json']);`, + ].join('\n'); + const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '--eval', runScaleDown], + { + env: { ...process.env, HOME: dir, USERPROFILE: dir }, + encoding: 'utf8', + }, + ); + + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.removed).toHaveLength(1); + + const saved = JSON.parse(readFileSync(statePath, 'utf-8')); + expect(saved.apiKey).toBe('preserve-me'); + expect(saved.instances.map((instance: FleetEntry) => instance.id)).toEqual(['inst-0002']); + }); +}); + // --------------------------------------------------------------------------- // Rollout strategy logic (unit test of getNextId with fleet context) // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/scale.ts b/packages/cli/src/commands/scale.ts index 6ecf0c71..9145a153 100644 --- a/packages/cli/src/commands/scale.ts +++ b/packages/cli/src/commands/scale.ts @@ -410,44 +410,47 @@ scaleCmd const removedHourly = toRemove.reduce((sum, i) => sum + i.hourlyRate, 0); const newHourly = currentHourly - removedHourly; - if (opts.json) { - console.log(JSON.stringify({ - removed: toRemove.map(i => ({ - id: i.id, - provider: i.provider, - status: i.status, - publicIp: i.publicIp, - hourlyRate: i.hourlyRate, - })), - fleet: { - instances: fleet.instances.length - removeCount, - hourly: newHourly, - }, - }, null, 2)); - return; - } + const result = { + removed: toRemove.map(i => ({ + id: i.id, + provider: i.provider, + status: i.status, + publicIp: i.publicIp, + hourlyRate: i.hourlyRate, + })), + fleet: { + instances: fleet.instances.length - removeCount, + hourly: newHourly, + }, + }; // Human-readable output - console.log(kleur.bold('\nšŸ“‰ Scale Down Plan')); - console.log(kleur.dim('─'.repeat(52))); - console.log(`${kleur.cyan('Removing:'.padEnd(20))} ${removeCount} instance(s)`); - if (opts.provider) { - console.log(`${kleur.cyan('Provider filter:'.padEnd(20))} ${opts.provider}`); - } - console.log(`${kleur.cyan('Current hourly:'.padEnd(20))} $${currentHourly.toFixed(3)}/hr`); - console.log(`${kleur.cyan('Savings:'.padEnd(20))} $${removedHourly.toFixed(3)}/hr ($${(removedHourly * 730).toFixed(2)}/mo)`); - console.log(`${kleur.cyan('Projected hourly:'.padEnd(20))} $${newHourly.toFixed(3)}/hr`); + if (!opts.json) { + console.log(kleur.bold('\nšŸ“‰ Scale Down Plan')); + console.log(kleur.dim('─'.repeat(52))); + console.log(`${kleur.cyan('Removing:'.padEnd(20))} ${removeCount} instance(s)`); + if (opts.provider) { + console.log(`${kleur.cyan('Provider filter:'.padEnd(20))} ${opts.provider}`); + } + console.log(`${kleur.cyan('Current hourly:'.padEnd(20))} $${currentHourly.toFixed(3)}/hr`); + console.log(`${kleur.cyan('Savings:'.padEnd(20))} $${removedHourly.toFixed(3)}/hr ($${(removedHourly * 730).toFixed(2)}/mo)`); + console.log(`${kleur.cyan('Projected hourly:'.padEnd(20))} $${newHourly.toFixed(3)}/hr`); - console.log(kleur.dim('─'.repeat(52))); - console.log(kleur.bold('Instances being torn down:')); - for (const inst of toRemove) { - const statusIcon = inst.status === 'failed' ? kleur.red('āœ–') : inst.status === 'stopped' ? kleur.yellow('ā– ') : kleur.green('ā—'); - console.log(` ${statusIcon} ${inst.id} ${kleur.dim(`(${inst.provider})`)} ${inst.publicIp ?? 'no IP'} $${inst.hourlyRate.toFixed(3)}/hr`); + console.log(kleur.dim('─'.repeat(52))); + console.log(kleur.bold('Instances being torn down:')); + for (const inst of toRemove) { + const statusIcon = inst.status === 'failed' ? kleur.red('āœ–') : inst.status === 'stopped' ? kleur.yellow('ā– ') : kleur.green('ā—'); + console.log(` ${statusIcon} ${inst.id} ${kleur.dim(`(${inst.provider})`)} ${inst.publicIp ?? 'no IP'} $${inst.hourlyRate.toFixed(3)}/hr`); + } + console.log(kleur.dim('─'.repeat(52))); } - console.log(kleur.dim('─'.repeat(52))); if (opts.dryRun) { - console.log(kleur.dim('Dry-run — no changes made.')); + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(kleur.dim('Dry-run — no changes made.')); + } return; } @@ -455,6 +458,11 @@ scaleCmd fleet.instances = fleet.instances.filter(i => !removedIds.has(i.id)); saveFleet(fleet); + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(kleur.green(`āœ… ${removeCount} instance(s) torn down.`)); console.log(kleur.dim(`Remaining fleet: ${fleet.instances.length} instance(s), $${newHourly.toFixed(3)}/hr`)); });