diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 0da9ddec..f0611930 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -599,7 +599,7 @@ export async function computeAlwaysOn(mode: string, serviceName: string | undefi if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) const on = res.body.service?.always_on - info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (default)'}`) + info(`compute ${res.body.service?.name ?? id}: always-on ${on ? 'ENABLED — machines stay warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle'}`) } // ---- limits (the resource ceiling; paid plans) ---- diff --git a/src/commands/services.ts b/src/commands/services.ts index d3e32197..5a4c305a 100644 --- a/src/commands/services.ts +++ b/src/commands/services.ts @@ -95,7 +95,10 @@ export function servicesAddRequestBody(type: string, name: string, branch: strin type, name, ...(branch ? { branch } : {}), public: !!opts.public, ...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}), ...(opts.region ? { region: opts.region } : {}), - ...(opts.alwaysOn ? { alwaysOn: true } : {}), + // Sent whenever the flag was given, false included: compute is born always-on by default + // (insta-platform #385, 2026-09-07), so `--no-always-on` must reach the API as an explicit + // false. Omitted means the platform default. + ...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}), ...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}), } } @@ -109,7 +112,8 @@ export async function servicesAdd(type: string, name: string, opts: ServicesAddO if (type !== 'compute') throw new Error('--port is only valid for compute services') parsePort(opts.port) // junk fails here, before any config/network access } - if (opts.alwaysOn && type !== 'compute') throw new Error('--always-on is only valid for compute services (for postgres, use `insta db always-on on` after creation)') + // Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only. + if (opts.alwaysOn !== undefined && type !== 'compute') throw new Error('--always-on / --no-always-on is only valid for compute services (for postgres, use `insta db always-on on|off` after creation)') if (opts.volume !== undefined) { if (type !== 'compute') throw new Error('--volume is only valid for compute services (postgres has one by default — grow it with `insta db volume --size`)') parseVolumeGib(opts.volume) // junk fails here, before any config/network access diff --git a/src/index.ts b/src/index.ts index feb51f38..8e931e3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,7 +135,8 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass .option('--public', 'storage only: serve the bucket with anonymous public-read (default private)') .option('--image ', 'compute only: run this container image at creation') .option('--port ', 'compute only: port the image listens on (default 8080)') - .option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)') + .option('--always-on', 'compute only: create as always-on — never scales to zero (the default for new compute services; all plans; billing is actual usage either way)') + .option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request') .option('--volume ', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume --size `; any plan may attach at the default 10 (the free cap, on every plan); larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle') .option('--json') .action(guard(async (type, name, o) => { @@ -236,7 +237,7 @@ compute.command('status [service]').description("Show a compute service's desire compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price") .option('--memory ', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu ', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o))) -compute.command('always-on [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way') +compute.command('always-on [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services); off = scale-to-zero. All plans; billing is actual usage either way') .option('--json').option('--branch ', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o))) const execCmd = compute.command('exec [service]').description("Run a one-shot command inside a compute service's machine (`insta compute exec [service] -- [args…]`) — no interactive shell/PTY: `command` is argv, no shell is invoked (use [\"sh\", \"-c\", \"...\"] for shell features). Wakes the machine first if it's scaled to zero — expect a few seconds of latency, billed as uptime, not an error. Exits with the remote command's own exit code (agents rely on this)") .action(guard((service, o) => computeCmd.computeExec(service, execCommand, o, { windowsFallback: execWindowsFallback }))) diff --git a/test/services.test.ts b/test/services.test.ts index 49d9c295..434fbfee 100644 --- a/test/services.test.ts +++ b/test/services.test.ts @@ -136,8 +136,12 @@ describe('servicesAddRequestBody', () => { expect(servicesAddRequestBody('postgres', 'db', 'main', { region: 'us-east' })).toMatchObject({ region: 'us-east' }) expect(servicesAddRequestBody('postgres', 'db', 'main', {})).not.toHaveProperty('region') }) - it('sends alwaysOn only when the flag is set (absent means the scale-to-zero default)', () => { + it('sends alwaysOn in BOTH states when the flag is given, omits it when absent (absent = the platform default, always-on for compute)', () => { expect(servicesAddRequestBody('compute', 'api', 'main', { alwaysOn: true })).toMatchObject({ alwaysOn: true }) + // --no-always-on must reach the API as an explicit false: an omitted key is reinterpreted as + // the always-on default by the platform (insta-platform #385), so dropping false would create + // exactly the always-on, idle-billed service the user opted out of. + expect(servicesAddRequestBody('compute', 'api', 'main', { alwaysOn: false })).toMatchObject({ alwaysOn: false }) expect(servicesAddRequestBody('compute', 'api', 'main', {})).not.toHaveProperty('alwaysOn') }) }) @@ -150,7 +154,12 @@ describe('servicesAdd validation (throws before any network/config access)', () await expect(servicesAdd('postgres', 'db', { port: '3000' })).rejects.toThrow(/--port is only valid for compute services/) }) it('rejects --always-on for a non-compute type, pointing at the db command instead', async () => { - await expect(servicesAdd('postgres', 'db', { alwaysOn: true })).rejects.toThrow(/--always-on is only valid for compute services/) + await expect(servicesAdd('postgres', 'db', { alwaysOn: true })).rejects.toThrow(/--always-on \/ --no-always-on is only valid for compute services/) + }) + it('rejects --no-always-on for a non-compute type too: an explicit false is just as compute-only', async () => { + // Presence check, not truthiness: a truthiness check would let `postgres db --no-always-on` + // reach the platform with alwaysOn:false instead of failing before config/network access. + await expect(servicesAdd('postgres', 'db', { alwaysOn: false })).rejects.toThrow(/--always-on \/ --no-always-on is only valid for compute services/) }) it('rejects --public for a non-storage type', async () => { await expect(servicesAdd('compute', 'api', { public: true })).rejects.toThrow(/--public is only valid for storage services/)