diff --git a/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts index 83a82fcfdd3..06eb532e3da 100644 --- a/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts +++ b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts @@ -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({ diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index d7d74d1e533..9db11660996 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -105,6 +105,7 @@ export { GitConnectionProjectListPublicDto, GitConnectionProjectPublicDto, GitConnectionPublicDto, + GitConnectionPushResultDto, ListGitConnectionsQueryDto, UpdateGitConnectionDto, gitConnectionTypeSchema, diff --git a/packages/@n8n/cli/package.json b/packages/@n8n/cli/package.json index 16a9427befd..e5b7641274d 100644 --- a/packages/@n8n/cli/package.json +++ b/packages/@n8n/cli/package.json @@ -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" }, diff --git a/packages/@n8n/cli/src/__tests__/client.test.ts b/packages/@n8n/cli/src/__tests__/client.test.ts index ae53ce36154..68c352fe3d1 100644 --- a/packages/@n8n/cli/src/__tests__/client.test.ts +++ b/packages/@n8n/cli/src/__tests__/client.test.ts @@ -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]))); diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index a337bf423ee..d814748d8ed 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -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(`/git-connections/${id}`); } + async pushGitConnectionProjects(id: string) { + return await this.post(`/git-connections/${id}/push`); + } + async listGitConnectionProjects(id: string) { return await this.get<{ projectIds: string[] }>(`/git-connections/${id}/projects`); } diff --git a/packages/@n8n/cli/src/commands/git-connections/push.ts b/packages/@n8n/cli/src/commands/git-connections/push.ts new file mode 100644 index 00000000000..68a5235482f --- /dev/null +++ b/packages/@n8n/cli/src/commands/git-connections/push.ts @@ -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, + ); + }); + } +} diff --git a/packages/@n8n/cli/src/index.ts b/packages/@n8n/cli/src/index.ts index 1ca0a0fa354..afd030f4db3 100644 --- a/packages/@n8n/cli/src/index.ts +++ b/packages/@n8n/cli/src/index.ts @@ -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, diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 08fad4184d8..4b4584c1971 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -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", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index f16cd191e59..7b4bdec2353 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -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, diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index d1a617534c8..36dda509830 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -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', diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index ecd0ca99000..38510147ea4 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -65,6 +65,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'gitConnection:delete', 'gitConnection:list', 'gitConnection:clone', + 'gitConnection:push', 'gitConnection:manageProjects', 'tag:create', 'tag:read', diff --git a/packages/@n8n/permissions/src/scope-information.ts b/packages/@n8n/permissions/src/scope-information.ts index 2ad57d392c5..b5302ebeade 100644 --- a/packages/@n8n/permissions/src/scope-information.ts +++ b/packages/@n8n/permissions/src/scope-information.ts @@ -157,6 +157,10 @@ export const scopeInformation: Partial> = { 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.', diff --git a/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts index 8c5e0bc6bbe..4fa6f8ddbf4 100644 --- a/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts +++ b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts @@ -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(); const projectRepository = mock(); const gitService = mock(); + const n8nPackagesService = mock(); const cipher = mock(); const instanceSettings = mock({ n8nFolder: '/tmp/n8n' }); + const logger = mock(); + 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({ id: 'actor' }); + + beforeEach(async () => { + n8nFolder = await mkdtemp(path.join(tmpdir(), 'n8n-git-connection-export-')); + exportService = new GitConnectionsService( + repository, + gitConnectionProjectRepository, + projectRepository, + gitService, + n8nPackagesService, + cipher, + mock({ 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' }; diff --git a/packages/cli/src/modules/git-connections.ee/git-connections.service.ts b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts index 0d73866aedd..5e5c8c260f7 100644 --- a/packages/cli/src/modules/git-connections.ee/git-connections.service.ts +++ b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts @@ -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 { + return await this.exportProjectsToRepository(connectionId, actor); + } + + private async exportProjectsToRepository( + connectionId: string, + actor: User, + ): Promise { + // 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, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts new file mode 100644 index 00000000000..ac1e57db87e --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project-to-directory.integration.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts index efbcf99d131..22749899563 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts @@ -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'), ); diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts index 83d1f4b9c2e..abc61de8470 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts @@ -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'), ); diff --git a/packages/cli/src/modules/n8n-packages/entities/folder/folder.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/folder/folder.exporter.ts index 5f5a1692888..44bce03ab09 100644 --- a/packages/cli/src/modules/n8n-packages/entities/folder/folder.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/folder/folder.exporter.ts @@ -137,7 +137,7 @@ export class FolderExporter { ): Promise { 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 { 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( diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts index f150cda5888..783da4f0625 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/project.exporter.ts @@ -75,7 +75,7 @@ export class ProjectExporter { target: string, request: ProjectExportRequest, ): Promise { - 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 { 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( diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts index 00ec5f5d9fd..f002abe8e63 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts @@ -37,7 +37,7 @@ export interface TagExportResult { @Service() export class TagExporter { - export(request: TagExportRequest): TagExportResult { + async export(request: TagExportRequest): Promise { const requirementsByTagId = new Map(); 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'), ); diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts index 6b0de68038b..49131aa7712 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts @@ -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), diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/auto-included-workflow.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/auto-included-workflow.exporter.test.ts index 6a01d99c70c..722615aaec7 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/auto-included-workflow.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/auto-included-workflow.exporter.test.ts @@ -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(); 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(); 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' }), diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/auto-included-workflow.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/auto-included-workflow.exporter.ts index 0a4d07848c4..c4645a29413 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/auto-included-workflow.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/auto-included-workflow.exporter.ts @@ -60,7 +60,9 @@ export class AutoIncludedWorkflowExporter { private readonly tagRequirementsExtractor: TagRequirementsExtractor, ) {} - export(request: AutoIncludedWorkflowExportRequest): AutoIncludedWorkflowExportResult { + async export( + request: AutoIncludedWorkflowExportRequest, + ): Promise { 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; @@ -150,10 +152,10 @@ export class AutoIncludedWorkflowExporter { folderEntries: ManifestEntry[]; projectEntries: ManifestEntry[]; allocators: ExportAllocators; - }): string { + }): Promise { 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; projectTargetsById: Map; projectEntries: ManifestEntry[]; allocator: UniqueFilenameAllocator; - }): string { + }): Promise { 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; folderEntries: ManifestEntry[]; allocators: Map; - }): string { + }): Promise { 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, includeTags: boolean, - ): ManifestEntry { + ): Promise { 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 }; } } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts index 6078785d90a..bb16b9adefa 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.exporter.ts @@ -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, diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-writer.test.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-writer.test.ts new file mode 100644 index 00000000000..a6f43ca4e37 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/directory-package-writer.test.ts @@ -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/); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts b/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts index 47266d80138..e154ccdf1ea 100644 --- a/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts +++ b/packages/cli/src/modules/n8n-packages/io/__tests__/utils/capturing-writer.ts @@ -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 {} } diff --git a/packages/cli/src/modules/n8n-packages/io/directory/directory-package-writer.ts b/packages/cli/src/modules/n8n-packages/io/directory/directory-package-writer.ts new file mode 100644 index 00000000000..9d3e3fe0ab2 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/io/directory/directory-package-writer.ts @@ -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 { + const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath)); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, content); + } + + async writeDirectory(entryPath: string): Promise { + const destination = this.resolveWithin(this.targetDir, normaliseEntryPath(entryPath)); + await mkdir(destination, { recursive: true }); + } + + async finalize(): Promise { + 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; + } +} diff --git a/packages/cli/src/modules/n8n-packages/io/package-writer.ts b/packages/cli/src/modules/n8n-packages/io/package-writer.ts index 4e73e1d3436..6fbd69338c3 100644 --- a/packages/cli/src/modules/n8n-packages/io/package-writer.ts +++ b/packages/cli/src/modules/n8n-packages/io/package-writer.ts @@ -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; + writeDirectory(path: string): void | Promise; } diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts index 9d05f3dd745..34bfe4806ee 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.service.ts @@ -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 { + 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 { + 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 { 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 { diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index 8985603ed2a..0209b07cd07 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -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 diff --git a/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts b/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts index 27ada014e44..0230b88529b 100644 --- a/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/git-connections.public.controller.ts @@ -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 { + return await (await this.gitConnectionsService()).push(id, req.user); + } + @Get('/:id/projects') @Licensed(LICENSE_FEATURES.GIT_CONNECTIONS) @ApiKeyScope('gitConnection:read') diff --git a/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/pushGitConnectionProjects.generated.yml b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/pushGitConnectionProjects.generated.yml new file mode 100644 index 00000000000..85e567cc670 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/git-connections/spec/paths/pushGitConnectionProjects.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts b/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts index f11d1f90a48..6dfe31e45f2 100644 --- a/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts +++ b/packages/cli/src/public-api/v1/openapi-gen/decorator-routes.ts @@ -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; type DocumentedErrorStatus = keyof typeof ERROR_RESPONSE_REFS; @@ -55,6 +56,7 @@ export const ERROR_RESPONSE_DESCRIPTIONS: Record 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. */ diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 2be3bcbb6b7..dbc138da6ed 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -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 diff --git a/packages/cli/src/public-api/v1/shared/spec/responses/_index.yml b/packages/cli/src/public-api/v1/shared/spec/responses/_index.yml index 679c62324da..8daa5cbca06 100644 --- a/packages/cli/src/public-api/v1/shared/spec/responses/_index.yml +++ b/packages/cli/src/public-api/v1/shared/spec/responses/_index.yml @@ -10,3 +10,5 @@ Forbidden: $ref: './forbidden.yml' PaymentRequired: $ref: './paymentRequired.yml' +ServiceUnavailable: + $ref: './serviceUnavailable.yml' diff --git a/packages/cli/src/public-api/v1/shared/spec/responses/serviceUnavailable.yml b/packages/cli/src/public-api/v1/shared/spec/responses/serviceUnavailable.yml new file mode 100644 index 00000000000..b7444571ea7 --- /dev/null +++ b/packages/cli/src/public-api/v1/shared/spec/responses/serviceUnavailable.yml @@ -0,0 +1 @@ +description: The requested service is temporarily unavailable. diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 9e9c218c12b..308b9ee83fc 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -238,6 +238,9 @@ "POST /source-control/pull": { "status": "gap" }, + "POST /git-connections/{id}/push": { + "status": "gap" + }, "GET /settings/security-policy": { "status": "gap" },