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:
committed by
GitHub
parent
da330f0648
commit
c3ba0123ad
15
packages/cli/src/environments/sourceControl/constants.ts
Normal file
15
packages/cli/src/environments/sourceControl/constants.ts
Normal 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
|
||||
`;
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface ExportResult {
|
||||
count: number;
|
||||
folder: string;
|
||||
files: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
}>;
|
||||
removedFiles?: string[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ICredentialDataDecryptedObject, ICredentialNodeAccess } from 'n8n-workflow';
|
||||
|
||||
export interface ExportableCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
nodesAccess: ICredentialNodeAccess[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface KeyPair {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
}
|
||||
@@ -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, {}>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SourceControlCommit {
|
||||
@IsString()
|
||||
message: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class SourceControlDisconnect {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
keepKeyPair?: boolean;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class SourceControlPush {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
force?: boolean;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SourceControlSetBranch {
|
||||
@IsString()
|
||||
branch: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SourceControlSetReadOnly {
|
||||
@IsBoolean()
|
||||
branchReadOnly: boolean;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user