diff --git a/packages/@n8n/cli/src/__tests__/client.test.ts b/packages/@n8n/cli/src/__tests__/client.test.ts index d94cae8d219..c59c652eb2d 100644 --- a/packages/@n8n/cli/src/__tests__/client.test.ts +++ b/packages/@n8n/cli/src/__tests__/client.test.ts @@ -48,8 +48,10 @@ describe('N8nClient packages', () => { const result = await client.exportPackage({ workflowIds: ['a', 'b'] }); - expect(Buffer.isBuffer(result)).toBe(true); - expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true); + expect(Buffer.isBuffer(result.archive)).toBe(true); + expect(result.archive.equals(Buffer.from([1, 2, 3]))).toBe(true); + // Older servers omit the counts header. + expect(result.counts).toBeUndefined(); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export'); @@ -61,14 +63,26 @@ describe('N8nClient packages', () => { const result = await client.exportPackage({ projectIds: ['proj-1', 'proj-2'] }); - expect(Buffer.isBuffer(result)).toBe(true); - expect(result.equals(Buffer.from([4, 5, 6]))).toBe(true); + expect(Buffer.isBuffer(result.archive)).toBe(true); + expect(result.archive.equals(Buffer.from([4, 5, 6]))).toBe(true); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://n8n.example.com/api/v1/n8n-packages/export'); expect(init.body).toBe(JSON.stringify({ projectIds: ['proj-1', 'proj-2'] })); }); + it('parses the X-N8n-Export-Counts header into counts when the server sends it', async () => { + const counts = { workflows: 2, folders: 1, credentials: 0, dataTables: 0, variables: 0 }; + const response = binaryResponse(200, new Uint8Array([1, 2, 3])); + response.headers.set('X-N8n-Export-Counts', JSON.stringify(counts)); + fetchMock.mockResolvedValue(response); + + const result = await client.exportPackage({ workflowIds: ['a'] }); + + expect(result.counts).toEqual(counts); + expect(result.archive.equals(Buffer.from([1, 2, 3]))).toBe(true); + }); + it('includes folderIds in the body when provided', async () => { fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1]))); diff --git a/packages/@n8n/cli/src/__tests__/package-export.test.ts b/packages/@n8n/cli/src/__tests__/package-export.test.ts index df118285aa3..27f70bdad50 100644 --- a/packages/@n8n/cli/src/__tests__/package-export.test.ts +++ b/packages/@n8n/cli/src/__tests__/package-export.test.ts @@ -22,13 +22,23 @@ interface ExportFlags { interface ExportInternals { parse: () => Promise<{ flags: ExportFlags }>; getClient: () => N8nClient; - succeed: () => void; + succeed: (message: string, ...args: unknown[]) => void; error: (message: string) => never; } +const DEFAULT_COUNTS = { + workflows: 0, + folders: 0, + credentials: 0, + dataTables: 0, + variables: 0, +}; + function stubCommand( flags: ExportFlags, - exportPackage = vi.fn().mockResolvedValue(Buffer.from([1, 2, 3])), + exportPackage = vi + .fn() + .mockResolvedValue({ archive: Buffer.from([1, 2, 3]), counts: DEFAULT_COUNTS }), ) { const command = new PackageExport([], {} as Config); const internals = command as unknown as ExportInternals; @@ -232,4 +242,37 @@ describe('package export command', () => { await expect(command.run()).rejects.toThrow(/at least one/i); expect(exportPackage).not.toHaveBeenCalled(); }); + + it('omits a zero folder count from a workflow-only export message', async () => { + const exportPackage = vi.fn().mockResolvedValue({ + archive: Buffer.from([1, 2, 3]), + counts: { workflows: 2, folders: 0, credentials: 0, dataTables: 0, variables: 0 }, + }); + const { command, internals } = stubCommand( + { workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' }, + exportPackage, + ); + + await command.run(); + + const message = vi.mocked(internals.succeed).mock.calls[0][0]; + expect(message).toBe('Exported 2 workflow(s) to /tmp/team.n8np'); + expect(message).not.toContain('folder'); + }); + + it('reports the real bundled workflow count for a folder export', async () => { + const exportPackage = vi.fn().mockResolvedValue({ + archive: Buffer.from([1, 2, 3]), + counts: { workflows: 3, folders: 1, credentials: 0, dataTables: 0, variables: 0 }, + }); + const { command, internals } = stubCommand( + { folderId: ['fld-1'], output: '/tmp/folders.n8np' }, + exportPackage, + ); + + await command.run(); + + const message = vi.mocked(internals.succeed).mock.calls[0][0]; + expect(message).toBe('Exported 3 workflow(s), 1 folder(s) to /tmp/folders.n8np'); + }); }); diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index 337a5f6c2d5..d62b0c4497f 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -34,6 +34,21 @@ export interface ExportPackageFields { missingWorkflowDependencyPolicy?: string; } +/** True per-entity counts of what ended up in an exported package. */ +export interface ExportPackageCounts { + workflows: number; + folders: number; + credentials: number; + dataTables: number; + variables: number; +} + +export interface ExportPackageResult { + archive: Buffer; + /** Undefined when talking to an older server that doesn't send the counts header. */ + counts?: ExportPackageCounts; +} + export class ApiError extends Error { constructor( readonly statusCode: number, @@ -78,6 +93,7 @@ export class N8nClient { query?: Record; formData?: FormData; responseType?: 'json' | 'binary'; + onResponse?: (response: Response) => void; } = {}, ): Promise { const url = new URL(`${this.baseUrl}${path}`); @@ -129,6 +145,8 @@ export class N8nClient { throw new ApiError(response.status, message, hint, errorBody); } + options.onResponse?.(response); + if (response.status === 204) { return undefined as T; } @@ -437,7 +455,7 @@ export class N8nClient { // ─── Packages (beta) ─────────────────────────────────────────── - async exportPackage(fields: ExportPackageFields): Promise { + async exportPackage(fields: ExportPackageFields): Promise { // Empty collections are dropped so the API's per-field "at least one" rule isn't tripped. const body: { workflowIds?: string[]; @@ -454,10 +472,26 @@ export class N8nClient { if (fields.missingWorkflowDependencyPolicy) body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy; - return await this.request('POST', '/n8n-packages/export', { + let counts: ExportPackageCounts | undefined; + const archive = await this.request('POST', '/n8n-packages/export', { body, responseType: 'binary', + // Older servers omit this header; counts then stays undefined. + onResponse: (response) => { + const header = response.headers.get('X-N8n-Export-Counts'); + if (header) counts = this.parseExportCounts(header); + }, }); + + return { archive, counts }; + } + + private parseExportCounts(header: string): ExportPackageCounts | undefined { + try { + return JSON.parse(header) as ExportPackageCounts; + } catch { + return undefined; + } } async importPackage( diff --git a/packages/@n8n/cli/src/commands/package/export.ts b/packages/@n8n/cli/src/commands/package/export.ts index 23fbfc3de5f..f2d667fde57 100644 --- a/packages/@n8n/cli/src/commands/package/export.ts +++ b/packages/@n8n/cli/src/commands/package/export.ts @@ -3,6 +3,24 @@ import * as fs from 'node:fs'; import { toPackagesError } from './shared'; import { BaseCommand } from '../../base-command'; +import type { ExportPackageCounts, ExportPackageResult } from '../../client'; + +/** + * Human-readable summary of an export, built from the real per-entity counts. + * Categories with a zero count are omitted so a workflow-only export never + * appends a spurious "0 folder(s)" and a folder export reports its bundled + * workflows. + */ +function describeExport(counts: ExportPackageCounts & { projects?: number }): string { + const parts: string[] = []; + if (counts.projects) parts.push(`${counts.projects} project(s)`); + if (counts.workflows) parts.push(`${counts.workflows} workflow(s)`); + if (counts.folders) parts.push(`${counts.folders} folder(s)`); + if (counts.credentials) parts.push(`${counts.credentials} credential(s)`); + if (counts.dataTables) parts.push(`${counts.dataTables} data table(s)`); + if (counts.variables) parts.push(`${counts.variables} variable(s)`); + return parts.length > 0 ? parts.join(', ') : 'nothing'; +} export default class PackageExport extends BaseCommand { static override description = 'Export workflows, folders, or projects as an n8n package (.n8np)'; @@ -76,9 +94,9 @@ export default class PackageExport extends BaseCommand { await this.execute(async () => { const client = this.getClient(flags); - let archive: Buffer; + let result: ExportPackageResult; try { - archive = await client.exportPackage( + result = await client.exportPackage( projectIds.length > 0 ? { projectIds, includeVariableValues, missingWorkflowDependencyPolicy } : { workflowIds, folderIds, includeVariableValues, missingWorkflowDependencyPolicy }, @@ -86,21 +104,33 @@ export default class PackageExport extends BaseCommand { } catch (error) { throw toPackagesError(error); } - fs.writeFileSync(flags.output, archive); + fs.writeFileSync(flags.output, result.archive); + + const { counts } = result; if (projectIds.length > 0) { - this.succeed(`Exported ${projectIds.length} project(s) to ${flags.output}`, flags, { + // Older servers omit counts; fall back to the requested project id count. + const summary = counts + ? describeExport({ ...counts, projects: projectIds.length }) + : `${projectIds.length} project(s)`; + this.succeed(`Exported ${summary} to ${flags.output}`, flags, { output: flags.output, projectIds, + ...(counts ? { counts } : {}), }); return; } - this.succeed( - `Exported ${workflowIds.length} workflow(s) and ${folderIds.length} folder(s) to ${flags.output}`, - flags, - { output: flags.output, workflowIds, folderIds }, - ); + // Older servers omit counts; fall back to the requested id counts. + const summary = counts + ? describeExport(counts) + : `${workflowIds.length} workflow(s) and ${folderIds.length} folder(s)`; + this.succeed(`Exported ${summary} to ${flags.output}`, flags, { + output: flags.output, + workflowIds, + folderIds, + ...(counts ? { counts } : {}), + }); }); } } diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts index 6b1290102b2..d656c5583e0 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts @@ -61,7 +61,7 @@ describe('folder package export', () => { const project = await createTeamProject('Project A', owner); const folder = await createFolder(project, { name: 'to_production' }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -119,7 +119,7 @@ describe('folder package export', () => { const toProduction = await createFolder(project, { name: 'to_production' }); const inProgress = await createFolder(project, { name: 'in_progress' }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [toProduction.id, inProgress.id], @@ -178,7 +178,7 @@ describe('folder package export', () => { subWorkflowId: dependency.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, folderIds: [exportedFolder.id], missingWorkflowDependencyPolicy: 'include-in-package', @@ -235,7 +235,7 @@ describe('folder package export', () => { subWorkflowId: externalChild.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, folderIds: [folder.id], workflowIds: [externalChild.id], @@ -271,7 +271,7 @@ describe('folder package export', () => { subWorkflowId: dependency.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, folderIds: [exportedFolder.id], workflowIds: [topLevelParent.id], @@ -296,7 +296,7 @@ describe('folder package export', () => { const child = await createFolder(project, { name: 'nested', parentFolder: parent }); const grandchild = await createFolder(project, { name: 'deep', parentFolder: child }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [parent.id], @@ -329,7 +329,7 @@ describe('folder package export', () => { createdAt: new Date('2026-02-01T00:00:00.000Z'), }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [older.id, newer.id], @@ -350,7 +350,7 @@ describe('folder package export', () => { const child = await createFolder(project, { name: 'nested', parentFolder: parent }); // Request only the child; its real parent stays behind. - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [child.id], @@ -401,7 +401,7 @@ describe('folder package export — with contained workflows', () => { const folder = await createFolder(project, { name: 'in_progress' }); const workflow = await createWorkflow({ name: 'triage', parentFolder: folder }, project); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -424,7 +424,7 @@ describe('folder package export — with contained workflows', () => { const child = await createFolder(project, { name: 'nested', parentFolder: parent }); const workflow = await createWorkflow({ name: 'playground', parentFolder: child }, project); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [parent.id], @@ -453,7 +453,7 @@ describe('folder package export — with contained workflows', () => { parentFolder: folder, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -495,7 +495,7 @@ describe('folder package export — with contained workflows', () => { parentFolder: folder, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -550,7 +550,7 @@ describe('folder package export — with contained workflows', () => { const folder = await createFolder(project, { name: 'in_progress' }); const workflow = await createWorkflow({ name: 'triage', parentFolder: folder }, project); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id], folderIds: [folder.id], @@ -574,7 +574,7 @@ describe('folder package export — with contained workflows', () => { const project = await createTeamProject('Project A', owner); const folder = await createFolder(project, { name: 'empty' }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -594,7 +594,7 @@ describe('folder package export — with contained workflows', () => { const workflow = await createWorkflow({ name: 'triage', parentFolder: parent }, project); const subfolder = await createFolder(project, { name: 'workflows', parentFolder: parent }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [parent.id], diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts index 48564ccd7ca..5b755720b3e 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts @@ -52,7 +52,7 @@ beforeEach(async () => { }); async function exportProjects(user: User, projectIds: string[]) { - return await readExport(await service.exportPackage({ user, projectIds })); + return await readExport((await service.exportPackage({ user, projectIds })).stream); } async function exportProject(user: User, projectId: string) { @@ -177,13 +177,12 @@ describe('project package export', () => { const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); try { - const { manifest, entries } = await readExport( - await service.exportPackage({ - user: owner, - projectIds: [projectA.id], - missingWorkflowDependencyPolicy: 'include-in-package', - }), - ); + const { stream } = await service.exportPackage({ + user: owner, + projectIds: [projectA.id], + missingWorkflowDependencyPolicy: 'include-in-package', + }); + const { manifest, entries } = await readExport(stream); const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!; expect(projectBEntry.target).toMatch(/^projects\//); @@ -220,13 +219,12 @@ describe('project package export', () => { subWorkflowId: dependency.id, }); - const { manifest } = await readExport( - await service.exportPackage({ - user: owner, - projectIds: [projectA.id], - missingWorkflowDependencyPolicy: 'include-in-package', - }), - ); + const { stream } = await service.exportPackage({ + user: owner, + projectIds: [projectA.id], + missingWorkflowDependencyPolicy: 'include-in-package', + }); + const { manifest } = await readExport(stream); const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!; const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!; @@ -245,13 +243,12 @@ describe('project package export', () => { subWorkflowId: dependency.id, }); - const { manifest } = await readExport( - await service.exportPackage({ - user: owner, - projectIds: [projectA.id], - missingWorkflowDependencyPolicy: 'include-in-package', - }), - ); + const { stream } = await service.exportPackage({ + user: owner, + projectIds: [projectA.id], + missingWorkflowDependencyPolicy: 'include-in-package', + }); + const { manifest } = await readExport(stream); const projectBEntry = manifest.projects!.find(({ id }) => id === projectB.id)!; const dependencyEntry = manifest.workflows!.find(({ id }) => id === dependency.id)!; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts index 1873ea8f9cb..5f591e50059 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -65,7 +65,7 @@ describe('workflow package export — with credentials', () => { credential, }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.credentials).toEqual([ @@ -124,7 +124,7 @@ describe('workflow package export — with credentials', () => { credential, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -164,7 +164,10 @@ describe('workflow package export — with credentials', () => { subWorkflowId: child.id, }); - const stream = await service.exportPackage({ user: owner, workflowIds: [parent.id, child.id] }); + const { stream } = await service.exportPackage({ + user: owner, + workflowIds: [parent.id, child.id], + }); const { manifest } = await readExport(stream); expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort()); @@ -191,7 +194,7 @@ describe('workflow package export — with credentials', () => { credentialType: 'httpHeaderAuth', }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.credentials).toBeUndefined(); @@ -235,7 +238,7 @@ describe('workflow package export — with credentials', () => { // credential was never shared with them. The export must still succeed, // recording the credential as a requirement using the name+type carried // in the workflow JSON. - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: sharee, workflowIds: [workflow.id], }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts index fa738d32c33..3f382df3596 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts @@ -71,7 +71,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.requirements).toEqual({ @@ -116,7 +116,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { entries } = await readExport(stream); const dataTableFile = entries.find( @@ -147,7 +147,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { entries } = await readExport(stream); const dataTableFile = entries.find((e) => e.name === 'data-tables/withrows/data-table.json'); @@ -174,7 +174,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id, mode: 'list' }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.requirements?.dataTables).toEqual([ @@ -203,7 +203,7 @@ describe('workflow package export — with data tables', () => { subWorkflowId: child.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [parent.id], missingWorkflowDependencyPolicy: 'include-in-package', @@ -237,7 +237,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -268,7 +268,7 @@ describe('workflow package export — with data tables', () => { ], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.requirements?.dataTables?.map((d) => d.id).sort()).toEqual( @@ -300,7 +300,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: tableB.id }], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -326,7 +326,10 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: owner, projectIds: [salesProject.id] }); + const { stream } = await service.exportPackage({ + user: owner, + projectIds: [salesProject.id], + }); const { manifest, entries } = await readExport(stream); const projectTarget = manifest.projects![0].target; @@ -355,7 +358,7 @@ describe('workflow package export — with data tables', () => { parentFolder: folder, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], @@ -392,7 +395,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest } = await readExport(stream); expect(manifest.requirements?.dataTables).toEqual([ @@ -418,7 +421,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: viewer, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: viewer, workflowIds: [workflow.id] }); const { manifest } = await readExport(stream); expect(manifest.requirements?.dataTables).toEqual([ @@ -443,7 +446,7 @@ describe('workflow package export — with data tables', () => { references: [{ dataTableId: dataTable.id }], }); - const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); const { manifest } = await readExport(stream); expect(manifest.requirements?.dataTables).toEqual([ diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts index ad4d3d6c8bd..7fc8ffc115a 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts @@ -61,7 +61,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.variables).toEqual([ @@ -106,7 +106,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id], includeVariableValues: false, @@ -145,7 +145,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.variables).toEqual([ @@ -190,7 +190,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: member, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest).not.toHaveProperty('variables'); @@ -218,7 +218,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); // The catalog carries the project-scoped variable's id, not the global one's. @@ -261,7 +261,7 @@ describe('workflow package export — with variables', () => { variableNames: ['SHARED_VAR'], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -285,7 +285,7 @@ describe('workflow package export — with variables', () => { variableNames: ['legacy-key'], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest.variables).toEqual([ @@ -311,7 +311,7 @@ describe('workflow package export — with variables', () => { variableNames: ['DOES_NOT_EXIST'], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest).not.toHaveProperty('variables'); @@ -338,7 +338,7 @@ describe('workflow package export — with variables', () => { const sharee = await createMember(); await shareWorkflowWithUsers(workflow, [sharee]); - const stream = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest).not.toHaveProperty('variables'); @@ -388,7 +388,7 @@ describe('workflow package export — with variables', () => { variableNames: ['PROJECT_ONLY'], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -418,7 +418,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ user: owner, projectIds: [project.id] }); + const { stream } = await service.exportPackage({ user: owner, projectIds: [project.id] }); const { manifest, entries } = await readExport(stream); const projectEntry = manifest.projects!.find((entry) => entry.id === project.id)!; @@ -448,7 +448,7 @@ describe('workflow package export — with variables', () => { // The sharee can reach the workflow via the direct share, but has no // access to the owner project's variables. The export must still succeed // with a requirements-only entry carrying no value. - const stream = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: sharee, workflowIds: [workflow.id] }); const { manifest, entries } = await readExport(stream); expect(manifest).not.toHaveProperty('variables'); @@ -488,7 +488,7 @@ describe('workflow package export — with variables', () => { variableNames: [], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id], canExportVariableValues: false, @@ -509,7 +509,7 @@ describe('workflow package export — with variables', () => { variableNames: ['API_URL'], }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id], includeVariableValues: false, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts index 989da1931dd..8e08dc96dde 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts @@ -46,7 +46,7 @@ describe('workflow package export', () => { }); async function exportSingleWorkflow(user: User, workflowId: string) { - const stream = await service.exportPackage({ user, workflowIds: [workflowId] }); + const { stream } = await service.exportPackage({ user, workflowIds: [workflowId] }); return await readExport(stream); } @@ -100,7 +100,7 @@ describe('workflow package export', () => { const wfA = await createWorkflow({ name: 'Alpha', nodes: [], connections: {} }, project); const wfB = await createWorkflow({ name: 'Beta', nodes: [], connections: {} }, project); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -149,7 +149,7 @@ describe('workflow package export', () => { project, ); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -173,7 +173,7 @@ describe('workflow package export', () => { const wfA = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); const wfB = await createWorkflow({ name: 'Duplicate', nodes: [], connections: {} }, project); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id], }); @@ -220,7 +220,7 @@ describe('workflow package export', () => { '2 workflow dependencies not included in the package', ); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflowA.id, workflowB.id, workflowC.id], }); @@ -274,7 +274,7 @@ describe('workflow package export', () => { service.exportPackage({ user: owner, workflowIds: [parent.id] }), ).rejects.toThrow('workflow dependency not included in the package'); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [parent.id, child.id], }); @@ -326,7 +326,7 @@ describe('workflow package export', () => { service.exportPackage({ user: owner, workflowIds: [parent.id] }), ).rejects.toThrow('workflow dependency not included in the package'); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [parent.id, child.id], }); @@ -364,7 +364,7 @@ describe('workflow package export', () => { service.exportPackage({ user: owner, workflowIds: [parent.id] }), ).rejects.toThrow('workflow dependency not included in the package'); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [parent.id, errorHandler.id], }); @@ -393,7 +393,7 @@ describe('workflow package export', () => { subWorkflowId: child.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [parentA.id, parentB.id, child.id], }); @@ -473,7 +473,7 @@ describe('workflow package export', () => { subWorkflowId: workflowB.id, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflowA.id], missingWorkflowDependencyPolicy: 'include-in-package', @@ -554,7 +554,7 @@ describe('workflow package export', () => { ]; await Container.get(WorkflowRepository).save(workflowA); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflowA.id], missingWorkflowDependencyPolicy: 'include-in-package', @@ -591,7 +591,7 @@ describe('workflow package export', () => { ]; await Container.get(WorkflowRepository).save(workflowA); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflowA.id, workflowB.id], }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts index 45f38d5797b..5c3aefb88b5 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts @@ -225,7 +225,7 @@ describe('workflow package import — with data tables', () => { references: [{ dataTableId: sourceTable.id }], }); - const stream = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); + const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] }); const packageBuffer = await streamToBuffer(stream); // Simulate importing on another instance: ids are global, so the source @@ -715,7 +715,7 @@ describe('workflow package import — with data tables', () => { parentFolder: folder, }); - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user: owner, workflowIds: [], folderIds: [folder.id], diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts index 9e5764b723b..aac52f73c5f 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts @@ -70,7 +70,7 @@ async function importPackage(params: ImportParams) { } async function exportWorkflowPackage(user: User, workflowId: string): Promise { - const stream = await service.exportPackage({ + const { stream } = await service.exportPackage({ user, workflowIds: [workflowId], includeVariableValues: true, diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index f9bb0ab8842..b6d0496c798 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -1,6 +1,5 @@ import { Service } from '@n8n/di'; import { InstanceSettings } from 'n8n-core'; -import type { Readable } from 'node:stream'; import { N8N_VERSION } from '@/constants'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; @@ -31,7 +30,9 @@ import { TarPackageWriter } from './io/tar/tar-package-writer'; import { PackageImportConfig } from './n8n-packages.config'; import { MissingWorkflowDependencyPolicy, + type ExportPackageEventCounts, type ExportPackageRequest, + type ExportPackageResult, type ImportPackageRequest, type ImportResult, } from './n8n-packages.types'; @@ -64,7 +65,7 @@ export class N8nPackagesService { private readonly autoIncludedWorkflowExporter: AutoIncludedWorkflowExporter, ) {} - async exportPackage(request: ExportPackageRequest): Promise { + async exportPackage(request: ExportPackageRequest): Promise { // TODO: remove this once reference-only is supported const { missingWorkflowDependencyPolicy } = request; if (missingWorkflowDependencyPolicy === MissingWorkflowDependencyPolicy.ReferenceOnly) { @@ -249,6 +250,14 @@ export class N8nPackagesService { const stream = writer.finalize(); + const counts: ExportPackageEventCounts = { + workflows: allWorkflowsInPackage.length, + folders: allFolders.length, + credentials: credentialExportResult.entries.length, + dataTables: dataTableExportResult.entries.length, + variables: variableExportResult.entries.length, + }; + this.eventService.emit('n8n-package-exported', { user: request.user, ...(allWorkflowsInPackage.length @@ -256,16 +265,10 @@ export class N8nPackagesService { : {}), ...(allFolders.length ? { folderIds: allFolders.map(({ id }) => id) } : {}), ...(allProjects.length ? { projectIds: allProjects.map(({ id }) => id) } : {}), - counts: { - workflows: allWorkflowsInPackage.length, - folders: allFolders.length, - credentials: credentialExportResult.entries.length, - dataTables: dataTableExportResult.entries.length, - variables: variableExportResult.entries.length, - }, + counts, }); - return stream; + return { stream, counts }; } async importPackage(request: ImportPackageRequest): Promise { diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index 3de9c197ad7..606ca397b73 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -1,4 +1,5 @@ import type { User } from '@n8n/db'; +import type { Readable } from 'node:stream'; import type { DataTableResolutionFailure } from './entities/data-table/data-table.types'; import type { VariableResolutionFailure } from './entities/variable/variable.types'; @@ -222,6 +223,16 @@ export type ExportPackageEventCounts = { variables: number; }; +/** + * Result of an export: the archive stream plus the true per-entity counts of + * what actually ended up in the package (after folder bundling and + * auto-inclusion). Consumers surface these instead of the requested id counts. + */ +export interface ExportPackageResult { + stream: Readable; + counts: ExportPackageEventCounts; +} + export interface ImportedWorkflowSummary { sourceWorkflowId: string; localId: string; diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts index deb4c827eb0..ca95d55cff0 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/__tests__/n8n-packages.handler.test.ts @@ -39,6 +39,14 @@ beforeAll(async () => { importPackage = handler.importPackage[1]; }); +const EXPORT_COUNTS = { + workflows: 2, + folders: 1, + credentials: 0, + dataTables: 0, + variables: 0, +}; + describe('n8n-packages handler', () => { let mockService: Mocked; let mockEventService: Mocked; @@ -225,7 +233,7 @@ describe('n8n-packages handler', () => { it('does not reject upfront without variable:list scope; forwards canExportVariableValues=false for the service to enforce', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run(makeRequest({ workflowIds: ['wf-1'] }, ['workflow:export']), res); @@ -246,7 +254,7 @@ describe('n8n-packages handler', () => { it('allows value-less export without variable:list scope', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( @@ -344,7 +352,7 @@ describe('n8n-packages handler', () => { it('streams the export for a valid workflow request', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( @@ -369,12 +377,20 @@ describe('n8n-packages handler', () => { 'Content-Disposition', 'attachment; filename="export.n8np"', ); + expect(res.setHeader).toHaveBeenCalledWith( + 'X-N8n-Export-Counts', + JSON.stringify(EXPORT_COUNTS), + ); + expect(res.setHeader).toHaveBeenCalledWith( + 'Access-Control-Expose-Headers', + 'X-N8n-Export-Counts', + ); expect(mockEventService.emit).not.toHaveBeenCalled(); }); it('forwards a non-default missing workflow dependency policy', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( @@ -404,7 +420,7 @@ describe('n8n-packages handler', () => { it('streams the export for a valid project request', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( @@ -428,7 +444,7 @@ describe('n8n-packages handler', () => { it('streams the export for a valid folder request', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( @@ -452,7 +468,7 @@ describe('n8n-packages handler', () => { it('forwards includeVariableValues=false to the service', async () => { const stream = new PassThrough(); - mockService.exportPackage.mockResolvedValue(stream); + mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS }); const res = makeResponse(); const resultPromise = run( diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts index f64da34f54e..fe3a54dbabb 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts @@ -4,7 +4,6 @@ import { Container } from '@n8n/di'; import type { ApiKeyScope } from '@n8n/permissions'; import type { Response } from 'express'; import { UserError } from 'n8n-workflow'; -import type { Readable } from 'node:stream'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; @@ -14,6 +13,7 @@ import { PackageEntityNotFoundError, } from '@/modules/n8n-packages/entities/package-export.errors'; import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service'; +import type { ExportPackageResult } from '@/modules/n8n-packages/n8n-packages.types'; import { classifyPackageFailure } from '@/modules/n8n-packages/package-failure-classifier'; import { resolveImportPackageUpload } from '@/modules/n8n-packages/utils/import-package-upload'; @@ -23,6 +23,9 @@ import { publicApiCompositeScope } from '../../shared/middlewares/global.middlew const PACKAGE_EXPORT_SCOPES = 'project:export,workflow:export'; +/** Header carrying the JSON-serialized true per-entity counts of the exported package. */ +const EXPORT_COUNTS_HEADER = 'X-N8n-Export-Counts'; + type ExportPackageRequest = AuthenticatedRequest< {}, {}, @@ -80,9 +83,15 @@ function assertPackageImportApiKeyScopes(req: AuthenticatedRequest) { } } -async function streamPackageExport(res: Response, stream: Readable): Promise { +async function streamPackageExport( + res: Response, + { stream, counts }: ExportPackageResult, +): Promise { res.setHeader('Content-Type', 'application/gzip'); res.setHeader('Content-Disposition', 'attachment; filename="export.n8np"'); + res.setHeader(EXPORT_COUNTS_HEADER, JSON.stringify(counts)); + // Cross-origin browser clients can only read the counts header if it's exposed. + res.setHeader('Access-Control-Expose-Headers', EXPORT_COUNTS_HEADER); return await new Promise((resolve, reject) => { stream.on('error', reject); @@ -131,7 +140,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = { projectIds, ); - const stream = await Container.get(N8nPackagesService).exportPackage({ + const exportResult = await Container.get(N8nPackagesService).exportPackage({ user: req.user, workflowIds, folderIds, @@ -141,7 +150,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = { missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy, }); - return await streamPackageExport(res, stream); + return await streamPackageExport(res, exportResult); } catch (error) { Container.get(EventService).emit('n8n-package-export-failed', { user: req.user, diff --git a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml index bd86ef8cc68..f916c705df4 100644 --- a/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml +++ b/packages/cli/src/public-api/v1/handlers/n8n-packages/spec/paths/n8n-packages.export.yml @@ -27,6 +27,14 @@ post: responses: '200': description: A gzipped tar archive containing the exported package contents. + headers: + X-N8n-Export-Counts: + description: | + JSON-serialized per-entity counts of what actually ended up in the + package (after folder bundling and auto-inclusion), e.g. + `{"workflows":2,"folders":1,"credentials":0,"dataTables":0,"variables":0}`. + schema: + type: string content: application/gzip: schema: