Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,144 @@
type sFtpError = {
error: string;
description?: string;
}
type sFtpErrorWithCode = {
[code: number]: sFtpError;
}
const sftpErrors: sFtpErrorWithCode = {
0: {
error: 'OK',
description: 'Indicates successful completion of the operation.',
},
1: {
error: 'EOF',
description: 'An attempt to read past the end-of-file was made; or, there are no more directory entries to return.',
},
2: {
error: 'No such file',
description: 'A reference was made to a file which does not exist.',
},
3: {
error: 'Permission denied',
description: 'The user does not have sufficient permissions to perform the operation.',
},
4: {
error: 'Failure',
description: 'An error occurred, but no specific error code exists to describe the failure. This error message should always have meaningful text in the error message field.',
},
5: {
error: 'Bad message',
description: 'A badly formatted packet or other SFTP protocol incompatibility was detected.',
},
6: {
error: 'No connection',
description: 'There is no connection to the server. This error may be used locally, but must not be returned by a server.',
},
7: {
error: 'Connection lost',
description: 'The connection to the server was lost. This error may be used locally, but must not be returned by a server.',
},
8: {
error: 'Operation unsupported',
description: 'An attempted operation could not be completed by the server because the server does not support the operation.',
},
9: {
error: 'Invalid handle',
description: 'The handle value was invalid.',
},
10: {
error: 'No such path',
description: 'The file path does not exist or is invalid.',
},
11: {
error: 'File already exists',
description: 'The file already exists.',
},
12: {
error: 'Write protect',
description: 'The file is on read-only media, or the media is write protected.',
},
13: {
error: 'No media',
description: 'The requested operation cannot be completed because there is no media available in the drive.',
},
14: {
error: 'No space on file-system',
description: 'The requested operation cannot be completed because there is insufficient free space on the filesystem.',
},
15: {
error: 'Quota exceeded',
description: "The operation cannot be completed because it would exceed the users storage quota.",
},
16: {
error: 'Unknown principal',
description: 'A principal referenced by the request (either the owner, group, or who field of an ACL), was unknown.',
},
17: {
error: 'Lock conflict',
description: 'The file could not be opened because it is locked by another process.',
},
18: {
error: 'Directory not empty',
description: 'The directory is not empty.',
},
19: {
error: 'Not a directory',
description: 'The specified file is not a directory.',
},
20: {
error: 'Invalid filename',
description: 'The filename is not valid.',
},
21: {
error: 'Link loop',
description: 'Too many symbolic links encountered or an SSH_FXF_NOFOLLOW encountered a symbolic link as the final component.',
},
22: {
error: 'Cannot delete',
description: 'The file cannot be deleted. One possible reason is that the advisory read-only attribute-bit is set.',
},
23: {
error: 'Invalid parameter',
description: 'One of the parameters was out of range or the parameters specified cannot be used together.',
},
24: {
error: 'File is a directory',
description: 'The specified file was a directory in a context where a directory cannot be used.',
},
25: {
error: 'Range lock conflict',
description: 'A read or write operation failed because another processs mandatory byte-range lock overlaps with the request.',
},
26: {
error: 'Range lock refused',
description: 'A request for a byte range lock was refused.',
},
27: {
error: 'Delete pending',
description: 'An operation was attempted on a file for which a delete operation is pending.',
},
28: {
error: 'File corrupt',
description: 'The file is corrupt; a filesystem integrity check should be run.',
},
29: {
error: 'Owner invalid',
description: 'The principal specified cannot be assigned as an owner of a file.',
},
30: {
error: 'Group invalid',
description: 'The principal specified cannot be assigned as the primary group of a file.',
},
31: {
error: 'No matching byte range lock',
description: 'The requested operation could not be completed because the specified byte range lock has not been granted.',
},
};
export function getSftpError(code: number): sFtpError {
const error = sftpErrors[code];
return error ?? { error: 'Unknown error code' };
}

View File

