diff --git a/src/commands/deploy/deploy.ts b/src/commands/deploy/deploy.ts index 7828979231d..cd51999b15a 100644 --- a/src/commands/deploy/deploy.ts +++ b/src/commands/deploy/deploy.ts @@ -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' @@ -583,6 +584,7 @@ const runDeploy = async ({ functionLogsUrl: string edgeFunctionLogsUrl: string sourceZipFileName?: string + uploadList: UploadFile[] }> => { let results let deployId = existingDeployId @@ -710,6 +712,7 @@ const runDeploy = async ({ functionLogsUrl, edgeFunctionLogsUrl, sourceZipFileName: uploadSourceZipResult?.sourceZipFileName, + uploadList: results.uploadList, } } @@ -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> runBuildCommand: boolean + showUploaded: boolean }): void => { const msgData: Record = { 'Build logs': terminalLink(results.logsUrl, results.logsUrl, { fallback: false }), @@ -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()) { @@ -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:') @@ -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:') @@ -1154,6 +1222,83 @@ const ensureSiteExists = async ( return promptForSiteAction(options, command, site) } +export const printAnonymousDeployResults = ({ + claimCommand, + claimUrl, + deployId, + isPasswordProtected, + json, + showUploaded, + siteId, + siteUrl, + uploadList, +}: { + claimCommand: string + claimUrl: string + deployId: string + isPasswordProtected: boolean + json: boolean + showUploaded: boolean + siteId: string + siteUrl: string + uploadList: UploadFile[] +}): void => { + if (json) { + const jsonData: Record = { + site_id: siteId, + site_url: siteUrl, + deploy_id: deployId, + claim_url: claimUrl, + claim_command: claimCommand, + ...(isPasswordProtected ? { password: 'My-Drop-Site' } : {}), + } + + if (showUploaded) { + jsonData.uploaded_files = uploadList.filter((f) => f.assetType === 'file').map((f) => f.normalizedPath) + jsonData.uploaded_functions = uploadList.filter((f) => f.assetType === 'function').map((f) => f.normalizedPath) + jsonData.uploaded_edge_functions = uploadList + .filter((f) => f.assetType === 'edge-function') + .map((f) => f.normalizedPath) + } + + logJson(jsonData) + return + } + + log('') + log(chalk.cyanBright.bold(`🚀 Deploy complete\n${'─'.repeat(64)}`)) + log('') + + const boxContent = isPasswordProtected + ? `Site URL: ${terminalLink(siteUrl, siteUrl, { fallback: false })}\n\nPassword: My-Drop-Site` + : `Site URL: ${terminalLink(siteUrl, siteUrl, { fallback: false })}` + + log( + boxen(boxContent, { + padding: 1, + margin: 1, + textAlignment: 'center', + borderStyle: 'round', + borderColor: NETLIFY_CYAN_HEX, + title: `⬥ Anonymous deploy is live ⬥ `, + titleAlignment: 'center', + }), + ) + log(` ${chalk.bold('Claim on Netlify:')}`) + log(` ${claimUrl}`) + log('') + log(` ${chalk.bold('Claim via CLI:')}`) + log(` ${claimCommand}`) + log('') + warn('Anonymously deployed sites need to be claimed within 60 minutes.') + + if (showUploaded) { + printUploadedAssets(uploadList) + } + + log('') +} + const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand) => { const { workingDir } = command const { site, config } = command.netlify @@ -1264,10 +1409,10 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand throw error } - const uploadList = getUploadList(deployInfo.required, filesShaMap) as UploadListItem[] + const uploadList = getUploadList(deployInfo.required, filesShaMap) as UploadFile[] if (uploadList.length > 0) { - await uploadDropFiles(dropApiOptions, deployInfo.deploy_id, uploadList, dropToken, { + await uploadDropFiles(dropApiOptions, deployInfo.deploy_id, uploadList as unknown as UploadListItem[], dropToken, { statusCb, }) } @@ -1285,45 +1430,17 @@ const anonymousDeploy = async (options: DeployOptionValues, command: BaseCommand const isPasswordProtected = !options.createdVia || options.createdVia === 'drop' const claimUrl = `https://app.netlify.com/drop/${deployInfo.subdomain}#drop_token=${dropToken}` - if (options.json) { - logJson({ - site_id: deployInfo.id, - site_url: siteUrl, - deploy_id: deployInfo.deploy_id, - claim_url: claimUrl, - claim_command: `netlify claim --site ${deployInfo.id} --token ${dropToken}`, - ...(isPasswordProtected ? { password: 'My-Drop-Site' } : {}), - }) - return - } - - log('') - log(chalk.cyanBright.bold(`🚀 Deploy complete\n${'─'.repeat(64)}`)) - log('') - - const boxContent = isPasswordProtected - ? `Site URL: ${terminalLink(siteUrl, siteUrl, { fallback: false })}\n\nPassword: My-Drop-Site` - : `Site URL: ${terminalLink(siteUrl, siteUrl, { fallback: false })}` - - log( - boxen(boxContent, { - padding: 1, - margin: 1, - textAlignment: 'center', - borderStyle: 'round', - borderColor: NETLIFY_CYAN_HEX, - title: `⬥ Anonymous deploy is live ⬥ `, - titleAlignment: 'center', - }), - ) - log(` ${chalk.bold('Claim on Netlify:')}`) - log(` ${claimUrl}`) - log('') - log(` ${chalk.bold('Claim via CLI:')}`) - log(` netlify claim --site ${deployInfo.id} --token ${dropToken}`) - log('') - warn('Anonymously deployed sites need to be claimed within 60 minutes.') - log('') + printAnonymousDeployResults({ + claimCommand: `netlify claim --site ${deployInfo.id} --token ${dropToken}`, + claimUrl, + deployId: deployInfo.deploy_id, + isPasswordProtected, + json: options.json ?? false, + showUploaded: options.showUploaded ?? false, + siteId: deployInfo.id, + siteUrl, + uploadList, + }) } export const deploy = async (options: DeployOptionValues, command: BaseCommand) => { @@ -1478,6 +1595,7 @@ export const deploy = async (options: DeployOptionValues, command: BaseCommand) results, deployToProduction, uploadSourceZip: !!options.uploadSourceZip, + showUploaded: !!options.showUploaded, }) if (options.open) { diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts index 549651ae245..7bd2ff9444e 100644 --- a/src/commands/deploy/index.ts +++ b/src/commands/deploy/index.ts @@ -110,6 +110,7 @@ For detailed configuration options, see the Netlify documentation.`, false, ) .option('--created-via ', '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', @@ -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/' diff --git a/src/commands/deploy/option_values.ts b/src/commands/deploy/option_values.ts index 2f74d76999b..dcc5328e02b 100644 --- a/src/commands/deploy/option_values.ts +++ b/src/commands/deploy/option_values.ts @@ -21,6 +21,7 @@ export type DeployOptionValues = BaseOptionValues & { prod: boolean prodIfUnlocked: boolean secretEnv?: DeployEnvironmentVariable[] + showUploaded?: boolean site?: string siteName?: string skipFunctionsCache: boolean diff --git a/tests/unit/commands/deploy/deploy.test.ts b/tests/unit/commands/deploy/deploy.test.ts new file mode 100644 index 00000000000..23cd9460645 --- /dev/null +++ b/tests/unit/commands/deploy/deploy.test.ts @@ -0,0 +1,322 @@ +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, printAnonymousDeployResults } 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 + 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 + 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 + 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 + expect(data).toHaveProperty('site_id', 'site-123') + expect(data).toHaveProperty('deploy_id', 'deploy-456') + expect(data).toHaveProperty('deploy_url') + expect(data).toHaveProperty('logs') + }) + }) +}) + +describe('printAnonymousDeployResults', () => { + const baseParams = { + claimCommand: 'netlify claim --site site-123 --token tok', + claimUrl: 'https://app.netlify.com/drop/mysite#drop_token=tok', + deployId: 'deploy-456', + isPasswordProtected: true, + siteId: 'site-123', + siteUrl: 'https://mysite.netlify.app', + uploadList: [] as UploadFile[], + } + + describe('--show-uploaded not set', () => { + test('does not print upload section in text mode', () => { + printAnonymousDeployResults({ ...baseParams, json: false, showUploaded: false }) + + const output = logMessages.join('\n') + expect(output).not.toContain('Uploaded assets') + }) + + test('does not include uploaded keys in JSON output', () => { + printAnonymousDeployResults({ + ...baseParams, + json: true, + showUploaded: false, + uploadList: [staticFile('/index.html')], + }) + + expect(jsonMessages).toHaveLength(1) + const data = jsonMessages[0] as Record + 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 text mode', () => { + printAnonymousDeployResults({ + ...baseParams, + json: false, + showUploaded: true, + uploadList: [staticFile('/index.html'), staticFile('/about.html')], + }) + + const output = logMessages.join('\n') + expect(output).toContain('Uploaded assets (2 total)') + expect(output).toContain('/index.html') + expect(output).toContain('/about.html') + }) + + test('prints upload section with empty list in text mode', () => { + printAnonymousDeployResults({ ...baseParams, json: false, showUploaded: true, uploadList: [] }) + + const output = logMessages.join('\n') + expect(output).toContain('Uploaded assets (0 total)') + expect(output.match(/\(none\)/g)?.length).toBe(3) + }) + + test('includes uploaded_files in JSON output', () => { + printAnonymousDeployResults({ + ...baseParams, + json: true, + showUploaded: true, + uploadList: [staticFile('/index.html'), staticFile('/about.html')], + }) + + expect(jsonMessages).toHaveLength(1) + const data = jsonMessages[0] as Record + expect(data.uploaded_files).toEqual(['/index.html', '/about.html']) + expect(data.uploaded_functions).toEqual([]) + expect(data.uploaded_edge_functions).toEqual([]) + }) + + test('includes empty arrays in JSON output when nothing was uploaded', () => { + printAnonymousDeployResults({ ...baseParams, json: true, showUploaded: true, uploadList: [] }) + + expect(jsonMessages).toHaveLength(1) + const data = jsonMessages[0] as Record + expect(data.uploaded_files).toEqual([]) + expect(data.uploaded_functions).toEqual([]) + expect(data.uploaded_edge_functions).toEqual([]) + }) + + test('JSON output still includes standard anonymous deploy fields', () => { + printAnonymousDeployResults({ ...baseParams, json: true, showUploaded: true }) + + expect(jsonMessages).toHaveLength(1) + const data = jsonMessages[0] as Record + expect(data).toHaveProperty('site_id', 'site-123') + expect(data).toHaveProperty('deploy_id', 'deploy-456') + expect(data).toHaveProperty('claim_url') + expect(data).toHaveProperty('claim_command') + }) + }) +})