From 021e9ac148c5a98131cb35b2cfa3dfb46e63960a Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 17:04:28 -0600 Subject: [PATCH] Validate Netlify deploy config --- .../targets/deploy-netlify/src/index.test.ts | 20 ++++++++++++ packages/targets/deploy-netlify/src/index.ts | 31 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/targets/deploy-netlify/src/index.test.ts b/packages/targets/deploy-netlify/src/index.test.ts index e61a5fbc..6ae82029 100644 --- a/packages/targets/deploy-netlify/src/index.test.ts +++ b/packages/targets/deploy-netlify/src/index.test.ts @@ -68,6 +68,26 @@ describe('Netlify deployment target', () => { }); }); + it('rejects invalid Netlify config before plan or CLI work', async () => { + await expect(adapter.build(fakeBuildContext() as any, { + dir: ' ', + })).rejects.toThrow('deploy-netlify requires dir'); + + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: true, + }) as any, { + siteId: 'site/123', + })).rejects.toThrow('siteId must be a single URL path segment'); + + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: true, + }) as any, { + message: ' ', + })).rejects.toThrow('deploy-netlify requires message'); + }); + it('requires a vault token for real deployments', async () => { await expect(adapter.ship(fakeShipContext({ version: '1.2.3', diff --git a/packages/targets/deploy-netlify/src/index.ts b/packages/targets/deploy-netlify/src/index.ts index 899dc31d..a6424a2c 100644 --- a/packages/targets/deploy-netlify/src/index.ts +++ b/packages/targets/deploy-netlify/src/index.ts @@ -9,12 +9,41 @@ interface Config { message?: string; } +function requireText(value: string | undefined, field: string): string { + const text = value?.trim(); + if (!text) throw new Error(`deploy-netlify requires ${field}`); + return text; +} + +function optionalText(value: string | undefined, field: string): string | undefined { + return value === undefined ? undefined : requireText(value, field); +} + +function optionalSiteId(value: string | undefined): string | undefined { + const id = optionalText(value, 'siteId'); + if (id && /[\\/?#\x00-\x1F\x7F]/.test(id)) { + throw new Error('deploy-netlify siteId must be a single URL path segment'); + } + return id; +} + +function normalizedConfig(config: Config): Config { + return { + ...config, + siteId: optionalSiteId(config.siteId), + dir: optionalText(config.dir, 'dir'), + message: optionalText(config.message, 'message'), + }; +} + function deployDir(ctx: { projectDir: string }, config: Config): string { + config = normalizedConfig(config); if (!config.dir) return ctx.projectDir; return isAbsolute(config.dir) ? config.dir : join(ctx.projectDir, config.dir); } function deployArgs(ctx: { channel: string; projectDir: string; version: string }, config: Config, token?: string): string[] { + config = normalizedConfig(config); const prod = config.prod ?? ctx.channel === 'stable'; const args = ['--yes', 'netlify-cli', 'deploy', '--json', '--dir', deployDir(ctx, config)]; if (prod) args.push('--prod'); @@ -26,6 +55,7 @@ function deployArgs(ctx: { channel: string; projectDir: string; version: string } function renderPlan(ctx: { channel: string; projectDir: string; version: string }, config: Config): string { + config = normalizedConfig(config); const prod = config.prod ?? ctx.channel === 'stable'; return `${JSON.stringify({ provider: 'netlify', @@ -66,6 +96,7 @@ export default defineTarget({ return { artifact: planPath }; }, async ship(ctx, config) { + config = normalizedConfig(config); const prod = config.prod ?? ctx.channel === 'stable'; ctx.log(`netlify deploy ${prod ? '--prod' : ''} ยท site=${config.siteId ?? 'linked'}`); if (ctx.dryRun) return { id: 'dry-run', meta: { command: ['npx', ...deployArgs(ctx, config)] } };