@@ -0,0 +1,82 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { endClient, getClient, getProtocolBackwardCompatibility, sftpAuth } from '../..';
import { Readable } from 'stream';
import { getSftpError } from './common';
async function createFileWithSFTP(client: Client, fileName: string, fileContent: string) {
const remotePathExists = await client.exists(fileName);
if (!remotePathExists) {
// Extract the directory path from the fileName
const remoteDirectory = fileName.substring(0, fileName.lastIndexOf('/'));
// Create the directory if it doesn't exist
await client.mkdir(remoteDirectory, true);
}
await client.put(Buffer.from(fileContent), fileName);
}
async function createFileWithFTP(client: FTPClient, fileName: string, fileContent: string) {
// Extract the directory path from the fileName
const remoteDirectory = fileName.substring(0, fileName.lastIndexOf('/'));
// Create the directory if it doesn't exist
await client.ensureDir(remoteDirectory);
// Upload the file content
const buffer = Buffer.from(fileContent);
await client.uploadFrom(Readable.from(buffer), fileName);
}
export const createFile = createAction({
auth: sftpAuth,
name: 'create_file',
displayName: 'Create File from Text',
description: 'Create a new file in the given path',
props: {
fileName: Property.ShortText({
displayName: 'File Path',
required: true,
}),
fileContent: Property.LongText({
displayName: 'File content',
required: true,
}),
},
async run(context) {
const fileName = context.propsValue['fileName'];
const fileContent = context.propsValue['fileContent'];
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
const client = await getClient(context.auth.props);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await createFileWithFTP(client as FTPClient, fileName, fileContent);
break;
default:
case 'sftp':
await createFileWithSFTP(client as Client, fileName, fileContent);
break;
}
return {
status: 'success',
};
} catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,63 @@
import { endClient, getClient, getProtocolBackwardCompatibility, sftpAuth } from '../../index';
import { Property, createAction } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { getSftpError } from './common';
export const createFolderAction = createAction({
auth: sftpAuth,
name: 'createFolder',
displayName: 'Create Folder',
description: 'Creates a folder at given path.',
props: {
folderPath: Property.ShortText({
displayName: 'Folder Path',
required: true,
description: 'The new folder path e.g. `./myfolder`. For FTP/FTPS, it will create nested folders if necessary.',
}),
recursive: Property.Checkbox({
displayName: 'Recursive',
defaultValue: false,
required: false,
description: 'For SFTP only: Create parent directories if they do not exist',
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const directoryPath = context.propsValue.folderPath;
const recursive = context.propsValue.recursive ?? false;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await (client as FTPClient).ensureDir(directoryPath);
break;
default:
case 'sftp':
await (client as Client).mkdir(directoryPath, recursive);
break;
}
return {
status: 'success',
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,64 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { endClient, getClient, getProtocolBackwardCompatibility, sftpAuth } from '../..';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import Client from 'ssh2-sftp-client';
import { getSftpError } from './common';
async function deleteFileFromFTP(client: FTPClient, filePath: string) {
await client.remove(filePath);
}
async function deleteFileFromSFTP(client: Client, filePath: string) {
await client.delete(filePath);
}
export const deleteFileAction = createAction({
auth: sftpAuth,
name: 'deleteFile',
displayName: 'Delete file',
description: 'Deletes a file at given path.',
props: {
filePath: Property.ShortText({
displayName: 'File Path',
required: true,
description: 'The path of the file to delete e.g. `./myfolder/test.mp3`',
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const filePath = context.propsValue.filePath;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await deleteFileFromFTP(client as FTPClient, filePath);
break;
default:
case 'sftp':
await deleteFileFromSFTP(client as Client, filePath);
break;
}
return {
status: 'success',
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,76 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { endClient, getClient, getProtocolBackwardCompatibility, sftpAuth } from '../..';
import { getSftpError } from './common';
async function deleteFolderFTP(client: FTPClient, directoryPath: string, recursive: boolean) {
if (recursive) {
await client.removeDir(directoryPath);
} else {
await client.removeEmptyDir(directoryPath);
}
}
async function deleteFolderSFTP(client: Client, directoryPath: string, recursive: boolean) {
await client.rmdir(directoryPath, recursive);
}
export const deleteFolderAction = createAction({
auth: sftpAuth,
name: 'deleteFolder',
displayName: 'Delete Folder',
description: 'Deletes an existing folder at given path.',
props: {
folderPath: Property.ShortText({
displayName: 'Folder Path',
required: true,
description: 'The path of the folder to delete e.g. `./myfolder`',
}),
recursive: Property.Checkbox({
displayName: 'Recursive',
defaultValue: false,
required: false,
description:
'Enable this option to delete the folder and all its contents, including subfolders and files.',
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const directoryPath = context.propsValue.folderPath;
const recursive = context.propsValue.recursive ?? false;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await deleteFolderFTP(client as FTPClient, directoryPath, recursive);
break;
default:
case 'sftp':
await deleteFolderSFTP(client as Client, directoryPath, recursive);
break;
}
return {
status: 'success',
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,86 @@
import { endClient, sftpAuth } from '../../index';
import { Property, createAction } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { getClient, getProtocolBackwardCompatibility } from '../..';
import { getSftpError } from './common';
import { unknown } from 'zod';
async function listSFTP(client: Client, directoryPath: string) {
const contents = await client.list(directoryPath);
await client.end();
return contents;
}
async function listFTP(client: FTPClient, directoryPath: string) {
const contents = await client.list(directoryPath);
return contents.map(item => ({
type: item.type === 1 ? 'd' : '-',
name: item.name,
size: item.size,
modifyTime: item.modifiedAt,
accessTime: item.modifiedAt, // FTP doesn't provide access time
rights: {
user: item.permissions || '',
group: '',
other: ''
},
owner: '',
group: ''
}));
}
export const listFolderContentsAction = createAction({
auth: sftpAuth,
name: 'listFolderContents',
displayName: 'List Folder Contents',
description: 'Lists the contents of a given folder.',
props: {
directoryPath: Property.ShortText({
displayName: 'Directory Path',
required: true,
description: 'The path of the folder to list e.g. `./myfolder`',
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const directoryPath = context.propsValue.directoryPath;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
let contents;
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
contents = await listFTP(client as FTPClient, directoryPath);
break;
default:
case 'sftp':
contents = await listSFTP(client as Client, directoryPath);
break;
}
return {
status: 'success',
contents: contents,
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
content: null,
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
content: null,
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,82 @@
import { endClient, sftpAuth } from '../../index';
import { Property, createAction } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { getClient, getProtocolBackwardCompatibility } from '../..';
import { Writable } from 'stream';
import { getSftpError } from './common';
async function readFTP(client: FTPClient, filePath: string) {
const chunks: Buffer[] = [];
const writeStream = new Writable({
write(chunk: Buffer, _encoding: string, callback: () => void) {
chunks.push(chunk);
callback();
}
});
await client.downloadTo(writeStream, filePath);
return Buffer.concat(chunks);
}
async function readSFTP(client: Client, filePath: string) {
const fileContent = await client.get(filePath);
await client.end();
return fileContent as Buffer;
}
export const readFileContent = createAction({
auth: sftpAuth,
name: 'read_file_content',
displayName: 'Read File Content',
description: 'Read the content of a file.',
props: {
filePath: Property.ShortText({
displayName: 'File Path',
required: true,
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const filePath = context.propsValue['filePath'];
const fileName = filePath.split('/').pop() ?? filePath;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
let fileContent: Buffer;
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
fileContent = await readFTP(client as FTPClient, filePath);
break;
default:
case 'sftp':
fileContent = await readSFTP(client as Client, filePath);
break;
}
return {
file: await context.files.write({
fileName: fileName,
data: fileContent,
}),
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
content: null,
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
content: null,
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,81 @@
import { endClient, sftpAuth } from '../../index';
import { Property, createAction } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { getClient, getProtocolBackwardCompatibility } from '../..';
import { MarkdownVariant } from '@activepieces/shared';
import { getSftpError } from './common';
async function renameFTP(client: FTPClient, oldPath: string, newPath: string) {
await client.rename(oldPath, newPath);
}
async function renameSFTP(client: Client, oldPath: string, newPath: string) {
await client.rename(oldPath, newPath);
await client.end();
}
export const renameFileOrFolderAction = createAction({
auth: sftpAuth,
name: 'renameFileOrFolder',
displayName: 'Rename File or Folder',
description: 'Renames a file or folder at given path.',
props: {
information: Property.MarkDown({
value: 'Depending on the server you can also use this to move a file to another directory, as long as the directory exists.',
variant: MarkdownVariant.INFO,
}),
oldPath: Property.ShortText({
displayName: 'Old Path',
required: true,
description:
'The path of the file or folder to rename e.g. `./myfolder/test.mp3`',
}),
newPath: Property.ShortText({
displayName: 'New Path',
required: true,
description:
'The new path of the file or folder e.g. `./myfolder/new-name.mp3`',
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const oldPath = context.propsValue.oldPath;
const newPath = context.propsValue.newPath;
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await renameFTP(client as FTPClient, oldPath, newPath);
break;
default:
case 'sftp':
await renameSFTP(client as Client, oldPath, newPath);
break;
}
return {
status: 'success',
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
content: null,
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
content: null,
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,80 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FTPError } from 'basic-ftp';
import { endClient, getClient, getProtocolBackwardCompatibility, sftpAuth } from '../..';
import { Readable } from 'stream';
import { getSftpError } from './common';
async function uploadFileToFTP(client: FTPClient, fileName: string, fileContent: { data: any }) {
const remoteDirectory = fileName.substring(0, fileName.lastIndexOf('/'));
await client.ensureDir(remoteDirectory);
await client.uploadFrom(Readable.from(fileContent.data), fileName);
}
async function uploadFileToSFTP(client: Client, fileName: string, fileContent: { data: any }) {
const remotePathExists = await client.exists(fileName);
if (!remotePathExists) {
const remoteDirectory = fileName.substring(0, fileName.lastIndexOf('/'));
await client.mkdir(remoteDirectory, true);
}
await client.put(fileContent.data, fileName);
await client.end();
}
export const uploadFileAction = createAction({
auth: sftpAuth,
name: 'upload_file',
displayName: 'Upload File',
description: 'Upload a file to the given path.',
props: {
fileName: Property.ShortText({
displayName: 'File Path',
required: true,
description:
'The path on the sftp server to store the file. e.g. `./myfolder/test.mp3`',
}),
fileContent: Property.File({
displayName: 'File content',
required: true,
}),
},
async run(context) {
const client = await getClient(context.auth.props);
const fileName = context.propsValue['fileName'];
const fileContent = context.propsValue['fileContent'];
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(context.auth.props.protocol);
try {
switch (protocolBackwardCompatibility) {
case 'ftps':
case 'ftp':
await uploadFileToFTP(client as FTPClient, fileName, fileContent);
break;
default:
case 'sftp':
await uploadFileToSFTP(client as Client, fileName, fileContent);
break;
}
return {
status: 'success',
};
}
catch (err) {
if (err instanceof FTPError) {
console.error(getSftpError(err.code));
return {
status: 'error',
content: null,
error: getSftpError(err.code),
};
} else {
return {
status: 'error',
content: null,
error: err
}
}
} finally {
await endClient(client, context.auth.props.protocol);
}
},
});

View File

@@ -0,0 +1,86 @@
import { PiecePropValueSchema, Property, createTrigger, TriggerStrategy, AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework';
import { DedupeStrategy, Polling, pollingHelper } from '@activepieces/pieces-common';
import { sftpAuth, getClient, getProtocolBackwardCompatibility, endClient } from '../..';
import dayjs from 'dayjs';
import Client from 'ssh2-sftp-client';
import { Client as FTPClient, FileInfo as FTPFileInfo } from 'basic-ftp';
function getModifyTime(file: Client.FileInfo | FTPFileInfo, protocol: string): number {
return protocol === 'sftp' ?
(file as Client.FileInfo).modifyTime :
dayjs((file as FTPFileInfo).modifiedAt).valueOf();
}
const polling: Polling<AppConnectionValueForAuthProperty<typeof sftpAuth>, { path: string; ignoreHiddenFiles?: boolean }> = {
strategy: DedupeStrategy.TIMEBASED,
items: async ({ auth, propsValue, lastFetchEpochMS }) => {
let client: Client | FTPClient | null = null;
try {
const protocolBackwardCompatibility = await getProtocolBackwardCompatibility(auth.props.protocol);
client = await getClient(auth.props);
const files = await client.list(propsValue.path);
const filteredFiles = files.filter(file => {
const modTime = getModifyTime(file, protocolBackwardCompatibility);
return dayjs(modTime).valueOf() > lastFetchEpochMS;
});
const finalFiles: (Client.FileInfo | FTPFileInfo)[] = propsValue.ignoreHiddenFiles ?
filteredFiles.filter(file => !file.name.startsWith('.')) :
filteredFiles;
return finalFiles.map(file => {
const modTime = getModifyTime(file, protocolBackwardCompatibility);
return {
data: {
...file,
path: `${propsValue.path}/${file.name}`,
},
epochMilliSeconds: dayjs(modTime).valueOf(),
};
});
} catch (err) {
return [];
} finally {
if (client) {
await endClient(client, auth.props.protocol);
}
}
},
};
export const newOrModifiedFile = createTrigger({
auth: sftpAuth,
name: 'new_file',
displayName: 'New File',
description: 'Trigger when a new file is created or modified.',
props: {
path: Property.ShortText({
displayName: 'Path',
description: 'The path to watch for new files',
required: true,
defaultValue: './',
}),
ignoreHiddenFiles: Property.Checkbox({
displayName: 'Ignore hidden files',
description: 'Ignore hidden files',
required: false,
defaultValue: false,
}),
},
type: TriggerStrategy.POLLING,
onEnable: async (context) => {
await pollingHelper.onEnable(polling, context);
},
onDisable: async (context) => {
await pollingHelper.onDisable(polling, context);
},
run: async (context) => {
return await pollingHelper.poll(polling, context);
},
test: async (context) => {
return await pollingHelper.test(polling, context);
},
sampleData: null,
});