Skip to content
15 changes: 14 additions & 1 deletion src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@denar90 I think it would be good to add something here that shows a warning and then quits for windows users if they are using this new flag, since I think they are not able to use the zip functionality

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, I moved on command level and default falg to false


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()
Expand Down
9 changes: 8 additions & 1 deletion src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { env } from 'process'
import { env, platform } from 'process'

import { Option } from 'commander'
import terminalLink from 'terminal-link'
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -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)
})
1 change: 1 addition & 0 deletions src/commands/deploy/option_values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export type DeployOptionValues = BaseOptionValues & {
team?: string
timeout?: number
trigger?: boolean
uploadSourceZip?: boolean
}
163 changes: 163 additions & 0 deletions src/utils/deploy/upload-source-zip.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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
}
}
}
}
Loading
Loading