feat(core): Export git connection projects to repository folder (no-changelog) (#36719)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Irénée 2026-08-21 14:18:40 +00:00 committed by GitHub
parent c4892a4a1a
commit 50954e34cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 767 additions and 100 deletions

View File

@ -58,6 +58,28 @@ export const gitConnectionPublicSchema = z.object({
export class GitConnectionPublicDto extends Z.class(gitConnectionPublicSchema.shape) {}
/**
* Per-entity counts of what actually landed in the exported package (after folder
* bundling and auto-inclusion), surfaced by a push so the caller knows what was
* written to the working copy.
*/
export const gitConnectionExportCountsSchema = z.object({
workflows: z.number().int().nonnegative(),
folders: z.number().int().nonnegative(),
credentials: z.number().int().nonnegative(),
dataTables: z.number().int().nonnegative(),
variables: z.number().int().nonnegative(),
tags: z.number().int().nonnegative(),
});
/** Outcome of a push: which connection, and per-entity counts of what was exported to its working copy. */
export const gitConnectionPushResultSchema = z.object({
connectionId: z.string(),
counts: gitConnectionExportCountsSchema,
});
export class GitConnectionPushResultDto extends Z.class(gitConnectionPushResultSchema.shape) {}
export const gitConnectionSummarySchema = gitConnectionPublicSchema.omit({ publicKey: true });
export class GitConnectionListPublicDto extends Z.class({

View File

@ -105,6 +105,7 @@ export {
GitConnectionProjectListPublicDto,
GitConnectionProjectPublicDto,
GitConnectionPublicDto,
GitConnectionPushResultDto,
ListGitConnectionsQueryDto,
UpdateGitConnectionDto,
gitConnectionTypeSchema,

View File

@ -63,6 +63,9 @@
"config": {
"description": "View and update CLI configuration"
},
"git-connections": {
"description": "Create and manage Git repository connections"
},
"source-control": {
"description": "Pull changes from the configured source control provider"
},

View File

@ -42,6 +42,22 @@ describe('N8nClient packages', () => {
vi.unstubAllGlobals();
});
describe('pushGitConnectionProjects', () => {
it('POSTs to the push endpoint and returns the connection id and export counts', async () => {
const response = {
connectionId: 'connection-id',
counts: { workflows: 0, folders: 0, credentials: 0, dataTables: 0, variables: 0, tags: 0 },
};
fetchMock.mockResolvedValue(jsonResponse(200, response));
await expect(client.pushGitConnectionProjects('connection-id')).resolves.toEqual(response);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://n8n.example.com/api/v1/git-connections/connection-id/push');
expect(init.method).toBe('POST');
});
});
describe('exportPackage', () => {
it('posts the workflow IDs as JSON and returns the archive bytes', async () => {
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1, 2, 3])));

View File

@ -60,6 +60,12 @@ export interface ExportPackageResult {
counts?: ExportPackageCounts;
}
/** Outcome of pushing a Git connection's projects to its working copy. */
export type PushGitConnectionResult = {
connectionId: string;
counts: ExportPackageCounts;
};
export class ApiError extends Error {
constructor(
readonly statusCode: number,
@ -248,6 +254,10 @@ export class N8nClient {
return await this.del<undefined>(`/git-connections/${id}`);
}
async pushGitConnectionProjects(id: string) {
return await this.post<PushGitConnectionResult>(`/git-connections/${id}/push`);
}
async listGitConnectionProjects(id: string) {
return await this.get<{ projectIds: string[] }>(`/git-connections/${id}/projects`);
}

View File

@ -0,0 +1,24 @@
import { Args } from '@oclif/core';
import { BaseCommand } from '../../base-command';
export default class GitConnectionsPush extends BaseCommand {
static override description =
'Export linked projects to a Git connection working copy (work in progress; does not commit or push)';
static override args = {
id: Args.string({ description: 'ID of the Git connection', required: true }),
};
static override flags = { ...BaseCommand.baseFlags };
async run() {
const { args, flags } = await this.parse(GitConnectionsPush);
await this.execute(async () => {
const result = await this.getClient(flags).pushGitConnectionProjects(args.id);
this.succeed(
`Projects exported to the local working copy for Git connection ${args.id}. This work-in-progress command did not commit or push changes to the selected branch.`,
flags,
result,
);
});
}
}

View File

@ -30,6 +30,7 @@ import GitConnectionsDisconnect from './commands/git-connections/disconnect';
import GitConnectionsGet from './commands/git-connections/get';
import GitConnectionsList from './commands/git-connections/list';
import GitConnectionsListProjects from './commands/git-connections/list-projects';
import GitConnectionsPush from './commands/git-connections/push';
import GitConnectionsRemoveProject from './commands/git-connections/remove-project';
import GitConnectionsUpdate from './commands/git-connections/update';
import Login from './commands/login';
@ -113,6 +114,8 @@ export const commands = {
'tag:update': TagUpdate,
'tag:delete': TagDelete,
'git-connections:push': GitConnectionsPush,
'project:list': ProjectList,
'project:get': ProjectGet,
'project:create': ProjectCreate,

View File

@ -75,6 +75,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"gitConnection:delete",
"gitConnection:list",
"gitConnection:clone",
"gitConnection:push",
"gitConnection:manageProjects",
"tag:create",
"tag:read",

View File

@ -34,7 +34,7 @@ export const RESOURCES = {
securityAudit: ['generate'] as const,
securitySettings: ['manage'] as const,
sourceControl: ['pull', 'push', 'manage'] as const,
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'manageProjects'] as const,
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects'] as const,
tag: [...DEFAULT_OPERATIONS] as const,
user: [
'resetPassword',
@ -108,7 +108,7 @@ export const API_KEY_RESOURCES = {
credential: ['create', 'read', 'update', 'move', 'delete', 'list'] as const,
eventBusDestination: ['test', 'create', 'read', 'update', 'delete', 'list'] as const,
sourceControl: ['pull'] as const,
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'manageProjects'] as const,
gitConnection: [...DEFAULT_OPERATIONS, 'clone', 'push', 'manageProjects'] as const,
workflowTags: ['update', 'list'] as const,
executionTags: ['update', 'list'] as const,
communityPackage: ['install', 'uninstall', 'update', 'list'] as const,

View File

@ -19,6 +19,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'gitConnection:delete',
'gitConnection:list',
'gitConnection:clone',
'gitConnection:push',
'gitConnection:manageProjects',
'securityAudit:generate',
'securitySettings:manage',

View File

@ -65,6 +65,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'gitConnection:delete',
'gitConnection:list',
'gitConnection:clone',
'gitConnection:push',
'gitConnection:manageProjects',
'tag:create',
'tag:read',

View File

@ -157,6 +157,10 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
displayName: 'Clone Git Connection',
description: 'Allows cloning and removing the local working copy of a Git connection.',
},
'gitConnection:push': {
displayName: 'Push Git Connection',
description: 'Allows pushing all projects linked to a Git connection to its remote repository.',
},
'gitConnection:manageProjects': {
displayName: 'Manage Git Connection Projects',
description: 'Allows adding projects to and removing projects from a Git connection.',

View File

@ -1,6 +1,10 @@
import type { CreateGitConnectionDto, UpdateGitConnectionDto } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { ProjectRepository, User } from '@n8n/db';
import type { Cipher, InstanceSettings } from 'n8n-core';
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { MockedFunction } from 'vitest';
import { mock } from 'vitest-mock-extended';
@ -8,6 +12,11 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import {
MissingWorkflowDependencyPolicy,
WorkflowVersionPolicy,
} from '@/modules/n8n-packages/n8n-packages.types';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { GitConnection } from '../database/entities/git-connection.entity';
@ -24,16 +33,21 @@ describe('GitConnectionsService (credential state machine)', () => {
const gitConnectionProjectRepository = mock<GitConnectionProjectRepository>();
const projectRepository = mock<ProjectRepository>();
const gitService = mock<GitConnectionsGitService>();
const n8nPackagesService = mock<N8nPackagesService>();
const cipher = mock<Cipher>();
const instanceSettings = mock<InstanceSettings>({ n8nFolder: '/tmp/n8n' });
const logger = mock<Logger>();
logger.scoped.mockReturnValue(logger);
const service = new GitConnectionsService(
repository,
gitConnectionProjectRepository,
projectRepository,
gitService,
n8nPackagesService,
cipher,
instanceSettings,
logger,
);
const baseEntity = () => ({
@ -41,6 +55,7 @@ describe('GitConnectionsService (credential state machine)', () => {
name: 'c',
repositoryUrl: 'git@github.com:o/r.git',
branchName: 'main',
baseCommit: null,
createdAt: new Date(),
updatedAt: new Date(),
});
@ -212,6 +227,140 @@ describe('GitConnectionsService (credential state machine)', () => {
});
});
describe('push export', () => {
let n8nFolder: string;
let exportService: GitConnectionsService;
const actor = mock<User>({ id: 'actor' });
beforeEach(async () => {
n8nFolder = await mkdtemp(path.join(tmpdir(), 'n8n-git-connection-export-'));
exportService = new GitConnectionsService(
repository,
gitConnectionProjectRepository,
projectRepository,
gitService,
n8nPackagesService,
cipher,
mock<InstanceSettings>({ n8nFolder }),
logger,
);
repository.findOneBy.mockResolvedValue(sshEntity());
gitConnectionProjectRepository.findProjectIdsByConnection.mockResolvedValue([
'project-a',
'project-b',
]);
n8nPackagesService.exportPackageToDirectory.mockImplementation(
async (_request, { targetDir }) => {
await mkdir(path.join(targetDir, 'projects', 'alpha'), { recursive: true });
await writeFile(path.join(targetDir, 'manifest.json'), '{"projects":[]}');
await writeFile(path.join(targetDir, 'projects', 'alpha', 'project.json'), '{}');
return {
counts: {
workflows: 0,
folders: 0,
credentials: 0,
dataTables: 0,
variables: 0,
tags: 0,
},
};
},
);
});
afterEach(async () => {
await rm(n8nFolder, { recursive: true, force: true });
});
it('exports all linked projects into the n8n-export subfolder, leaving the root untouched', async () => {
const repositoryFolder = path.join(n8nFolder, 'git-connections', '1', 'repository');
const exportFolder = path.join(repositoryFolder, 'n8n-export');
await mkdir(path.join(repositoryFolder, '.git'), { recursive: true });
await writeFile(path.join(repositoryFolder, '.git', 'HEAD'), 'ref: refs/heads/main');
// A file the user keeps at the repository root must survive the export.
await writeFile(path.join(repositoryFolder, 'README.md'), '# my repo');
// A stale export from a project that is no longer linked must be removed.
await mkdir(exportFolder, { recursive: true });
await writeFile(path.join(exportFolder, 'stale.json'), '{}');
const result = await exportService.push('1', actor);
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
expect(gitConnectionProjectRepository.findProjectIdsByConnection).toHaveBeenCalledWith('1');
expect(n8nPackagesService.exportPackageToDirectory).toHaveBeenCalledWith(
{
user: actor,
projectIds: ['project-a', 'project-b'],
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.Fail,
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
},
{ targetDir: stagingFolder },
);
expect(path.dirname(stagingFolder)).toBe(repositoryFolder);
expect(path.basename(stagingFolder)).toMatch(/^\.n8n-export-/);
expect(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')).toBe(
'{"projects":[]}',
);
// `.git` and user files at the root are untouched.
expect(await readFile(path.join(repositoryFolder, '.git', 'HEAD'), 'utf-8')).toBe(
'ref: refs/heads/main',
);
expect(await readFile(path.join(repositoryFolder, 'README.md'), 'utf-8')).toBe('# my repo');
expect(result).toEqual({
connectionId: '1',
counts: {
workflows: 0,
folders: 0,
credentials: 0,
dataTables: 0,
variables: 0,
tags: 0,
},
});
// The stale export is gone; only the freshly written package remains.
await expect(stat(path.join(exportFolder, 'stale.json'))).rejects.toThrow();
await expect(stat(stagingFolder)).rejects.toThrow();
});
it('keeps the previous export and repository root intact when export fails', async () => {
const repositoryFolder = path.join(n8nFolder, 'git-connections', '1', 'repository');
const exportFolder = path.join(repositoryFolder, 'n8n-export');
await mkdir(path.join(repositoryFolder, '.git'), { recursive: true });
await writeFile(path.join(repositoryFolder, '.git', 'HEAD'), 'ref: refs/heads/main');
await writeFile(path.join(repositoryFolder, 'README.md'), '# my repo');
await mkdir(exportFolder, { recursive: true });
await writeFile(path.join(exportFolder, 'manifest.json'), '{"previous":true}');
n8nPackagesService.exportPackageToDirectory.mockRejectedValueOnce(
new BadRequestError('A linked project dependency is missing'),
);
await expect(exportService.push('1', actor)).rejects.toThrow(BadRequestError);
const stagingFolder = n8nPackagesService.exportPackageToDirectory.mock.calls[0][1].targetDir;
expect(await readFile(path.join(repositoryFolder, '.git', 'HEAD'), 'utf-8')).toBe(
'ref: refs/heads/main',
);
expect(await readFile(path.join(repositoryFolder, 'README.md'), 'utf-8')).toBe('# my repo');
expect(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')).toBe(
'{"previous":true}',
);
await expect(stat(stagingFolder)).rejects.toThrow();
});
it('does not query projects or write files for a missing connection', async () => {
repository.findOneBy.mockResolvedValueOnce(null);
await expect(exportService.push('missing', actor)).rejects.toThrow(
'Git connection not found',
);
expect(gitConnectionProjectRepository.findProjectIdsByConnection).not.toHaveBeenCalled();
expect(n8nPackagesService.exportPackageToDirectory).not.toHaveBeenCalled();
});
});
describe('adding and removing projects', () => {
const teamProject = { id: 'p1', type: 'team' };
const personalProject = { id: 'p1', type: 'personal' };

View File

@ -3,18 +3,26 @@ import {
GitConnectionProjectListPublicDto,
GitConnectionProjectPublicDto,
type GitConnectionPublicDto,
type GitConnectionPushResultDto,
type UpdateGitConnectionDto,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import { ProjectRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { Cipher, InstanceSettings } from 'n8n-core';
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
import path from 'node:path';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { N8nPackagesService } from '@/modules/n8n-packages/n8n-packages.service';
import {
MissingWorkflowDependencyPolicy,
WorkflowVersionPolicy,
} from '@/modules/n8n-packages/n8n-packages.types';
import { userHasScopes } from '@/permissions.ee/check-access';
import { GitConnectionProject } from './database/entities/git-connection-project.entity';
@ -23,6 +31,13 @@ import { GitConnectionProjectRepository } from './database/repositories/git-conn
import { GitConnectionRepository } from './database/repositories/git-connection.repository';
import { GitConnectionsGitService } from './git-connections-git.service';
/**
* Subfolder of the working copy that holds the n8n-managed export. Keeping it
* separate from the repository root leaves `.git` and any files the user commits
* at the root untouched, and scopes the export's cleanup to a single directory.
*/
const EXPORT_SUBFOLDER = 'n8n-export';
type ManageProjectLinkOptions = {
user: User;
connectionId: string;
@ -36,9 +51,13 @@ export class GitConnectionsService {
private readonly gitConnectionProjectRepository: GitConnectionProjectRepository,
private readonly projectRepository: ProjectRepository,
private readonly gitService: GitConnectionsGitService,
private readonly n8nPackagesService: N8nPackagesService,
private readonly cipher: Cipher,
private readonly instanceSettings: InstanceSettings,
) {}
private readonly logger: Logger,
) {
this.logger = this.logger.scoped('git-connections');
}
async create(input: CreateGitConnectionDto) {
this.gitService.validateRepositoryUrl(input.repositoryUrl, input.connectionType);
@ -128,6 +147,58 @@ export class GitConnectionsService {
await this.repository.remove(connection);
}
async push(connectionId: string, actor: User): Promise<GitConnectionPushResultDto> {
return await this.exportProjectsToRepository(connectionId, actor);
}
private async exportProjectsToRepository(
connectionId: string,
actor: User,
): Promise<GitConnectionPushResultDto> {
// Validates the connection exists (throws NotFound otherwise) before any export work.
await this.getEntity(connectionId);
const projectIds =
await this.gitConnectionProjectRepository.findProjectIdsByConnection(connectionId);
const repositoryFolder = path.join(this.rootFolder(connectionId), 'repository');
const exportFolder = path.join(repositoryFolder, EXPORT_SUBFOLDER);
this.logger.info('Exporting projects to Git connection repository', {
connectionId,
projectCount: projectIds.length,
});
await mkdir(repositoryFolder, { recursive: true });
const stagingFolder = await mkdtemp(path.join(repositoryFolder, `.${EXPORT_SUBFOLDER}-`));
try {
const exportResult = await this.n8nPackagesService.exportPackageToDirectory(
{
user: actor,
projectIds,
includeVariableValues: true,
canExportVariableValues: true,
includeTags: true,
missingWorkflowDependencyPolicy: MissingWorkflowDependencyPolicy.Fail,
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
},
{ targetDir: stagingFolder },
);
// Replace the managed export only after the new package is complete.
await rm(exportFolder, { recursive: true, force: true });
await rename(stagingFolder, exportFolder);
// TODO: commit the working copy and push to the remote, then surface the
// git operation result here.
return {
connectionId,
counts: exportResult.counts,
};
} finally {
await rm(stagingFolder, { recursive: true, force: true });
}
}
async addProject({
user,
connectionId,

View File

@ -0,0 +1,103 @@
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createOwner } from '@test-integration/db/users';
import { EventService } from '@/events/event.service';
import { N8nPackagesService } from '../n8n-packages.service';
import { FORMAT_VERSION } from '../spec/constants';
let service: N8nPackagesService;
let owner: User;
let targetDir: string;
beforeAll(async () => {
await testModules.loadModules(['n8n-packages']);
await testDb.init();
service = Container.get(N8nPackagesService);
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
await testDb.truncate([
'Folder',
'WorkflowEntity',
'SharedWorkflow',
'ProjectRelation',
'Project',
]);
owner = await createOwner();
targetDir = await mkdtemp(path.join(tmpdir(), 'n8n-export-dir-'));
});
afterEach(async () => {
await rm(targetDir, { recursive: true, force: true });
});
async function readJson(...segments: string[]) {
return JSON.parse(await readFile(path.join(targetDir, ...segments), 'utf-8'));
}
describe('exportPackageToDirectory', () => {
it('writes the unzipped package straight into the target directory', async () => {
const project = await createTeamProject('Alpha Project', owner);
await createWorkflow({ name: 'WF One', nodes: [], connections: {} }, project);
const result = await service.exportPackageToDirectory(
{ user: owner, projectIds: [project.id] },
{ targetDir },
);
const manifest = await readJson('manifest.json');
expect(manifest).toMatchObject({ packageFormatVersion: FORMAT_VERSION });
expect(manifest.projects).toEqual([
{ id: project.id, name: 'Alpha Project', target: 'projects/alpha-project' },
]);
expect(await readJson('projects/alpha-project/project.json')).toEqual({
id: project.id,
name: 'Alpha Project',
});
expect(result.counts.workflows).toBe(1);
});
it('writes multiple projects into one combined package', async () => {
const alpha = await createTeamProject('Alpha Project', owner);
const beta = await createTeamProject('Beta Project', owner);
await createWorkflow({ name: 'Alpha Workflow', nodes: [], connections: {} }, alpha);
await createWorkflow({ name: 'Beta Workflow', nodes: [], connections: {} }, beta);
const result = await service.exportPackageToDirectory(
{ user: owner, projectIds: [alpha.id, beta.id] },
{ targetDir },
);
const manifest = await readJson('manifest.json');
expect(manifest.projects).toEqual([
{ id: alpha.id, name: 'Alpha Project', target: 'projects/alpha-project' },
{ id: beta.id, name: 'Beta Project', target: 'projects/beta-project' },
]);
expect(await readdir(targetDir)).toEqual(expect.arrayContaining(['manifest.json', 'projects']));
expect(result.counts.workflows).toBe(2);
});
it('does not emit the user package-export event', async () => {
const project = await createTeamProject('Alpha Project', owner);
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
await service.exportPackageToDirectory(
{ user: owner, projectIds: [project.id] },
{ targetDir },
);
expect(emitSpy).not.toHaveBeenCalledWith('n8n-package-exported', expect.anything());
emitSpy.mockRestore();
});
});

View File

@ -83,8 +83,8 @@ export class CredentialExporter {
);
const baseDir = this.resolveBaseDir(credential, request.projectTargetsById);
const target = allocatorFor(baseDir).allocate(name);
request.writer.writeDirectory(target);
request.writer.writeFile(
await request.writer.writeDirectory(target);
await request.writer.writeFile(
`${target}/credential.json`,
JSON.stringify(this.credentialSerializer.serialize(credential, { data }), null, '\t'),
);

View File

@ -74,8 +74,8 @@ export class DataTableExporter {
const baseDir = this.resolveBaseDir(dataTable, request.projectTargetsById);
const target = allocatorFor(baseDir).allocate(dataTable.name);
request.writer.writeDirectory(target);
request.writer.writeFile(
await request.writer.writeDirectory(target);
await request.writer.writeFile(
`${target}/data-table.json`,
JSON.stringify(this.dataTableSerializer.serialize(dataTable), null, '\t'),
);

View File

@ -137,7 +137,7 @@ export class FolderExporter {
): Promise<FolderExportResult> {
const { childrenByParent, workflowIdsByFolder, request } = context;
this.exportFolderShell(folder, target, effectiveParentId, request.writer);
await this.exportFolderShell(folder, target, effectiveParentId, request.writer);
const workflowIds = workflowIdsByFolder.get(folder.id) ?? [];
const contained = await this.exportContainedWorkflows(workflowIds, target, request);
@ -160,15 +160,15 @@ export class FolderExporter {
return this.mergeFolderExportResults([own, descendants]);
}
private exportFolderShell(
private async exportFolderShell(
folder: Folder,
target: string,
effectiveParentId: string | null,
writer: PackageWriter,
): void {
): Promise<void> {
const serialized = this.folderSerializer.serialize(folder, effectiveParentId);
writer.writeDirectory(target);
writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
await writer.writeDirectory(target);
await writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
}
private async exportContainedWorkflows(

View File

@ -75,7 +75,7 @@ export class ProjectExporter {
target: string,
request: ProjectExportRequest,
): Promise<ProjectExportResult> {
this.exportProjectShell(project, target, request.writer);
await this.exportProjectShell(project, target, request.writer);
const folders = await this.exportProjectFolders(project.id, target, request);
const rootWorkflows = await this.exportProjectRootWorkflows(project.id, target, request);
@ -88,10 +88,14 @@ export class ProjectExporter {
};
}
private exportProjectShell(project: Project, target: string, writer: PackageWriter): void {
private async exportProjectShell(
project: Project,
target: string,
writer: PackageWriter,
): Promise<void> {
const serialized = this.projectSerializer.serialize(project);
writer.writeDirectory(target);
writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
await writer.writeDirectory(target);
await writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
}
private async exportProjectFolders(

View File

@ -37,7 +37,7 @@ export interface TagExportResult {
@Service()
export class TagExporter {
export(request: TagExportRequest): TagExportResult {
async export(request: TagExportRequest): Promise<TagExportResult> {
const requirementsByTagId = new Map<string, PackageTagRequirement>();
for (const { workflowId, tag } of request.usages) {
@ -60,8 +60,8 @@ export class TagExporter {
for (const { id, name } of requirements) {
const tagDirectory = allocator.allocate(name);
const serializedTag = serializedTagSchema.parse(serializePayload({ id, name }));
request.writer.writeDirectory(tagDirectory);
request.writer.writeFile(
await request.writer.writeDirectory(tagDirectory);
await request.writer.writeFile(
`${tagDirectory}/tag.json`,
JSON.stringify(serializedTag, null, '\t'),
);

View File

@ -91,8 +91,8 @@ export class VariableExporter {
const baseDir = this.resolveBaseDir(variable, request.projectTargetsById);
const target = allocatorFor(baseDir).allocate(variable.key);
request.writer.writeDirectory(target);
request.writer.writeFile(
await request.writer.writeDirectory(target);
await request.writer.writeFile(
`${target}/variable.json`,
JSON.stringify(
this.serializeOrBlock(variable, request.includeVariableValues),

View File

@ -76,12 +76,12 @@ function emptyRequest(writer: CapturingWriter, workflows: AutoIncludedWorkflow[]
}
describe('AutoIncludedWorkflowExporter', () => {
it('writes a top-level workflow under workflows/', () => {
it('writes a top-level workflow under workflows/', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-triage', name: 'Triage' });
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [includedWorkflow({ workflow, placement: 'top-level' })]),
);
@ -91,12 +91,12 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(writer.files.map((f) => f.path)).toContain('workflows/triage/workflow.json');
});
it('skips a workflow already present in the existing workflow entries', () => {
it('skips a workflow already present in the existing workflow entries', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-dup', name: 'Already Here' });
const result = exporter.export({
const result = await exporter.export({
...emptyRequest(writer, [includedWorkflow({ workflow })]),
existingWorkflowEntries: [
{ id: 'wf-dup', name: 'Already Here', target: 'workflows/already-here' },
@ -107,12 +107,12 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(writer.files).toEqual([]);
});
it('disambiguates a workflow target that collides with an existing entry', () => {
it('disambiguates a workflow target that collides with an existing entry', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-new', name: 'Same Name' });
const result = exporter.export({
const result = await exporter.export({
...emptyRequest(writer, [includedWorkflow({ workflow })]),
// a different workflow already occupies workflows/same-name
existingWorkflowEntries: [
@ -123,13 +123,13 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(result.workflowEntries[0].target).toBe('workflows/same-name-2');
});
it('places a folder workflow under its serialized folder chain', () => {
it('places a folder workflow under its serialized folder chain', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-nested', name: 'Nested' });
const chain = [makeFolder('f-root', 'Root'), makeFolder('f-child', 'Child')];
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [
includedWorkflow({ workflow, placement: 'folder', folderChain: chain }),
]),
@ -147,13 +147,13 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(childFolder.parentFolderId).toBe('f-root');
});
it('reuses an existing folder entry instead of recreating it', () => {
it('reuses an existing folder entry instead of recreating it', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-nested', name: 'Nested' });
const chain = [makeFolder('f-root', 'Root')];
const result = exporter.export({
const result = await exporter.export({
...emptyRequest(writer, [
includedWorkflow({ workflow, placement: 'folder', folderChain: chain }),
]),
@ -165,13 +165,13 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(writer.files.some((f) => f.path === 'folders/root/folder.json')).toBe(false);
});
it('creates a project shell for a project workflow and reports its target', () => {
it('creates a project shell for a project workflow and reports its target', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-p', name: 'In Project' });
const project = makeProject('proj-9', 'Marketing');
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [
includedWorkflow({
workflow,
@ -190,14 +190,14 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(writer.files.some((f) => f.path === 'projects/marketing/project.json')).toBe(true);
});
it('nests a project workflow with a folder chain under the project folders/', () => {
it('nests a project workflow with a folder chain under the project folders/', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-pf', name: 'Deep' });
const project = makeProject('proj-9', 'Marketing');
const chain = [makeFolder('f-a', 'Campaigns')];
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [
includedWorkflow({
workflow,
@ -213,13 +213,13 @@ describe('AutoIncludedWorkflowExporter', () => {
);
});
it('reuses an existing project entry and preserves its target', () => {
it('reuses an existing project entry and preserves its target', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-p', name: 'In Project' });
const project = makeProject('proj-9', 'Marketing');
const result = exporter.export({
const result = await exporter.export({
...emptyRequest(writer, [
includedWorkflow({
workflow,
@ -237,7 +237,7 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(writer.files.some((f) => f.path === 'projects/marketing/project.json')).toBe(false);
});
it('extracts credential, data-table, and variable requirements from each workflow', () => {
it('extracts credential, data-table, and variable requirements from each workflow', async () => {
const credentialExtractor = mock<CredentialRequirementsExtractor>();
credentialExtractor.extract.mockReturnValue([
{
@ -255,7 +255,7 @@ describe('AutoIncludedWorkflowExporter', () => {
const exporter = makeExporter(credentialExtractor, dataTableExtractor, variableExtractor);
const writer = new CapturingWriter();
const result = exporter.export(emptyRequest(writer, [includedWorkflow()]));
const result = await exporter.export(emptyRequest(writer, [includedWorkflow()]));
expect(result.requirements.credentials).toEqual([
{
@ -271,7 +271,7 @@ describe('AutoIncludedWorkflowExporter', () => {
]);
});
it('collects each workflow node list into requirements.nodeTypes', () => {
it('collects each workflow node list into requirements.nodeTypes', async () => {
const nodeA = {
id: 'n1',
name: 'HTTP',
@ -285,7 +285,7 @@ describe('AutoIncludedWorkflowExporter', () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [includedWorkflow({ workflow: a }), includedWorkflow({ workflow: b })]),
);
@ -295,14 +295,14 @@ describe('AutoIncludedWorkflowExporter', () => {
]);
});
it('does not extract requirements from a skipped (already-exported) workflow', () => {
it('does not extract requirements from a skipped (already-exported) workflow', async () => {
const credentialExtractor = mock<CredentialRequirementsExtractor>();
credentialExtractor.extract.mockReturnValue([]);
const exporter = makeExporter(credentialExtractor);
const writer = new CapturingWriter();
const workflow = makeWorkflow({ id: 'wf-dup', name: 'Already Here' });
const result = exporter.export({
const result = await exporter.export({
...emptyRequest(writer, [includedWorkflow({ workflow })]),
existingWorkflowEntries: [
{ id: 'wf-dup', name: 'Already Here', target: 'workflows/already-here' },
@ -313,13 +313,13 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(result.requirements.nodeTypes).toEqual([]);
});
it('reserves the workflows/ segment so a child folder named "workflows" does not collide with its parent workflow directory', () => {
it('reserves the workflows/ segment so a child folder named "workflows" does not collide with its parent workflow directory', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const root = makeFolder('f-root', 'Root');
const wfFolder = makeFolder('f-wf', 'Workflows');
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [
// A workflow placed directly in Root → folders/root/workflows/alpha
includedWorkflow({
@ -346,12 +346,12 @@ describe('AutoIncludedWorkflowExporter', () => {
expect(nestedWorkflow?.target).toBe('folders/root/workflows-2/workflows/beta');
});
it('shares one folder shell between two workflows in the same folder', () => {
it('shares one folder shell between two workflows in the same folder', async () => {
const exporter = makeExporter();
const writer = new CapturingWriter();
const chain = [makeFolder('f-root', 'Root')];
const result = exporter.export(
const result = await exporter.export(
emptyRequest(writer, [
includedWorkflow({
workflow: makeWorkflow({ id: 'wf-a', name: 'Alpha' }),

View File

@ -60,7 +60,9 @@ export class AutoIncludedWorkflowExporter {
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
) {}
export(request: AutoIncludedWorkflowExportRequest): AutoIncludedWorkflowExportResult {
async export(
request: AutoIncludedWorkflowExportRequest,
): Promise<AutoIncludedWorkflowExportResult> {
const allocators: ExportAllocators = {
workflows: new Map(),
folders: new Map(),
@ -103,7 +105,7 @@ export class AutoIncludedWorkflowExporter {
for (const included of request.workflows) {
if (workflowEntriesById.has(included.workflow.id)) continue;
const baseDir = this.resolveWorkflowBaseDir({
const baseDir = await this.resolveWorkflowBaseDir({
included,
writer: request.writer,
folderEntriesById,
@ -113,7 +115,7 @@ export class AutoIncludedWorkflowExporter {
projectEntries,
allocators,
});
const entry = this.writeWorkflow(
const entry = await this.writeWorkflow(
included.workflow,
baseDir,
request.writer,
@ -141,7 +143,7 @@ export class AutoIncludedWorkflowExporter {
};
}
private resolveWorkflowBaseDir(options: {
private async resolveWorkflowBaseDir(options: {
included: AutoIncludedWorkflow;
writer: PackageWriter;
folderEntriesById: Map<string, ManifestEntry>;
@ -150,10 +152,10 @@ export class AutoIncludedWorkflowExporter {
folderEntries: ManifestEntry[];
projectEntries: ManifestEntry[];
allocators: ExportAllocators;
}): string {
}): Promise<string> {
const { included } = options;
if (included.placement === 'project') {
const projectTarget = this.ensureProjectShell({
const projectTarget = await this.ensureProjectShell({
project: included.ownerProject,
writer: options.writer,
projectEntriesById: options.projectEntriesById,
@ -166,7 +168,7 @@ export class AutoIncludedWorkflowExporter {
return `${projectTarget}/workflows`;
}
const folderTarget = this.ensureFolderChain({
const folderTarget = await this.ensureFolderChain({
chain: included.folderChain,
baseDir: `${projectTarget}/folders`,
writer: options.writer,
@ -178,7 +180,7 @@ export class AutoIncludedWorkflowExporter {
}
if (included.placement === 'folder' && included.folderChain.length > 0) {
const folderTarget = this.ensureFolderChain({
const folderTarget = await this.ensureFolderChain({
chain: included.folderChain,
baseDir: 'folders',
writer: options.writer,
@ -192,21 +194,24 @@ export class AutoIncludedWorkflowExporter {
return 'workflows';
}
private ensureProjectShell(options: {
private async ensureProjectShell(options: {
project: Project;
writer: PackageWriter;
projectEntriesById: Map<string, ManifestEntry>;
projectTargetsById: Map<string, string>;
projectEntries: ManifestEntry[];
allocator: UniqueFilenameAllocator;
}): string {
}): Promise<string> {
const existing = options.projectEntriesById.get(options.project.id);
if (existing) return existing.target;
const target = options.allocator.allocate(options.project.name);
const serialized = this.projectSerializer.serialize(options.project);
options.writer.writeDirectory(target);
options.writer.writeFile(`${target}/project.json`, JSON.stringify(serialized, null, '\t'));
await options.writer.writeDirectory(target);
await options.writer.writeFile(
`${target}/project.json`,
JSON.stringify(serialized, null, '\t'),
);
const entry = { id: options.project.id, name: options.project.name, target };
options.projectEntries.push(entry);
@ -215,14 +220,14 @@ export class AutoIncludedWorkflowExporter {
return target;
}
private ensureFolderChain(options: {
private async ensureFolderChain(options: {
chain: Folder[];
baseDir: string;
writer: PackageWriter;
folderEntriesById: Map<string, ManifestEntry>;
folderEntries: ManifestEntry[];
allocators: Map<string, UniqueFilenameAllocator>;
}): string {
}): Promise<string> {
let parentTarget: string | undefined;
let effectiveParentId: string | null = null;
@ -241,8 +246,11 @@ export class AutoIncludedWorkflowExporter {
if (parentTarget) allocator.reserve('workflows');
const target = allocator.allocate(folder.name);
const serialized = this.folderSerializer.serialize(folder, effectiveParentId);
options.writer.writeDirectory(target);
options.writer.writeFile(`${target}/folder.json`, JSON.stringify(serialized, null, '\t'));
await options.writer.writeDirectory(target);
await options.writer.writeFile(
`${target}/folder.json`,
JSON.stringify(serialized, null, '\t'),
);
const entry = { id: folder.id, name: folder.name, target };
options.folderEntries.push(entry);
@ -263,17 +271,17 @@ export class AutoIncludedWorkflowExporter {
return parentTarget;
}
private writeWorkflow(
private async writeWorkflow(
workflow: WorkflowEntity,
baseDir: string,
writer: PackageWriter,
allocators: Map<string, UniqueFilenameAllocator>,
includeTags: boolean,
): ManifestEntry {
): Promise<ManifestEntry> {
const target = allocatorFor(allocators, baseDir, 'workflow').allocate(workflow.name);
const serialized = this.workflowSerializer.serialize(workflow, { includeTags });
writer.writeDirectory(target);
writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
await writer.writeDirectory(target);
await writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
return { id: workflow.id, name: workflow.name, target };
}
}

View File

@ -88,8 +88,11 @@ export class WorkflowExporter {
includeTags: request.includeTags,
});
request.writer.writeDirectory(target);
request.writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
await request.writer.writeDirectory(target);
await request.writer.writeFile(
`${target}/workflow.json`,
JSON.stringify(serialized, null, '\t'),
);
entries.push({
id: workflow.id,

View File

@ -0,0 +1,60 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { DirectoryPackageWriter } from '../directory/directory-package-writer';
describe('DirectoryPackageWriter', () => {
let targetDir: string;
beforeEach(async () => {
targetDir = await mkdtemp(path.join(tmpdir(), 'n8n-dir-writer-'));
});
afterEach(async () => {
await rm(targetDir, { recursive: true, force: true });
});
it('writes files and directories directly to disk', async () => {
const writer = new DirectoryPackageWriter(targetDir);
await writer.writeFile('manifest.json', '{"packageFormatVersion":"1"}');
await writer.writeDirectory('projects/alpha/workflows');
await writer.writeFile('projects/alpha/workflows/wf/workflow.json', '{"id":"wf"}');
expect(await readFile(path.join(targetDir, 'manifest.json'), 'utf-8')).toBe(
'{"packageFormatVersion":"1"}',
);
expect(
await readFile(path.join(targetDir, 'projects/alpha/workflows/wf/workflow.json'), 'utf-8'),
).toBe('{"id":"wf"}');
expect((await stat(path.join(targetDir, 'projects/alpha/workflows'))).isDirectory()).toBe(true);
});
it('writes files before finalize is called', async () => {
const writer = new DirectoryPackageWriter(targetDir);
await writer.writeFile('manifest.json', '{}');
expect((await stat(path.join(targetDir, 'manifest.json'))).isFile()).toBe(true);
});
it('creates missing parent directories for a file entry', async () => {
const writer = new DirectoryPackageWriter(targetDir);
// No explicit writeDirectory for the parents.
await writer.writeFile('a/b/c/deep.json', '{"deep":true}');
expect(await readFile(path.join(targetDir, 'a/b/c/deep.json'), 'utf-8')).toBe('{"deep":true}');
});
it('normalises leading "./" entry paths', async () => {
const writer = new DirectoryPackageWriter(targetDir);
await writer.writeFile('./manifest.json', '{}');
expect(await readFile(path.join(targetDir, 'manifest.json'), 'utf-8')).toBe('{}');
});
it('refuses to write outside the target directory', async () => {
const writer = new DirectoryPackageWriter(targetDir);
await expect(writer.writeFile('../escape.json', '{}')).rejects.toThrow(/outside the target/);
});
});

View File

@ -1,5 +1,3 @@
import type { Readable } from 'node:stream';
import type { PackageWriter } from '../../package-writer';
export class CapturingWriter implements PackageWriter {
@ -15,7 +13,5 @@ export class CapturingWriter implements PackageWriter {
this.directories.push(path);
}
finalize(): Readable {
throw new Error('CapturingWriter is for unit tests and does not produce a stream');
}
finalize(): void {}
}

View File

@ -0,0 +1,42 @@
import { UnexpectedError } from 'n8n-workflow';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { PackageWriter } from '../package-writer';
function normaliseEntryPath(entryPath: string): string {
return entryPath.startsWith('./') ? entryPath.slice(2) : entryPath;
}
/**
* Writes the n8n-packages layout as loose files into a directory (the "unzipped"
* format), rather than a tar archive.
*/
export class DirectoryPackageWriter implements PackageWriter {
constructor(private readonly targetDir: string) {}
async writeFile(entryPath: string, content: string | Buffer): Promise<void> {
const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath));
await mkdir(path.dirname(destination), { recursive: true });
await writeFile(destination, content);
}
async writeDirectory(entryPath: string): Promise<void> {
const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath));
await mkdir(destination, { recursive: true });
}
async finalize(): Promise<void> {
return;
}
/** Entry paths are exporter-generated slugs, but keep writes inside the base dir defensively. */
private resolveWithin(baseDir: string, entryPath: string): string {
const destination = path.resolve(baseDir, entryPath);
const relative = path.relative(baseDir, destination);
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
throw new UnexpectedError(`Refusing to write package entry outside the target: ${entryPath}`);
}
return destination;
}
}

View File

@ -1,7 +1,5 @@
import type { Readable } from 'node:stream';
/** Write surface for exporters: append files and directories. */
export interface PackageWriter {
writeFile(path: string, content: string | Buffer): void;
writeDirectory(path: string): void;
finalize(): Readable;
writeFile(path: string, content: string | Buffer): void | Promise<void>;
writeDirectory(path: string): void | Promise<void>;
}

View File

@ -31,6 +31,8 @@ import {
import { WorkflowDependencyResolver } from './entities/workflow/workflow-dependency-resolver';
import { WorkflowRequirementExporter } from './entities/workflow/workflow-requirement.exporter';
import { WorkflowExporter } from './entities/workflow/workflow.exporter';
import { DirectoryPackageWriter } from './io/directory/directory-package-writer';
import type { PackageWriter } from './io/package-writer';
import { TarPackageReader } from './io/tar/tar-package-reader';
import { TarPackageWriter } from './io/tar/tar-package-writer';
import { PackageImportConfig } from './n8n-packages.config';
@ -41,6 +43,7 @@ import {
type ExportPackageEventCounts,
type ExportPackageRequest,
type ExportPackageResult,
type ExportPackageSummary,
type ImportPackageRequest,
type ImportResult,
} from './n8n-packages.types';
@ -52,6 +55,14 @@ import {
} from './spec/manifest.schema';
import type { PackageRequirements } from './spec/requirements.schema';
interface WrittenExport {
counts: ExportPackageEventCounts;
workflowIds: string[];
folderIds: string[];
projectIds: string[];
credentialExportPolicy: CredentialExportPolicy;
}
@Service()
export class N8nPackagesService {
constructor(
@ -76,11 +87,46 @@ export class N8nPackagesService {
) {}
async exportPackage(request: ExportPackageRequest): Promise<ExportPackageResult> {
const writer = new TarPackageWriter();
const result = await this.writeExport(writer, request);
const stream = writer.finalize();
// This event represents a user-facing archive export, not an internal directory write.
this.eventService.emit('n8n-package-exported', {
user: request.user,
...(result.workflowIds.length ? { workflowIds: result.workflowIds } : {}),
...(result.folderIds.length ? { folderIds: result.folderIds } : {}),
...(result.projectIds.length ? { projectIds: result.projectIds } : {}),
counts: result.counts,
credentialExportPolicy: result.credentialExportPolicy,
});
return { stream, counts: result.counts };
}
/**
* Exports the same n8n-packages layout as {@link exportPackage}, but as loose
* files on disk (the unzipped format) under `target.targetDir` instead of a tar
* stream. Reuses the full export orchestration; only the writer differs.
*/
async exportPackageToDirectory(
request: ExportPackageRequest,
target: { targetDir: string },
): Promise<ExportPackageSummary> {
const writer = new DirectoryPackageWriter(target.targetDir);
const result = await this.writeExport(writer, request);
await writer.finalize();
return { counts: result.counts };
}
private async writeExport(
writer: PackageWriter,
request: ExportPackageRequest,
): Promise<WrittenExport> {
const { missingWorkflowDependencyPolicy } = request;
const isReferenceOnly =
missingWorkflowDependencyPolicy === MissingWorkflowDependencyPolicy.ReferenceOnly;
const writer = new TarPackageWriter();
const workflowIds = request.workflowIds ?? [];
const folderIds = request.folderIds ?? [];
const projectIds = request.projectIds ?? [];
@ -161,7 +207,7 @@ export class N8nPackagesService {
workflowVersionPolicy,
});
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
autoIncludedExportResult = await this.autoIncludedWorkflowExporter.export({
writer,
workflows: autoIncludedWorkflowResolution.autoIncludedWorkflows,
existingWorkflowEntries: allWorkflowsBeforeAutoInclude,
@ -247,7 +293,7 @@ export class N8nPackagesService {
projectTargetsById,
});
const tagExportResult = this.tagExporter.export({
const tagExportResult = await this.tagExporter.export({
usages: requirements.tags,
writer,
});
@ -282,9 +328,7 @@ export class N8nPackagesService {
...(allProjects.length > 0 ? { projects: allProjects } : {}),
});
writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));
const stream = writer.finalize();
await writer.writeFile('manifest.json', JSON.stringify(manifest, null, '\t'));
const counts: ExportPackageEventCounts = {
workflows: allWorkflowsInPackage.length,
@ -295,18 +339,13 @@ export class N8nPackagesService {
tags: tagExportResult.entries.length,
};
this.eventService.emit('n8n-package-exported', {
user: request.user,
...(allWorkflowsInPackage.length
? { workflowIds: allWorkflowsInPackage.map(({ id }) => id) }
: {}),
...(allFolders.length ? { folderIds: allFolders.map(({ id }) => id) } : {}),
...(allProjects.length ? { projectIds: allProjects.map(({ id }) => id) } : {}),
return {
counts,
workflowIds: allWorkflowsInPackage.map(({ id }) => id),
folderIds: allFolders.map(({ id }) => id),
projectIds: allProjects.map(({ id }) => id),
credentialExportPolicy,
});
return { stream, counts };
};
}
async importPackage(request: ImportPackageRequest): Promise<ImportResult> {

View File

@ -383,15 +383,24 @@ export type ExportPackageEventCounts = {
};
/**
* 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.
* Summary of what an export produced: the true per-entity counts of what
* actually ended up in the package (after folder bundling and auto-inclusion).
* Always available regardless of the sink — consumers surface these instead of
* the requested id counts.
*/
export interface ExportPackageResult {
stream: Readable;
export interface ExportPackageSummary {
counts: ExportPackageEventCounts;
}
/**
* Result of an export where the package itself is returned to the caller as an
* archive stream, on top of the summary. Contrast with a directory export, which
* writes to disk in place and only returns the {@link ExportPackageSummary}.
*/
export interface ExportPackageResult extends ExportPackageSummary {
stream: Readable;
}
/**
* The outcome for one package workflow, folding in what the publish phase decided for it. Import
* writes and publishes in two separate phases, but a consumer cannot act on that distinction, so

View File

@ -5,6 +5,7 @@ import {
GitConnectionProjectListPublicDto,
GitConnectionProjectPublicDto,
GitConnectionPublicDto,
GitConnectionPushResultDto,
ListGitConnectionsQueryDto,
MAX_ITEMS_PER_PAGE,
UpdateGitConnectionDto,
@ -196,6 +197,27 @@ export class GitConnectionsPublicController {
await (await this.gitConnectionsService()).delete(id);
}
@Post('/:id/push')
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
@ApiKeyScope('gitConnection:push')
@GlobalScope('gitConnection:push')
@ApiSummary('Push all projects linked to a Git connection')
@ApiDescription(
'Work in progress. Exports all linked projects to the local repository working copy. It does not commit or push changes to the selected branch yet.',
)
@ApiTags(tags)
@ApiResponse(200, GitConnectionPushResultDto)
@ApiErrorResponse(400)
@ApiErrorResponse(404)
@ApiErrorResponse(503)
async pushGitConnectionProjects(
req: AuthenticatedRequest,
_res: Response,
@Param('id') id: string,
): Promise<GitConnectionPushResultDto> {
return await (await this.gitConnectionsService()).push(id, req.user);
}
@Get('/:id/projects')
@Licensed(LICENSE_FEATURES.GIT_CONNECTIONS)
@ApiKeyScope('gitConnection:read')

View File

@ -0,0 +1,66 @@
operationId: pushGitConnectionProjects
tags:
- GitConnections
summary: Push all projects linked to a Git connection
description: Work in progress. Exports all linked projects to the local repository working copy. It does not commit or push changes to the selected branch yet.
x-required-scope: gitConnection:push
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
name: id
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
connectionId:
type: string
counts:
type: object
properties:
workflows:
type: integer
minimum: 0
folders:
type: integer
minimum: 0
credentials:
type: integer
minimum: 0
dataTables:
type: integer
minimum: 0
variables:
type: integer
minimum: 0
tags:
type: integer
minimum: 0
required:
- workflows
- folders
- credentials
- dataTables
- variables
- tags
required:
- connectionId
- counts
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'503':
$ref: ../../../../shared/spec/responses/serviceUnavailable.yml

View File

@ -36,6 +36,7 @@ export const ERROR_RESPONSE_REFS = {
403: { $ref: '../../../../shared/spec/responses/forbidden.yml' },
404: { $ref: '../../../../shared/spec/responses/notFound.yml' },
409: { $ref: '../../../../shared/spec/responses/conflict.yml' },
503: { $ref: '../../../../shared/spec/responses/serviceUnavailable.yml' },
} as const satisfies Record<number, { $ref: string }>;
type DocumentedErrorStatus = keyof typeof ERROR_RESPONSE_REFS;
@ -55,6 +56,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record<DocumentedErrorStatus, string>
403: 'Forbidden',
404: 'The specified resource was not found.',
409: 'Conflict',
503: 'The requested service is temporarily unavailable.',
};
/** A `ResponseDtoClass` narrowed to the two fields the generator actually reads off it. */

View File

@ -21,6 +21,9 @@ paths:
/git-connections/{id}/disconnect:
post:
$ref: ./handlers/git-connections/spec/paths/disconnectGitConnection.generated.yml
/git-connections/{id}/push:
post:
$ref: ./handlers/git-connections/spec/paths/pushGitConnectionProjects.generated.yml
/git-connections/{id}/projects:
get:
$ref: ./handlers/git-connections/spec/paths/getGitConnectionProjects.generated.yml

View File

@ -10,3 +10,5 @@ Forbidden:
$ref: './forbidden.yml'
PaymentRequired:
$ref: './paymentRequired.yml'
ServiceUnavailable:
$ref: './serviceUnavailable.yml'

View File

@ -0,0 +1 @@
description: The requested service is temporarily unavailable.

View File

@ -238,6 +238,9 @@
"POST /source-control/pull": {
"status": "gap"
},
"POST /git-connections/{id}/push": {
"status": "gap"
},
"GET /settings/security-policy": {
"status": "gap"
},