fix(n8n-cli): Report real exported entity counts in package export

Surface the true per-entity counts computed during export through the
public-API response (as an X-N8n-Export-Counts header) so the CLI prints
accurate counts instead of the requested id-flag lengths. A workflow-only
export no longer appends a spurious "0 folder(s)" and a folder export now
reports its bundled workflow count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-07-27 09:02:50 +00:00
parent 1f4e65419a
commit 79de5bdb72
17 changed files with 293 additions and 122 deletions

View File

@ -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])));

View File

@ -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');
});
});

View File

@ -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<string, string>;
formData?: FormData;
responseType?: 'json' | 'binary';
onResponse?: (response: Response) => void;
} = {},
): Promise<T> {
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<Buffer> {
async exportPackage(fields: ExportPackageFields): Promise<ExportPackageResult> {
// 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<Buffer>('POST', '/n8n-packages/export', {
let counts: ExportPackageCounts | undefined;
const archive = await this.request<Buffer>('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(

View File

@ -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 } : {}),
});
});
}
}

View File

@ -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],

View File

@ -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)!;

View File

@ -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],
});

View File

@ -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([

View File

@ -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,

View File

@ -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],
});

View File

@ -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],

View File

@ -70,7 +70,7 @@ async function importPackage(params: ImportParams) {
}
async function exportWorkflowPackage(user: User, workflowId: string): Promise<Buffer> {
const stream = await service.exportPackage({
const { stream } = await service.exportPackage({
user,
workflowIds: [workflowId],
includeVariableValues: true,

View File

@ -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<Readable> {
async exportPackage(request: ExportPackageRequest): Promise<ExportPackageResult> {
// 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<ImportResult> {

View File

@ -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;

View File

@ -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<N8nPackagesService>;
let mockEventService: Mocked<EventService>;
@ -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(

View File

@ -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<Response> {
async function streamPackageExport(
res: Response,
{ stream, counts }: ExportPackageResult,
): Promise<Response> {
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<Response>((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,

View File

@ -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: