diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index bc04e3ad17d..089549b460c 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' @@ -542,9 +543,21 @@ 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 }) await command.netlify.frameworksAPIPaths.functions.ensureExists() diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 272fb958c56..ea10937707a 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' @@ -73,6 +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)) .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.', @@ -114,6 +115,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/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index 675d051a980..cc8f4cd8f57 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -20,4 +20,5 @@ export type DeployOptionValues = BaseOptionValues & { team?: string 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..ca8b5fe4afb --- /dev/null +++ b/src/utils/deploy/upload-source-zip.ts @@ -0,0 +1,163 @@ +import { execFile } from 'child_process' +import { readFile } from 'fs/promises' +import { join } from 'path' +import { promisify } from 'util' +import type { PathLike } from 'fs' +import { platform } from 'os' + +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, + 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, filename) + + 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.statusText}`) + } +} + +export const uploadSourceZip = async ({ + sourceDir, + uploadUrl, + filename, + statusCb = () => {}, +}: UploadSourceZipOptions): Promise => { + let zipPath: PathLike | undefined + + try { + // Create zip from source directory + try { + zipPath = await createSourceZip({ sourceDir, filename, 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 + 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', + msg: `Source zip uploaded successfully`, + phase: 'stop', + }) + + log(`✔ Source code uploaded`) + } 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..273d1528c9f --- /dev/null +++ b/tests/unit/utils/deploy/upload-source-zip.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest' +import type { Response } from 'node-fetch' +import type { ChildProcess } from 'child_process' + +// Mock all dependencies at the top level +vi.mock('node-fetch', () => ({ + default: vi.fn(), +})) + +vi.mock('child_process', () => ({ + execFile: vi.fn(), +})) + +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 () => { + // 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') + + // 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 + }) + + 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(mockChildProcess.execFile).toHaveBeenCalledWith( + 'zip', + expect.arrayContaining(['-r', expect.stringMatching(/test-source\.zip$/), '.']), + expect.objectContaining({ + cwd: '/test/source', + maxBuffer: 104857600, + }), + expect.any(Function), + ) + + expect(mockFetch.default).toHaveBeenCalledWith( + 'https://s3.example.com/upload-url', + expect.objectContaining({ + method: 'PUT', + body: Buffer.from('mock zip content'), + }), + ) + + expect(mockStatusCb).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'source-zip-upload', + msg: 'Creating source zip...', + phase: 'start', + }), + ) + + expect(mockFs.unlink).toHaveBeenCalledWith(expect.stringMatching(/test-source\.zip$/)) + expect(mockCommandHelpers.log).toHaveBeenCalledWith('✔ Source code uploaded') + }) + + 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') + 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 + }) + + 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', + uploadUrl: 'https://s3.example.com/upload-url', + filename: 'test-source.zip', + statusCb: mockStatusCb, + }), + ).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 unknown as string, + }), + ) + + expect(mockCommandHelpers.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to upload source zip')) + }) + + 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') + 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 + }) + + 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(mockChildProcess.execFile).toHaveBeenCalledWith( + 'zip', + expect.arrayContaining(['-x', 'node_modules', '-x', '.git', '-x', '.netlify', '-x', '.env']), + expect.objectContaining({ + cwd: '/test/source', + maxBuffer: 104857600, + }), + 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', + }), + ) + }) +})