From d9f3ce7f864cd023e98e753a7a994babf88b8032 Mon Sep 17 00:00:00 2001 From: v01dstar Date: Sun, 20 Sep 2026 07:49:36 +0000 Subject: [PATCH 1/3] feat: support path-only compute volume updates --- src/commands/compute.ts | 12 +++++++----- src/index.ts | 4 ++-- test/volume-mount-path.test.ts | 8 +++----- test/volume.test.ts | 1 - 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index d8a547f..d6e6e9d 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 ? [` 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,9 @@ 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'): string { + if (body.volume.pending && !body.attached) return `${type} ${name}: mount path ${body.volume.appliedMountPath ?? '(not deployed)'} → ${body.volume.mountPath} pending — deploy to apply (restarts the service). Update application paths, variables and startup commands yourself.` + if (body.changed === false) return `${type} ${name}: volume unchanged at ${body.volume.mountPath}` 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. @@ -723,7 +726,6 @@ type VolumeOpts = LifeOpts & { size?: string; mountPath?: string; delete?: boole // ApiError messages as-is). export async function serviceVolume(type: ManagedType, serviceName: string | undefined, opts: VolumeOpts): Promise { 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,14 +743,14 @@ 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) + 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) diff --git a/src/index.ts b/src/index.ts index 4611860..5248efe 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) => { @@ -309,7 +309,7 @@ compute.command('watch-paths [service]').description("Show or change which paths 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)') + .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..e016242 100644 --- a/test/volume-mount-path.test.ts +++ b/test/volume-mount-path.test.ts @@ -35,11 +35,9 @@ 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 () => { + 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 }) diff --git a/test/volume.test.ts b/test/volume.test.ts index 4cd8afa..6ffc953 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -185,7 +185,6 @@ 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/) }) }) From bc29bcd7e75171792ea509d1fd11f8b6fec1570f Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Mon, 21 Sep 2026 23:21:30 -0700 Subject: [PATCH 2/3] feat: stage compute startup commands from the CLI --- src/commands/compute.ts | 17 +++++++++++++++++ src/index.ts | 7 ++++++- test/volume-mount-path.test.ts | 22 +++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index d6e6e9d..5760128 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -2057,3 +2057,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((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 and variables.') +} diff --git a/src/index.ts b/src/index.ts index 5248efe..d447a0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,7 +308,12 @@ 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") +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 volume paths and variables before deploying once.') + .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)') diff --git a/test/volume-mount-path.test.ts b/test/volume-mount-path.test.ts index e016242..0f665c9 100644 --- a/test/volume-mount-path.test.ts +++ b/test/volume-mount-path.test.ts @@ -6,7 +6,7 @@ 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 { 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) @@ -49,3 +49,23 @@ 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() + }) +}) From 638c055d5bc5c4a2127fcb77f8e2865724dabb7b Mon Sep 17 00:00:00 2001 From: Yang Zhang Date: Tue, 22 Sep 2026 00:20:21 -0700 Subject: [PATCH 3/3] fix: validate path-only edits and report volume changes accurately --- src/commands/compute.ts | 22 ++++++++++++++-------- src/index.ts | 2 +- test/volume-mount-path.test.ts | 21 +++++++++++++++++++++ test/volume.test.ts | 20 ++++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 5760128..df61517 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -667,7 +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 ? [` pending: ${volume.appliedMountPath ?? '(not deployed)'} → ${volume.mountPath}; deploy to apply (restarts the service). Application configuration is not updated automatically.`] : []), + ...(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}`, ] } @@ -675,9 +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; appliedMountPath?: string | null; pending?: boolean }; cap: { volumeGib: number }; attached?: boolean; changed?: boolean }, type: ManagedType = 'compute'): string { - if (body.volume.pending && !body.attached) return `${type} ${name}: mount path ${body.volume.appliedMountPath ?? '(not deployed)'} → ${body.volume.mountPath} pending — deploy to apply (restarts the service). Update application paths, variables and startup commands yourself.` - if (body.changed === false) return `${type} ${name}: volume unchanged at ${body.volume.mountPath}` +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. @@ -686,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 @@ -725,6 +726,7 @@ 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.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() @@ -750,11 +752,15 @@ export async function serviceVolume(type: ManagedType, serviceName: string | und return } + 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) @@ -2066,11 +2072,11 @@ export async function computeStartCommand(serviceName: string | undefined, opts: const svc = resolveSoleService(services, 'compute', serviceName) if (opts.set === undefined && !opts.clear) { if (opts.json) return printJson({ service: svc }) - info((svc as { start_command?: string | null }).start_command || '(image default)') + 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 and variables.') + 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 d447a0b..19931f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,7 +308,7 @@ 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('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 volume paths and variables before deploying once.') +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)') diff --git a/test/volume-mount-path.test.ts b/test/volume-mount-path.test.ts index 0f665c9..550d54d 100644 --- a/test/volume-mount-path.test.ts +++ b/test/volume-mount-path.test.ts @@ -6,6 +6,7 @@ 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 { info, printJson } from '../src/util.js' import { computeVolume, computeStartCommand } from '../src/commands/compute.js' import { servicesAdd, serviceAddedLine, serviceListLine } from '../src/commands/services.js' beforeEach(() => { @@ -36,6 +37,7 @@ describe('volume mount path requests', () => { describe('mount path validation and list display', () => { 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 }) }) @@ -69,3 +71,22 @@ describe('startup command staging', () => { 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 6ffc953..1cc1bcf 100644 --- a/test/volume.test.ts +++ b/test/volume.test.ts @@ -188,3 +188,23 @@ describe('custom mount paths', () => { 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') + }) +})