From c80113e454ebefe829b011310f5dd6626165a27f Mon Sep 17 00:00:00 2001 From: Netlify Date: Wed, 20 Aug 2025 22:33:08 +0300 Subject: [PATCH 1/9] feat: add flag to deploy command to upload zip --- src/commands/deploy/deploy.ts | 15 +- src/commands/deploy/index.ts | 6 + src/commands/deploy/option_values.ts | 1 + src/utils/deploy/upload-source-zip.ts | 134 +++++++++++++ .../utils/deploy/upload-source-zip.test.ts | 179 ++++++++++++++++++ 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/utils/deploy/upload-source-zip.ts create mode 100644 tests/unit/utils/deploy/upload-source-zip.test.ts diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 209ad35d0e0..be6b408c0f4 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -40,6 +40,7 @@ import { } from '../../utils/command-helpers.js' import { DEFAULT_DEPLOY_TIMEOUT } from '../../utils/deploy/constants.js' import { type DeployEvent, deploySite } from '../../utils/deploy/deploy-site.js' +import { uploadSourceZip } from '../../utils/deploy/upload-source-zip.js' import { getEnvelopeEnv } from '../../utils/env/index.js' import { getFunctionsManifestPath, getInternalFunctionsDir } from '../../utils/functions/index.js' import openBrowser from '../../utils/open-browser.js' @@ -465,8 +466,20 @@ const runDeploy = async ({ } const draft = !deployToProduction && !alias - results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } }) + const createDeployBody = { draft, branch: alias, include_upload_url: options.uploadSourceZip } + + results = await api.createSiteDeploy({ siteId, title, body: createDeployBody }) deployId = results.id + + // Handle source zip upload if requested and URL provided + if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) { + await uploadSourceZip({ + sourceDir: site.root, + uploadUrl: results.source_zip_upload_url, + filename: results.source_zip_filename, + statusCb: silent ? () => {} : deployProgressCb() + }) + } const internalFunctionsFolder = await getInternalFunctionsDir({ base: site.root, packagePath, ensureExists: true }) diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 3d0e7604576..087a60eed2c 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -129,6 +129,11 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co 'Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment', false, ) + .option( + '--upload-source-zip', + 'Upload source code as a zip file', + false, + ) .addExamples([ 'netlify deploy', 'netlify deploy --site my-first-project', @@ -140,6 +145,7 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', 'netlify deploy --context deploy-preview', + 'netlify deploy --upload-source-zip # Upload source code', ]) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/site-deploys/overview/' diff --git a/src/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index d2bd8c73eb9..37621f6d652 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -18,4 +18,5 @@ export type DeployOptionValues = BaseOptionValues & { skipFunctionsCache: boolean timeout?: number trigger?: boolean + uploadSourceZip?: boolean } diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts new file mode 100644 index 00000000000..f9e36cf6f85 --- /dev/null +++ b/src/utils/deploy/upload-source-zip.ts @@ -0,0 +1,134 @@ +import { execFile } from 'child_process' +import { readFile, stat } from 'fs/promises' +import { join, relative } from 'path' +import { promisify } from 'util' +import type { PathLike } from 'fs' + +import fetch from 'node-fetch' + +import { log, warn } from '../command-helpers.js' +import { temporaryDirectory } from '../temporary-file.js' +import type { DeployEvent } from './status-cb.js' + +const execFileAsync = promisify(execFile) + +interface UploadSourceZipOptions { + sourceDir: string + uploadUrl: string + filename: string + statusCb?: (status: DeployEvent) => void +} + +const DEFAULT_IGNORE_PATTERNS = [ + 'node_modules', + '.git', + '.netlify', + '.next', + 'dist', + 'build', + '.nuxt', + '.output', + '.vercel', + '__pycache__', + '.venv', + '.env', + '.DS_Store', + 'Thumbs.db', + '*.log', + '.nyc_output', + 'coverage', + '.cache', + '.tmp', + '.temp', +] + +const createSourceZip = async (sourceDir: string, statusCb: (status: DeployEvent) => void) => { + const tmpDir = temporaryDirectory() + const zipPath = join(tmpDir, 'source.zip') + + statusCb({ + type: 'source-zip-upload', + msg: `Creating source zip...`, + phase: 'start', + }) + + // Create exclusion list for zip command + const excludeArgs = DEFAULT_IGNORE_PATTERNS.flatMap((pattern) => ['-x', pattern]) + + // Use system zip command to create the archive + await execFileAsync('zip', ['-r', zipPath, '.', ...excludeArgs], { + cwd: sourceDir, + maxBuffer: 1024 * 1024 * 100, // 100MB buffer + }) + + return zipPath +} + +const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void): Promise => { + const zipBuffer = await readFile(zipPath) + const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2) + + statusCb({ + type: 'source-zip-upload', + msg: `Uploading source zip (${sizeMB} MB)...`, + phase: 'progress', + }) + + const response = await fetch(uploadUrl, { + method: 'PUT', + body: zipBuffer, + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': zipBuffer.length.toString(), + }, + }) + + if (!response.ok) { + throw new Error(`Failed to upload zip: ${response.status} ${response.statusText}`) + } +} + +export const uploadSourceZip = async ({ + sourceDir, + uploadUrl, + filename, + statusCb = () => {}, +}: UploadSourceZipOptions): Promise => { + let zipPath: PathLike | undefined + + try { + // Create zip from source directory + zipPath = await createSourceZip(sourceDir, statusCb) + + // Upload to S3 + await uploadZipToS3(zipPath, uploadUrl, statusCb) + + statusCb({ + type: 'source-zip-upload', + msg: `Source zip uploaded successfully`, + phase: 'stop', + }) + + log(`✔ Source code uploaded`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + + statusCb({ + type: 'source-zip-upload', + msg: `Failed to upload source zip: ${errorMsg}`, + phase: 'error', + }) + + warn(`Failed to upload source zip: ${errorMsg}`) + throw error + } finally { + // Clean up temporary zip file + if (zipPath) { + try { + await import('fs/promises').then((fs) => fs.unlink(zipPath as unknown as PathLike)) + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts new file mode 100644 index 00000000000..da66a337484 --- /dev/null +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, vi } from 'vitest' + +import { uploadSourceZip } from '../../../../src/utils/deploy/upload-source-zip.js' + +type ExecCallback = (error: Error | null, result: { stdout: string; stderr: string }) => void + +vi.mock('node-fetch') +vi.mock('child_process') +vi.mock('fs/promises') +vi.mock('../../../../src/utils/command-helpers.js') +vi.mock('../../../../src/utils/temporary-file.js') + +describe('uploadSourceZip', () => { + test('creates zip and uploads successfully', async () => { + const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { + callback(null, { stdout: '', stderr: '' }) + }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + }) + + const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) + const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) + const mockUnlink = vi.fn().mockResolvedValue(undefined) + const mockLog = vi.fn() + const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') + + vi.doMock('node-fetch', () => ({ default: mockFetch })) + vi.doMock('child_process', () => ({ execFile: mockExecFile })) + vi.doMock('fs/promises', () => ({ + readFile: mockReadFile, + stat: mockStat, + unlink: mockUnlink + })) + vi.doMock('../../../../src/utils/command-helpers.js', () => ({ log: mockLog })) + vi.doMock('../../../../src/utils/temporary-file.js', () => ({ + temporaryDirectory: mockTemporaryDirectory + })) + + const mockStatusCb = vi.fn() + + await uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }) + + expect(mockExecFile).toHaveBeenCalledWith( + 'zip', + expect.arrayContaining(['-r', '/tmp/test-temp-dir/source.zip', '.']), + expect.objectContaining({ cwd: '/test/source' }), + expect.any(Function), + ) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://s3.example.com/upload-url', + expect.objectContaining({ + method: 'PUT', + body: Buffer.from('mock zip content'), + headers: expect.objectContaining({ + 'Content-Type': 'application/zip', + 'Content-Length': '16', + }) as Record, + }), + ) + + expect(mockStatusCb).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'source-zip-upload', + msg: 'Creating source code zip...', + phase: 'start', + }), + ) + + expect(mockUnlink).toHaveBeenCalledWith('/tmp/test-temp-dir/source.zip') + expect(mockLog).toHaveBeenCalledWith('Source code uploaded to enable Netlify Agent Runners') + }) + + test('handles upload failure correctly', async () => { + const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { + callback(null, { stdout: '', stderr: '' }) + }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + }) + + const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) + const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) + const mockUnlink = vi.fn().mockResolvedValue(undefined) + const mockWarn = vi.fn() + const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') + + vi.doMock('node-fetch', () => ({ default: mockFetch })) + vi.doMock('child_process', () => ({ execFile: mockExecFile })) + vi.doMock('fs/promises', () => ({ + readFile: mockReadFile, + stat: mockStat, + unlink: mockUnlink + })) + vi.doMock('../../../../src/utils/command-helpers.js', () => ({ warn: mockWarn })) + vi.doMock('../../../../src/utils/temporary-file.js', () => ({ + temporaryDirectory: mockTemporaryDirectory + })) + + const mockStatusCb = vi.fn() + + await expect( + uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }), + ).rejects.toThrow('Failed to upload zip: 403 Forbidden') + + expect(mockStatusCb).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'source-zip-upload', + phase: 'error', + msg: expect.stringContaining('Failed to upload source zip') as string, + }), + ) + + expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('Failed to upload source zip')) + }) + + test('includes proper exclusion patterns in zip command', async () => { + const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { + callback(null, { stdout: '', stderr: '' }) + }) + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + }) + + const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) + const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) + const mockUnlink = vi.fn().mockResolvedValue(undefined) + const mockLog = vi.fn() + const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') + + vi.doMock('node-fetch', () => ({ default: mockFetch })) + vi.doMock('child_process', () => ({ execFile: mockExecFile })) + vi.doMock('fs/promises', () => ({ + readFile: mockReadFile, + stat: mockStat, + unlink: mockUnlink + })) + vi.doMock('../../../../src/utils/command-helpers.js', () => ({ log: mockLog })) + vi.doMock('../../../../src/utils/temporary-file.js', () => ({ + temporaryDirectory: mockTemporaryDirectory + })) + + const mockStatusCb = vi.fn() + + await uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }) + + expect(mockExecFile).toHaveBeenCalledWith( + 'zip', + expect.arrayContaining(['-x', 'node_modules', '.git', '.netlify', '.env']), + expect.objectContaining({ cwd: '/test/source' }), + expect.any(Function), + ) + }) +}) \ No newline at end of file From e2307fedc14443b15270e70e86ab1dfc16b3fa2a Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Thu, 21 Aug 2025 17:17:20 +0300 Subject: [PATCH 2/9] fix: improvements --- src/commands/deploy/index.ts | 9 +++--- src/utils/deploy/upload-source-zip.ts | 43 +++++++++++++++++++-------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 087a60eed2c..c394c0e76cc 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -129,10 +129,10 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co 'Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment', false, ) - .option( - '--upload-source-zip', - 'Upload source code as a zip file', - false, + .addOption( + new Option('--upload-source-zip', 'Upload source code as a zip file') + .default(false) + .hideHelp(true), ) .addExamples([ 'netlify deploy', @@ -145,7 +145,6 @@ Support for package.json's main field, and intrinsic index.js entrypoints are co 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', 'netlify deploy --context deploy-preview', - 'netlify deploy --upload-source-zip # Upload source code', ]) .addHelpText('after', () => { const docsUrl = 'https://docs.netlify.com/site-deploys/overview/' diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index f9e36cf6f85..51fd3ad1b40 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -3,6 +3,7 @@ import { readFile, stat } from 'fs/promises' import { join, relative } from 'path' import { promisify } from 'util' import type { PathLike } from 'fs' +import { platform } from 'os' import fetch from 'node-fetch' @@ -43,6 +44,11 @@ const DEFAULT_IGNORE_PATTERNS = [ ] const createSourceZip = async (sourceDir: string, statusCb: (status: DeployEvent) => void) => { + // Check for Windows - this feature is not supported on Windows + if (platform() === 'win32') { + throw new Error('Source zip upload is not supported on Windows') + } + const tmpDir = temporaryDirectory() const zipPath = join(tmpDir, 'source.zip') @@ -98,10 +104,32 @@ export const uploadSourceZip = async ({ try { // Create zip from source directory - zipPath = await createSourceZip(sourceDir, statusCb) + try { + zipPath = await createSourceZip(sourceDir, statusCb) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + statusCb({ + type: 'source-zip-upload', + msg: `Failed to create source zip: ${errorMsg}`, + phase: 'error', + }) + warn(`Failed to create source zip: ${errorMsg}`) + throw error + } // Upload to S3 - await uploadZipToS3(zipPath, uploadUrl, statusCb) + try { + await uploadZipToS3(zipPath, uploadUrl, statusCb) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + statusCb({ + type: 'source-zip-upload', + msg: `Failed to upload source zip: ${errorMsg}`, + phase: 'error', + }) + warn(`Failed to upload source zip: ${errorMsg}`) + throw error + } statusCb({ type: 'source-zip-upload', @@ -110,17 +138,6 @@ export const uploadSourceZip = async ({ }) log(`✔ Source code uploaded`) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - - statusCb({ - type: 'source-zip-upload', - msg: `Failed to upload source zip: ${errorMsg}`, - phase: 'error', - }) - - warn(`Failed to upload source zip: ${errorMsg}`) - throw error } finally { // Clean up temporary zip file if (zipPath) { From 8e63ff70296fed78349a991d3a0a63575477fe45 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Thu, 21 Aug 2025 18:03:53 +0300 Subject: [PATCH 3/9] fix: use filename from server --- src/commands/deploy/index.ts | 8 +++++++- src/utils/deploy/upload-source-zip.ts | 28 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 7fc8d76dc26..ca302c58583 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -1,4 +1,4 @@ -import { env } from 'process' +import { env, platform } from 'process' import { Option } from 'commander' import terminalLink from 'terminal-link' @@ -119,6 +119,12 @@ For more information about Netlify deploys, see ${terminalLink(docsUrl, docsUrl, return logAndThrowError('--team flag can only be used with --create-site flag') } + // Handle Windows + source zip upload + if (options.uploadSourceZip && platform === 'win32') { + warn('Source zip upload is not supported on Windows. Disabling --upload-source-zip option.') + options.uploadSourceZip = false + } + const { deploy } = await import('./deploy.js') await deploy(options, command) }) diff --git a/src/utils/deploy/upload-source-zip.ts b/src/utils/deploy/upload-source-zip.ts index 51fd3ad1b40..ca8b5fe4afb 100644 --- a/src/utils/deploy/upload-source-zip.ts +++ b/src/utils/deploy/upload-source-zip.ts @@ -1,6 +1,6 @@ import { execFile } from 'child_process' -import { readFile, stat } from 'fs/promises' -import { join, relative } from 'path' +import { readFile } from 'fs/promises' +import { join } from 'path' import { promisify } from 'util' import type { PathLike } from 'fs' import { platform } from 'os' @@ -43,14 +43,22 @@ const DEFAULT_IGNORE_PATTERNS = [ '.temp', ] -const createSourceZip = async (sourceDir: string, statusCb: (status: DeployEvent) => void) => { +const createSourceZip = async ({ + sourceDir, + filename, + statusCb, +}: { + sourceDir: string + filename: string + statusCb: (status: DeployEvent) => void +}) => { // Check for Windows - this feature is not supported on Windows if (platform() === 'win32') { throw new Error('Source zip upload is not supported on Windows') } const tmpDir = temporaryDirectory() - const zipPath = join(tmpDir, 'source.zip') + const zipPath = join(tmpDir, filename) statusCb({ type: 'source-zip-upload', @@ -70,10 +78,14 @@ const createSourceZip = async (sourceDir: string, statusCb: (status: DeployEvent return zipPath } -const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (status: DeployEvent) => void): Promise => { +const uploadZipToS3 = async ( + zipPath: string, + uploadUrl: string, + statusCb: (status: DeployEvent) => void, +): Promise => { const zipBuffer = await readFile(zipPath) const sizeMB = (zipBuffer.length / 1024 / 1024).toFixed(2) - + statusCb({ type: 'source-zip-upload', msg: `Uploading source zip (${sizeMB} MB)...`, @@ -90,7 +102,7 @@ const uploadZipToS3 = async (zipPath: string, uploadUrl: string, statusCb: (stat }) if (!response.ok) { - throw new Error(`Failed to upload zip: ${response.status} ${response.statusText}`) + throw new Error(`Failed to upload zip: ${response.statusText}`) } } @@ -105,7 +117,7 @@ export const uploadSourceZip = async ({ try { // Create zip from source directory try { - zipPath = await createSourceZip(sourceDir, statusCb) + zipPath = await createSourceZip({ sourceDir, filename, statusCb }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) statusCb({ From c41a581287bf741079101b371d38bce31433dcac Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 11:45:14 +0300 Subject: [PATCH 4/9] fix: tests --- .../utils/deploy/upload-source-zip.test.ts | 211 ++++++++++-------- 1 file changed, 112 insertions(+), 99 deletions(-) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index da66a337484..5837f30d1d2 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -1,116 +1,132 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, test, vi, beforeEach } from 'vitest' +import type { HeadersInit, Response } from 'node-fetch' +import type { ChildProcess } from 'child_process' -import { uploadSourceZip } from '../../../../src/utils/deploy/upload-source-zip.js' +// Mock all dependencies at the top level +vi.mock('node-fetch', () => ({ + default: vi.fn(), +})) -type ExecCallback = (error: Error | null, result: { stdout: string; stderr: string }) => void +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})) -vi.mock('node-fetch') -vi.mock('child_process') -vi.mock('fs/promises') -vi.mock('../../../../src/utils/command-helpers.js') -vi.mock('../../../../src/utils/temporary-file.js') +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + unlink: vi.fn(), +})) + +vi.mock('../../../../src/utils/command-helpers.js', () => ({ + log: vi.fn(), + warn: vi.fn(), +})) + +vi.mock('../../../../src/utils/temporary-file.js', () => ({ + temporaryDirectory: vi.fn(), +})) + +// Mock OS to return non-Windows platform to avoid platform checks +vi.mock('os', () => ({ + platform: vi.fn().mockReturnValue('darwin'), +})) describe('uploadSourceZip', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + test('creates zip and uploads successfully', async () => { - const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { - callback(null, { stdout: '', stderr: '' }) - }) - - const mockFetch = vi.fn().mockResolvedValue({ + // Import after mocks are set up + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + // Setup mocks using vi.mocked() + const mockFetch = await import('node-fetch') + const mockChildProcess = await import('child_process') + const mockFs = await import('fs/promises') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + vi.mocked(mockFetch.default).mockResolvedValue({ ok: true, status: 200, statusText: 'OK', + } as unknown as Response) + + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(null, '', '') + } + return {} as ChildProcess }) - const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) - const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) - const mockUnlink = vi.fn().mockResolvedValue(undefined) - const mockLog = vi.fn() - const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') - - vi.doMock('node-fetch', () => ({ default: mockFetch })) - vi.doMock('child_process', () => ({ execFile: mockExecFile })) - vi.doMock('fs/promises', () => ({ - readFile: mockReadFile, - stat: mockStat, - unlink: mockUnlink - })) - vi.doMock('../../../../src/utils/command-helpers.js', () => ({ log: mockLog })) - vi.doMock('../../../../src/utils/temporary-file.js', () => ({ - temporaryDirectory: mockTemporaryDirectory - })) + vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) + vi.mocked(mockCommandHelpers.log).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') const mockStatusCb = vi.fn() - + await uploadSourceZip({ sourceDir: '/test/source', uploadUrl: 'https://s3.example.com/upload-url', filename: 'test-source.zip', statusCb: mockStatusCb, }) - - expect(mockExecFile).toHaveBeenCalledWith( + + expect(mockChildProcess.execFile).toHaveBeenCalledWith( 'zip', - expect.arrayContaining(['-r', '/tmp/test-temp-dir/source.zip', '.']), + expect.arrayContaining(['-r', '/tmp/test-temp-dir/test-source.zip', '.']), expect.objectContaining({ cwd: '/test/source' }), expect.any(Function), ) - - expect(mockFetch).toHaveBeenCalledWith( + + expect(mockFetch.default).toHaveBeenCalledWith( 'https://s3.example.com/upload-url', expect.objectContaining({ method: 'PUT', body: Buffer.from('mock zip content'), - headers: expect.objectContaining({ - 'Content-Type': 'application/zip', - 'Content-Length': '16', - }) as Record, }), ) - + expect(mockStatusCb).toHaveBeenCalledWith( expect.objectContaining({ type: 'source-zip-upload', - msg: 'Creating source code zip...', + msg: 'Creating source zip...', phase: 'start', }), ) - - expect(mockUnlink).toHaveBeenCalledWith('/tmp/test-temp-dir/source.zip') - expect(mockLog).toHaveBeenCalledWith('Source code uploaded to enable Netlify Agent Runners') + + expect(mockFs.unlink).toHaveBeenCalledWith('/tmp/test-temp-dir/test-source.zip') + expect(mockCommandHelpers.log).toHaveBeenCalledWith('✔ Source code uploaded') }) test('handles upload failure correctly', async () => { - const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { - callback(null, { stdout: '', stderr: '' }) - }) - - const mockFetch = vi.fn().mockResolvedValue({ + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockFetch = await import('node-fetch') + const mockChildProcess = await import('child_process') + const mockFs = await import('fs/promises') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + vi.mocked(mockFetch.default).mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden', + } as unknown as Response) + + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(null, '', '') + } + return {} as ChildProcess }) - const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) - const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) - const mockUnlink = vi.fn().mockResolvedValue(undefined) - const mockWarn = vi.fn() - const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') - - vi.doMock('node-fetch', () => ({ default: mockFetch })) - vi.doMock('child_process', () => ({ execFile: mockExecFile })) - vi.doMock('fs/promises', () => ({ - readFile: mockReadFile, - stat: mockStat, - unlink: mockUnlink - })) - vi.doMock('../../../../src/utils/command-helpers.js', () => ({ warn: mockWarn })) - vi.doMock('../../../../src/utils/temporary-file.js', () => ({ - temporaryDirectory: mockTemporaryDirectory - })) + vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) + vi.mocked(mockCommandHelpers.warn).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') const mockStatusCb = vi.fn() - + await expect( uploadSourceZip({ sourceDir: '/test/source', @@ -118,62 +134,59 @@ describe('uploadSourceZip', () => { filename: 'test-source.zip', statusCb: mockStatusCb, }), - ).rejects.toThrow('Failed to upload zip: 403 Forbidden') - + ).rejects.toThrow('Failed to upload zip: Forbidden') + expect(mockStatusCb).toHaveBeenCalledWith( expect.objectContaining({ type: 'source-zip-upload', phase: 'error', - msg: expect.stringContaining('Failed to upload source zip') as string, + msg: expect.stringContaining('Failed to upload source zip') as unknown as string, }), ) - - expect(mockWarn).toHaveBeenCalledWith(expect.stringContaining('Failed to upload source zip')) + + expect(mockCommandHelpers.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to upload source zip')) }) test('includes proper exclusion patterns in zip command', async () => { - const mockExecFile = vi.fn((_command, _args, _options, callback: ExecCallback) => { - callback(null, { stdout: '', stderr: '' }) - }) - - const mockFetch = vi.fn().mockResolvedValue({ + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockFetch = await import('node-fetch') + const mockChildProcess = await import('child_process') + const mockFs = await import('fs/promises') + const mockCommandHelpers = await import('../../../../src/utils/command-helpers.js') + const mockTempFile = await import('../../../../src/utils/temporary-file.js') + + vi.mocked(mockFetch.default).mockResolvedValue({ ok: true, status: 200, statusText: 'OK', + } as unknown as Response) + + vi.mocked(mockChildProcess.execFile).mockImplementation((_command, _args, _options, callback) => { + if (callback) { + callback(null, '', '') + } + return {} as ChildProcess }) - const mockReadFile = vi.fn().mockResolvedValue(Buffer.from('mock zip content')) - const mockStat = vi.fn().mockResolvedValue({ size: 1024 }) - const mockUnlink = vi.fn().mockResolvedValue(undefined) - const mockLog = vi.fn() - const mockTemporaryDirectory = vi.fn().mockReturnValue('/tmp/test-temp-dir') - - vi.doMock('node-fetch', () => ({ default: mockFetch })) - vi.doMock('child_process', () => ({ execFile: mockExecFile })) - vi.doMock('fs/promises', () => ({ - readFile: mockReadFile, - stat: mockStat, - unlink: mockUnlink - })) - vi.doMock('../../../../src/utils/command-helpers.js', () => ({ log: mockLog })) - vi.doMock('../../../../src/utils/temporary-file.js', () => ({ - temporaryDirectory: mockTemporaryDirectory - })) + vi.mocked(mockFs.readFile).mockResolvedValue(Buffer.from('mock zip content')) + vi.mocked(mockCommandHelpers.log).mockImplementation(() => {}) + vi.mocked(mockTempFile.temporaryDirectory).mockReturnValue('/tmp/test-temp-dir') const mockStatusCb = vi.fn() - + await uploadSourceZip({ sourceDir: '/test/source', uploadUrl: 'https://s3.example.com/upload-url', filename: 'test-source.zip', statusCb: mockStatusCb, }) - - expect(mockExecFile).toHaveBeenCalledWith( + + expect(mockChildProcess.execFile).toHaveBeenCalledWith( 'zip', expect.arrayContaining(['-x', 'node_modules', '.git', '.netlify', '.env']), expect.objectContaining({ cwd: '/test/source' }), expect.any(Function), ) }) -}) \ No newline at end of file +}) From 707a287ecfccea7854b8cd894087be1f1b99e22d Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 11:45:30 +0300 Subject: [PATCH 5/9] fix: tests --- tests/unit/utils/deploy/upload-source-zip.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 5837f30d1d2..5a735349d3a 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi, beforeEach } from 'vitest' -import type { HeadersInit, Response } from 'node-fetch' +import type { Response } from 'node-fetch' import type { ChildProcess } from 'child_process' // Mock all dependencies at the top level From b966c083a45806ba198411380e107ed5f8681e8a Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 11:48:01 +0300 Subject: [PATCH 6/9] fix: list --- src/commands/deploy/deploy.ts | 4 ++-- src/commands/deploy/index.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 07693f33f43..089549b460c 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -547,14 +547,14 @@ const runDeploy = async ({ results = await api.createSiteDeploy({ siteId, title, body: createDeployBody }) deployId = results.id - + // Handle source zip upload if requested and URL provided if (options.uploadSourceZip && results.source_zip_upload_url && results.source_zip_filename) { await uploadSourceZip({ sourceDir: site.root, uploadUrl: results.source_zip_upload_url, filename: results.source_zip_filename, - statusCb: silent ? () => {} : deployProgressCb() + statusCb: silent ? () => {} : deployProgressCb(), }) } diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index ca302c58583..ea10937707a 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -73,11 +73,7 @@ For detailed configuration options, see the Netlify documentation.`, 'Ignore any functions created as part of a previous `build` or `deploy` commands, forcing them to be bundled again as part of the deployment', false, ) - .addOption( - new Option('--upload-source-zip', 'Upload source code as a zip file') - .default(false) - .hideHelp(true), - ) + .addOption(new Option('--upload-source-zip', 'Upload source code as a zip file').default(false).hideHelp(true)) .option( '--create-site [name]', 'Create a new site and deploy to it. Optionally specify a name, otherwise a random name will be generated. Requires --team flag if you have multiple teams.', From 68e6301cbf4bb709afd0a25127d60556e4285621 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 11:57:14 +0300 Subject: [PATCH 7/9] fix: tests --- .../utils/deploy/upload-source-zip.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 5a735349d3a..5efaee6ef9d 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -36,6 +36,10 @@ describe('uploadSourceZip', () => { }) test('creates zip and uploads successfully', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + // Import after mocks are set up const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') @@ -100,6 +104,10 @@ describe('uploadSourceZip', () => { }) test('handles upload failure correctly', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') @@ -148,6 +156,10 @@ describe('uploadSourceZip', () => { }) test('includes proper exclusion patterns in zip command', async () => { + // Ensure OS platform mock returns non-Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('darwin') + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') @@ -189,4 +201,32 @@ describe('uploadSourceZip', () => { expect.any(Function), ) }) + + test('throws error on Windows platform', async () => { + // Mock OS platform to return Windows + const mockOs = await import('os') + vi.mocked(mockOs.platform).mockReturnValue('win32') + + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') + + const mockStatusCb = vi.fn() + + await expect( + uploadSourceZip({ + sourceDir: '/test/source', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }), + ).rejects.toThrow('Source zip upload is not supported on Windows') + + // Should call error status callback + expect(mockStatusCb).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'source-zip-upload', + phase: 'error', + msg: 'Failed to create source zip: Source zip upload is not supported on Windows', + }), + ) + }) }) From 60f3621e061b43606d438338e27a64db932445d3 Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 11:58:55 +0300 Subject: [PATCH 8/9] fix: list --- tests/unit/utils/deploy/upload-source-zip.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 5efaee6ef9d..19eac4c4361 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -39,7 +39,7 @@ describe('uploadSourceZip', () => { // Ensure OS platform mock returns non-Windows const mockOs = await import('os') vi.mocked(mockOs.platform).mockReturnValue('darwin') - + // Import after mocks are set up const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') @@ -107,7 +107,7 @@ describe('uploadSourceZip', () => { // Ensure OS platform mock returns non-Windows const mockOs = await import('os') vi.mocked(mockOs.platform).mockReturnValue('darwin') - + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') @@ -159,7 +159,7 @@ describe('uploadSourceZip', () => { // Ensure OS platform mock returns non-Windows const mockOs = await import('os') vi.mocked(mockOs.platform).mockReturnValue('darwin') - + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') const mockFetch = await import('node-fetch') @@ -206,11 +206,11 @@ describe('uploadSourceZip', () => { // Mock OS platform to return Windows const mockOs = await import('os') vi.mocked(mockOs.platform).mockReturnValue('win32') - + const { uploadSourceZip } = await import('../../../../src/utils/deploy/upload-source-zip.js') - + const mockStatusCb = vi.fn() - + await expect( uploadSourceZip({ sourceDir: '/test/source', From 0d2e7eef6de8011232ac2dd4427f3ef3d5ec972e Mon Sep 17 00:00:00 2001 From: Artem Denysov Date: Fri, 22 Aug 2025 12:03:49 +0300 Subject: [PATCH 9/9] fix: test --- .../unit/utils/deploy/upload-source-zip.test.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unit/utils/deploy/upload-source-zip.test.ts b/tests/unit/utils/deploy/upload-source-zip.test.ts index 19eac4c4361..273d1528c9f 100644 --- a/tests/unit/utils/deploy/upload-source-zip.test.ts +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -78,8 +78,11 @@ describe('uploadSourceZip', () => { expect(mockChildProcess.execFile).toHaveBeenCalledWith( 'zip', - expect.arrayContaining(['-r', '/tmp/test-temp-dir/test-source.zip', '.']), - expect.objectContaining({ cwd: '/test/source' }), + expect.arrayContaining(['-r', expect.stringMatching(/test-source\.zip$/), '.']), + expect.objectContaining({ + cwd: '/test/source', + maxBuffer: 104857600, + }), expect.any(Function), ) @@ -99,7 +102,7 @@ describe('uploadSourceZip', () => { }), ) - expect(mockFs.unlink).toHaveBeenCalledWith('/tmp/test-temp-dir/test-source.zip') + expect(mockFs.unlink).toHaveBeenCalledWith(expect.stringMatching(/test-source\.zip$/)) expect(mockCommandHelpers.log).toHaveBeenCalledWith('✔ Source code uploaded') }) @@ -196,8 +199,11 @@ describe('uploadSourceZip', () => { expect(mockChildProcess.execFile).toHaveBeenCalledWith( 'zip', - expect.arrayContaining(['-x', 'node_modules', '.git', '.netlify', '.env']), - expect.objectContaining({ cwd: '/test/source' }), + expect.arrayContaining(['-x', 'node_modules', '-x', '.git', '-x', '.netlify', '-x', '.env']), + expect.objectContaining({ + cwd: '/test/source', + maxBuffer: 104857600, + }), expect.any(Function), ) })