diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 05d08a7db5..9136e15b17 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -29,7 +29,7 @@ FROM node:14.15-alpine USER root -RUN apk add --update graphicsmagick tzdata tini su-exec +RUN apk add --update graphicsmagick tzdata tini su-exec git WORKDIR /data diff --git a/docker/images/n8n-debian/Dockerfile b/docker/images/n8n-debian/Dockerfile index bfe10565aa..95c79828e4 100644 --- a/docker/images/n8n-debian/Dockerfile +++ b/docker/images/n8n-debian/Dockerfile @@ -6,7 +6,7 @@ RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" RUN \ apt-get update && \ - apt-get -y install graphicsmagick gosu + apt-get -y install graphicsmagick gosu git # Set a custom user to not have n8n run as root USER root diff --git a/docker/images/n8n-rpi/Dockerfile b/docker/images/n8n-rpi/Dockerfile index 329b7e7dfe..8a3f94838e 100644 --- a/docker/images/n8n-rpi/Dockerfile +++ b/docker/images/n8n-rpi/Dockerfile @@ -6,7 +6,7 @@ RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" RUN \ apt-get update && \ - apt-get -y install graphicsmagick gosu + apt-get -y install graphicsmagick gosu git RUN npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} diff --git a/packages/nodes-base/credentials/GitPassword.credentials.ts b/packages/nodes-base/credentials/GitPassword.credentials.ts new file mode 100644 index 0000000000..aae97789d5 --- /dev/null +++ b/packages/nodes-base/credentials/GitPassword.credentials.ts @@ -0,0 +1,28 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class GitPassword implements ICredentialType { + name = 'gitPassword'; + displayName = 'Git'; + properties = [ + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + decription: 'The username to authenticate with.', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + description: 'The password to use in combination with the user', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Git/Git.node.ts b/packages/nodes-base/nodes/Git/Git.node.ts new file mode 100644 index 0000000000..f4e6ef4b16 --- /dev/null +++ b/packages/nodes-base/nodes/Git/Git.node.ts @@ -0,0 +1,437 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + addConfigFields, + addFields, + cloneFields, + commitFields, + logFields, + pushFields, + tagFields, +} from './descriptions'; + +import simpleGit, { + LogOptions, + SimpleGit, + SimpleGitOptions, +} from 'simple-git'; + +import { + access, + mkdir, +} from 'fs/promises'; + +import { URL } from 'url'; + +export class Git implements INodeType { + description: INodeTypeDescription = { + displayName: 'Git', + name: 'git', + icon: 'file:git.svg', + group: ['transform'], + version: 1, + description: 'Control git.', + defaults: { + name: 'Git', + color: '#808080', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'gitPassword', + required: true, + displayOptions: { + show: { + authentication: [ + 'gitPassword', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Authenticate', + value: 'gitPassword', + }, + { + name: 'None', + value: 'none', + }, + ], + displayOptions: { + show: { + operation: [ + 'clone', + 'push', + ], + }, + }, + default: 'none', + description: 'The way to authenticate.', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'log', + description: 'Operation to perform', + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a file or folder to commit', + }, + { + name: 'Add Config', + value: 'addConfig', + description: 'Add configuration property', + }, + { + name: 'Clone', + value: 'clone', + description: 'Clone a repository', + }, + { + name: 'Commit', + value: 'commit', + description: 'Commit files or folders to git', + }, + { + name: 'Fetch', + value: 'fetch', + description: 'Fetch from remote repository', + }, + { + name: 'List Config', + value: 'listConfig', + description: 'Return current configuration', + }, + { + name: 'Log', + value: 'log', + description: 'Return git commit history', + }, + { + name: 'Pull', + value: 'pull', + description: 'Pull from remote repository', + }, + { + name: 'Push', + value: 'push', + description: 'Push to remote repository', + }, + { + name: 'Push Tags', + value: 'pushTags', + description: 'Push Tags to remote repository', + }, + { + name: 'Status', + value: 'status', + description: 'Return status of current repository', + }, + { + name: 'Tag', + value: 'tag', + description: 'Create a new tag', + }, + { + name: 'User Setup', + value: 'userSetup', + description: 'Set the user', + }, + ], + }, + + { + displayName: 'Repository Path', + name: 'repositoryPath', + type: 'string', + displayOptions: { + hide: { + operation: [ + 'clone', + ], + }, + }, + default: '', + placeholder: '/tmp/repository', + required: true, + description: 'Local path of the git repository to operate on.', + }, + { + displayName: 'New Repository Path', + name: 'repositoryPath', + type: 'string', + displayOptions: { + show: { + operation: [ + 'clone', + ], + }, + }, + default: '', + placeholder: '/tmp/repository', + required: true, + description: 'Local path to which the git repository should be cloned into.', + }, + + ...addFields, + ...addConfigFields, + ...cloneFields, + ...commitFields, + ...logFields, + ...pushFields, + ...tagFields, + // ...userSetupFields, + ], + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + + const prepareRepository = (repositoryPath: string): string => { + const authentication = this.getNodeParameter('authentication', 0) as string; + + if (authentication === 'gitPassword') { + const gitCredentials = this.getCredentials('gitPassword') as IDataObject; + + const url = new URL(repositoryPath); + url.username = gitCredentials.username as string; + url.password = gitCredentials.password as string; + + return url.toString(); + } + + return repositoryPath; + }; + + const operation = this.getNodeParameter('operation', 0) as string; + let item: INodeExecutionData; + const returnItems: INodeExecutionData[] = []; + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + item = items[itemIndex]; + + const repositoryPath = this.getNodeParameter('repositoryPath', itemIndex, '') as string; + const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; + + if (operation === 'clone') { + // Create repository folder if it does not exist + try { + await access(repositoryPath); + } catch (error) { + await mkdir(repositoryPath); + } + } + + const gitOptions: Partial = { + baseDir: repositoryPath, + }; + + const git: SimpleGit = simpleGit(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_TERMINAL_PROMPT', '0'); + + if (operation === 'add') { + // ---------------------------------- + // add + // ---------------------------------- + + const pathsToAdd = this.getNodeParameter('pathsToAdd', itemIndex, '') as string; + + await git.add(pathsToAdd.split(',')); + + returnItems.push({ json: { success: true } }); + + } else if (operation === 'addConfig') { + // ---------------------------------- + // addConfig + // ---------------------------------- + + const key = this.getNodeParameter('key', itemIndex, '') as string; + const value = this.getNodeParameter('value', itemIndex, '') as string; + let append = false; + + if (options.mode === 'append') { + append = true; + } + + await git.addConfig(key, value, append); + returnItems.push({ json: { success: true } }); + + } else if (operation === 'clone') { + // ---------------------------------- + // clone + // ---------------------------------- + + let sourceRepository = this.getNodeParameter('sourceRepository', itemIndex, '') as string; + sourceRepository = prepareRepository(sourceRepository); + + await git.clone(sourceRepository, '.'); + + returnItems.push({ json: { success: true } }); + + } else if (operation === 'commit') { + // ---------------------------------- + // commit + // ---------------------------------- + + const message = this.getNodeParameter('message', itemIndex, '') as string; + + let pathsToAdd: string[] | undefined = undefined; + if (options.files !== undefined) { + pathsToAdd = (options.pathsToAdd as string).split(','); + } + + await git.commit(message, pathsToAdd); + + returnItems.push({ json: { success: true } }); + + } else if (operation === 'fetch') { + // ---------------------------------- + // fetch + // ---------------------------------- + + await git.fetch(); + returnItems.push({ json: { success: true } }); + + } else if (operation === 'log') { + // ---------------------------------- + // log + // ---------------------------------- + + const logOptions: LogOptions = {}; + + const returnAll = this.getNodeParameter('returnAll', itemIndex, false) as boolean; + if (returnAll === false) { + logOptions.maxCount = this.getNodeParameter('limit', itemIndex, 100) as number; + } + if (options.file) { + logOptions.file = options.file as string; + } + + const log = await git.log(logOptions); + + // @ts-ignore + returnItems.push(...this.helpers.returnJsonArray(log.all)); + + } else if (operation === 'pull') { + // ---------------------------------- + // pull + // ---------------------------------- + + await git.pull(); + returnItems.push({ json: { success: true } }); + + } else if (operation === 'push') { + // ---------------------------------- + // push + // ---------------------------------- + + if (options.repository) { + const targetRepository = prepareRepository(options.targetRepository as string); + await git.push(targetRepository); + } else { + const authentication = this.getNodeParameter('authentication', 0) as string; + if (authentication === 'gitPassword') { + // Try to get remote repository path from git repository itself to add + // authentication data + const config = await git.listConfig(); + let targetRepository; + for (const fileName of Object.keys(config.values)) { + if (config.values[fileName]['remote.origin.url']) { + targetRepository = config.values[fileName]['remote.origin.url']; + break; + } + } + + targetRepository = prepareRepository(targetRepository as string); + await git.push(targetRepository); + } else { + await git.push(); + } + } + + returnItems.push({ json: { success: true } }); + + } else if (operation === 'pushTags') { + // ---------------------------------- + // pushTags + // ---------------------------------- + + await git.pushTags(); + returnItems.push({ json: { success: true } }); + + } else if (operation === 'listConfig') { + // ---------------------------------- + // listConfig + // ---------------------------------- + + const config = await git.listConfig(); + + const data = []; + for (const fileName of Object.keys(config.values)) { + data.push({ + _file: fileName, + ...config.values[fileName], + }); + } + + // @ts-ignore + returnItems.push(...this.helpers.returnJsonArray(data)); + + } else if (operation === 'status') { + // ---------------------------------- + // status + // ---------------------------------- + + const status = await git.status(); + + // @ts-ignore + returnItems.push(...this.helpers.returnJsonArray([status])); + + } else if (operation === 'tag') { + // ---------------------------------- + // tag + // ---------------------------------- + + const name = this.getNodeParameter('name', itemIndex, '') as string; + + await git.addTag(name); + returnItems.push({ json: { success: true } }); + + } + + } catch (error) { + + if (this.continueOnFail()) { + returnItems.push({ json: { error: error.toString() } }); + continue; + } + + throw error; + } + } + + return this.prepareOutputData(returnItems); + } +} diff --git a/packages/nodes-base/nodes/Git/descriptions/AddConfigDescription.ts b/packages/nodes-base/nodes/Git/descriptions/AddConfigDescription.ts new file mode 100644 index 0000000000..db336d5b9e --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/AddConfigDescription.ts @@ -0,0 +1,71 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const addConfigFields = [ + { + displayName: 'Key', + name: 'key', + type: 'string', + displayOptions: { + show: { + operation: [ + 'addConfig', + ], + }, + }, + default: '', + placeholder: 'user.email', + description: 'Name of the key to set.', + required: true, + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + show: { + operation: [ + 'addConfig', + ], + }, + }, + default: '', + placeholder: 'name@example.com', + description: 'Value of the key to set.', + required: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'addConfig', + ], + }, + }, + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Mode', + name: 'mode', + type: 'options', + options: [ + { + name: 'Append', + value: 'append', + }, + { + name: 'Set', + value: 'set', + }, + ], + default: 'set', + description: 'Append setting rather than set it in the local config.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/AddDescription.ts b/packages/nodes-base/nodes/Git/descriptions/AddDescription.ts new file mode 100644 index 0000000000..478d3f4de6 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/AddDescription.ts @@ -0,0 +1,22 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const addFields = [ + { + displayName: 'Paths to Add', + name: 'pathsToAdd', + type: 'string', + displayOptions: { + show: { + operation: [ + 'add', + ], + }, + }, + default: '', + placeholder: 'README.md', + description: 'Comma separated list of paths (absolute or relative to Repository Path) of files or folders to add.', + required: true, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/CloneDescription.ts b/packages/nodes-base/nodes/Git/descriptions/CloneDescription.ts new file mode 100644 index 0000000000..21546e9eb5 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/CloneDescription.ts @@ -0,0 +1,22 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const cloneFields = [ + { + displayName: 'Source Repository', + name: 'sourceRepository', + type: 'string', + displayOptions: { + show: { + operation: [ + 'clone', + ], + }, + }, + default: '', + placeholder: 'https://github.com/n8n-io/n8n', + description: 'The URL or path of the repository to clone.', + required: true, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts b/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts new file mode 100644 index 0000000000..5f955a2a58 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/CommitDescription.ts @@ -0,0 +1,45 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const commitFields = [ + { + displayName: 'Message', + name: 'message', + type: 'string', + displayOptions: { + show: { + operation: [ + 'commit', + ], + }, + }, + default: '', + description: 'The commit message to use.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'commit', + ], + }, + }, + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Paths to Add', + name: 'pathsToAdd', + type: 'string', + default: '', + placeholder: '/data/file1.json', + description: `Comma separated list of paths (absolute or relative to Repository Path) of
+ files or folders to commit. If not set will all "added" files and folders be committed.`, + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/LogDescription.ts b/packages/nodes-base/nodes/Git/descriptions/LogDescription.ts new file mode 100644 index 0000000000..f17e903e5d --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/LogDescription.ts @@ -0,0 +1,64 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const logFields = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'log', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'log', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'log', + ], + }, + }, + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'File', + name: 'file', + type: 'string', + default: 'README.md', + description: 'The path (absolute or relative to Repository Path) of file or folder to get the history of.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts b/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts new file mode 100644 index 0000000000..797559b6d5 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/PushDescription.ts @@ -0,0 +1,30 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const pushFields = [ + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'push', + ], + }, + }, + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Target Repository', + name: 'targetRepository', + type: 'string', + default: '', + placeholder: 'https://github.com/n8n-io/n8n', + description: 'The URL or path of the repository to push to.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/TagDescription.ts b/packages/nodes-base/nodes/Git/descriptions/TagDescription.ts new file mode 100644 index 0000000000..bfd0f39fb0 --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/TagDescription.ts @@ -0,0 +1,21 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tagFields = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + displayOptions: { + show: { + operation: [ + 'tag', + ], + }, + }, + default: '', + description: 'The name of the tag to create.', + required: true, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Git/descriptions/index.ts b/packages/nodes-base/nodes/Git/descriptions/index.ts new file mode 100644 index 0000000000..568f5df05e --- /dev/null +++ b/packages/nodes-base/nodes/Git/descriptions/index.ts @@ -0,0 +1,7 @@ +export * from './AddDescription'; +export * from './AddConfigDescription'; +export * from './CloneDescription'; +export * from './CommitDescription'; +export * from './LogDescription'; +export * from './PushDescription'; +export * from './TagDescription'; diff --git a/packages/nodes-base/nodes/Git/git.svg b/packages/nodes-base/nodes/Git/git.svg new file mode 100644 index 0000000000..2e42bc7d4d --- /dev/null +++ b/packages/nodes-base/nodes/Git/git.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a52a1781e6..3617a667f5 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -88,6 +88,7 @@ "dist/credentials/GetResponseOAuth2Api.credentials.js", "dist/credentials/GhostAdminApi.credentials.js", "dist/credentials/GhostContentApi.credentials.js", + "dist/credentials/GitPassword.credentials.js", "dist/credentials/GithubApi.credentials.js", "dist/credentials/GithubOAuth2Api.credentials.js", "dist/credentials/GitlabApi.credentials.js", @@ -369,6 +370,7 @@ "dist/nodes/GetResponse/GetResponse.node.js", "dist/nodes/GetResponse/GetResponseTrigger.node.js", "dist/nodes/Ghost/Ghost.node.js", + "dist/nodes/Git/Git.node.js", "dist/nodes/Github/Github.node.js", "dist/nodes/Github/GithubTrigger.node.js", "dist/nodes/Gitlab/Gitlab.node.js", @@ -650,6 +652,7 @@ "request": "^2.88.2", "rhea": "^1.0.11", "rss-parser": "^3.7.0", + "simple-git": "^2.36.2", "snowflake-sdk": "^1.5.3", "ssh2-sftp-client": "^5.2.1", "tmp-promise": "^3.0.2",