diff --git a/src/commands/compute.ts b/src/commands/compute.ts index d8a547f..df61517 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -653,7 +653,7 @@ export function parseCpu(raw: string): number { // Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view; // only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks. -export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string } | null, cap: { volumeGib: number }, type: ManagedType = 'compute'): string[] { +export function volumeLines(name: string, volume: { sizeGib: number; mountPath: string; appliedMountPath?: string | null; pending?: boolean } | null, cap: { volumeGib: number }, type: ManagedType = 'compute'): string[] { if (!volume) return [ type === 'compute' ? `compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size \` — it mounts at /data on the next deploy)` @@ -667,6 +667,7 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath: : 'grow with --size (grow-only); the volume cannot be deleted — remove the service instead' return [ `${type} ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`, + ...(volume.pending && volume.appliedMountPath != null ? [` pending: ${volume.appliedMountPath ?? '(not deployed)'} → ${volume.mountPath}; deploy to apply (restarts the service). Application configuration is not updated automatically.`] : []), ` billing is actual data stored — the size is a cap, not a price; ${grow}`, ] } @@ -674,7 +675,10 @@ export function volumeLines(name: string, volume: { sizeGib: number; mountPath: // Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what // tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live // disk was already extended); the wire size is authoritative in both cases. -export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string }; cap: { volumeGib: number }; attached?: boolean }, type: ManagedType = 'compute'): string { +export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; mountPath: string; appliedMountPath?: string | null; pending?: boolean }; cap: { volumeGib: number }; attached?: boolean; changed?: boolean }, type: ManagedType = 'compute', sizeRequested = true): string { + const pending = body.volume.pending && body.volume.appliedMountPath != null + ? `; mount path ${body.volume.appliedMountPath} → ${body.volume.mountPath} pending — deploy or restart to apply. Update application paths and startup commands yourself.` : '' + if (body.changed === false && !body.attached) return `${type} ${name}: volume unchanged: ${body.volume.sizeGib}Gi at ${body.volume.mountPath}${pending}` if (body.attached) { // Only a compute service has a deploy step for the mount to wait on; a managed database has // no deploy, so its disk is simply mounted. @@ -683,7 +687,7 @@ export function volumeWriteLine(name: string, body: { volume: { sizeGib: number; : `mounts at ${body.volume.mountPath}` return `${type} ${name}: volume ${body.volume.sizeGib}Gi attached — ${mounts} (plan max ${body.cap.volumeGib}Gi)` } - return `${type} ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)` + return `${type} ${name}: volume ${sizeRequested ? 'grown to ' : ''}${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)${pending}` } // Render the DELETE result. Pure, exported for tests. Deleting is the only way off the volume @@ -722,8 +726,8 @@ type VolumeOpts = LifeOpts & { size?: string; mountPath?: string; delete?: boole // 403/400 messages carry the upgrade hints and must reach the user verbatim (the guard prints // ApiError messages as-is). export async function serviceVolume(type: ManagedType, serviceName: string | undefined, opts: VolumeOpts): Promise { + if (opts.size !== undefined && !opts.size.trim()) throw new Error('--size must not be empty; specify a whole Gi value or omit --size for a path-only edit') if (opts.delete && opts.mountPath !== undefined) throw new Error('--delete cannot be combined with --mount-path') - if (opts.mountPath !== undefined && !opts.size) throw new Error('--mount-path requires --size when attaching a volume') if (opts.delete && opts.size) throw new Error('--delete cannot be combined with --size (one changes the volume, the other destroys it)') const api = await ApiClient.load() const p = await requireProject() @@ -741,18 +745,22 @@ export async function serviceVolume(type: ManagedType, serviceName: string | und return } - if (!opts.size) { + if (!opts.size && opts.mountPath === undefined) { const r = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`) if (opts.json) return printJson(r) for (const line of volumeLines(svc.name, r.volume, r.cap, type)) info(line) return } - const sizeGib = parseVolumeGib(opts.size) + if (opts.mountPath !== undefined && opts.size === undefined) { + const current = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/volume`) + if (!current.volume) throw new Error(`no volume attached; attach one with insta compute volume ${svc.name} --size --mount-path ${opts.mountPath}`) + } + const sizeGib = opts.size === undefined ? undefined : parseVolumeGib(opts.size) const res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/volume`, { sizeGib, ...(opts.mountPath !== undefined ? { mountPath: opts.mountPath } : {}) }) if (handleApproval(res, opts.json)) return if (opts.json) return printJson(res.body) - info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type)) + info(volumeWriteLine(res.body.service?.name ?? svc.name, res.body, type, opts.size !== undefined)) } export const computeVolume = (serviceName: string | undefined, opts: VolumeOpts): Promise => serviceVolume('compute', serviceName, opts) @@ -2055,3 +2063,20 @@ export function hostPatternFor(host: string, suffixes?: readonly string[]): stri const parts = host.split('.') return [parts[0], '*', ...parts.slice(2)].join('.') } + +export async function computeStartCommand(serviceName: string | undefined, opts: Opts & { set?: string; clear?: boolean }): Promise { + if (opts.set !== undefined && opts.clear) throw new Error('use --set or --clear, not both') + const api = await ApiClient.load() + const p = await requireProject() + const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(opts.branch ?? p.branch)}`) + const svc = resolveSoleService(services, 'compute', serviceName) + if (opts.set === undefined && !opts.clear) { + if (opts.json) return printJson({ service: svc }) + info(`compute ${svc.name}: startup command ${(svc as { start_command?: string | null }).start_command || '(image default)'}`) + return + } + const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/services/${svc.id}`, { startCommand: opts.clear ? '' : opts.set }) + if (handleApproval(res, opts.json)) return + if (opts.json) return printJson(res.body) + info('Startup command saved; deploy or restart after staging the volume path. CLI secrets changes deploy immediately; use Console to combine variable, command and path changes.') +} diff --git a/src/index.ts b/src/index.ts index 4611860..19931f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,7 +135,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (ass .option('--port ', 'compute only: port the image listens on (default 8080)') .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('--mount-path ', 'compute only: container mount path for a new volume (requires --volume; default /data; fixed after attachment)') + .option('--mount-path ', 'compute only: container mount path for a new volume (requires --volume; default /data)') .option('--volume ', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume --size `). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume ` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle') .option('--json') .action(guard(async (type, name, o) => { @@ -308,8 +308,13 @@ compute.command('watch-paths [service]').description("Show or change which paths .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => githubCmd.computeWatchPaths(service, o))) compute.command('disconnect-repo [service]').description('Disconnect the GitHub repository from a compute service. The service keeps running its current image; pushes no longer deploy it, and its build history stays') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => githubCmd.computeDisconnectRepo(service, o))) -compute.command('volume [service]').description("Show, attach, grow, or delete a compute service's persistent volume. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price") - .option('--mount-path ', 'container mount path for a new volume (requires --size; default /data; fixed after attachment)') +compute.command('start-command [service]').description('Show or stage a compute startup command for the next deployment. Runs through sh -c; --clear restores the image default. Stage the volume path and command before deploying. CLI secrets writes redeploy immediately; use Console to combine variables, path and command in one deployment.') + .option('--set ', 'startup command to use on the next deploy') + .option('--clear', 'use the image default command on the next deploy') + .option('--json').option('--branch ', 'branch (default: current)') + .action(guard((service, o) => computeCmd.computeStartCommand(service, o))) +compute.command('volume [service]').description("Show, attach, grow, remount, or delete a compute service's persistent volume. --mount-path alone stages an existing volume path change, pending until deploy or restart; an unchanged normalized path is a no-op readback. No flag: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan up to its own plan cap — 10Gi free, 50Gi paid by default — which is also what a disk with no size named is born at; a size above the free cap is paid; the disk mounts at --mount-path (default /data) on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). --delete DESTROYS the disk and ALL its data immediately (no detach, no undo; billing stops now, and suspend fast-wake + scale-out return). Billing is actual data stored — the size is a cap, not a price") + .option('--mount-path ', 'configure mount path; existing volume changes apply on the next deploy (restarts the service)') .option('--size ', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)') .option('--delete', 'destroy the volume and ALL its data (irreversible; download anything you need first)') .option('--json').option('--branch ', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o))) diff --git a/test/volume-mount-path.test.ts b/test/volume-mount-path.test.ts index 8e64315..550d54d 100644 --- a/test/volume-mount-path.test.ts +++ b/test/volume-mount-path.test.ts @@ -6,7 +6,8 @@ vi.mock('../src/api.js', async (original) => ({ requireProject: async () => ({ projectId: 'p1', branch: 'main' }), })) vi.mock('../src/util.js', async (original) => ({ ...await original(), info: vi.fn(), printJson: vi.fn() })) -import { computeVolume } from '../src/commands/compute.js' +import { info, printJson } from '../src/util.js' +import { computeVolume, computeStartCommand } from '../src/commands/compute.js' import { servicesAdd, serviceAddedLine, serviceListLine } from '../src/commands/services.js' beforeEach(() => { fake.request.mockReset(); fake.rawRequest.mockReset(); fake.load.mockReset().mockResolvedValue(fake) @@ -35,11 +36,10 @@ describe('volume mount path requests', () => { }) describe('mount path validation and list display', () => { - it.each([undefined, ''])('rejects mount path with missing or empty size (%j) before loading configuration', async (size) => { - await expect(computeVolume('web', { size, mountPath: '/cache' })).rejects.toThrow('--mount-path requires --size') - expect(fake.load).not.toHaveBeenCalled() - expect(fake.request).not.toHaveBeenCalled() - expect(fake.rawRequest).not.toHaveBeenCalled() + it('sends a path-only edit without an implicit resize', async () => { + fake.request.mockResolvedValueOnce({ services: [{ id: 's1', type: 'compute', name: 'web' }] }).mockResolvedValueOnce({ volume: { sizeGib: 1, mountPath: '/data' } }) + await computeVolume('web', { mountPath: '/cache' }) + expect(fake.rawRequest).toHaveBeenCalledWith('PUT', '/projects/p1/services/s1/volume', { mountPath: '/cache', sizeGib: undefined }) }) it.each(['/app/storage', '/data', null, undefined])('shows the recorded compute volume path (%j), defaulting legacy rows to /data', (path) => { const line = serviceListLine({ type: 'compute', name: 'web', status: 'active', id: 's1', machine_count: 1, volume_gib: 1, volume_mount_path: path }) @@ -51,3 +51,42 @@ describe('mount path validation and list display', () => { expect(line).not.toContain('/cache') }) }) + +describe('startup command staging', () => { + it('saves a command without deploying and honors branch selection', async () => { + await computeStartCommand('web', { set: 'exec postgres -D /new/pg', branch: 'preview' }) + expect(fake.request).toHaveBeenCalledWith('GET', '/projects/p1/services?branch=preview') + expect(fake.rawRequest).toHaveBeenCalledTimes(1) + expect(fake.rawRequest).toHaveBeenCalledWith('PATCH', '/projects/p1/services/s1', { startCommand: 'exec postgres -D /new/pg' }) + }) + it('clears to the image default', async () => { + await computeStartCommand('web', { clear: true }) + expect(fake.rawRequest).toHaveBeenCalledWith('PATCH', '/projects/p1/services/s1', { startCommand: '' }) + }) + it('reads without mutation and rejects conflicting options before loading credentials', async () => { + await computeStartCommand('web', {}) + expect(fake.rawRequest).not.toHaveBeenCalled() + fake.load.mockClear() + await expect(computeStartCommand('web', { set: 'x', clear: true })).rejects.toThrow('not both') + expect(fake.load).not.toHaveBeenCalled() + }) +}) + +it('rejects path-only attachment before issuing a write', async () => { + fake.request.mockResolvedValueOnce({ services: [{ id: 's1', type: 'compute', name: 'web' }] }).mockResolvedValueOnce({ volume: null }) + await expect(computeVolume('web', { mountPath: '/cache' })).rejects.toThrow('no volume attached') + expect(fake.rawRequest).not.toHaveBeenCalled() +}) +it('rejects an explicitly empty size', async () => { + await expect(computeVolume('web', { size: '', mountPath: '/cache' })).rejects.toThrow('--size must not be empty') + expect(fake.load).not.toHaveBeenCalled() +}) + +it('reads the saved startup command in text and JSON', async () => { + const service = { id: 's1', type: 'compute', name: 'web', start_command: 'exec app' } + fake.request.mockResolvedValue({ services: [service] }) + await computeStartCommand('web', {}) + expect(info).toHaveBeenCalledWith('compute web: startup command exec app') + await computeStartCommand('web', { json: true }) + expect(printJson).toHaveBeenCalledWith({ service }) +}) diff --git a/test/volume.test.ts b/test/volume.test.ts index 4cd8afa..1cc1bcf 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -185,7 +185,26 @@ describe('custom mount paths', () => { it('rejects paths without an attachment and conflicting deletion flags before accessing config', async () => { await expect(servicesAdd('compute', 'web', { mountPath: '/app/storage' })).rejects.toThrow(/requires --volume/) await expect(servicesAdd('postgres', 'db', { mountPath: '/app/storage', volume: '1' })).rejects.toThrow(/compute/) - await expect(computeVolume('web', { mountPath: '/app/storage' })).rejects.toThrow(/requires --size/) await expect(computeVolume('web', { mountPath: '/app/storage', delete: true })).rejects.toThrow(/cannot be combined/) }) }) + +describe('pending volume output', () => { + const cap = { volumeGib: 50 } + it('confirms growth as well as a pending remount', () => { + const line = volumeWriteLine('web', { volume: { sizeGib: 20, mountPath: '/new', appliedMountPath: '/data', pending: true }, cap, changed: true, attached: false }) + expect(line).toContain('grown to 20Gi') + expect(line).toContain('/data → /new pending') + }) + it('does not call a first attachment a remount', () => { + const volume = { sizeGib: 1, mountPath: '/data', appliedMountPath: null, pending: true } + expect(volumeWriteLine('web', { volume, cap, attached: true })).toContain('1Gi attached') + expect(volumeLines('web', volume, cap).join(' ')).not.toContain('pending:') + expect(volumeWriteLine('web', { volume, cap, changed: false })).toContain('unchanged: 1Gi') + }) + it('shows actual path edits and does not claim path-only growth', () => { + const volume = { sizeGib: 1, mountPath: '/new', appliedMountPath: '/data', pending: true } + expect(volumeLines('web', volume, cap).join(' ')).toContain('/data → /new') + expect(volumeWriteLine('web', { volume, cap, changed: true }, 'compute', false)).not.toContain('grown') + }) +})