feat: Migrate integer primary keys to nanoids (#6345)

* first commit for postgres migration

* (not working)

* sqlite migration

* quicksave

* fix tests

* fix pg test

* fix postgres

* fix variables import

* fix execution saving

* add user settings fix

* change migration to single lines

* patch preferences endpoint

* cleanup

* improve variable import

* cleanup unusued code

* Update packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts

Co-authored-by: Omar Ajoue <krynble@gmail.com>

* address review notes

* fix var update/import

* refactor: Separate execution data to its own table (#6323)

* wip: Temporary migration process

* refactor: Create boilerplate repository methods for executions

* fix: Lint issues

* refactor: Added search endpoint to repository

* refactor: Make the execution list work again

* wip: Updating how we create and update executions everywhere

* fix: Lint issues and remove most of the direct access to execution model

* refactor: Remove includeWorkflowData flag and fix more tests

* fix: Lint issues

* fix: Fixed ordering of executions for FE, removed transaction when saving execution and removed unnecessary update

* refactor: Add comment about missing feature

* refactor: Refactor counting executions

* refactor: Add migration for other dbms and fix issues found

* refactor: Fix lint issues

* refactor: Remove unnecessary comment and auto inject repo to internal hooks

* refactor: remove type assertion

* fix: Fix broken tests

* fix: Remove unnecessary import

* Remove unnecessary toString() call

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* fix: Address comments after review

* refactor: Remove unused import

* fix: Lint issues

* fix: Add correct migration files

---------

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* remove null values from credential export

* fix: Fix an issue with queue mode where all running execution would be returned

* fix: Update n8n node to allow for workflow ids with letters

* set upstream on set branch

* remove typo

* add nodeAccess to credentials

* fix unsaved run check for undefined id

* fix(core): Rename version control feature to source control (#6480)

* rename versionControl to sourceControl

* fix source control tooltip wording

---------

Co-authored-by: Romain Minaud <romain.minaud@gmail.com>

* fix(editor): Pay 548 hide the set up version control button (#6485)

* feat(DebugHelper Node): Fix and include in main app (#6406)

* improve node a bit

* fixing continueOnFail() ton contain error in json

* improve pairedItem

* fix random data returning object results

* fix nanoId length typo

* update pnpm-lock file

---------

Co-authored-by: Marcus <marcus@n8n.io>

* fix(editor): Remove setup source control CTA button

* fix(editor): Remove setup source control CTA button

---------

Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com>
Co-authored-by: Marcus <marcus@n8n.io>

* fix(editor): Update source control docs links (#6488)

* feat(DebugHelper Node): Fix and include in main app (#6406)

* improve node a bit

* fixing continueOnFail() ton contain error in json

* improve pairedItem

* fix random data returning object results

* fix nanoId length typo

* update pnpm-lock file

---------

Co-authored-by: Marcus <marcus@n8n.io>

* feat(editor): Replace root events with event bus events (no-changelog) (#6454)

* feat: replace root events with event bus events

* fix: prevent cypress from replacing global with globalThis in import path

* feat: remove emitter mixin

* fix: replace component events with event bus

* fix: fix linting issue

* fix: fix breaking expression switch

* chore: prettify ndv e2e suite code

* fix(editor): Update source control docs links

---------

Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com>
Co-authored-by: Marcus <marcus@n8n.io>
Co-authored-by: Alex Grozav <alex@grozav.com>

* fix tag endpoint regex

---------

Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Romain Minaud <romain.minaud@gmail.com>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
Co-authored-by: Marcus <marcus@n8n.io>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Michael Auerswald
2023-06-20 19:13:18 +02:00
committed by GitHub
parent da330f0648
commit c3ba0123ad
156 changed files with 3499 additions and 2594 deletions

View File

@@ -0,0 +1,15 @@
export const SOURCE_CONTROL_PREFERENCES_DB_KEY = 'features.sourceControl';
export const SOURCE_CONTROL_GIT_FOLDER = 'git';
export const SOURCE_CONTROL_GIT_KEY_COMMENT = 'n8n deploy key';
export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows';
export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credentials';
export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variables.json';
export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json';
export const SOURCE_CONTROL_SSH_FOLDER = 'ssh';
export const SOURCE_CONTROL_SSH_KEY_NAME = 'key';
export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main';
export const SOURCE_CONTROL_ORIGIN = 'origin';
export const SOURCE_CONTROL_API_ROOT = 'source-control';
export const SOURCE_CONTROL_README = `
# n8n Source Control
`;

View File

@@ -0,0 +1,21 @@
import type { RequestHandler } from 'express';
import { isSourceControlLicensed } from '../sourceControlHelper.ee';
import Container from 'typedi';
import { SourceControlPreferencesService } from '../sourceControlPreferences.service.ee';
export const sourceControlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) => {
const sourceControlPreferencesService = Container.get(SourceControlPreferencesService);
if (sourceControlPreferencesService.isSourceControlLicensedAndEnabled()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};
export const sourceControlLicensedMiddleware: RequestHandler = (req, res, next) => {
if (isSourceControlLicensed()) {
next();
} else {
res.status(401).json({ status: 'error', message: 'Unauthorized' });
}
};

View File

@@ -0,0 +1,235 @@
import { Authorized, Get, Post, Patch, RestController } from '@/decorators';
import {
sourceControlLicensedMiddleware,
sourceControlLicensedAndEnabledMiddleware,
} from './middleware/sourceControlEnabledMiddleware.ee';
import { SourceControlService } from './sourceControl.service.ee';
import { SourceControlRequest } from './types/requests';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import { BadRequestError } from '@/ResponseHelper';
import type { PullResult, PushResult, StatusResult } from 'simple-git';
import express from 'express';
import type { ImportResult } from './types/importResult';
import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee';
import type { SourceControlledFile } from './types/sourceControlledFile';
import { SOURCE_CONTROL_API_ROOT, SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
@RestController(`/${SOURCE_CONTROL_API_ROOT}`)
export class SourceControlController {
constructor(
private sourceControlService: SourceControlService,
private sourceControlPreferencesService: SourceControlPreferencesService,
) {}
@Authorized('any')
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
async getPreferences(): Promise<SourceControlPreferences> {
// returns the settings with the privateKey property redacted
return this.sourceControlPreferencesService.getPreferences();
}
@Authorized(['global', 'owner'])
@Post('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
async setPreferences(req: SourceControlRequest.UpdatePreferences) {
if (
req.body.branchReadOnly === undefined &&
this.sourceControlPreferencesService.isSourceControlConnected()
) {
throw new BadRequestError(
'Cannot change preferences while connected to a source control provider. Please disconnect first.',
);
}
try {
const sanitizedPreferences: Partial<SourceControlPreferences> = {
...req.body,
initRepo: req.body.initRepo ?? true, // default to true if not specified
connected: undefined,
publicKey: undefined,
};
await this.sourceControlPreferencesService.validateSourceControlPreferences(
sanitizedPreferences,
);
const updatedPreferences = await this.sourceControlPreferencesService.setPreferences(
sanitizedPreferences,
);
if (sanitizedPreferences.initRepo === true) {
try {
await this.sourceControlService.initializeRepository({
...updatedPreferences,
branchName:
updatedPreferences.branchName === ''
? SOURCE_CONTROL_DEFAULT_BRANCH
: updatedPreferences.branchName,
initRepo: true,
});
if (this.sourceControlPreferencesService.getPreferences().branchName !== '') {
await this.sourceControlPreferencesService.setPreferences({
connected: true,
});
}
} catch (error) {
// if initialization fails, run cleanup to remove any intermediate state and throw the error
await this.sourceControlService.disconnect({ keepKeyPair: true });
throw error;
}
}
await this.sourceControlService.init();
return this.sourceControlPreferencesService.getPreferences();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Patch('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
async updatePreferences(req: SourceControlRequest.UpdatePreferences) {
try {
const sanitizedPreferences: Partial<SourceControlPreferences> = {
...req.body,
initRepo: false,
connected: undefined,
publicKey: undefined,
repositoryUrl: undefined,
authorName: undefined,
authorEmail: undefined,
};
const currentPreferences = this.sourceControlPreferencesService.getPreferences();
await this.sourceControlPreferencesService.validateSourceControlPreferences(
sanitizedPreferences,
);
if (
sanitizedPreferences.branchName &&
sanitizedPreferences.branchName !== currentPreferences.branchName
) {
await this.sourceControlService.setBranch(sanitizedPreferences.branchName);
}
if (sanitizedPreferences.branchColor || sanitizedPreferences.branchReadOnly !== undefined) {
await this.sourceControlPreferencesService.setPreferences(
{
branchColor: sanitizedPreferences.branchColor,
branchReadOnly: sanitizedPreferences.branchReadOnly,
},
true,
);
}
await this.sourceControlService.init();
return this.sourceControlPreferencesService.getPreferences();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Post('/disconnect', { middlewares: [sourceControlLicensedMiddleware] })
async disconnect(req: SourceControlRequest.Disconnect) {
try {
return await this.sourceControlService.disconnect(req.body);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized('any')
@Get('/get-branches', { middlewares: [sourceControlLicensedMiddleware] })
async getBranches() {
try {
return await this.sourceControlService.getBranches();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Post('/push-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async pushWorkfolder(
req: SourceControlRequest.PushWorkFolder,
res: express.Response,
): Promise<PushResult | SourceControlledFile[]> {
if (this.sourceControlPreferencesService.isBranchReadOnly()) {
throw new BadRequestError('Cannot push onto read-only branch.');
}
try {
const result = await this.sourceControlService.pushWorkfolder(req.body);
if ((result as PushResult).pushed) {
res.statusCode = 200;
} else {
res.statusCode = 409;
}
return result;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Post('/pull-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async pullWorkfolder(
req: SourceControlRequest.PullWorkFolder,
res: express.Response,
): Promise<SourceControlledFile[] | ImportResult | PullResult | StatusResult | undefined> {
try {
const result = await this.sourceControlService.pullWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
importAfterPull: req.body.importAfterPull ?? true,
});
if ((result as ImportResult)?.workflows) {
res.statusCode = 200;
} else {
res.statusCode = 409;
}
return result;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async resetWorkfolder(
req: SourceControlRequest.PullWorkFolder,
): Promise<ImportResult | undefined> {
try {
return await this.sourceControlService.resetWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
importAfterPull: req.body.importAfterPull ?? true,
});
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized('any')
@Get('/get-status', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async getStatus() {
try {
return await this.sourceControlService.getStatus();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized('any')
@Get('/status', { middlewares: [sourceControlLicensedMiddleware] })
async status(): Promise<StatusResult> {
try {
return await this.sourceControlService.status();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
@Authorized(['global', 'owner'])
@Post('/generate-key-pair', { middlewares: [sourceControlLicensedMiddleware] })
async generateKeyPair(): Promise<SourceControlPreferences> {
try {
const result = await this.sourceControlPreferencesService.generateAndSaveKeyPair();
return result;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
}

View File

@@ -0,0 +1,424 @@
import { Service } from 'typedi';
import path from 'path';
import * as Db from '@/Db';
import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_README,
SOURCE_CONTROL_SSH_FOLDER,
SOURCE_CONTROL_SSH_KEY_NAME,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import { LoggerProxy } from 'n8n-workflow';
import { SourceControlGitService } from './sourceControlGit.service.ee';
import { UserSettings } from 'n8n-core';
import type { PushResult, StatusResult } from 'simple-git';
import type { ExportResult } from './types/exportResult';
import { SourceControlExportService } from './sourceControlExport.service.ee';
import { BadRequestError } from '../../ResponseHelper';
import type { ImportResult } from './types/importResult';
import type { SourceControlPushWorkFolder } from './types/sourceControlPushWorkFolder';
import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder';
import type {
SourceControlledFileLocation,
SourceControlledFile,
SourceControlledFileStatus,
SourceControlledFileType,
} from './types/sourceControlledFile';
import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee';
import { writeFileSync } from 'fs';
import { SourceControlImportService } from './sourceControlImport.service.ee';
@Service()
export class SourceControlService {
private sshKeyName: string;
private sshFolder: string;
private gitFolder: string;
constructor(
private gitService: SourceControlGitService,
private sourceControlPreferencesService: SourceControlPreferencesService,
private sourceControlExportService: SourceControlExportService,
private sourceControlImportService: SourceControlImportService,
) {
const userFolder = UserSettings.getUserN8nFolderPath();
this.sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER);
this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER);
this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME);
}
async init(): Promise<void> {
this.gitService.resetService();
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
await this.sourceControlPreferencesService.loadFromDbAndApplySourceControlPreferences();
await this.gitService.initService({
sourceControlPreferences: this.sourceControlPreferencesService.getPreferences(),
gitFolder: this.gitFolder,
sshKeyName: this.sshKeyName,
sshFolder: this.sshFolder,
});
}
async disconnect(options: { keepKeyPair?: boolean } = {}) {
try {
await this.sourceControlPreferencesService.setPreferences({
connected: false,
branchName: '',
});
await this.sourceControlExportService.deleteRepositoryFolder();
if (!options.keepKeyPair) {
await this.sourceControlPreferencesService.deleteKeyPairFiles();
}
this.gitService.resetService();
return this.sourceControlPreferencesService.sourceControlPreferences;
} catch (error) {
throw Error(`Failed to disconnect from source control: ${(error as Error).message}`);
}
}
async initializeRepository(preferences: SourceControlPreferences) {
if (!this.gitService.git) {
await this.init();
}
LoggerProxy.debug('Initializing repository...');
await this.gitService.initRepository(preferences);
let getBranchesResult;
try {
getBranchesResult = await this.getBranches();
} catch (error) {
if ((error as Error).message.includes('Warning: Permanently added')) {
LoggerProxy.debug('Added repository host to the list of known hosts. Retrying...');
getBranchesResult = await this.getBranches();
} else {
throw error;
}
}
if (getBranchesResult.branches.includes(preferences.branchName)) {
await this.gitService.setBranch(preferences.branchName);
} else {
if (getBranchesResult.branches?.length === 0) {
try {
writeFileSync(path.join(this.gitFolder, '/README.md'), SOURCE_CONTROL_README);
await this.gitService.stage(new Set<string>(['README.md']));
await this.gitService.commit('Initial commit');
await this.gitService.push({
branch: preferences.branchName,
force: true,
});
getBranchesResult = await this.getBranches();
} catch (fileError) {
LoggerProxy.error(`Failed to create initial commit: ${(fileError as Error).message}`);
}
} else {
await this.sourceControlPreferencesService.setPreferences({
branchName: '',
connected: true,
});
}
}
return getBranchesResult;
}
async export() {
const result: {
tags: ExportResult | undefined;
credentials: ExportResult | undefined;
variables: ExportResult | undefined;
workflows: ExportResult | undefined;
} = {
credentials: undefined,
tags: undefined,
variables: undefined,
workflows: undefined,
};
try {
// comment next line if needed
await this.sourceControlExportService.cleanWorkFolder();
result.tags = await this.sourceControlExportService.exportTagsToWorkFolder();
result.variables = await this.sourceControlExportService.exportVariablesToWorkFolder();
result.workflows = await this.sourceControlExportService.exportWorkflowsToWorkFolder();
result.credentials = await this.sourceControlExportService.exportCredentialsToWorkFolder();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
return result;
}
async import(options: SourceControllPullOptions): Promise<ImportResult | undefined> {
try {
return await this.sourceControlImportService.importFromWorkFolder(options);
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
}
async getBranches(): Promise<{ branches: string[]; currentBranch: string }> {
// fetch first to get include remote changes
await this.gitService.fetch();
return this.gitService.getBranches();
}
async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> {
await this.sourceControlPreferencesService.setPreferences({
branchName: branch,
connected: branch?.length > 0,
});
return this.gitService.setBranch(branch);
}
// will reset the branch to the remote branch and pull
// this will discard all local changes
async resetWorkfolder(options: SourceControllPullOptions): Promise<ImportResult | undefined> {
const currentBranch = await this.gitService.getCurrentBranch();
await this.sourceControlExportService.cleanWorkFolder();
await this.gitService.resetBranch({
hard: true,
target: currentBranch.remote,
});
await this.gitService.pull();
if (options.importAfterPull) {
return this.import(options);
}
return;
}
async pushWorkfolder(
options: SourceControlPushWorkFolder,
): Promise<PushResult | SourceControlledFile[]> {
if (this.sourceControlPreferencesService.isBranchReadOnly()) {
throw new BadRequestError('Cannot push onto read-only branch.');
}
if (!options.skipDiff) {
const diffResult = await this.getStatus();
const possibleConflicts = diffResult?.filter((file) => file.conflict);
if (possibleConflicts?.length > 0 && options.force !== true) {
await this.unstage();
return diffResult;
}
}
await this.unstage();
await this.stage(options);
await this.gitService.commit(options.message ?? 'Updated Workfolder');
return this.gitService.push({
branch: this.sourceControlPreferencesService.getBranchName(),
force: options.force ?? false,
});
}
async pullWorkfolder(
options: SourceControllPullOptions,
): Promise<ImportResult | StatusResult | undefined> {
await this.resetWorkfolder({
importAfterPull: false,
userId: options.userId,
force: false,
});
await this.export(); // refresh workfolder
const status = await this.gitService.status();
if (status.modified.length > 0 && options.force !== true) {
return status;
}
await this.resetWorkfolder({ ...options, importAfterPull: false });
if (options.importAfterPull) {
return this.import(options);
}
return;
}
async stage(
options: Pick<SourceControlPushWorkFolder, 'fileNames' | 'credentialIds' | 'workflowIds'>,
): Promise<{ staged: string[] } | string> {
const { fileNames, credentialIds, workflowIds } = options;
const status = await this.gitService.status();
let mergedFileNames = new Set<string>();
fileNames?.forEach((e) => mergedFileNames.add(e));
credentialIds?.forEach((e) =>
mergedFileNames.add(this.sourceControlExportService.getCredentialsPath(e)),
);
workflowIds?.forEach((e) =>
mergedFileNames.add(this.sourceControlExportService.getWorkflowPath(e)),
);
if (mergedFileNames.size === 0) {
mergedFileNames = new Set<string>([
...status.not_added,
...status.created,
...status.modified,
]);
}
const deletedFiles = new Set<string>(status.deleted);
deletedFiles.forEach((e) => mergedFileNames.delete(e));
await this.unstage();
const stageResult = await this.gitService.stage(mergedFileNames, deletedFiles);
if (!stageResult) {
const statusResult = await this.gitService.status();
return { staged: statusResult.staged };
}
return stageResult;
}
async unstage(): Promise<StatusResult | string> {
const stageResult = await this.gitService.resetBranch();
if (!stageResult) {
return this.gitService.status();
}
return stageResult;
}
async status(): Promise<StatusResult> {
return this.gitService.status();
}
private async fileNameToSourceControlledFile(
fileName: string,
location: SourceControlledFileLocation,
statusResult: StatusResult,
): Promise<SourceControlledFile | undefined> {
let id: string | undefined = undefined;
let name = '';
let conflict = false;
let status: SourceControlledFileStatus = 'unknown';
let type: SourceControlledFileType = 'file';
// initialize status from git status result
if (statusResult.not_added.find((e) => e === fileName)) status = 'new';
else if (statusResult.conflicted.find((e) => e === fileName)) {
status = 'conflicted';
conflict = true;
} else if (statusResult.created.find((e) => e === fileName)) status = 'created';
else if (statusResult.deleted.find((e) => e === fileName)) status = 'deleted';
else if (statusResult.modified.find((e) => e === fileName)) status = 'modified';
if (fileName.startsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER)) {
type = 'workflow';
if (status === 'deleted') {
id = fileName
.replace(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER, '')
.replace(/[\/,\\]/, '')
.replace('.json', '');
if (location === 'remote') {
const existingWorkflow = await Db.collections.Workflow.find({
where: { id },
});
if (existingWorkflow?.length > 0) {
name = existingWorkflow[0].name;
}
} else {
name = '(deleted)';
}
} else {
const workflow = await this.sourceControlExportService.getWorkflowFromFile(fileName);
if (!workflow?.id) {
if (location === 'local') {
return;
}
id = fileName
.replace(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER + '/', '')
.replace('.json', '');
status = 'created';
} else {
id = workflow.id;
name = workflow.name;
}
}
}
if (fileName.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
type = 'credential';
if (status === 'deleted') {
id = fileName
.replace(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, '')
.replace(/[\/,\\]/, '')
.replace('.json', '');
if (location === 'remote') {
const existingCredential = await Db.collections.Credentials.find({
where: { id },
});
if (existingCredential?.length > 0) {
name = existingCredential[0].name;
}
} else {
name = '(deleted)';
}
} else {
const credential = await this.sourceControlExportService.getCredentialFromFile(fileName);
if (!credential?.id) {
if (location === 'local') {
return;
}
id = fileName
.replace(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER + '/', '')
.replace('.json', '');
status = 'created';
} else {
id = credential.id;
name = credential.name;
}
}
}
if (fileName.startsWith(SOURCE_CONTROL_VARIABLES_EXPORT_FILE)) {
id = 'variables';
name = 'variables';
type = 'variables';
}
if (fileName.startsWith(SOURCE_CONTROL_TAGS_EXPORT_FILE)) {
id = 'tags';
name = 'tags';
type = 'tags';
}
if (!id) return;
return {
file: fileName,
id,
name,
type,
status,
location,
conflict,
};
}
async getStatus(): Promise<SourceControlledFile[]> {
await this.export();
await this.stage({});
await this.gitService.fetch();
const sourceControlledFiles: SourceControlledFile[] = [];
const diffRemote = await this.gitService.diffRemote();
const diffLocal = await this.gitService.diffLocal();
const status = await this.gitService.status();
await Promise.all([
...(diffRemote?.files.map(async (e) => {
const resolvedFile = await this.fileNameToSourceControlledFile(e.file, 'remote', status);
if (resolvedFile) {
sourceControlledFiles.push(resolvedFile);
}
}) ?? []),
...(diffLocal?.files.map(async (e) => {
const resolvedFile = await this.fileNameToSourceControlledFile(e.file, 'local', status);
if (resolvedFile) {
sourceControlledFiles.push(resolvedFile);
}
}) ?? []),
]);
sourceControlledFiles.forEach((e, index, array) => {
const similarItems = array.filter(
(f) => f.type === e.type && (f.file === e.file || f.id === e.id),
);
if (similarItems.length > 1) {
similarItems.forEach((item) => {
item.conflict = true;
});
}
});
return sourceControlledFiles;
}
}

View File

@@ -0,0 +1,336 @@
import { Service } from 'typedi';
import path from 'path';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import * as Db from '@/Db';
import glob from 'fast-glob';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { LoggerProxy, jsonParse } from 'n8n-workflow';
import { writeFile as fsWriteFile, readFile as fsReadFile, rm as fsRm } from 'fs/promises';
import { Credentials, UserSettings } from 'n8n-core';
import type { IWorkflowToImport } from '@/Interfaces';
import type { ExportableWorkflow } from './types/exportableWorkflow';
import type { ExportableCredential } from './types/exportableCredential';
import type { ExportResult } from './types/exportResult';
import type { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee';
@Service()
export class SourceControlExportService {
private gitFolder: string;
private workflowExportFolder: string;
private credentialExportFolder: string;
constructor() {
const userFolder = UserSettings.getUserN8nFolderPath();
this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER);
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
this.credentialExportFolder = path.join(
this.gitFolder,
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
);
}
getWorkflowPath(workflowId: string): string {
return path.join(this.workflowExportFolder, `${workflowId}.json`);
}
getCredentialsPath(credentialsId: string): string {
return path.join(this.credentialExportFolder, `${credentialsId}.json`);
}
getTagsPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
}
getVariablesPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE);
}
async getWorkflowFromFile(
filePath: string,
root = this.gitFolder,
): Promise<IWorkflowToImport | undefined> {
try {
const importedWorkflow = jsonParse<IWorkflowToImport>(
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
);
return importedWorkflow;
} catch (error) {
return undefined;
}
}
async getCredentialFromFile(
filePath: string,
root = this.gitFolder,
): Promise<ExportableCredential | undefined> {
try {
const credential = jsonParse<ExportableCredential>(
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
);
return credential;
} catch (error) {
return undefined;
}
}
async cleanWorkFolder() {
try {
const workflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
});
const credentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
});
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
await Promise.all(tagsFile.map(async (e) => fsRm(e)));
await Promise.all(variablesFile.map(async (e) => fsRm(e)));
await Promise.all(workflowFiles.map(async (e) => fsRm(e)));
await Promise.all(credentialFiles.map(async (e) => fsRm(e)));
LoggerProxy.debug('Cleaned work folder.');
} catch (error) {
LoggerProxy.error(`Failed to clean work folder: ${(error as Error).message}`);
}
}
async deleteRepositoryFolder() {
try {
await fsRm(this.gitFolder, { recursive: true });
} catch (error) {
LoggerProxy.error(`Failed to delete work folder: ${(error as Error).message}`);
}
}
private async rmDeletedWorkflowsFromExportFolder(
workflowsToBeExported: SharedWorkflow[],
): Promise<Set<string>> {
const sharedWorkflowsFileNames = new Set<string>(
workflowsToBeExported.map((e) => this.getWorkflowPath(e?.workflow?.name)),
);
const existingWorkflowsInFolder = new Set<string>(
await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
}),
);
const deletedWorkflows = new Set(existingWorkflowsInFolder);
for (const elem of sharedWorkflowsFileNames) {
deletedWorkflows.delete(elem);
}
try {
await Promise.all([...deletedWorkflows].map(async (e) => fsRm(e)));
} catch (error) {
LoggerProxy.error(`Failed to delete workflows from work folder: ${(error as Error).message}`);
}
return deletedWorkflows;
}
private async writeExportableWorkflowsToExportFolder(workflowsToBeExported: SharedWorkflow[]) {
await Promise.all(
workflowsToBeExported.map(async (e) => {
if (!e.workflow) {
LoggerProxy.debug(
`Found no corresponding workflow ${e.workflowId ?? 'unknown'}, skipping export`,
);
return;
}
const fileName = this.getWorkflowPath(e.workflow?.id);
const sanitizedWorkflow: ExportableWorkflow = {
active: e.workflow?.active,
id: e.workflow?.id,
name: e.workflow?.name,
nodes: e.workflow?.nodes,
connections: e.workflow?.connections,
settings: e.workflow?.settings,
triggerCount: e.workflow?.triggerCount,
owner: e.user.email,
versionId: e.workflow?.versionId,
};
LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`);
return fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2));
}),
);
}
async exportWorkflowsToWorkFolder(): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.workflowExportFolder]);
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
relations: ['workflow', 'role', 'user'],
where: {
role: {
name: 'owner',
scope: 'workflow',
},
},
});
// before exporting, figure out which workflows have been deleted and remove them from the export folder
const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows);
// write the workflows to the export folder as json files
await this.writeExportableWorkflowsToExportFolder(sharedWorkflows);
return {
count: sharedWorkflows.length,
folder: this.workflowExportFolder,
files: sharedWorkflows.map((e) => ({
id: e?.workflow?.id,
name: this.getWorkflowPath(e?.workflow?.name),
})),
removedFiles: [...removedFiles],
};
} catch (error) {
throw Error(`Failed to export workflows to work folder: ${(error as Error).message}`);
}
}
async exportVariablesToWorkFolder(): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.gitFolder]);
const variables = await Db.collections.Variables.find();
// do not export empty variables
if (variables.length === 0) {
return {
count: 0,
folder: this.gitFolder,
files: [],
};
}
const fileName = this.getVariablesPath();
const sanitizedVariables = variables.map((e) => ({ ...e, value: '' }));
await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2));
return {
count: sanitizedVariables.length,
folder: this.gitFolder,
files: [
{
id: '',
name: fileName,
},
],
};
} catch (error) {
throw Error(`Failed to export variables to work folder: ${(error as Error).message}`);
}
}
async exportTagsToWorkFolder(): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.gitFolder]);
const tags = await Db.collections.Tag.find();
// do not export empty tags
if (tags.length === 0) {
return {
count: 0,
folder: this.gitFolder,
files: [],
};
}
const mappings = await Db.collections.WorkflowTagMapping.find();
const fileName = this.getTagsPath();
await fsWriteFile(
fileName,
JSON.stringify(
{
tags: tags.map((tag) => ({ id: tag.id, name: tag.name })),
mappings,
},
null,
2,
),
);
return {
count: tags.length,
folder: this.gitFolder,
files: [
{
id: '',
name: fileName,
},
],
};
} catch (error) {
throw Error(`Failed to export variables to work folder: ${(error as Error).message}`);
}
}
private replaceCredentialData = (
data: ICredentialDataDecryptedObject,
): ICredentialDataDecryptedObject => {
for (const [key] of Object.entries(data)) {
try {
if (data[key] === null) {
delete data[key]; // remove invalid null values
} else if (typeof data[key] === 'object') {
data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject);
} else if (typeof data[key] === 'string') {
data[key] = (data[key] as string)?.startsWith('={{') ? data[key] : '';
} else if (typeof data[key] === 'number') {
// TODO: leaving numbers in for now, but maybe we should remove them
continue;
}
} catch (error) {
LoggerProxy.error(`Failed to sanitize credential data: ${(error as Error).message}`);
throw error;
}
}
return data;
};
async exportCredentialsToWorkFolder(): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.credentialExportFolder]);
const sharedCredentials = await Db.collections.SharedCredentials.find({
relations: ['credentials', 'role', 'user'],
});
const encryptionKey = await UserSettings.getEncryptionKey();
await Promise.all(
sharedCredentials.map(async (sharedCredential) => {
const { name, type, nodesAccess, data, id } = sharedCredential.credentials;
const credentialObject = new Credentials({ id, name }, type, nodesAccess, data);
const plainData = credentialObject.getData(encryptionKey);
const sanitizedData = this.replaceCredentialData(plainData);
const fileName = path.join(
this.credentialExportFolder,
`${sharedCredential.credentials.id}.json`,
);
const sanitizedCredential: ExportableCredential = {
id: sharedCredential.credentials.id,
name: sharedCredential.credentials.name,
type: sharedCredential.credentials.type,
data: sanitizedData,
nodesAccess: sharedCredential.credentials.nodesAccess,
};
LoggerProxy.debug(`Writing credential ${sharedCredential.credentials.id} to ${fileName}`);
return fsWriteFile(fileName, JSON.stringify(sanitizedCredential, null, 2));
}),
);
return {
count: sharedCredentials.length,
folder: this.credentialExportFolder,
files: sharedCredentials.map((e) => ({
id: e.credentials.id,
name: path.join(this.credentialExportFolder, `${e.credentials.name}.json`),
})),
};
} catch (error) {
throw Error(`Failed to export credentials to work folder: ${(error as Error).message}`);
}
}
}

View File

@@ -0,0 +1,331 @@
import { Service } from 'typedi';
import { execSync } from 'child_process';
import { LoggerProxy } from 'n8n-workflow';
import path from 'path';
import type {
CommitResult,
DiffResult,
FetchResult,
PullResult,
PushResult,
SimpleGit,
SimpleGitOptions,
StatusResult,
} from 'simple-git';
import { simpleGit } from 'simple-git';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import { SOURCE_CONTROL_DEFAULT_BRANCH, SOURCE_CONTROL_ORIGIN } from './constants';
import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee';
@Service()
export class SourceControlGitService {
git: SimpleGit | null = null;
private gitOptions: Partial<SimpleGitOptions> = {};
/**
* Run pre-checks before initialising git
* Checks for existence of required binaries (git and ssh)
*/
preInitCheck(): boolean {
LoggerProxy.debug('GitService.preCheck');
try {
const gitResult = execSync('git --version', {
stdio: ['pipe', 'pipe', 'pipe'],
});
LoggerProxy.debug(`Git binary found: ${gitResult.toString()}`);
} catch (error) {
throw new Error(`Git binary not found: ${(error as Error).message}`);
}
try {
const sshResult = execSync('ssh -V', {
stdio: ['pipe', 'pipe', 'pipe'],
});
LoggerProxy.debug(`SSH binary found: ${sshResult.toString()}`);
} catch (error) {
throw new Error(`SSH binary not found: ${(error as Error).message}`);
}
return true;
}
async initService(options: {
sourceControlPreferences: SourceControlPreferences;
gitFolder: string;
sshFolder: string;
sshKeyName: string;
}): Promise<void> {
const {
sourceControlPreferences: sourceControlPreferences,
gitFolder,
sshKeyName,
sshFolder,
} = options;
LoggerProxy.debug('GitService.init');
if (this.git !== null) {
return;
}
this.preInitCheck();
LoggerProxy.debug('Git pre-check passed');
sourceControlFoldersExistCheck([gitFolder, sshFolder]);
const sshKnownHosts = path.join(sshFolder, 'known_hosts');
const sshCommand = `ssh -o UserKnownHostsFile=${sshKnownHosts} -o StrictHostKeyChecking=no -i ${sshKeyName}`;
this.gitOptions = {
baseDir: gitFolder,
binary: 'git',
maxConcurrentProcesses: 6,
trimmed: false,
};
this.git = simpleGit(this.gitOptions)
// Tell git not to ask for any information via the terminal like for
// example the username. As nobody will be able to answer it would
// n8n keep on waiting forever.
.env('GIT_SSH_COMMAND', sshCommand)
.env('GIT_TERMINAL_PROMPT', '0');
if (!(await this.checkRepositorySetup())) {
await this.git.init();
}
if (!(await this.hasRemote(sourceControlPreferences.repositoryUrl))) {
if (sourceControlPreferences.connected && sourceControlPreferences.repositoryUrl) {
await this.initRepository(sourceControlPreferences);
}
}
}
resetService() {
this.git = null;
}
async checkRepositorySetup(): Promise<boolean> {
if (!this.git) {
throw new Error('Git is not initialized');
}
if (!(await this.git.checkIsRepo())) {
return false;
}
try {
await this.git.status();
return true;
} catch (error) {
return false;
}
}
async hasRemote(remote: string): Promise<boolean> {
if (!this.git) {
throw new Error('Git is not initialized');
}
try {
const remotes = await this.git.getRemotes(true);
const foundRemote = remotes.find(
(e) => e.name === SOURCE_CONTROL_ORIGIN && e.refs.push === remote,
);
if (foundRemote) {
LoggerProxy.debug(`Git remote found: ${foundRemote.name}: ${foundRemote.refs.push}`);
return true;
}
} catch (error) {
throw new Error(`Git is not initialized ${(error as Error).message}`);
}
LoggerProxy.debug(`Git remote not found: ${remote}`);
return false;
}
async initRepository(
sourceControlPreferences: Pick<
SourceControlPreferences,
'repositoryUrl' | 'authorEmail' | 'authorName' | 'branchName' | 'initRepo'
>,
): Promise<void> {
if (!this.git) {
throw new Error('Git is not initialized');
}
if (sourceControlPreferences.initRepo) {
try {
await this.git.init();
} catch (error) {
LoggerProxy.debug(`Git init: ${(error as Error).message}`);
}
}
try {
await this.git.addRemote(SOURCE_CONTROL_ORIGIN, sourceControlPreferences.repositoryUrl);
} catch (error) {
if ((error as Error).message.includes('remote origin already exists')) {
LoggerProxy.debug(`Git remote already exists: ${(error as Error).message}`);
} else {
throw error;
}
}
await this.git.addConfig('user.email', sourceControlPreferences.authorEmail);
await this.git.addConfig('user.name', sourceControlPreferences.authorName);
if (sourceControlPreferences.initRepo) {
try {
const branches = await this.getBranches();
if (branches.branches?.length === 0) {
await this.git.raw(['branch', '-M', sourceControlPreferences.branchName]);
}
} catch (error) {
LoggerProxy.debug(`Git init: ${(error as Error).message}`);
}
}
}
async getBranches(): Promise<{ branches: string[]; currentBranch: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
}
try {
// Get remote branches
const { branches } = await this.git.branch(['-r']);
const remoteBranches = Object.keys(branches)
.map((name) => name.split('/')[1])
.filter((name) => name !== 'HEAD');
const { current } = await this.git.branch();
return {
branches: remoteBranches,
currentBranch: current,
};
} catch (error) {
throw new Error(`Could not get remote branches from repository ${(error as Error).message}`);
}
}
async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
}
await this.git.checkout(branch);
await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]);
return this.getBranches();
}
async fetch(): Promise<FetchResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
return this.git.fetch();
}
async getCurrentBranch(): Promise<{ current: string; remote: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const currentBranch = (await this.git.branch()).current;
return {
current: currentBranch,
remote: 'origin/' + currentBranch,
};
}
async diff(options?: { target?: string; dots?: '..' | '...' }): Promise<DiffResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const currentBranch = await this.getCurrentBranch();
const target = options?.target ?? currentBranch.remote;
const dots = options?.dots ?? '...';
return this.git.diffSummary([dots + target]);
}
async diffRemote(): Promise<DiffResult | undefined> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const currentBranch = await this.getCurrentBranch();
if (currentBranch.remote) {
const target = currentBranch.remote;
return this.git.diffSummary(['...' + target]);
}
return;
}
async diffLocal(): Promise<DiffResult | undefined> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const currentBranch = await this.getCurrentBranch();
if (currentBranch.remote) {
const target = currentBranch.current;
return this.git.diffSummary([target]);
}
return;
}
async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
if (options.ffOnly) {
// eslint-disable-next-line @typescript-eslint/naming-convention
return this.git.pull(undefined, undefined, { '--ff-only': null });
}
return this.git.pull();
}
async push(
options: { force: boolean; branch: string } = {
force: false,
branch: SOURCE_CONTROL_DEFAULT_BRANCH,
},
): Promise<PushResult> {
const { force, branch } = options;
if (!this.git) {
throw new Error('Git is not initialized');
}
if (force) {
return this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
}
return this.git.push(SOURCE_CONTROL_ORIGIN, branch);
}
async stage(files: Set<string>, deletedFiles?: Set<string>): Promise<string> {
if (!this.git) {
throw new Error('Git is not initialized');
}
if (deletedFiles?.size) {
try {
await this.git.rm(Array.from(deletedFiles));
} catch (error) {
LoggerProxy.debug(`Git rm: ${(error as Error).message}`);
}
}
return this.git.add(Array.from(files));
}
async resetBranch(
options: { hard?: boolean; target: string } = { hard: false, target: 'HEAD' },
): Promise<string> {
if (!this.git) {
throw new Error('Git is not initialized');
}
if (options?.hard) {
return this.git.raw(['reset', '--hard', options.target]);
}
return this.git.raw(['reset', options.target]);
// built-in reset method does not work
// return this.git.reset();
}
async commit(message: string): Promise<CommitResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
return this.git.commit(message);
}
async status(): Promise<StatusResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const statusResult = await this.git.status();
return statusResult;
}
}

View File

@@ -0,0 +1,67 @@
import Container from 'typedi';
import { License } from '../../License';
import { generateKeyPairSync } from 'crypto';
import sshpk from 'sshpk';
import type { KeyPair } from './types/keyPair';
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
import { LoggerProxy } from 'n8n-workflow';
import { SOURCE_CONTROL_GIT_KEY_COMMENT } from './constants';
export function sourceControlFoldersExistCheck(folders: string[]) {
// running these file access function synchronously to avoid race conditions
folders.forEach((folder) => {
try {
accessSync(folder, fsConstants.F_OK);
} catch {
try {
mkdirSync(folder);
} catch (error) {
LoggerProxy.error((error as Error).message);
}
}
});
}
export function isSourceControlLicensed() {
const license = Container.get(License);
return license.isSourceControlLicensed();
}
export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
const keyPair: KeyPair = {
publicKey: '',
privateKey: '',
};
let generatedKeyPair: KeyPair;
switch (keyType) {
case 'ed25519':
generatedKeyPair = generateKeyPairSync('ed25519', {
privateKeyEncoding: { format: 'pem', type: 'pkcs8' },
publicKeyEncoding: { format: 'pem', type: 'spki' },
});
break;
case 'rsa':
generatedKeyPair = generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
break;
}
const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem');
keyPublic.comment = SOURCE_CONTROL_GIT_KEY_COMMENT;
keyPair.publicKey = keyPublic.toString('ssh');
const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem');
keyPrivate.comment = SOURCE_CONTROL_GIT_KEY_COMMENT;
keyPair.privateKey = keyPrivate.toString('ssh-private');
return {
privateKey: keyPair.privateKey,
publicKey: keyPair.publicKey,
};
}

View File

@@ -0,0 +1,363 @@
import Container, { Service } from 'typedi';
import path from 'path';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import * as Db from '@/Db';
import glob from 'fast-glob';
import { LoggerProxy, jsonParse } from 'n8n-workflow';
import { readFile as fsReadFile } from 'fs/promises';
import { Credentials, UserSettings } from 'n8n-core';
import type { IWorkflowToImport } from '@/Interfaces';
import type { ExportableCredential } from './types/exportableCredential';
import { SharedWorkflow } from '@/databases/entities/SharedWorkflow';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Variables } from '@/databases/entities/Variables';
import type { ImportResult } from './types/importResult';
import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand';
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
import { TagEntity } from '@/databases/entities/TagEntity';
import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner';
import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder';
import { In } from 'typeorm';
import { isUniqueConstraintError } from '../../ResponseHelper';
@Service()
export class SourceControlImportService {
private gitFolder: string;
private workflowExportFolder: string;
private credentialExportFolder: string;
constructor() {
const userFolder = UserSettings.getUserN8nFolderPath();
this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER);
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
this.credentialExportFolder = path.join(
this.gitFolder,
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
);
}
private async getOwnerGlobalRole() {
const ownerCredentiallRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'global' },
});
if (!ownerCredentiallRole) {
throw new Error(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
}
return ownerCredentiallRole;
}
private async getOwnerCredentialRole() {
const ownerCredentiallRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'credential' },
});
if (!ownerCredentiallRole) {
throw new Error(`Failed to find owner. ${UM_FIX_INSTRUCTION}`);
}
return ownerCredentiallRole;
}
private async getOwnerWorkflowRole() {
const ownerWorkflowRole = await Db.collections.Role.findOne({
where: { name: 'owner', scope: 'workflow' },
});
if (!ownerWorkflowRole) {
throw new Error(`Failed to find owner workflow role. ${UM_FIX_INSTRUCTION}`);
}
return ownerWorkflowRole;
}
private async importCredentialsFromFiles(
userId: string,
): Promise<Array<{ id: string; name: string; type: string }>> {
const credentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
});
const existingCredentials = await Db.collections.Credentials.find();
const ownerCredentialRole = await this.getOwnerCredentialRole();
const ownerGlobalRole = await this.getOwnerGlobalRole();
const encryptionKey = await UserSettings.getEncryptionKey();
let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
await Db.transaction(async (transactionManager) => {
importCredentialsResult = await Promise.all(
credentialFiles.map(async (file) => {
LoggerProxy.debug(`Importing credentials file ${file}`);
const credential = jsonParse<ExportableCredential>(
await fsReadFile(file, { encoding: 'utf8' }),
);
const existingCredential = existingCredentials.find(
(e) => e.id === credential.id && e.type === credential.type,
);
const sharedOwner = await Db.collections.SharedCredentials.findOne({
select: ['userId'],
where: {
credentialsId: credential.id,
roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]),
},
});
const { name, type, data, id, nodesAccess } = credential;
const newCredentialObject = new Credentials({ id, name }, type, []);
if (existingCredential?.data) {
newCredentialObject.data = existingCredential.data;
} else {
newCredentialObject.setData(data, encryptionKey);
}
newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || [];
LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`);
await transactionManager.upsert(CredentialsEntity, newCredentialObject, ['id']);
if (!sharedOwner) {
const newSharedCredential = new SharedCredentials();
newSharedCredential.credentialsId = newCredentialObject.id as string;
newSharedCredential.userId = userId;
newSharedCredential.roleId = ownerGlobalRole.id;
await transactionManager.upsert(SharedCredentials, { ...newSharedCredential }, [
'credentialsId',
'userId',
]);
}
return {
id: newCredentialObject.id as string,
name: newCredentialObject.name,
type: newCredentialObject.type,
};
}),
);
});
return importCredentialsResult.filter((e) => e !== undefined);
}
private async importVariablesFromFile(valueOverrides?: {
[key: string]: string;
}): Promise<{ imported: string[] }> {
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
const result: { imported: string[] } = { imported: [] };
if (variablesFile.length > 0) {
LoggerProxy.debug(`Importing variables from file ${variablesFile[0]}`);
const importedVariables = jsonParse<Array<Partial<Variables>>>(
await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
{ fallbackValue: [] },
);
const overriddenKeys = Object.keys(valueOverrides ?? {});
for (const variable of importedVariables) {
if (!variable.key) {
continue;
}
// by default no value is stored remotely, so an empty string is retuned
// it must be changed to undefined so as to not overwrite existing values!
if (variable.value === '') {
variable.value = undefined;
}
if (overriddenKeys.includes(variable.key) && valueOverrides) {
variable.value = valueOverrides[variable.key];
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
}
try {
await Db.collections.Variables.upsert({ ...variable }, ['id']);
} catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) {
LoggerProxy.debug(`Variable ${variable.key} already exists, updating instead`);
try {
await Db.collections.Variables.update({ key: variable.key }, { ...variable });
} catch (errorUpdate) {
LoggerProxy.debug(`Failed to update variable ${variable.key}, skipping`);
LoggerProxy.debug((errorUpdate as Error).message);
}
}
} finally {
result.imported.push(variable.key);
}
}
// add remaining overrides as new variables
if (overriddenKeys.length > 0 && valueOverrides) {
for (const key of overriddenKeys) {
result.imported.push(key);
const newVariable = new Variables({ key, value: valueOverrides[key] });
await Db.collections.Variables.save(newVariable);
}
}
}
return result;
}
private async importTagsFromFile() {
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
if (tagsFile.length > 0) {
LoggerProxy.debug(`Importing tags from file ${tagsFile[0]}`);
const mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
{ fallbackValue: { tags: [], mappings: [] } },
);
const existingWorkflowIds = new Set(
(
await Db.collections.Workflow.find({
select: ['id'],
})
).map((e) => e.id),
);
await Db.transaction(async (transactionManager) => {
await Promise.all(
mappedTags.tags.map(async (tag) => {
await transactionManager.upsert(
TagEntity,
{
...tag,
},
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { id: true },
},
);
}),
);
await Promise.all(
mappedTags.mappings.map(async (mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
await transactionManager.upsert(
WorkflowTagMapping,
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { tagId: true, workflowId: true },
},
);
}),
);
});
return mappedTags;
}
return { tags: [], mappings: [] };
}
private async importWorkflowsFromFiles(
userId: string,
): Promise<Array<{ id: string; name: string }>> {
const workflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
});
const existingWorkflows = await Db.collections.Workflow.find({
select: ['id', 'name', 'active', 'versionId'],
});
const ownerWorkflowRole = await this.getOwnerWorkflowRole();
const workflowRunner = Container.get(ActiveWorkflowRunner);
let importWorkflowsResult = new Array<{ id: string; name: string }>();
await Db.transaction(async (transactionManager) => {
importWorkflowsResult = await Promise.all(
workflowFiles.map(async (file) => {
LoggerProxy.debug(`Parsing workflow file ${file}`);
const importedWorkflow = jsonParse<IWorkflowToImport>(
await fsReadFile(file, { encoding: 'utf8' }),
);
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
if (existingWorkflow?.versionId === importedWorkflow.versionId) {
LoggerProxy.debug(
`Skipping import of workflow ${
importedWorkflow.id ?? 'n/a'
} - versionId is up to date`,
);
return {
id: importedWorkflow.id ?? 'n/a',
name: 'skipped',
};
}
LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`);
importedWorkflow.active = existingWorkflow?.active ?? false;
LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
const upsertResult = await transactionManager.upsert(
WorkflowEntity,
{ ...importedWorkflow },
['id'],
);
if (upsertResult?.identifiers?.length !== 1) {
throw new Error(`Failed to upsert workflow ${importedWorkflow.id ?? 'new'}`);
}
// due to sequential Ids, this may have changed during the insert
// TODO: once IDs are unique and we removed autoincrement, remove this
const upsertedWorkflowId = upsertResult.identifiers[0].id as string;
await transactionManager.upsert(
SharedWorkflow,
{
workflowId: upsertedWorkflowId,
userId,
roleId: ownerWorkflowRole.id,
},
['workflowId', 'userId'],
);
if (existingWorkflow?.active) {
try {
// remove active pre-import workflow
LoggerProxy.debug(`Deactivating workflow id ${existingWorkflow.id}`);
await workflowRunner.remove(existingWorkflow.id);
// try activating the imported workflow
LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowRunner.add(existingWorkflow.id, 'activate');
} catch (error) {
LoggerProxy.error(
`Failed to activate workflow ${existingWorkflow.id}`,
error as Error,
);
}
}
return {
id: importedWorkflow.id ?? 'unknown',
name: file,
};
}),
);
});
return importWorkflowsResult;
}
async importFromWorkFolder(options: SourceControllPullOptions): Promise<ImportResult> {
try {
const importedVariables = await this.importVariablesFromFile(options.variables);
const importedCredentials = await this.importCredentialsFromFiles(options.userId);
const importWorkflows = await this.importWorkflowsFromFiles(options.userId);
const importTags = await this.importTagsFromFile();
return {
variables: importedVariables,
credentials: importedCredentials,
workflows: importWorkflows,
tags: importTags,
};
} catch (error) {
throw Error(`Failed to import workflows from work folder: ${(error as Error).message}`);
}
}
}

View File

@@ -0,0 +1,183 @@
import { Service } from 'typedi';
import { SourceControlPreferences } from './types/sourceControlPreferences';
import type { ValidationError } from 'class-validator';
import { validate } from 'class-validator';
import { readFileSync as fsReadFileSync, existsSync as fsExistsSync } from 'fs';
import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises';
import {
generateSshKeyPair,
isSourceControlLicensed,
sourceControlFoldersExistCheck,
} from './sourceControlHelper.ee';
import { UserSettings } from 'n8n-core';
import { LoggerProxy, jsonParse } from 'n8n-workflow';
import * as Db from '@/Db';
import {
SOURCE_CONTROL_SSH_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_SSH_KEY_NAME,
SOURCE_CONTROL_PREFERENCES_DB_KEY,
} from './constants';
import path from 'path';
@Service()
export class SourceControlPreferencesService {
private _sourceControlPreferences: SourceControlPreferences = new SourceControlPreferences();
private sshKeyName: string;
private sshFolder: string;
private gitFolder: string;
constructor() {
const userFolder = UserSettings.getUserN8nFolderPath();
this.sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER);
this.gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER);
this.sshKeyName = path.join(this.sshFolder, SOURCE_CONTROL_SSH_KEY_NAME);
}
public get sourceControlPreferences(): SourceControlPreferences {
return {
...this._sourceControlPreferences,
connected: this._sourceControlPreferences.connected ?? false,
publicKey: this.getPublicKey(),
};
}
// merge the new preferences with the existing preferences when setting
public set sourceControlPreferences(preferences: Partial<SourceControlPreferences>) {
this._sourceControlPreferences = SourceControlPreferences.merge(
preferences,
this._sourceControlPreferences,
);
}
getPublicKey(): string {
try {
return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' });
} catch (error) {
LoggerProxy.error(`Failed to read public key: ${(error as Error).message}`);
}
return '';
}
hasKeyPairFiles(): boolean {
return fsExistsSync(this.sshKeyName) && fsExistsSync(this.sshKeyName + '.pub');
}
async deleteKeyPairFiles(): Promise<void> {
try {
await fsRm(this.sshFolder, { recursive: true });
} catch (error) {
LoggerProxy.error(`Failed to delete ssh folder: ${(error as Error).message}`);
}
}
/**
* Will generate an ed25519 key pair and save it to the database and the file system
* Note: this will overwrite any existing key pair
*/
async generateAndSaveKeyPair(): Promise<SourceControlPreferences> {
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
const keyPair = generateSshKeyPair('ed25519');
if (keyPair.publicKey && keyPair.privateKey) {
try {
await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, {
encoding: 'utf8',
mode: 0o666,
});
await fsWriteFile(this.sshKeyName, keyPair.privateKey, { encoding: 'utf8', mode: 0o600 });
} catch (error) {
throw Error(`Failed to save key pair: ${(error as Error).message}`);
}
}
return this.getPreferences();
}
isBranchReadOnly(): boolean {
return this._sourceControlPreferences.branchReadOnly;
}
isSourceControlConnected(): boolean {
return this.sourceControlPreferences.connected;
}
isSourceControlLicensedAndEnabled(): boolean {
return this.isSourceControlConnected() && isSourceControlLicensed();
}
getBranchName(): string {
return this.sourceControlPreferences.branchName;
}
getPreferences(): SourceControlPreferences {
return this.sourceControlPreferences;
}
async validateSourceControlPreferences(
preferences: Partial<SourceControlPreferences>,
allowMissingProperties = true,
): Promise<ValidationError[]> {
const preferencesObject = new SourceControlPreferences(preferences);
const validationResult = await validate(preferencesObject, {
forbidUnknownValues: false,
skipMissingProperties: allowMissingProperties,
stopAtFirstError: false,
validationError: { target: false },
});
if (validationResult.length > 0) {
throw new Error(`Invalid source control preferences: ${JSON.stringify(validationResult)}`);
}
return validationResult;
}
async setPreferences(
preferences: Partial<SourceControlPreferences>,
saveToDb = true,
): Promise<SourceControlPreferences> {
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
if (!this.hasKeyPairFiles()) {
LoggerProxy.debug('No key pair files found, generating new pair');
await this.generateAndSaveKeyPair();
}
this.sourceControlPreferences = preferences;
if (saveToDb) {
const settingsValue = JSON.stringify(this._sourceControlPreferences);
try {
await Db.collections.Settings.save({
key: SOURCE_CONTROL_PREFERENCES_DB_KEY,
value: settingsValue,
loadOnStartup: true,
});
} catch (error) {
throw new Error(`Failed to save source control preferences: ${(error as Error).message}`);
}
}
return this.sourceControlPreferences;
}
async loadFromDbAndApplySourceControlPreferences(): Promise<
SourceControlPreferences | undefined
> {
const loadedPreferences = await Db.collections.Settings.findOne({
where: { key: SOURCE_CONTROL_PREFERENCES_DB_KEY },
});
if (loadedPreferences) {
try {
const preferences = jsonParse<SourceControlPreferences>(loadedPreferences.value);
if (preferences) {
// set local preferences but don't write back to db
await this.setPreferences(preferences, false);
return preferences;
}
} catch (error) {
LoggerProxy.warn(
`Could not parse Source Control settings from database: ${(error as Error).message}`,
);
}
}
await this.setPreferences(new SourceControlPreferences(), true);
return this.sourceControlPreferences;
}
}

View File

@@ -0,0 +1,9 @@
export interface ExportResult {
count: number;
folder: string;
files: Array<{
id: string;
name: string;
}>;
removedFiles?: string[];
}

View File

@@ -0,0 +1,9 @@
import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow';
export interface ExportableCredential {
id: string;
name: string;
type: string;
data: ICredentialDataDecryptedObject;
nodesAccess: ICredentialNodeAccess[];
}

View File

@@ -0,0 +1,13 @@
import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow';
export interface ExportableWorkflow {
active: boolean;
id: string;
name: string;
nodes: INode[];
connections: IConnections;
settings?: IWorkflowSettings;
triggerCount: number;
owner: string;
versionId: string;
}

View File

@@ -0,0 +1,13 @@
import type { TagEntity } from '@/databases/entities/TagEntity';
import type { WorkflowTagMapping } from '@/databases/entities/WorkflowTagMapping';
export interface ImportResult {
workflows: Array<{
id: string;
name: string;
}>;
credentials: Array<{ id: string; name: string; type: string }>;
variables: { imported: string[] };
tags: { tags: TagEntity[]; mappings: WorkflowTagMapping[] };
removedFiles?: string[];
}

View File

@@ -0,0 +1,4 @@
export interface KeyPair {
privateKey: string;
publicKey: string;
}

View File

@@ -0,0 +1,22 @@
import type { AuthenticatedRequest } from '@/requests';
import type { SourceControlPreferences } from './sourceControlPreferences';
import type { SourceControlSetBranch } from './sourceControlSetBranch';
import type { SourceControlCommit } from './sourceControlCommit';
import type { SourceControlStage } from './sourceControlStage';
import type { SourceControlPush } from './sourceControlPush';
import type { SourceControlPushWorkFolder } from './sourceControlPushWorkFolder';
import type { SourceControlPullWorkFolder } from './sourceControlPullWorkFolder';
import type { SourceControlDisconnect } from './sourceControlDisconnect';
import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly';
export declare namespace SourceControlRequest {
type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial<SourceControlPreferences>, {}>;
type SetReadOnly = AuthenticatedRequest<{}, {}, SourceControlSetReadOnly, {}>;
type SetBranch = AuthenticatedRequest<{}, {}, SourceControlSetBranch, {}>;
type Commit = AuthenticatedRequest<{}, {}, SourceControlCommit, {}>;
type Stage = AuthenticatedRequest<{}, {}, SourceControlStage, {}>;
type Push = AuthenticatedRequest<{}, {}, SourceControlPush, {}>;
type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>;
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class SourceControlCommit {
@IsString()
message: string;
}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class SourceControlDisconnect {
@IsBoolean()
@IsOptional()
keepKeyPair?: boolean;
}

View File

@@ -0,0 +1,55 @@
import { IsBoolean, IsEmail, IsHexColor, IsOptional, IsString } from 'class-validator';
export class SourceControlPreferences {
constructor(preferences: Partial<SourceControlPreferences> | undefined = undefined) {
if (preferences) Object.assign(this, preferences);
}
@IsBoolean()
connected: boolean;
@IsString()
repositoryUrl: string;
@IsString()
authorName: string;
@IsEmail()
authorEmail: string;
@IsString()
branchName = 'main';
@IsBoolean()
branchReadOnly: boolean;
@IsHexColor()
branchColor: string;
@IsOptional()
@IsString()
readonly publicKey?: string;
@IsOptional()
@IsBoolean()
readonly initRepo?: boolean;
static fromJSON(json: Partial<SourceControlPreferences>): SourceControlPreferences {
return new SourceControlPreferences(json);
}
static merge(
preferences: Partial<SourceControlPreferences>,
defaultPreferences: Partial<SourceControlPreferences>,
): SourceControlPreferences {
return new SourceControlPreferences({
connected: preferences.connected ?? defaultPreferences.connected,
repositoryUrl: preferences.repositoryUrl ?? defaultPreferences.repositoryUrl,
authorName: preferences.authorName ?? defaultPreferences.authorName,
authorEmail: preferences.authorEmail ?? defaultPreferences.authorEmail,
branchName: preferences.branchName ?? defaultPreferences.branchName,
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,
});
}
}

View File

@@ -0,0 +1,29 @@
import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator';
export class SourceControlPullWorkFolder {
@IsBoolean()
@IsOptional()
force?: boolean;
@IsBoolean()
@IsOptional()
importAfterPull?: boolean = true;
@IsString({ each: true })
@IsOptional()
files?: Set<string>;
@IsObject()
@IsOptional()
variables?: { [key: string]: string };
}
export class SourceControllPullOptions {
userId: string;
force?: boolean;
variables?: { [key: string]: string };
importAfterPull?: boolean = true;
}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class SourceControlPush {
@IsBoolean()
@IsOptional()
force?: boolean;
}

View File

@@ -0,0 +1,27 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class SourceControlPushWorkFolder {
@IsBoolean()
@IsOptional()
force?: boolean;
@IsString({ each: true })
@IsOptional()
fileNames?: Set<string>;
@IsString({ each: true })
@IsOptional()
workflowIds?: Set<string>;
@IsString({ each: true })
@IsOptional()
credentialIds?: Set<string>;
@IsString()
@IsOptional()
message?: string;
@IsBoolean()
@IsOptional()
skipDiff?: boolean;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class SourceControlSetBranch {
@IsString()
branch: string;
}

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class SourceControlSetReadOnly {
@IsBoolean()
branchReadOnly: boolean;
}

View File

@@ -0,0 +1,15 @@
import { IsOptional, IsString } from 'class-validator';
export class SourceControlStage {
@IsString({ each: true })
@IsOptional()
fileNames?: Set<string>;
@IsString({ each: true })
@IsOptional()
workflowIds?: Set<string>;
@IsString({ each: true })
@IsOptional()
credentialIds?: Set<string>;
}

View File

@@ -0,0 +1,19 @@
export type SourceControlledFileStatus =
| 'new'
| 'modified'
| 'deleted'
| 'created'
| 'renamed'
| 'conflicted'
| 'unknown';
export type SourceControlledFileLocation = 'local' | 'remote';
export type SourceControlledFileType = 'credential' | 'workflow' | 'tags' | 'variables' | 'file';
export type SourceControlledFile = {
file: string;
id: string;
name: string;
type: SourceControlledFileType;
status: SourceControlledFileStatus;
location: SourceControlledFileLocation;
conflict: boolean;
};