From 62ef0de16e4944b555b9e9cc13edeb9f62372120 Mon Sep 17 00:00:00 2001 From: Lyu Date: Fri, 7 Aug 2026 22:17:21 -0700 Subject: [PATCH] feat(compute): support the self-hosted Docker provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compute deploy` assumed Fly: it always sent a region, always sent scale-to-zero, and in source mode always minted a Fly deploy token and shelled out to flyctl. A self-hoster running the new Docker driver got a region silently recorded and never honoured, and `compute deploy ` could not work at all. The CLI now asks GET /api/metadata what the configured provider can do and shapes the request accordingly: - region is omitted when the provider has none, so the stored row does not claim a choice that never took effect; passing --region explicitly says so rather than failing - scaleToZero is omitted when unsupported, likewise for --always-on - source mode branches on `sourceBuild`. `context-upload` packs the directory and POSTs it to /:id/build, which builds, tags and deploys in one call — no deploy token, no flyctl on PATH, no follow-up PATCH. `flyctl` keeps the existing path. `none` fails with a message naming --image as the way through, checked before the Dockerfile lookup since a missing Dockerfile is beside the point there. The context packer is deliberately not modelled on the deployments bundler, which strips node_modules, dist and build. Those exclusions are right for a static site and wrong for a Docker build, where a Dockerfile may COPY any of them and dropping one produces a failure nothing on screen explains. It follows Docker's own contract — everything, minus .dockerignore — with .git the single unconditional exclusion, since it is usually the largest thing in the tree and carries every secret ever committed. Built with the archiver and ignore packages already in use here, so no new dependency. Capability lookup never throws: a CLI that cannot deploy because a probe failed is worse than one that tries the way it always has, so any failure and any older backend fall back to the Fly-shaped behaviour, field by field. Existing deploy tests asserted on ossFetch call *indices*, which the capability probe shifted. They now route the mock by URL, which survives the next added call too. Eight new tests: four on capability-driven request shaping, four on the packer (default inclusion, .dockerignore with negations, .git exclusion, tar validity). Both new behaviours verified by reverting the code and watching them fail. Co-Authored-By: Claude Opus 5 (1M context) --- src/commands/compute/deploy.test.ts | 143 ++++++++++++++++++++++++---- src/commands/compute/deploy.ts | 121 +++++++++++++++++++++-- src/lib/build-context.test.ts | 101 ++++++++++++++++++++ src/lib/build-context.ts | 87 +++++++++++++++++ src/lib/compute-capabilities.ts | 74 ++++++++++++++ 5 files changed, 502 insertions(+), 24 deletions(-) create mode 100644 src/lib/build-context.test.ts create mode 100644 src/lib/build-context.ts create mode 100644 src/lib/compute-capabilities.ts diff --git a/src/commands/compute/deploy.test.ts b/src/commands/compute/deploy.test.ts index 559fd90..4eaccf3 100644 --- a/src/commands/compute/deploy.test.ts +++ b/src/commands/compute/deploy.test.ts @@ -16,13 +16,65 @@ vi.mock('../../lib/errors.js', async (importOriginal) => { import { Command } from 'commander'; import { registerComputeDeployCommand } from './deploy.js'; +/** + * Route the mock by URL rather than by call order. + * + * `compute deploy` now asks /api/metadata what the provider can do before shaping + * the request, so an assertion keyed on `mock.calls[1]` breaks as soon as another + * call is added. Routing by URL survives that. + */ +function routeOssFetch(capabilities?: Record) { + const caps = + capabilities ?? { + scaleToZero: true, + regions: true, + ingressModes: ['host'], + sourceBuild: 'flyctl', + deployTokenIssuance: true, + }; + ossFetchMock.mockImplementation((url: string, init?: { method?: string }) => { + if (url === '/api/metadata') { + return Promise.resolve({ + json: async () => ({ compute: { defaultProvider: 'test', providers: { test: caps } } }), + }); + } + if (url === '/api/compute/services' && !init?.method) { + return Promise.resolve({ json: async () => [] }); + } + return Promise.resolve({ + json: async () => ({ + id: 'svc-1', + name: 'cache', + status: 'started', + endpointUrl: 'https://cache.fly.dev', + port: 6379, + service: { name: 'cache', status: 'running' }, + imageTag: 'insforge-x/cache:abc', + logs: ['Step 1/2 : FROM alpine'], + }), + }); + }); +} + +/** Body of the POST that creates or prepares the service. */ +function createCallBody(): Record { + const call = ossFetchMock.mock.calls.find( + ([url, init]) => + typeof url === 'string' && + url.startsWith('/api/compute/services') && + (init as { method?: string } | undefined)?.method === 'POST' + ); + if (!call) { + throw new Error('no create/prepare POST was made'); + } + return JSON.parse((call[1] as { body: string }).body) as Record; +} + + describe('compute deploy --protocol', () => { beforeEach(() => { ossFetchMock.mockReset(); - ossFetchMock.mockResolvedValueOnce({ json: async () => [] }); // initial list - ossFetchMock.mockResolvedValueOnce({ - json: async () => ({ name: 'cache', status: 'started', endpointUrl: 'https://cache.fly.dev', port: 6379 }), - }); + routeOssFetch(); }); it('includes protocol="tcp" in request body when --protocol tcp', async () => { @@ -37,8 +89,7 @@ describe('compute deploy --protocol', () => { '--protocol', 'tcp', '--port', '6379', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.protocol).toBe('tcp'); expect(body.port).toBe(6379); }); @@ -52,8 +103,7 @@ describe('compute deploy --protocol', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'web', '--port', '8080', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect('protocol' in body).toBe(false); }); @@ -74,10 +124,7 @@ describe('compute deploy --protocol', () => { describe('compute deploy --always-on / --scale-to-zero', () => { beforeEach(() => { ossFetchMock.mockReset(); - ossFetchMock.mockResolvedValueOnce({ json: async () => [] }); // initial list - ossFetchMock.mockResolvedValueOnce({ - json: async () => ({ name: 'api', status: 'running', endpointUrl: 'https://api.fly.dev', port: 8080, scaleToZero: false }), - }); + routeOssFetch(); }); it('includes scaleToZero=false in request body when --always-on', async () => { @@ -89,8 +136,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', '--always-on', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.scaleToZero).toBe(false); }); @@ -103,8 +149,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', '--scale-to-zero', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect(body.scaleToZero).toBe(true); }); @@ -117,8 +162,7 @@ describe('compute deploy --always-on / --scale-to-zero', () => { 'node', 'lim', 'compute', 'deploy', '--image', 'nginx', '--name', 'api', ]); - const createCall = ossFetchMock.mock.calls[1]; - const body = JSON.parse(createCall[1].body); + const body = createCallBody(); expect('scaleToZero' in body).toBe(false); }); @@ -135,3 +179,66 @@ describe('compute deploy --always-on / --scale-to-zero', () => { ).rejects.toThrow(/mutually exclusive/); }); }); + +describe('compute deploy against a single-host provider', () => { + const DOCKER = { + scaleToZero: false, + regions: false, + ingressModes: ['none', 'port', 'host'], + sourceBuild: 'context-upload', + deployTokenIssuance: false, + }; + + beforeEach(() => { + ossFetchMock.mockReset(); + }); + + // Sending a region to a provider with one host records a choice that never takes + // effect, and nothing on screen says so. + it('omits region when the provider has none', async () => { + routeOssFetch(DOCKER); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', + ]); + expect(createCallBody()).not.toHaveProperty('region'); + }); + + it('omits scaleToZero when the provider cannot honour it', async () => { + routeOssFetch(DOCKER); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', '--always-on', + ]); + expect(createCallBody()).not.toHaveProperty('scaleToZero'); + }); + + // The gate must not be a blanket removal — a provider with regions still gets one. + it('still sends region to a provider that has regions', async () => { + routeOssFetch(); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await cmd.parseAsync([ + 'node', 'lim', 'compute', 'deploy', + '--image', 'nginx:alpine', '--name', 'web', '--port', '8080', '--region', 'lhr', + ]); + expect(createCallBody().region).toBe('lhr'); + }); + + it('refuses source mode when the provider cannot build at all', async () => { + routeOssFetch({ ...DOCKER, sourceBuild: 'none' }); + const cmd = new Command(); + cmd.exitOverride(); + registerComputeDeployCommand(cmd.command('compute')); + await expect( + cmd.parseAsync(['node', 'lim', 'compute', 'deploy', '.', '--name', 'web']) + ).rejects.toThrow(/cannot build from source/); + }); +}); diff --git a/src/commands/compute/deploy.ts b/src/commands/compute/deploy.ts index c99be92..5c421c4 100644 --- a/src/commands/compute/deploy.ts +++ b/src/commands/compute/deploy.ts @@ -12,6 +12,8 @@ import { ensureFlyctlAvailable, flyctlBuildAndPush, } from '../../lib/flyctl.js'; +import { fetchComputeCapabilities } from '../../lib/compute-capabilities.js'; +import { packBuildContext } from '../../lib/build-context.js'; // `compute deploy` has two modes: // @@ -44,7 +46,7 @@ export function registerComputeDeployCommand(computeCmd: Command): void { 'shared-1x' ) .option('--memory ', 'Memory in MB', '512') - .option('--region ', 'Fly.io region', 'iad') + .option('--region ', 'Region (providers that have more than one)', 'iad') .option('--env ', 'Env vars as JSON object') .option( '--env-file ', @@ -127,16 +129,36 @@ export function registerComputeDeployCommand(computeCmd: Command): void { envVars = parseEnvFile(resolve(opts.envFile)); } + // Ask the backend what its provider can do before shaping the request. A + // self-hosted Docker daemon has one region and no scale-to-zero, and + // sending those anyway records a choice that never takes effect. + const { provider, capabilities } = await fetchComputeCapabilities(); + if (!capabilities.regions && process.argv.includes('--region') && !json) { + outputInfo( + `Ignoring --region: the ${provider ?? 'configured'} provider runs on a single host.` + ); + } + if (!capabilities.scaleToZero && scaleToZero === false && !json) { + outputInfo( + `Ignoring --always-on: the ${provider ?? 'configured'} provider has no ` + + 'scale-to-zero, so services already run continuously.' + ); + } + const baseBody: Record = { name: opts.name, port, cpu: opts.cpu, memory, - region: opts.region, + // Omitted where it means nothing, so the stored row does not claim a + // region the provider never honoured. + ...(capabilities.regions ? { region: opts.region } : {}), }; if (envVars) baseBody.envVars = envVars; if (opts.protocol === 'tcp') baseBody.protocol = 'tcp'; - if (scaleToZero !== undefined) baseBody.scaleToZero = scaleToZero; + if (scaleToZero !== undefined && capabilities.scaleToZero) { + baseBody.scaleToZero = scaleToZero; + } // ─── Image mode ───────────────────────────────────────────────── if (!dir) { @@ -188,8 +210,16 @@ export function registerComputeDeployCommand(computeCmd: Command): void { return; } - // ─── Source mode (Path A) ─────────────────────────────────────── + // ─── Source mode ──────────────────────────────────────────────── const absDir = resolve(dir); + // Provider capability first: when it cannot build at all, whether a + // Dockerfile exists is beside the point. + if (capabilities.sourceBuild === 'none') { + throw new CLIError( + `The ${provider ?? 'configured'} compute provider cannot build from source.\n` + + ` Build the image yourself and deploy it with --image .` + ); + } const dockerfilePath = join(absDir, 'Dockerfile'); if (!existsSync(dockerfilePath)) { throw new CLIError( @@ -199,10 +229,89 @@ export function registerComputeDeployCommand(computeCmd: Command): void { ` • Use --image to deploy a pre-built image instead` ); } - ensureFlyctlAvailable(); - if (!json) outputInfo(`Detected Dockerfile at ${dockerfilePath}`); + // ─── Source mode via context upload (self-hosted Docker) ───────── + // + // The backend owns the build here because it has the daemon: upload the + // context and it builds, tags and deploys in one call. So there is no + // deploy token to mint, no flyctl on PATH, and no follow-up PATCH. + if (capabilities.sourceBuild === 'context-upload') { + const listRes = await ossFetch('/api/compute/services'); + const found = ((await listRes.json()) as Array<{ id: string; name: string }>).find( + (s) => s.name === opts.name + ); + + let serviceId: string; + if (found) { + serviceId = found.id; + if (!json) outputInfo(`Found existing service "${opts.name}", rebuilding...`); + } else { + if (!json) outputInfo(`Creating service "${opts.name}"...`); + const prepareRes = await ossFetch('/api/compute/services/deploy', { + method: 'POST', + body: JSON.stringify(baseBody), + }); + serviceId = ((await prepareRes.json()) as { id: string }).id; + } + + if (!json) outputInfo('Packing build context...'); + const { tar, fileCount } = await packBuildContext(absDir); + if (!json) { + const mb = (tar.length / 1024 / 1024).toFixed(1); + outputInfo(`Uploading ${fileCount} file(s), ${mb} MB...`); + } + + let buildRes; + try { + buildRes = await ossFetch( + `/api/compute/services/${encodeURIComponent(serviceId)}/build`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-tar' }, + body: tar, + } + ); + } catch (buildErr) { + // Same rollback rule as the flyctl path: clean up a service this + // command created, leave an existing one running. + if (!found) { + await ossFetch(`/api/compute/services/${encodeURIComponent(serviceId)}`, { + method: 'DELETE', + }).catch(() => undefined); + if (!json) outputInfo(`Rolled back service "${opts.name}" after build failure.`); + } + throw buildErr; + } + + const built = (await buildRes.json()) as { + service?: Record; + imageTag: string; + logs?: string[]; + }; + if (json) { + outputJson(built); + } else { + for (const line of built.logs ?? []) { + console.log(` ${String(line).trimEnd()}`); + } + const svc = built.service ?? {}; + const status = String(svc.status ?? 'running'); + outputSuccess(`Service "${String(svc.name ?? opts.name)}" deployed [${status}]`); + console.log(` Image: ${built.imageTag}`); + if (svc.endpointUrl) { + console.log(` Endpoint: ${String(svc.endpointUrl)}`); + } else { + console.log(` No public endpoint — reachable on the project's internal network.`); + } + } + await reportCliUsage('cli.compute.deploy', true); + return; + } + + // ─── Source mode via flyctl remote build (Fly.io) ──────────────── + ensureFlyctlAvailable(); + // 1. Resolve service: list → find by name → /deploy if missing const listRes = await ossFetch('/api/compute/services'); const existing = ((await listRes.json()) as Array<{ diff --git a/src/lib/build-context.test.ts b/src/lib/build-context.test.ts new file mode 100644 index 0000000..176a836 --- /dev/null +++ b/src/lib/build-context.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { packBuildContext } from './build-context.js'; + +/** Entry names in a tar, read from the 100-byte name field of each 512-byte header. */ +function tarEntryNames(tar: Buffer): string[] { + const names: string[] = []; + for (let offset = 0; offset + 512 <= tar.length; offset += 512) { + const name = tar + .subarray(offset, offset + 100) + .toString('utf8') + .replace(/\0+$/, ''); + if (!name) { + continue; + } + names.push(name); + const size = parseInt( + tar + .subarray(offset + 124, offset + 136) + .toString('utf8') + .replace(/\0+$/, '') + .trim() || '0', + 8, + ); + // Skip the file body, rounded up to the next 512-byte block. + offset += Math.ceil(size / 512) * 512; + } + return names; +} + +describe('packBuildContext', () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ctx-')); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('includes the tree by default, unlike the static-deploy bundler', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + // A Dockerfile may legitimately COPY any of these, so none may be dropped. + await fs.mkdir(path.join(dir, 'dist')); + await fs.writeFile(path.join(dir, 'dist', 'app.js'), 'x'); + await fs.mkdir(path.join(dir, 'node_modules')); + await fs.writeFile(path.join(dir, 'node_modules', 'dep.js'), 'y'); + + const { tar, fileCount } = await packBuildContext(dir); + const names = tarEntryNames(tar); + + expect(names).toContain('Dockerfile'); + expect(names).toContain('dist/app.js'); + expect(names).toContain('node_modules/dep.js'); + expect(fileCount).toBe(3); + }); + + it('honours .dockerignore, including negations', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + await fs.writeFile( + path.join(dir, '.dockerignore'), + 'secrets/\n*.log\n!keep.log\n', + ); + await fs.mkdir(path.join(dir, 'secrets')); + await fs.writeFile(path.join(dir, 'secrets', 'key.pem'), 'private'); + await fs.writeFile(path.join(dir, 'debug.log'), 'noise'); + await fs.writeFile(path.join(dir, 'keep.log'), 'wanted'); + + const names = tarEntryNames((await packBuildContext(dir)).tar); + + expect(names).not.toContain('secrets/key.pem'); + expect(names).not.toContain('debug.log'); + expect(names).toContain('keep.log'); + expect(names).toContain('Dockerfile'); + }); + + // Usually the largest thing in the tree, almost never needed by a build, and it + // carries every secret ever committed. + it('always excludes .git', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + await fs.mkdir(path.join(dir, '.git', 'objects'), { recursive: true }); + await fs.writeFile(path.join(dir, '.git', 'config'), '[core]'); + await fs.writeFile(path.join(dir, '.git', 'objects', 'blob'), 'data'); + + const names = tarEntryNames((await packBuildContext(dir)).tar); + + expect(names.some((n) => n.startsWith('.git'))).toBe(false); + expect(names).toContain('Dockerfile'); + }); + + it('produces a tar the daemon can read (512-byte blocks, name in the header)', async () => { + await fs.writeFile(path.join(dir, 'Dockerfile'), 'FROM alpine\n'); + const { tar } = await packBuildContext(dir); + + expect(tar.length % 512).toBe(0); + expect(tar.subarray(0, 10).toString('utf8')).toContain('Dockerfile'); + }); +}); diff --git a/src/lib/build-context.ts b/src/lib/build-context.ts new file mode 100644 index 0000000..d67dd32 --- /dev/null +++ b/src/lib/build-context.ts @@ -0,0 +1,87 @@ +// Pack a directory into the tar that POST /api/compute/services/:id/build expects. +// +// Deliberately *not* modelled on the deployments bundler, which strips +// node_modules, dist, build and friends. Those exclusions are right for a static +// site and wrong for a Docker build: a Dockerfile may legitimately COPY any of +// them, and silently dropping one produces a build that fails for a reason nothing +// on screen explains. Docker's own contract is 'everything, minus .dockerignore', +// so that is what this does. When the context is too big the backend answers 413 +// and names .dockerignore as the fix. + +import archiver from 'archiver'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import ignore from 'ignore'; + +export interface BuildContext { + tar: Buffer; + fileCount: number; +} + +/** `.dockerignore` matcher, or null when the file is absent. */ +async function loadDockerignore(dir: string) { + try { + const raw = await fs.readFile(path.join(dir, '.dockerignore'), 'utf8'); + // Docker's .dockerignore is gitignore-ish; `ignore` covers the syntax that + // matters here. `!` negations and `**` both work. + return ignore().add(raw); + } catch { + return null; + } +} + +/** + * Tar `dir` for upload. + * + * Built in memory because the backend accepts one buffered body anyway, and the + * ceiling it enforces (64MB by default) is well under what a CLI can hold. + */ +export async function packBuildContext(dir: string): Promise { + const matcher = await loadDockerignore(dir); + const archive = archiver('tar'); + const chunks: Buffer[] = []; + archive.on('data', (c: Buffer) => chunks.push(c)); + + const done = new Promise((resolve, reject) => { + archive.on('end', resolve); + archive.on('error', reject); + }); + + let fileCount = 0; + async function walk(current: string): Promise { + const entries = await fs.readdir(current, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const absolute = path.join(current, entry.name); + const relative = path.relative(dir, absolute).split(path.sep).join('/'); + if (!relative) { + continue; + } + // `.git` is the one thing excluded unconditionally: it is often the largest + // thing in the tree, a Dockerfile that needs it is vanishingly rare, and + // shipping it means shipping every secret ever committed. + if (relative === '.git' || relative.startsWith('.git/')) { + continue; + } + if (matcher?.ignores(entry.isDirectory() ? `${relative}/` : relative)) { + continue; + } + if (entry.isDirectory()) { + await walk(absolute); + continue; + } + if (!entry.isFile()) { + // Sockets, fifos and dangling symlinks have no meaning in a build context. + continue; + } + archive.file(absolute, { name: relative }); + fileCount++; + } + } + + await walk(dir); + void archive.finalize(); + await done; + + return { tar: Buffer.concat(chunks), fileCount }; +} diff --git a/src/lib/compute-capabilities.ts b/src/lib/compute-capabilities.ts new file mode 100644 index 0000000..d2913df --- /dev/null +++ b/src/lib/compute-capabilities.ts @@ -0,0 +1,74 @@ +// What the backend's configured compute provider can actually do. +// +// Compute is no longer Fly-only: a self-hoster can run containers on their own +// Docker daemon instead, where there are no regions, no scale-to-zero, and source +// builds happen by uploading a context to the backend rather than by this CLI +// shelling out to flyctl. Sending a Fly-shaped request to that backend does not +// fail loudly — the region is simply recorded and ignored — so the CLI asks first. +// +// Reported as the `compute` slice of /api/metadata. The slice is absent on a +// backend older than it and when no driver is configured, so every field has to +// degrade to the Fly-shaped behaviour this CLI has always had. + +import { ossFetch } from './api/oss.js'; + +export interface ComputeCapabilities { + scaleToZero: boolean; + regions: boolean; + ingressModes: string[]; + sourceBuild: 'none' | 'flyctl' | 'context-upload'; + deployTokenIssuance: boolean; +} + +/** Assumed shape for a backend that does not report capabilities: Fly. */ +const LEGACY_FLY: ComputeCapabilities = { + scaleToZero: true, + regions: true, + ingressModes: ['host'], + sourceBuild: 'flyctl', + deployTokenIssuance: true, +}; + +interface MetadataWithCompute { + compute?: { + defaultProvider?: string; + providers?: Record>; + }; +} + +/** + * Capabilities of the provider new services go to, plus its name. + * + * Never throws: a CLI that cannot deploy because a capability probe failed is + * worse than one that tries the way it always has. `provider` is null when the + * backend reported nothing, which callers can use to explain a fallback. + */ +export async function fetchComputeCapabilities(): Promise<{ + provider: string | null; + capabilities: ComputeCapabilities; +}> { + try { + const res = await ossFetch('/api/metadata'); + const meta = (await res.json()) as MetadataWithCompute; + const provider = meta.compute?.defaultProvider; + const reported = provider ? meta.compute?.providers?.[provider] : undefined; + if (!provider || !reported) { + return { provider: null, capabilities: LEGACY_FLY }; + } + // Field by field, so a backend that grows a capability this CLI does not know + // about — or omits one it does — still yields a complete object. + return { + provider, + capabilities: { + scaleToZero: reported.scaleToZero ?? LEGACY_FLY.scaleToZero, + regions: reported.regions ?? LEGACY_FLY.regions, + ingressModes: reported.ingressModes ?? LEGACY_FLY.ingressModes, + sourceBuild: reported.sourceBuild ?? LEGACY_FLY.sourceBuild, + deployTokenIssuance: + reported.deployTokenIssuance ?? LEGACY_FLY.deployTokenIssuance, + }, + }; + } catch { + return { provider: null, capabilities: LEGACY_FLY }; + } +}