Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/commands/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
uploadDropFiles,
waitForDropDeploy,
} from '../../utils/deploy/drop-api.js'
import type { UploadFile } from '../../utils/deploy/upload-files.js'
import { getUploadList } from '../../utils/deploy/util.js'
import hashFiles from '../../utils/deploy/hash-files.js'
import { deployFileNormalizer, getEdgeFunctionsDistPathIfExists } from '../../utils/deploy/process-files.js'
Expand Down Expand Up @@ -583,6 +584,7 @@ const runDeploy = async ({
functionLogsUrl: string
edgeFunctionLogsUrl: string
sourceZipFileName?: string
uploadList: UploadFile[]
}> => {
let results
let deployId = existingDeployId
Expand Down Expand Up @@ -710,6 +712,7 @@ const runDeploy = async ({
functionLogsUrl,
edgeFunctionLogsUrl,
sourceZipFileName: uploadSourceZipResult?.sourceZipFileName,
uploadList: results.uploadList,
}
}

Expand Down Expand Up @@ -832,20 +835,65 @@ interface JsonData {
edge_function_logs: string
url?: string
source_zip_filename?: string
uploaded_files?: string[]
uploaded_functions?: string[]
uploaded_edge_functions?: string[]
}

const printResults = ({

export const printUploadedAssets = (uploadList: UploadFile[]): void => {
const staticFiles = uploadList.filter((f) => f.assetType === 'file').map((f) => f.normalizedPath)
const functions = uploadList.filter((f) => f.assetType === 'function').map((f) => f.normalizedPath)
const edgeFunctions = uploadList.filter((f) => f.assetType === 'edge-function').map((f) => f.normalizedPath)

log('')
log(chalk.cyanBright.bold(`Uploaded assets (${uploadList.length} total)`))
log('')

log(` Static files (${staticFiles.length}):`)
if (staticFiles.length === 0) {
log(' (none)')
} else {
for (const file of staticFiles) {
log(` ${file}`)
}
}
log('')

log(` Functions (${functions.length}):`)
if (functions.length === 0) {
log(' (none)')
} else {
for (const fn of functions) {
log(` ${fn}`)
}
}
log('')

log(` Edge functions (${edgeFunctions.length}):`)
if (edgeFunctions.length === 0) {
log(' (none)')
} else {
for (const ef of edgeFunctions) {
log(` ${ef}`)
}
}
}

export const printResults = ({
deployToProduction,
uploadSourceZip,
json,
results,
runBuildCommand,
showUploaded,
}: {
deployToProduction: boolean
uploadSourceZip: boolean
json: boolean
results: Awaited<ReturnType<typeof prepAndRunDeploy>>
runBuildCommand: boolean
showUploaded: boolean
}): void => {
const msgData: Record<string, string> = {
'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }),
Expand Down Expand Up @@ -876,6 +924,18 @@ const printResults = ({
jsonData.source_zip_filename = results.sourceZipFileName
}

if (showUploaded) {
jsonData.uploaded_files = results.uploadList
.filter((f) => f.assetType === 'file')
.map((f) => f.normalizedPath)
jsonData.uploaded_functions = results.uploadList
.filter((f) => f.assetType === 'function')
.map((f) => f.normalizedPath)
jsonData.uploaded_edge_functions = results.uploadList
.filter((f) => f.assetType === 'edge-function')
.map((f) => f.normalizedPath)
}

logJson(jsonData)
exit(0)
} else if (!isInteractive()) {
Expand All @@ -889,6 +949,10 @@ const printResults = ({
log(`Function logs: <${results.functionLogsUrl}>`)
log(`Edge function logs: <${results.edgeFunctionLogsUrl}>`)

if (showUploaded) {
printUploadedAssets(results.uploadList)
}

if (!deployToProduction) {
log()
log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:')
Expand Down Expand Up @@ -917,6 +981,10 @@ const printResults = ({

log(prettyjson.render(msgData))

if (showUploaded) {
printUploadedAssets(results.uploadList)
}

if (!deployToProduction) {
log()
log('If everything looks good on your draft URL, deploy it to your main project URL with the --prod flag:')
Expand Down Expand Up @@ -1478,6 +1546,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand)
results,
deployToProduction,
uploadSourceZip: !!options.uploadSourceZip,
showUploaded: !!options.showUploaded,
})

if (options.open) {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ For detailed configuration options, see the Netlify documentation.`,
false,
)
.option('--created-via <source>', 'Specify the source of the deploy (e.g., "cli", "drop")')
.option('--show-uploaded', 'Show list of files uploaded to the CDN during this deploy')
.addExamples([
'netlify deploy',
'netlify deploy --site my-first-project',
Expand All @@ -125,6 +126,7 @@ For detailed configuration options, see the Netlify documentation.`,
'netlify deploy --env "NODE_ENV=production" --secret-env "DATABASE_PASSWORD=$DB_PASSWORD"',
'netlify deploy --site-name my-new-site --team my-team # Create site and deploy',
'netlify deploy --allow-anonymous --dir ./public --no-build # Deploy without auth',
'netlify deploy --show-uploaded # Show which files were uploaded to the CDN',
])
.addHelpText('after', () => {
const docsUrl = 'https://docs.netlify.com/site-deploys/overview/'
Expand Down
1 change: 1 addition & 0 deletions src/commands/deploy/option_values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type DeployOptionValues = BaseOptionValues & {
prod: boolean
prodIfUnlocked: boolean
secretEnv?: DeployEnvironmentVariable[]
showUploaded?: boolean
site?: string
siteName?: string
skipFunctionsCache: boolean
Expand Down
226 changes: 226 additions & 0 deletions tests/unit/commands/deploy/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { describe, expect, test, vi, beforeEach } from 'vitest'

const { logMessages, jsonMessages } = vi.hoisted(() => {
const logMessages: string[] = []
const jsonMessages: unknown[] = []
return { logMessages, jsonMessages }
})

vi.mock('../../../../src/utils/command-helpers.js', async () => ({
...(await vi.importActual('../../../../src/utils/command-helpers.js')),
log: (...args: string[]) => {
logMessages.push(args.join(' '))
},
logJson: (message: unknown) => {
jsonMessages.push(message)
},
exit: vi.fn(),
}))

vi.mock('../../../../src/utils/scripted-commands.js', () => ({
isInteractive: vi.fn().mockReturnValue(false),
}))

import { printResults, printUploadedAssets } from '../../../../src/commands/deploy/deploy.js'
import type { UploadFile } from '../../../../src/utils/deploy/upload-files.js'

const makeResults = (overrides: object = {}) => ({
siteId: 'site-123',
siteName: 'my-site',
deployId: 'deploy-456',
siteUrl: 'https://my-site.netlify.app',
deployUrl: 'https://deploy-456--my-site.netlify.app',
logsUrl: 'https://app.netlify.com/projects/my-site/deploys/deploy-456',
functionLogsUrl: 'https://app.netlify.com/logs/functions',
edgeFunctionLogsUrl: 'https://app.netlify.com/logs/edge-functions',
sourceZipFileName: undefined,
uploadList: [] as UploadFile[],
...overrides,
})

const staticFile = (path: string): UploadFile => ({
assetType: 'file',
filepath: `/build${path}`,
normalizedPath: path,
})

const functionFile = (name: string): UploadFile => ({
assetType: 'function',
filepath: `/functions/${name}.zip`,
normalizedPath: name,
})

const edgeFunctionFile = (name: string): UploadFile => ({
assetType: 'edge-function',
filepath: `/edge-functions/${name}.js`,
normalizedPath: name,
hash: 'abc123',
})

beforeEach(() => {
logMessages.length = 0
jsonMessages.length = 0
})

describe('printUploadedAssets', () => {
test('prints grouped static files, functions, and edge functions', () => {
const uploadList: UploadFile[] = [
staticFile('/index.html'),
staticFile('/styles/main.css'),
functionFile('api'),
edgeFunctionFile('transform'),
]

printUploadedAssets(uploadList)

const output = logMessages.join('\n')
expect(output).toContain('Uploaded assets (4 total)')
expect(output).toContain('Static files (2)')
expect(output).toContain('/index.html')
expect(output).toContain('/styles/main.css')
expect(output).toContain('Functions (1)')
expect(output).toContain('api')
expect(output).toContain('Edge functions (1)')
expect(output).toContain('transform')
})

test('prints (none) for each empty group', () => {
printUploadedAssets([])

const output = logMessages.join('\n')
expect(output).toContain('Uploaded assets (0 total)')
expect(output).toContain('Static files (0)')
expect(output).toContain('Functions (0)')
expect(output).toContain('Edge functions (0)')
expect(output.match(/\(none\)/g)?.length).toBe(3)
})

test('prints only static files when no functions or edge functions uploaded', () => {
const uploadList: UploadFile[] = [staticFile('/index.html'), staticFile('/about.html')]

printUploadedAssets(uploadList)

const output = logMessages.join('\n')
expect(output).toContain('Static files (2)')
expect(output).toContain('Functions (0)')
expect(output).toContain('Edge functions (0)')
expect(output).not.toContain('(none)\n /index.html')
})
})

describe('printResults', () => {
const baseParams = {
deployToProduction: false,
uploadSourceZip: false,
runBuildCommand: true,
}

describe('--show-uploaded not set', () => {
test('does not print upload section in non-interactive mode', () => {
printResults({
...baseParams,
json: false,
results: makeResults({ uploadList: [staticFile('/index.html')] }),
showUploaded: false,
})

const output = logMessages.join('\n')
expect(output).not.toContain('Uploaded assets')
expect(output).not.toContain('/index.html')
})

test('does not include uploaded keys in JSON output', () => {
printResults({
...baseParams,
json: true,
results: makeResults({ uploadList: [staticFile('/index.html')] }),
showUploaded: false,
})

expect(jsonMessages).toHaveLength(1)
const data = jsonMessages[0] as Record<string, unknown>
expect(data).not.toHaveProperty('uploaded_files')
expect(data).not.toHaveProperty('uploaded_functions')
expect(data).not.toHaveProperty('uploaded_edge_functions')
})
})

describe('--show-uploaded set', () => {
test('prints upload section in non-interactive mode', () => {
printResults({
...baseParams,
json: false,
results: makeResults({
uploadList: [staticFile('/index.html'), functionFile('api')],
}),
showUploaded: true,
})

const output = logMessages.join('\n')
expect(output).toContain('Uploaded assets (2 total)')
expect(output).toContain('/index.html')
expect(output).toContain('api')
})

test('prints upload section with empty list in non-interactive mode', () => {
printResults({
...baseParams,
json: false,
results: makeResults({ uploadList: [] }),
showUploaded: true,
})

const output = logMessages.join('\n')
expect(output).toContain('Uploaded assets (0 total)')
expect(output.match(/\(none\)/g)?.length).toBe(3)
})

test('includes uploaded_files, uploaded_functions, uploaded_edge_functions in JSON output', () => {
printResults({
...baseParams,
json: true,
results: makeResults({
uploadList: [staticFile('/index.html'), functionFile('api'), edgeFunctionFile('transform')],
}),
showUploaded: true,
})

expect(jsonMessages).toHaveLength(1)
const data = jsonMessages[0] as Record<string, unknown>
expect(data.uploaded_files).toEqual(['/index.html'])
expect(data.uploaded_functions).toEqual(['api'])
expect(data.uploaded_edge_functions).toEqual(['transform'])
})

test('includes empty arrays in JSON output when nothing was uploaded', () => {
printResults({
...baseParams,
json: true,
results: makeResults({ uploadList: [] }),
showUploaded: true,
})

expect(jsonMessages).toHaveLength(1)
const data = jsonMessages[0] as Record<string, unknown>
expect(data.uploaded_files).toEqual([])
expect(data.uploaded_functions).toEqual([])
expect(data.uploaded_edge_functions).toEqual([])
})

test('JSON output still includes standard deploy fields', () => {
printResults({
...baseParams,
json: true,
results: makeResults(),
showUploaded: true,
})

expect(jsonMessages).toHaveLength(1)
const data = jsonMessages[0] as Record<string, unknown>
expect(data).toHaveProperty('site_id', 'site-123')
expect(data).toHaveProperty('deploy_id', 'deploy-456')
expect(data).toHaveProperty('deploy_url')
expect(data).toHaveProperty('logs')
})
})
})