From 4844223dbc7c76e9dbb644fe363bc8a7fc0288b8 Mon Sep 17 00:00:00 2001 From: James Gee <1285296+geemanjs@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:53:06 +0200 Subject: [PATCH] feat(core): Import a project with folders and workflows (no-changelog) (#34026) --- .../log-streaming-event-relay.test.ts | 4 +- .../__tests__/telemetry-event-relay.test.ts | 2 +- .../cli/src/events/maps/relay.event-map.ts | 2 +- .../cli/src/modules/n8n-packages/CLAUDE.md | 6 +- .../import-folders.integration.test.ts | 41 ++- .../import-projects.integration.test.ts | 319 +++++++++++++++++- .../engine/__tests__/import-result.test.ts | 63 ++++ .../engine/__tests__/import-telemetry.test.ts | 162 +++++++++ .../engine/__tests__/package-layout.test.ts | 13 + .../engine/import-orchestrator.ts | 47 ++- .../n8n-packages/engine/import-result.ts | 70 +++- .../n8n-packages/engine/import-telemetry.ts | 97 ++++++ .../n8n-packages/engine/n8n-package-parser.ts | 26 +- .../engine/project-package-importer.ts | 168 ++++++++- .../engine/workflow-package-importer.ts | 100 +----- .../__tests__/workflow-publisher.test.ts | 12 + .../entities/workflow/workflow-publisher.ts | 9 + .../n8n-packages/n8n-packages.types.ts | 8 + 18 files changed, 996 insertions(+), 153 deletions(-) create mode 100644 packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts create mode 100644 packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 3bb55d93be8..9398c34b482 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -63,7 +63,7 @@ describe('LogStreamingEventRelay', () => { lastName: 'User', role: { slug: 'global:admin' }, }, - projectId: 'proj-brie', + projectIds: ['proj-brie', 'proj-stilton'], folderId: 'folder-cheese', workflowIds: ['wf-cheddar', 'wf-brie'], options: { @@ -113,7 +113,7 @@ describe('LogStreamingEventRelay', () => { _firstName: 'Import', _lastName: 'User', globalRole: 'global:admin', - projectId: 'proj-brie', + projectIds: ['proj-brie', 'proj-stilton'], folderId: 'folder-cheese', workflowIds: ['wf-cheddar', 'wf-brie'], options: { diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts index 73021e8c23d..bcce8465ac0 100644 --- a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -2223,7 +2223,7 @@ describe('TelemetryEventRelay', () => { it('should track on `n8n-package-imported` event with params and counts', () => { const event: RelayEventMap['n8n-package-imported'] = { user: { id: 'user123' }, - projectId: 'project123', + projectIds: ['project123'], folderId: 'folder123', workflowIds: ['wf1', 'wf2', 'wf3'], options: { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index a2d401b975e..d8b2a6d5cb1 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -94,7 +94,7 @@ export type RelayEventMap = { 'n8n-package-imported': { user: UserLike; - projectId: string; + projectIds: string[]; folderId: string | null; workflowIds: string[]; options: ImportPackageEventOptions; diff --git a/packages/cli/src/modules/n8n-packages/CLAUDE.md b/packages/cli/src/modules/n8n-packages/CLAUDE.md index 7c0c60b95ff..4db8f2a0825 100644 --- a/packages/cli/src/modules/n8n-packages/CLAUDE.md +++ b/packages/cli/src/modules/n8n-packages/CLAUDE.md @@ -33,9 +33,9 @@ flowchart LR workflows + their folder shells + credential deps into a target project) → `WorkflowPackageImporter`. - `WorkflowPackageImporter` resolves the target scope from the request, then delegates the plan/gate/apply work to `ImportOrchestrator` (brings folders + workflows + credential deps into one project scope). - `ProjectPackageImporter` creates the project shells; reusing `ImportOrchestrator` for a project's own - contents is a follow-up. Don't split folder vs workflow: they share target resolution, credential - resolution, and publishing. + `ProjectPackageImporter` creates the project shells, then reuses `ImportOrchestrator` per project to + bring each one's own folders + workflows + credential deps into scope. Don't split folder vs workflow: + they share target resolution, credential resolution, and publishing. ### Adding an IMPORT property diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts index dbeb86e9932..936961f37ce 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-folders.integration.test.ts @@ -505,11 +505,48 @@ describe('folder shell import', () => { }, }); - // The folder-nested workflow is now imported (LIGO-723), so its missing must-preexist credential - // resolves through the same gate as a top-level workflow and blocks the import before any writes. await expect( importFolders({ user: owner, projectId: project.id, packageBuffer }), ).rejects.toBeInstanceOf(UnprocessableRequestError); expect(await findFolder('F1')).toBeNull(); }); + + it('blocks re-importing a matched workflow into a different folder and never re-parents it', async () => { + const first = await importFolders({ + user: owner, + projectId: project.id, + packageBuffer: await buildEntityPackageBuffer({ + folders: [{ target: 'folders/fa', folder: serializedFolder({ id: 'FA', name: 'fa' }) }], + workflows: [ + { + target: 'folders/fa/workflows/wf', + workflow: serializedWorkflow({ id: 'WF', name: 'wf' }), + }, + ], + }), + }); + const localId = first.workflows[0].localId; + expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA'); + + // Re-import the same source workflow nested under a different folder: the matched workflow is not + // re-parented; the mismatch blocks the whole import before anything is written. + await expect( + importFolders({ + user: owner, + projectId: project.id, + packageBuffer: await buildEntityPackageBuffer({ + folders: [{ target: 'folders/fb', folder: serializedFolder({ id: 'FB', name: 'fb' }) }], + workflows: [ + { + target: 'folders/fb/workflows/wf', + workflow: serializedWorkflow({ id: 'WF', name: 'wf' }), + }, + ], + }), + }), + ).rejects.toBeInstanceOf(ConflictError); + + expect((await findWorkflow(localId))?.parentFolder?.id).toBe('FA'); + expect(await findFolder('FB')).toBeNull(); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts index 34e565b0a2f..e4c40e56a80 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts @@ -1,10 +1,19 @@ import { LicenseState } from '@n8n/backend-common'; import { testDb, testModules } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; -import { FolderRepository, ProjectRelationRepository, ProjectRepository } from '@n8n/db'; +import { + FolderRepository, + ProjectRelationRepository, + ProjectRepository, + SharedWorkflowRepository, + WorkflowRepository, +} from '@n8n/db'; import { Container } from '@n8n/di'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; import { createOwner } from '@test-integration/db/users'; import { LicenseMocker } from '@test-integration/license'; @@ -12,12 +21,19 @@ import { N8nPackagesService } from '../n8n-packages.service'; import type { ImportPackageRequest } from '../n8n-packages.types'; import { buildEntityPackageBuffer, + credentialRequirementsFromWorkflows, serializedFolder, serializedProject, serializedWorkflow, + serializedWorkflowWithCredential, } from './fixtures/package-fixtures'; -async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?: string[]) { +async function importProjects( + user: User, + packageBuffer: Buffer, + apiKeyScopes?: string[], + overrides?: Partial, +) { const request: ImportPackageRequest = { user, packageBuffer, @@ -31,6 +47,7 @@ async function importProjects(user: User, packageBuffer: Buffer, apiKeyScopes?: dataTableMatchingMode: 'by-id', dataTableMissingMode: 'create', dataTableSchemaConflictPolicy: 'keep-existing', + ...overrides, }; return await Container.get(N8nPackagesService).importPackage(request); } @@ -41,6 +58,20 @@ async function findProject(id: string) { return await Container.get(ProjectRepository).findOne({ where: { id } }); } +async function findFolder(id: string) { + return await Container.get(FolderRepository).findOne({ + where: { id }, + relations: { homeProject: true }, + }); +} + +async function findWorkflow(id: string) { + return await Container.get(WorkflowRepository).findOne({ + where: { id }, + relations: { parentFolder: true }, + }); +} + async function isAdminOf(projectId: string, userId: string): Promise { const count = await Container.get(ProjectRelationRepository).count({ where: { projectId, userId, role: { slug: 'project:admin' } }, @@ -53,7 +84,7 @@ beforeAll(async () => { await testDb.init(); licenseMocker.mockLicenseState(Container.get(LicenseState)); licenseMocker.setDefaults({ - features: ['feat:projectRole:admin'], + features: ['feat:projectRole:admin', 'feat:folders'], quotas: { 'quota:maxTeamProjects': 100 }, }); }); @@ -156,6 +187,23 @@ describe('project shell import', () => { expect((await findProject('P1'))?.name).toBe('brie renamed'); }); + it('rejects a project package whose manifest project id disagrees with its project.json', async () => { + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/p', project: serializedProject({ id: 'real-id', name: 'p' }) }, + ], + // Manifest points the same target at a different id than project.json declares. The project is + // created under project.json's id, but its contents scope by manifest id — so they must agree. + manifestExtras: { projects: [{ id: 'manifest-id', name: 'p', target: 'projects/p' }] }, + }); + + await expect(importProjects(owner, packageBuffer)).rejects.toThrow( + /declares id "real-id" but the manifest lists it as "manifest-id"/, + ); + // Rejected while parsing, before any project shell is created. + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(0); + }); + it('rejects a project package when the API key lacks the project:create scope', async () => { const packageBuffer = await buildEntityPackageBuffer({ projects: [ @@ -181,30 +229,275 @@ describe('project shell import', () => { await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf(ForbiddenError); }); - it('ignores folders and workflows nested inside the project (deferred to a follow-up)', async () => { + it('recreates the project folder tree and places nested workflows into the project scope', async () => { const packageBuffer = await buildEntityPackageBuffer({ projects: [ - { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + { + target: 'projects/team-ligo', + project: serializedProject({ id: 'P1', name: 'team-ligo' }), + }, ], folders: [ + // An empty folder shell alongside a populated, nested hierarchy. { - target: 'projects/brie/folders/in_progress', - folder: serializedFolder({ id: 'NestedFolder', name: 'in_progress' }), + target: 'projects/team-ligo/folders/to_production', + folder: serializedFolder({ id: 'TP', name: 'to_production' }), + }, + { + target: 'projects/team-ligo/folders/in_progress', + folder: serializedFolder({ id: 'IP', name: 'in_progress' }), + }, + { + target: 'projects/team-ligo/folders/in_progress/nested', + folder: serializedFolder({ id: 'NE', name: 'nested', parentFolderId: 'IP' }), }, ], workflows: [ { - target: 'projects/brie/folders/in_progress/workflows/triage', - workflow: serializedWorkflow({ id: 'NestedWf', name: 'triage' }), + target: 'projects/team-ligo/folders/in_progress/workflows/triage', + workflow: serializedWorkflow({ id: 'WF1', name: 'triage' }), + }, + { + target: 'projects/team-ligo/folders/in_progress/nested/workflows/playground', + workflow: serializedWorkflow({ id: 'WF2', name: 'playground' }), }, ], }); const result = await importProjects(owner, packageBuffer); - expect(result.projects.map((p) => p.localId)).toEqual(['P1']); - expect(result.folders).toEqual([]); - expect(result.workflows).toEqual([]); - expect(await Container.get(FolderRepository).findOneBy({ id: 'NestedFolder' })).toBeNull(); + expect(result.projects).toEqual([ + { sourceProjectId: 'P1', localId: 'P1', name: 'team-ligo', status: 'created' }, + ]); + // Every folder (incl. the empty shell) lands in the project; the nested one keeps its parent. + for (const id of ['TP', 'IP', 'NE']) { + expect((await findFolder(id))?.homeProject.id).toBe('P1'); + } + expect((await findFolder('NE'))?.parentFolderId).toBe('IP'); + // Each workflow is placed under the folder it belongs to, scoped to the project. + const triage = result.workflows.find((w) => w.sourceWorkflowId === 'WF1')!; + const playground = result.workflows.find((w) => w.sourceWorkflowId === 'WF2')!; + expect(triage.projectId).toBe('P1'); + expect((await findWorkflow(triage.localId))?.parentFolder?.id).toBe('IP'); + expect((await findWorkflow(playground.localId))?.parentFolder?.id).toBe('NE'); + }); + + it('populates each project in a multi-project package into its own scope', async () => { + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + { target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) }, + ], + folders: [ + { target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) }, + { target: 'projects/stilton/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) }, + ], + workflows: [ + { + target: 'projects/brie/folders/a/workflows/wfa', + workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }), + }, + { + target: 'projects/stilton/folders/b/workflows/wfb', + workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }), + }, + ], + }); + + const result = await importProjects(owner, packageBuffer); + + expect(result.projects.map((p) => p.localId).sort()).toEqual(['P1', 'P2']); + expect((await findFolder('FA'))?.homeProject.id).toBe('P1'); + expect((await findFolder('FB'))?.homeProject.id).toBe('P2'); + const wfa = result.workflows.find((w) => w.sourceWorkflowId === 'WFA')!; + const wfb = result.workflows.find((w) => w.sourceWorkflowId === 'WFB')!; + expect(wfa.projectId).toBe('P1'); + expect(wfb.projectId).toBe('P2'); + expect((await findWorkflow(wfa.localId))?.parentFolder?.id).toBe('FA'); + expect((await findWorkflow(wfb.localId))?.parentFolder?.id).toBe('FB'); + }); + + it('reuses the project and folder and updates the workflow on re-import', async () => { + const pkg = async () => + await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + ], + folders: [ + { target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) }, + ], + workflows: [ + { + target: 'projects/brie/folders/a/workflows/wf', + workflow: serializedWorkflow({ id: 'WF', name: 'wf' }), + }, + ], + }); + + const first = await importProjects(owner, await pkg()); + const localId = first.workflows[0].localId; + + const second = await importProjects(owner, await pkg()); + + expect(second.workflows[0]).toMatchObject({ status: 'updated', localId, parentFolderId: 'FA' }); + expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1); + expect(await Container.get(FolderRepository).countBy({ id: 'FA' })).toBe(1); + expect(await Container.get(WorkflowRepository).countBy({ id: localId })).toBe(1); + }); + + it('resolves a project workflow credential in the project scope, blocking when it is missing', async () => { + const workflow = serializedWorkflowWithCredential({ + id: 'WF', + name: 'triage', + credentialId: 'missing-cred', + credentialName: 'Linear', + }); + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + ], + folders: [ + { target: 'projects/brie/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) }, + ], + workflows: [{ target: 'projects/brie/folders/a/workflows/triage', workflow }], + manifestExtras: { + requirements: { credentials: credentialRequirementsFromWorkflows([workflow]) }, + }, + }); + + // The project workflow's credential requirement resolves in the project scope; under must-preexist + // a missing credential blocks the import before anything is written — no folder, no project shell. + await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf( + UnprocessableRequestError, + ); + expect(await findFolder('FA')).toBeNull(); + expect(await findProject('P1')).toBeNull(); + }); + + it('imports a project-root workflow (no enclosing folder) at the project root', async () => { + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + ], + workflows: [ + { + target: 'projects/brie/workflows/root-wf', + workflow: serializedWorkflow({ id: 'RWF', name: 'root-wf' }), + }, + ], + }); + + const result = await importProjects(owner, packageBuffer); + + const summary = result.workflows.find((w) => w.sourceWorkflowId === 'RWF')!; + expect(summary).toMatchObject({ projectId: 'P1', parentFolderId: null }); + // Persisted at the project root (no parent folder), owned by the imported project. + expect((await findWorkflow(summary.localId))?.parentFolder).toBeNull(); + const shared = await Container.get(SharedWorkflowRepository).findOneBy({ + workflowId: summary.localId, + }); + expect(shared?.projectId).toBe('P1'); + }); + + it('gates the whole package: a later project blocking leaves earlier projects unwritten', async () => { + const blocked = serializedWorkflowWithCredential({ + id: 'WFB', + name: 'wfb', + credentialId: 'missing-cred', + credentialName: 'Linear', + }); + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/alpha', project: serializedProject({ id: 'P1', name: 'alpha' }) }, + { target: 'projects/beta', project: serializedProject({ id: 'P2', name: 'beta' }) }, + ], + folders: [ + { target: 'projects/alpha/folders/a', folder: serializedFolder({ id: 'FA', name: 'a' }) }, + { target: 'projects/beta/folders/b', folder: serializedFolder({ id: 'FB', name: 'b' }) }, + ], + workflows: [ + { + target: 'projects/alpha/folders/a/workflows/wfa', + workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }), + }, + { target: 'projects/beta/folders/b/workflows/wfb', workflow: blocked }, + ], + manifestExtras: { + requirements: { credentials: credentialRequirementsFromWorkflows([blocked]) }, + }, + }); + + // The second project's workflow needs a missing credential: every project is planned and + // validated before anything is written, so a block leaves nothing behind — not the first + // project's folder/workflow, nor either project shell. + await expect(importProjects(owner, packageBuffer)).rejects.toBeInstanceOf( + UnprocessableRequestError, + ); + expect(await findFolder('FA')).toBeNull(); + expect(await findFolder('FB')).toBeNull(); + expect(await findProject('P1')).toBeNull(); + expect(await findProject('P2')).toBeNull(); + }); + + it('creates a new project under publish-all, planned before the project exists', async () => { + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + ], + workflows: [ + { + target: 'projects/brie/workflows/wf', + workflow: serializedWorkflow({ id: 'WF', name: 'wf' }), + }, + ], + }); + + // publish-all checks publish permission during planning, which now runs before the project + // is created. A project being created has no row to look up, so the import must not fail — its + // creator is admin and can always publish. + const result = await importProjects(owner, packageBuffer, undefined, { + workflowPublishingPolicy: 'publish-all', + }); + + expect(result.projects).toEqual([ + { sourceProjectId: 'P1', localId: 'P1', name: 'brie', status: 'created' }, + ]); + expect(result.workflows.find((w) => w.sourceWorkflowId === 'WF')?.projectId).toBe('P1'); + expect(await findProject('P1')).not.toBeNull(); + }); + + it('emits a single n8n-package-imported event aggregating every project in the package', async () => { + const packageBuffer = await buildEntityPackageBuffer({ + projects: [ + { target: 'projects/brie', project: serializedProject({ id: 'P1', name: 'brie' }) }, + { target: 'projects/stilton', project: serializedProject({ id: 'P2', name: 'stilton' }) }, + ], + workflows: [ + { + target: 'projects/brie/workflows/wfa', + workflow: serializedWorkflow({ id: 'WFA', name: 'wfa' }), + }, + { + target: 'projects/stilton/workflows/wfb', + workflow: serializedWorkflow({ id: 'WFB', name: 'wfb' }), + }, + ], + }); + + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + try { + await importProjects(owner, packageBuffer); + + const importedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-imported'); + expect(importedEvents).toHaveLength(1); + + const payload = importedEvents[0][1] as RelayEventMap['n8n-package-imported']; + expect(payload.projectIds.sort()).toEqual(['P1', 'P2']); + expect(payload.workflowIds).toHaveLength(2); + expect(payload.folderId).toBeNull(); + expect(payload.counts.workflows.created).toBe(2); + } finally { + emitSpy.mockRestore(); + } }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts new file mode 100644 index 00000000000..1e152258882 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts @@ -0,0 +1,63 @@ +import type { PreparedWorkflow } from '../../entities/workflow/workflow-import.types'; +import type { ImportBindingMap } from '../../n8n-packages.types'; +import type { PackageCredentialRequirement } from '../../spec/requirements.schema'; +import { identifyRequirements, scopeCredentialBindingsToRequirements } from '../import-result'; + +const requirement = (id: string, usedByWorkflows: string[]): PackageCredentialRequirement => ({ + id, + name: id, + type: 'githubApi', + usedByWorkflows, +}); + +const prepared = (sourceWorkflowId: string): PreparedWorkflow => + ({ sourceWorkflowId }) as PreparedWorkflow; + +describe('identifyRequirements', () => { + it('returns undefined when there are no requirements', () => { + expect(identifyRequirements(undefined, [prepared('W1')])).toBeUndefined(); + }); + + it('keeps only in-scope workflows and drops requirements that no in-scope workflow uses', () => { + const requirements = [requirement('credA', ['W1', 'W2']), requirement('credB', ['W3'])]; + + const scoped = identifyRequirements(requirements, [prepared('W1')]); + + // credA stays (W1 is in scope) but with W2 trimmed off; credB drops entirely (W3 is out of scope). + expect(scoped).toEqual([requirement('credA', ['W1'])]); + }); +}); + +describe('scopeCredentialBindingsToRequirements', () => { + const bindings: ImportBindingMap = new Map([ + ['credA', 'target-a'], + ['credB', 'target-b'], + ]); + + it('returns undefined when no bindings were supplied', () => { + expect( + scopeCredentialBindingsToRequirements(undefined, [requirement('credA', ['W1'])]), + ).toBeUndefined(); + }); + + it('keeps only bindings whose source id this scope requires', () => { + // Simulates a multi-project import where credB belongs to another project's workflows. + const scoped = scopeCredentialBindingsToRequirements(bindings, [requirement('credA', ['W1'])]); + + expect(scoped).toEqual(new Map([['credA', 'target-a']])); + }); + + it('drops every binding when the scope has no requirements', () => { + expect(scopeCredentialBindingsToRequirements(bindings, undefined)).toEqual(new Map()); + expect(scopeCredentialBindingsToRequirements(bindings, [])).toEqual(new Map()); + }); + + it('keeps every binding when all are required by the scope', () => { + const scoped = scopeCredentialBindingsToRequirements(bindings, [ + requirement('credA', ['W1']), + requirement('credB', ['W2']), + ]); + + expect(scoped).toEqual(bindings); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts new file mode 100644 index 00000000000..ec7ca9541a8 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts @@ -0,0 +1,162 @@ +import type { WorkflowEntity } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import type { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/maps/relay.event-map'; + +import type { CredentialApplyResult } from '../../entities/credential/credential.types'; +import type { DataTableImportRequest } from '../../entities/data-table/data-table.types'; +import type { WorkflowImportOutcome } from '../../entities/workflow/workflow-import.types'; +import type { ImportContext, ImportPackageRequest } from '../../n8n-packages.types'; +import type { PackageManifest } from '../../spec/manifest.schema'; +import type { PackageCredentialRequirement } from '../../spec/requirements.schema'; +import type { ImportOrchestrationResult } from '../import-orchestrator'; +import { emitPackageImportedEvent, type PackageImportScope } from '../import-telemetry'; + +const outcome = ( + id: string, + sourceWorkflowId: string, + status: WorkflowImportOutcome['status'], +): WorkflowImportOutcome => ({ + status, + sourceWorkflowId, + workflow: mock({ id }), + publishing: { state: 'unchanged' }, +}); + +const requirement = (id: string): PackageCredentialRequirement => ({ + id, + name: id, + type: 'githubApi', + usedByWorkflows: ['ignored'], +}); + +const scope = (input: { + projectId: string; + folderId?: string | null; + outcomes: WorkflowImportOutcome[]; + credentialResult: CredentialApplyResult; + requirements?: PackageCredentialRequirement[]; + dataTable?: { matched: number; created: number; requirements: number }; +}): PackageImportScope => { + const context: ImportContext = { + user: mock(), + projectId: input.projectId, + folderId: input.folderId ?? null, + }; + const dt = input.dataTable ?? { matched: 0, created: 0, requirements: 0 }; + const imported: ImportOrchestrationResult = { + workflowOutcomes: input.outcomes, + folderSummaries: [], + bindings: { workflows: new Map(), credentials: new Map() }, + credentialResult: input.credentialResult, + dataTablePlan: { creations: new Array(dt.created), failures: [], matchedCount: dt.matched }, + }; + return { + context, + imported, + credentialRequest: { + requirements: input.requirements, + matchingMode: 'id-only', + missingMode: 'create-stub', + credentialBindings: undefined, + }, + dataTableRequest: mock({ + requirements: dt.requirements === 0 ? undefined : new Array(dt.requirements), + }), + }; +}; + +const request = mock({ + user: mock(), + workflowConflictPolicy: 'new-version', + workflowIdPolicy: 'new', + credentialMatchingMode: 'id-only', + credentialMissingMode: 'create-stub', + workflowPublishingPolicy: 'preserve-published-state', +}); + +const manifest = mock({ sourceId: 'src-1', packageFormatVersion: '1' }); + +function lastImportedPayload( + eventService: ReturnType>, +): RelayEventMap['n8n-package-imported'] { + expect(eventService.emit).toHaveBeenCalledTimes(1); + const [eventName, payload] = eventService.emit.mock.calls[0]; + expect(eventName).toBe('n8n-package-imported'); + return payload as RelayEventMap['n8n-package-imported']; +} + +describe('emitPackageImportedEvent', () => { + it('aggregates counts, project ids and credential ids across every scope', () => { + const eventService = mock(); + + emitPackageImportedEvent(eventService, { + request, + manifest, + scopes: [ + scope({ + projectId: 'P1', + folderId: 'F1', + outcomes: [outcome('wf1', 'WF1', 'created'), outcome('wf2', 'WF2', 'skipped')], + credentialResult: { + bindings: new Map([['credA', 'target-a']]), + matched: ['credA'], + stubbed: [], + }, + requirements: [requirement('credA')], + dataTable: { matched: 1, created: 0, requirements: 1 }, + }), + scope({ + projectId: 'P2', + outcomes: [outcome('wf3', 'WF3', 'updated')], + credentialResult: { + bindings: new Map([['credB', 'stub-b']]), + matched: [], + stubbed: ['credB'], + }, + requirements: [requirement('credB')], + dataTable: { matched: 0, created: 2, requirements: 2 }, + }), + ], + }); + + const payload = lastImportedPayload(eventService); + expect(payload.projectIds).toEqual(['P1', 'P2']); + // Skipped workflows are excluded; only wf1 and wf3 were actually written. + expect(payload.workflowIds).toEqual(['wf1', 'wf3']); + // A multi-scope import has no single folder to attribute the event to. + expect(payload.folderId).toBeNull(); + // Credential ids are resolved through each scope's binding map (source id -> target id). + expect(payload.credentialIds).toEqual({ + matched: ['target-a'], + created: ['stub-b'], + updated: [], + }); + expect(payload.counts).toEqual({ + workflows: { created: 1, updated: 1, skipped: 1 }, + credentials: { matched: 1, created: 1, requirements: 2 }, + dataTables: { matched: 1, created: 2, requirements: 3 }, + }); + expect(payload.packageSourceId).toBe('src-1'); + }); + + it('preserves the folder id for a single-scope import', () => { + const eventService = mock(); + + emitPackageImportedEvent(eventService, { + request, + manifest, + scopes: [ + scope({ + projectId: 'P1', + folderId: 'F1', + outcomes: [outcome('wf1', 'WF1', 'created')], + credentialResult: { bindings: new Map(), matched: [], stubbed: [] }, + }), + ], + }); + + expect(lastImportedPayload(eventService).folderId).toBe('F1'); + }); +}); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts index 78799f22246..6555e90d35c 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts @@ -78,5 +78,18 @@ describe('package-layout', () => { it('returns null for a project-root workflow even when its project has no folder entry', () => { expect(deriveParentFolderId('projects/unknown/workflows/wf', map)).toBeNull(); }); + + it('splits on the LAST /workflows/ so a folder literally named "workflows" resolves correctly', () => { + // A folder named "workflows" keeps its bare slug when its parent has no directly-contained + // workflows; a workflow inside it must resolve to that folder, not its grandparent. + const withWorkflowsFolder = new Map([ + ['folders/a', 'A'], + ['folders/a/workflows', 'W'], + ]); + expect(deriveParentFolderId('folders/a/workflows/workflows/wf', withWorkflowsFolder)).toBe( + 'W', + ); + expect(deriveParentFolderId('folders/a/workflows/wf', withWorkflowsFolder)).toBe('A'); + }); }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index f447c20cecf..142a65fa889 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -1,5 +1,6 @@ import { Service } from '@n8n/di'; +import { toImportBlockedError } from './import-blocked.error'; import { CredentialImporter } from '../entities/credential/credential-importer'; import { workflowsBlockedFromPublish } from '../entities/credential/credential-missing-mode'; import type { @@ -13,8 +14,12 @@ import type { DataTableImportPlan, DataTableImportRequest, } from '../entities/data-table/data-table.types'; +import type { + FolderImportContext, + FolderImportPlan, + PreparedFolder, +} from '../entities/folder/folder-import.types'; import { FolderImporter } from '../entities/folder/folder-importer'; -import type { FolderImportPlan, PreparedFolder } from '../entities/folder/folder-import.types'; import type { PreparedWorkflow, WorkflowImportOutcome, @@ -31,7 +36,6 @@ import type { ImportWorkflowProperties, PackageImportBindings, } from '../n8n-packages.types'; -import { toImportBlockedError } from './import-blocked.error'; export interface ImportOrchestrationInput { context: ImportContext; @@ -40,6 +44,8 @@ export interface ImportOrchestrationInput { credentialRequest: CredentialBindingRequest; dataTableRequest: DataTableImportRequest; options: ImportWorkflowProperties & ImportFolderProperties; + /** The target project does not exist yet and will be created by this import (project packages). */ + projectPendingCreation?: boolean; } export interface ImportOrchestrationResult { @@ -50,6 +56,16 @@ export interface ImportOrchestrationResult { dataTablePlan: DataTableImportPlan; } +export interface ImportPlan { + input: ImportOrchestrationInput; + folderContext: FolderImportContext; + credentialPlan: CredentialResolution; + workflowPlan: WorkflowImportPlan; + folderPlan: FolderImportPlan; + dataTablePlan: DataTableImportPlan; + blockingIssues: BlockingIssue[]; +} + /** * Coordinates the credential, folder, and workflow importers to bring a package's * contents into one resolved project scope @@ -65,13 +81,21 @@ export class ImportOrchestrator { ) {} async import(input: ImportOrchestrationInput): Promise { + const plan = await this.plan(input); + if (plan.blockingIssues.length > 0) { + throw toImportBlockedError(plan.blockingIssues); + } + return await this.apply(plan); + } + + async plan(input: ImportOrchestrationInput): Promise { const { context, folders, workflows, credentialRequest, dataTableRequest, options } = input; - // PublishAll requires publish scope up front; other policies are checked per workflow. await this.workflowPublisher.assertCanPublish( context.user, context.projectId, options.workflowPublishingPolicy, + input.projectPendingCreation, ); const credentialPlan = await this.credentialImporter.plan(context, credentialRequest); @@ -88,9 +112,20 @@ export class ImportOrchestrator { dataTablePlan, }); - if (blockingIssues.length > 0) { - throw toImportBlockedError(blockingIssues); - } + return { + input, + folderContext, + credentialPlan, + workflowPlan, + folderPlan, + dataTablePlan, + blockingIssues, + }; + } + + async apply(plan: ImportPlan): Promise { + const { input, folderContext, credentialPlan, workflowPlan, folderPlan, dataTablePlan } = plan; + const { context, credentialRequest, options } = input; const folderSummaries = await this.folderImporter.apply(folderContext, folderPlan); diff --git a/packages/cli/src/modules/n8n-packages/engine/import-result.ts b/packages/cli/src/modules/n8n-packages/engine/import-result.ts index fd9fbe06728..9883115b862 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-result.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-result.ts @@ -1,16 +1,22 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types'; +import type { + PreparedWorkflow, + WorkflowImportOutcome, +} from '../entities/workflow/workflow-import.types'; import { serializeBindings } from '../n8n-packages.types'; import type { + ImportBindingMap, ImportCredentialSummary, ImportedFolderSummary, ImportedProjectSummary, + ImportedWorkflowSummary, ImportPackageSummary, ImportResult, PackageImportBindings, } from '../n8n-packages.types'; import type { PackageManifest } from '../spec/manifest.schema'; +import type { PackageCredentialRequirement } from '../spec/requirements.schema'; export function toPackageSummary(manifest: PackageManifest): ImportPackageSummary { return { @@ -20,11 +26,25 @@ export function toPackageSummary(manifest: PackageManifest): ImportPackageSummar }; } -/** Assembles the wire {@link ImportResult} from each entity importer's outcome. */ +export function toImportedWorkflowSummaries( + outcomes: WorkflowImportOutcome[], + projectId: string, +): ImportedWorkflowSummary[] { + return outcomes.map(({ workflow, sourceWorkflowId, status, publishing }) => ({ + sourceWorkflowId, + localId: workflow.id, + name: workflow.name, + projectId, + parentFolderId: workflow.parentFolder?.id ?? null, + activeVersionId: workflow.activeVersionId ?? null, + publishing, + status, + })); +} + export function buildImportResult(input: { package: ImportPackageSummary; - projectId: string | null; - workflows: WorkflowImportOutcome[]; + workflows: ImportedWorkflowSummary[]; folders: ImportedFolderSummary[]; projects: ImportedProjectSummary[]; bindings: PackageImportBindings; @@ -32,16 +52,7 @@ export function buildImportResult(input: { }): ImportResult { return { package: input.package, - workflows: input.workflows.map(({ workflow, sourceWorkflowId, status, publishing }) => ({ - sourceWorkflowId, - localId: workflow.id, - name: workflow.name, - projectId: input.projectId ?? '', - parentFolderId: workflow.parentFolder?.id ?? null, - activeVersionId: workflow.activeVersionId ?? null, - publishing, - status, - })), + workflows: input.workflows, folders: input.folders, projects: input.projects, bindings: serializeBindings(input.bindings), @@ -64,3 +75,34 @@ export function assertPackageImportApiKeyScopes( } } } + +/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */ +export function identifyRequirements( + requirements: T[] | undefined, + workflows: PreparedWorkflow[], +): T[] | undefined { + if (!requirements) return undefined; + + const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId)); + return requirements + .map((requirement) => ({ + ...requirement, + usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)), + })) + .filter((requirement) => requirement.usedByWorkflows.length > 0); +} + +/** + * Restricts explicit credential bindings to those a scope's requirements declare. A project package + * shares one binding map across every project, but each project only sees its own requirements — without + * this, a binding for a credential used solely in another project looks orphaned and blocks the import. + */ +export function scopeCredentialBindingsToRequirements( + bindings: ImportBindingMap | undefined, + requirements: PackageCredentialRequirement[] | undefined, +): ImportBindingMap | undefined { + if (!bindings) return undefined; + + const requirementIds = new Set((requirements ?? []).map((requirement) => requirement.id)); + return new Map([...bindings].filter(([sourceId]) => requirementIds.has(sourceId))); +} diff --git a/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts new file mode 100644 index 00000000000..128275b1040 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/engine/import-telemetry.ts @@ -0,0 +1,97 @@ +import type { EventService } from '@/events/event.service'; + +import type { CredentialBindingRequest } from '../entities/credential/credential.types'; +import type { DataTableImportRequest } from '../entities/data-table/data-table.types'; +import type { WorkflowImportOutcome } from '../entities/workflow/workflow-import.types'; +import type { ImportContext, ImportPackageRequest } from '../n8n-packages.types'; +import type { ImportOrchestrationResult } from './import-orchestrator'; +import type { PackageManifest } from '../spec/manifest.schema'; + +export interface PackageImportScope { + context: ImportContext; + imported: ImportOrchestrationResult; + credentialRequest: CredentialBindingRequest; + dataTableRequest: DataTableImportRequest; +} + +export function emitPackageImportedEvent( + eventService: EventService, + params: { + request: ImportPackageRequest; + manifest: PackageManifest; + scopes: PackageImportScope[]; + }, +): void { + const { request, manifest, scopes } = params; + + const workflowOutcomes = scopes.flatMap(({ imported }) => imported.workflowOutcomes); + const credentialResults = scopes.map(({ imported }) => imported.credentialResult); + const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped'); + const countByStatus = (status: WorkflowImportOutcome['status']) => + workflowOutcomes.filter((outcome) => outcome.status === status).length; + const credentialRequirements = scopes.reduce( + (total, { credentialRequest }) => total + (credentialRequest.requirements?.length ?? 0), + 0, + ); + + const matchedCredentialIds = credentialResults.flatMap(({ matched, bindings }) => + matched.map((sourceId) => bindings.get(sourceId)!), + ); + const createdCredentialIds = credentialResults.flatMap(({ stubbed, bindings }) => + stubbed.map((sourceId) => bindings.get(sourceId)!), + ); + + const dataTablePlans = scopes.map(({ imported }) => imported.dataTablePlan); + const dataTableRequirements = scopes.reduce( + (total, { dataTableRequest }) => total + (dataTableRequest.requirements?.length ?? 0), + 0, + ); + const dataTablesMatched = dataTablePlans.reduce((total, plan) => total + plan.matchedCount, 0); + const dataTablesCreated = dataTablePlans.reduce( + (total, plan) => total + plan.creations.length, + 0, + ); + + const folderId = scopes.length === 1 ? scopes[0].context.folderId : null; + + eventService.emit('n8n-package-imported', { + user: request.user, + projectIds: scopes.map(({ context }) => context.projectId), + folderId, + workflowIds: importedWorkflows.map(({ workflow }) => workflow.id), + options: { + workflowConflictPolicy: request.workflowConflictPolicy, + workflowIdPolicy: request.workflowIdPolicy, + credentialMatchingMode: request.credentialMatchingMode, + credentialMissingMode: request.credentialMissingMode, + workflowPublishingPolicy: request.workflowPublishingPolicy, + dataTableMatchingMode: request.dataTableMatchingMode, + dataTableMissingMode: request.dataTableMissingMode, + dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy, + }, + packageSourceId: manifest.sourceId, + packageVersion: manifest.packageFormatVersion, + credentialIds: { + matched: matchedCredentialIds, + created: createdCredentialIds, + updated: [], + }, + counts: { + workflows: { + created: countByStatus('created'), + updated: countByStatus('updated'), + skipped: countByStatus('skipped'), + }, + credentials: { + matched: matchedCredentialIds.length, + created: createdCredentialIds.length, + requirements: credentialRequirements, + }, + dataTables: { + matched: dataTablesMatched, + created: dataTablesCreated, + requirements: dataTableRequirements, + }, + }, + }); +} diff --git a/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts b/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts index 1b840d3a9c9..220c93074cf 100644 --- a/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts +++ b/packages/cli/src/modules/n8n-packages/engine/n8n-package-parser.ts @@ -17,7 +17,7 @@ import { packageManifestSchema } from '../spec/manifest.schema'; import { serializedDataTableSchema } from '../spec/serialized/data-table.schema'; import type { SerializedDataTable } from '../spec/serialized/data-table.schema'; import { serializedFolderSchema, type SerializedFolder } from '../spec/serialized/folder.schema'; -import { serializedProjectSchema } from '../spec/serialized/project.schema'; +import { serializedProjectSchema, type SerializedProject } from '../spec/serialized/project.schema'; import type { SerializedWorkflow } from '../spec/serialized/workflow.schema'; /** @@ -180,20 +180,30 @@ export class N8nPackageParser { const path = `${entry.target}/project.json`; const wire = await this.readJson(reader, path, 'project'); + let project: SerializedProject; try { - const project = serializedProjectSchema.parse(wire); - return { - sourceProjectId: project.id, - name: project.name, - ...(project.description !== undefined ? { description: project.description } : {}), - ...(project.icon !== undefined ? { icon: project.icon } : {}), - }; + project = serializedProjectSchema.parse(wire); } catch (cause) { if (cause instanceof ZodError) { throw new UserError(`Package project file at ${path} failed schema validation.`, { cause }); } throw cause; } + + // Project contents scope by manifest id, but the project is created/matched under project.json's + // id — a mismatch would import folders and workflows into the wrong project. + if (project.id !== entry.id) { + throw new UserError( + `Package project at ${path} declares id "${project.id}" but the manifest lists it as "${entry.id}".`, + ); + } + + return { + sourceProjectId: project.id, + name: project.name, + ...(project.description !== undefined ? { description: project.description } : {}), + ...(project.icon !== undefined ? { icon: project.icon } : {}), + }; } private async readJson( diff --git a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts index 78975b32da0..cf5882e732e 100644 --- a/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/project-package-importer.ts @@ -1,25 +1,48 @@ +import { LicenseState } from '@n8n/backend-common'; import { Service } from '@n8n/di'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { EventService } from '@/events/event.service'; + +import type { CredentialBindingRequest } from '../entities/credential/credential.types'; +import type { DataTableImportRequest } from '../entities/data-table/data-table.types'; import { ProjectImporter } from '../entities/project/project-importer'; import type { PackageReader } from '../io/package-reader'; -import { createBindings } from '../n8n-packages.types'; -import type { ImportPackageRequest, ImportResult } from '../n8n-packages.types'; +import type { + BlockingIssue, + ImportedFolderSummary, + ImportedWorkflowSummary, + ImportPackageRequest, + ImportResult, + PackageImportBindings, +} from '../n8n-packages.types'; +import { mergeBindings } from '../n8n-packages.types'; +import { toImportBlockedError } from './import-blocked.error'; +import { + ImportOrchestrator, + type ImportOrchestrationInput, + type ImportPlan, +} from './import-orchestrator'; import { assertPackageImportApiKeyScopes, buildImportResult, + identifyRequirements, + scopeCredentialBindingsToRequirements, + toImportedWorkflowSummaries, toPackageSummary, } from './import-result'; +import { emitPackageImportedEvent, type PackageImportScope } from './import-telemetry'; import { N8nPackageParser } from './n8n-package-parser'; -import type { PackageManifest } from '../spec/manifest.schema'; +import type { ManifestEntry, PackageManifest } from '../spec/manifest.schema'; -/** - * Imports a package containing projects into the target instance. - **/ @Service() export class ProjectPackageImporter { constructor( private readonly packageParser: N8nPackageParser, private readonly projectImporter: ProjectImporter, + private readonly importOrchestrator: ImportOrchestrator, + private readonly eventService: EventService, + private readonly licenseState: LicenseState, ) {} async import( @@ -27,19 +50,138 @@ export class ProjectPackageImporter { reader: PackageReader, manifest: PackageManifest, ): Promise { - assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']); + this.assertAdequatePermissions(request, manifest); const projects = await this.packageParser.getProjects(reader); - const plan = await this.projectImporter.plan(request.user, projects); - const projectSummaries = await this.projectImporter.apply(request.user, plan); + const projectPlan = await this.projectImporter.plan(request.user, projects); + // Projects the user is creating (vs matching an existing one). They will be admin of these, + // so publish is always allowed and the project need not exist while its contents are planned. + const pendingCreateIds = new Set( + projectPlan.filter((item) => item.action === 'create').map((item) => item.sourceProjectId), + ); + + // Plan and validate every project's contents before writing anything, so a blocking issue in + // any project leaves nothing behind — not folders, workflows, nor the project shells. + const planned: Array<{ project: ManifestEntry; plan: ImportPlan }> = []; + const blockingIssues: BlockingIssue[] = []; + for (const project of manifest.projects ?? []) { + const input = await this.buildImportContextForProject( + request, + reader, + manifest, + project, + pendingCreateIds.has(project.id), + ); + const plan = await this.importOrchestrator.plan(input); + planned.push({ project, plan }); + blockingIssues.push(...plan.blockingIssues); + } + if (blockingIssues.length > 0) { + throw toImportBlockedError(blockingIssues); + } + + const projectSummaries = await this.projectImporter.apply(request.user, projectPlan); + + const workflows: ImportedWorkflowSummary[] = []; + const folders: ImportedFolderSummary[] = []; + const scopedBindings: PackageImportBindings[] = []; + const matched: string[] = []; + const stubbed: string[] = []; + const scopes: PackageImportScope[] = []; + + for (const { project, plan } of planned) { + const imported = await this.importOrchestrator.apply(plan); + workflows.push(...toImportedWorkflowSummaries(imported.workflowOutcomes, project.id)); + folders.push(...imported.folderSummaries); + scopedBindings.push(imported.bindings); + matched.push(...imported.credentialResult.matched); + stubbed.push(...imported.credentialResult.stubbed); + scopes.push({ + context: plan.input.context, + imported, + credentialRequest: plan.input.credentialRequest, + dataTableRequest: plan.input.dataTableRequest, + }); + } + + emitPackageImportedEvent(this.eventService, { request, manifest, scopes }); return buildImportResult({ package: toPackageSummary(manifest), - projectId: null, - workflows: [], - folders: [], + workflows, + folders, projects: projectSummaries, - bindings: createBindings(), + bindings: mergeBindings(...scopedBindings), + credentials: { matched, stubbed }, }); } + + private async buildImportContextForProject( + request: ImportPackageRequest, + reader: PackageReader, + manifest: PackageManifest, + project: ManifestEntry, + projectPendingCreation: boolean, + ): Promise { + const basePrefix = `${project.target}/`; + const folders = await this.packageParser.getFolders(reader, basePrefix); + const workflows = await this.packageParser.getWorkflows(reader, basePrefix); + + // Requirements and bindings are both scoped to this project's workflows so another project's + // binding is not seen as an orphan here (which would block the whole multi-project import). + const requirements = identifyRequirements(manifest.requirements?.credentials, workflows); + const credentialRequest: CredentialBindingRequest = { + requirements, + matchingMode: request.credentialMatchingMode, + missingMode: request.credentialMissingMode, + credentialBindings: scopeCredentialBindingsToRequirements( + request.bindings?.credentials, + requirements, + ), + }; + + const dataTableRequest: DataTableImportRequest = { + requirements: identifyRequirements(manifest.requirements?.dataTables, workflows), + packageDataTables: await this.packageParser.getDataTables(reader), + matchingMode: request.dataTableMatchingMode, + missingMode: request.dataTableMissingMode, + schemaConflictPolicy: request.dataTableSchemaConflictPolicy, + }; + + return { + context: { + user: request.user, + projectId: project.id, + folderId: null, + }, + folders, + workflows, + credentialRequest, + dataTableRequest, + options: request, + projectPendingCreation, + }; + } + + private assertAdequatePermissions( + request: ImportPackageRequest, + manifest: PackageManifest, + ): void { + // A project package can create new projects or update matched ones (by source id), so require both — + // mirroring the folder create+update assertion below. + assertPackageImportApiKeyScopes(request.apiKeyScopes, ['project:create', 'project:update']); + + if ((manifest.folders?.length ?? 0) > 0) { + if (!this.licenseState.isLicensed('feat:folders')) { + throw new ForbiddenError( + 'Your license does not allow folders. Importing a package with folders requires a license that supports folders.', + ); + } + assertPackageImportApiKeyScopes(request.apiKeyScopes, ['folder:create', 'folder:update']); + } + + if ((manifest.workflows?.length ?? 0) > 0) { + assertPackageImportApiKeyScopes(request.apiKeyScopes, ['workflow:import']); + } + } } diff --git a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts index 7a09b17a54c..deb54781d45 100644 --- a/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts +++ b/packages/cli/src/modules/n8n-packages/engine/workflow-package-importer.ts @@ -12,20 +12,19 @@ import { ProjectService } from '@/services/project.service.ee'; import type { CredentialBindingRequest } from '../entities/credential/credential.types'; import type { DataTableImportRequest } from '../entities/data-table/data-table.types'; -import type { - PreparedWorkflow, - WorkflowImportOutcome, -} from '../entities/workflow/workflow-import.types'; -import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types'; import type { PackageReader } from '../io/package-reader'; -import type { PackageManifest } from '../spec/manifest.schema'; -import { ImportOrchestrator, type ImportOrchestrationResult } from './import-orchestrator'; +import type { ImportContext, ImportPackageRequest, ImportResult } from '../n8n-packages.types'; +import { ImportOrchestrator } from './import-orchestrator'; import { assertPackageImportApiKeyScopes, buildImportResult, + identifyRequirements, + toImportedWorkflowSummaries, toPackageSummary, } from './import-result'; +import { emitPackageImportedEvent } from './import-telemetry'; import { N8nPackageParser } from './n8n-package-parser'; +import type { PackageManifest } from '../spec/manifest.schema'; /** * Imports loose top-level workflows, their folder shells, and credential & data table deps into a target project. @@ -92,19 +91,15 @@ export class WorkflowPackageImporter { options: request, }); - this.emitImportedEvent( + emitPackageImportedEvent(this.eventService, { request, - context, manifest, - imported, - credentialRequest, - dataTableRequest, - ); + scopes: [{ context, imported, credentialRequest, dataTableRequest }], + }); return buildImportResult({ package: toPackageSummary(manifest), - projectId: context.projectId, - workflows: imported.workflowOutcomes, + workflows: toImportedWorkflowSummaries(imported.workflowOutcomes, context.projectId), folders: imported.folderSummaries, projects: [], bindings: imported.bindings, @@ -123,65 +118,6 @@ export class WorkflowPackageImporter { } } - private emitImportedEvent( - request: ImportPackageRequest, - context: ImportContext, - manifest: PackageManifest, - imported: ImportOrchestrationResult, - credentialRequest: CredentialBindingRequest, - dataTableRequest: DataTableImportRequest, - ): void { - const { workflowOutcomes, credentialResult, dataTablePlan } = imported; - const importedWorkflows = workflowOutcomes.filter(({ status }) => status !== 'skipped'); - const countByStatus = (status: WorkflowImportOutcome['status']) => - workflowOutcomes.filter((outcome) => outcome.status === status).length; - - this.eventService.emit('n8n-package-imported', { - user: context.user, - projectId: context.projectId, - folderId: context.folderId, - workflowIds: importedWorkflows.map(({ workflow }) => workflow.id), - options: { - workflowConflictPolicy: request.workflowConflictPolicy, - workflowIdPolicy: request.workflowIdPolicy, - credentialMatchingMode: request.credentialMatchingMode, - credentialMissingMode: request.credentialMissingMode, - workflowPublishingPolicy: request.workflowPublishingPolicy, - dataTableMatchingMode: request.dataTableMatchingMode, - dataTableMissingMode: request.dataTableMissingMode, - dataTableSchemaConflictPolicy: request.dataTableSchemaConflictPolicy, - }, - packageSourceId: manifest.sourceId, - packageVersion: manifest.packageFormatVersion, - credentialIds: { - matched: credentialResult.matched.map( - (sourceId) => credentialResult.bindings.get(sourceId)!, - ), - created: credentialResult.stubbed.map( - (sourceId) => credentialResult.bindings.get(sourceId)!, - ), - updated: [], - }, - counts: { - workflows: { - created: countByStatus('created'), - updated: countByStatus('updated'), - skipped: countByStatus('skipped'), - }, - credentials: { - matched: credentialResult.matched.length, - created: credentialResult.stubbed.length, - requirements: credentialRequest.requirements?.length ?? 0, - }, - dataTables: { - matched: dataTablePlan.matchedCount, - created: dataTablePlan.creations.length, - requirements: dataTableRequest.requirements?.length ?? 0, - }, - }, - }); - } - private async findImportLocation( user: User, projectId: string | undefined, @@ -236,19 +172,3 @@ export class WorkflowPackageImporter { } } } - -/** Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. */ -function identifyRequirements( - requirements: T[] | undefined, - workflows: PreparedWorkflow[], -): T[] | undefined { - if (!requirements) return undefined; - - const importedIds = new Set(workflows.map((workflow) => workflow.sourceWorkflowId)); - return requirements - .map((requirement) => ({ - ...requirement, - usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)), - })) - .filter((requirement) => requirement.usedByWorkflows.length > 0); -} diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts index bb5b5fd982f..a0900e07084 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts @@ -41,6 +41,18 @@ describe('WorkflowPublisher', () => { expect(projectService.getProjectWithScope).not.toHaveBeenCalled(); }); + it('does nothing for a pending-create project even under publish-all', async () => { + // The project does not exist yet; its creator will be admin, so there is nothing to check. + await publisher.assertCanPublish( + user, + 'new-project', + WorkflowPublishingPolicy.PublishAll, + true, + ); + + expect(projectService.getProjectWithScope).not.toHaveBeenCalled(); + }); + it('passes when the user can publish in the target project', async () => { projectService.getProjectWithScope.mockResolvedValue(mock({ id: 'project-1' })); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts index c3aa42cb654..9aaa296ed57 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow-publisher.ts @@ -40,16 +40,25 @@ export class WorkflowPublisher { * Fail the import before any writes when {@link WorkflowPublishingPolicy.PublishAll} * is selected and the actor lacks `workflow:publish`. Other policies skip this check; * publish permission is checked per workflow in workflowService + * + * `projectPendingCreation` lets this run before the target project exists: a project the + * user is importing as new will be created with them as admin, so they can always publish + * in it and there is nothing to look up yet. */ async assertCanPublish( user: User, projectId: string, policy: WorkflowPublishingPolicy, + projectPendingCreation = false, ): Promise { if (policy !== WorkflowPublishingPolicy.PublishAll) { return; } + if (projectPendingCreation) { + return; + } + const project = await this.projectService.getProjectWithScope(user, projectId, [ 'workflow:publish', ]); 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 d5c2827d432..7adf5012fd9 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -269,6 +269,14 @@ export function createBindings(seed: Partial = {}): Packa }; } +/** Combines per-scope binding maps into one — used when a project package imports several scopes. */ +export function mergeBindings(...bindings: PackageImportBindings[]): PackageImportBindings { + return { + workflows: new Map(bindings.flatMap(({ workflows }) => [...workflows])), + credentials: new Map(bindings.flatMap(({ credentials }) => [...credentials])), + }; +} + /** Plain-object form of {@link PackageImportBindings}, suitable for JSON responses. */ export type SerializedBindings = Record>;