From bda4e6f6980c5b2bb5f95f2923e1b42f33e73a5e Mon Sep 17 00:00:00 2001 From: Semy Ingle Date: Wed, 13 May 2026 17:58:12 +0100 Subject: [PATCH] Implement Firebase deploy target --- .../targets/deploy-firebase/src/index.test.ts | 75 ++++++++++++++++++- packages/targets/deploy-firebase/src/index.ts | 70 +++++++++++++++-- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/packages/targets/deploy-firebase/src/index.test.ts b/packages/targets/deploy-firebase/src/index.test.ts index c2877a6c..5e32eb0a 100644 --- a/packages/targets/deploy-firebase/src/index.test.ts +++ b/packages/targets/deploy-firebase/src/index.test.ts @@ -1,4 +1,77 @@ -import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'deploy', requireKind: true }); + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('Firebase deployment target', () => { + it('writes a deploy plan with the resolved Firebase CLI command', async () => { + const outDir = await mkdtemp(join(tmpdir(), 'sh1pt-firebase-')); + const projectDir = await mkdtemp(join(tmpdir(), 'sh1pt-project-')); + tempDirs.push(outDir, projectDir); + + const result = await adapter.build(fakeBuildContext({ + outDir, + projectDir, + version: '1.2.3', + }) as any, { + projectId: 'my-firebase-project', + only: ['hosting', 'functions'], + config: 'firebase.json', + message: 'release 1.2.3', + }); + + expect(result.artifact).toBe(join(outDir, 'firebase-deploy.json')); + const plan = JSON.parse(await readFile(result.artifact, 'utf-8')); + expect(plan.provider).toBe('firebase'); + expect(plan.projectId).toBe('my-firebase-project'); + expect(plan.only).toEqual(['hosting', 'functions']); + expect(plan.config).toBe(join(projectDir, 'firebase.json')); + expect(plan.command).toEqual([ + 'npx', + '--yes', + 'firebase-tools', + 'deploy', + '--project', + 'my-firebase-project', + '--json', + '--only', + 'hosting,functions', + '--config', + join(projectDir, 'firebase.json'), + '--message', + 'release 1.2.3', + ]); + }); + + it('keeps dry-run shipping side-effect free', async () => { + await expect(adapter.ship(fakeShipContext({ + dryRun: true, + }) as any, { + projectId: 'my-firebase-project', + only: ['hosting'], + })).resolves.toMatchObject({ + id: 'dry-run', + meta: { + command: expect.arrayContaining(['firebase-tools', 'deploy', '--only', 'hosting']), + }, + }); + }); + + it('requires a vault token for real deployments', async () => { + await expect(adapter.ship(fakeShipContext({ + dryRun: false, + }) as any, { + projectId: 'my-firebase-project', + })).rejects.toThrow('FIREBASE_TOKEN not in vault'); + }); +}); diff --git a/packages/targets/deploy-firebase/src/index.ts b/packages/targets/deploy-firebase/src/index.ts index e98311bb..f82c93ef 100644 --- a/packages/targets/deploy-firebase/src/index.ts +++ b/packages/targets/deploy-firebase/src/index.ts @@ -1,23 +1,83 @@ -import { defineTarget, manualSetup } from '@profullstack/sh1pt-core'; +import { defineTarget, exec, manualSetup } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; interface Config { projectId: string; only?: string[]; + config?: string; + message?: string; +} + +function configPath(ctx: { projectDir: string }, config: Config): string | undefined { + if (!config.config) return undefined; + return isAbsolute(config.config) ? config.config : join(ctx.projectDir, config.config); +} + +function deployArgs(ctx: { projectDir: string }, config: Config, token?: string): string[] { + const args = ['--yes', 'firebase-tools', 'deploy', '--project', config.projectId, '--json']; + if (config.only?.length) args.push('--only', config.only.join(',')); + const firebaseConfig = configPath(ctx, config); + if (firebaseConfig) args.push('--config', firebaseConfig); + if (config.message) args.push('--message', config.message); + if (token) args.push('--token', token); + return args; +} + +function renderPlan(ctx: { projectDir: string; version: string }, config: Config): string { + return `${JSON.stringify({ + provider: 'firebase', + projectId: config.projectId, + only: config.only ?? [], + config: configPath(ctx, config) ?? null, + version: ctx.version, + command: ['npx', ...deployArgs(ctx, config)], + }, null, 2)}\n`; +} + +function parseDeployUrl(stdout: string, projectId: string): string { + try { + const data = JSON.parse(stdout) as { result?: { hosting?: Record } }; + const hosting = data.result?.hosting; + const firstSite = hosting ? Object.values(hosting)[0] as { url?: string } | undefined : undefined; + if (firstSite?.url) return firstSite.url; + } catch { + // Keep the stable Firebase Hosting fallback below. + } + return `https://${projectId}.web.app`; } export default defineTarget({ id: 'deploy-firebase', kind: 'web', label: 'Firebase Hosting / Functions', - async build(ctx) { + async build(ctx, config) { + const planPath = join(ctx.outDir, 'firebase-deploy.json'); ctx.log('firebase emulators:exec --only hosting,functions'); - return { artifact: `${ctx.outDir}/firebase-build` }; + await mkdir(ctx.outDir, { recursive: true }); + await writeFile(planPath, renderPlan(ctx, config), 'utf-8'); + return { artifact: planPath }; }, async ship(ctx, config) { const only = config.only?.length ? ` --only ${config.only.join(',')}` : ''; ctx.log(`firebase deploy --project ${config.projectId}${only}`); - if (ctx.dryRun) return { id: 'dry-run' }; - return { id: `${config.projectId}@${ctx.version}`, url: `https://${config.projectId}.web.app` }; + if (ctx.dryRun) return { id: 'dry-run', meta: { command: ['npx', ...deployArgs(ctx, config)] } }; + + const token = ctx.secret('FIREBASE_TOKEN'); + if (!token) { + throw new Error('FIREBASE_TOKEN not in vault - run: sh1pt secret set FIREBASE_TOKEN '); + } + + const result = await exec('npx', deployArgs(ctx, config, token), { + cwd: ctx.projectDir, + env: { ...ctx.env, FIREBASE_TOKEN: token }, + log: ctx.log, + throwOnNonZero: true, + }); + return { + id: `${config.projectId}@${ctx.version}`, + url: parseDeployUrl(result.stdout, config.projectId), + }; }, setup: manualSetup({ label: 'Firebase CLI',