├── .babel.config.js ├── .eslintrc.cjs ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── commands │ ├── apps.ts │ ├── custom_component.ts │ ├── db.ts │ ├── login.ts │ ├── logout.ts │ ├── rpc.ts │ ├── scaffold.ts │ ├── signup.ts │ ├── telemetry.ts │ ├── terraform.ts │ ├── whoami.ts │ └── workflows.ts ├── index.ts ├── loginPages │ ├── loginFail.html │ └── loginSuccess.html ├── resources │ └── workflowTemplate.ts └── utils │ ├── apps.ts │ ├── connectionString.ts │ ├── cookies.test.ts │ ├── cookies.ts │ ├── credentials.ts │ ├── csv.ts │ ├── date.ts │ ├── faker.ts │ ├── fileSave.ts │ ├── networking.ts │ ├── playgroundQuery.ts │ ├── postgres.ts │ ├── puppeteer.ts │ ├── resources.ts │ ├── table.test.ts │ ├── table.ts │ ├── telemetry.test.ts │ ├── telemetry.ts │ ├── terraformGen.ts │ ├── validation.test.ts │ ├── validation.ts │ └── workflows.ts └── tsconfig.json /.babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | env: { 4 | node: true, 5 | }, 6 | overrides: [ 7 | { 8 | files: ["*.ts", "*.tsx"], // TypeScript files 9 | extends: [ 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 12 | "plugin:import/recommended", 13 | "plugin:import/typescript", 14 | ], 15 | parserOptions: { 16 | project: ["./tsconfig.json"], 17 | }, 18 | rules: { 19 | "@typescript-eslint/no-explicit-any": "off", 20 | "@typescript-eslint/no-var-requires": "off", 21 | "@typescript-eslint/no-unsafe-call": "off", 22 | "@typescript-eslint/no-unsafe-return": "off", 23 | "@typescript-eslint/no-unsafe-member-access": "off", 24 | "@typescript-eslint/no-unsafe-assignment": "off", 25 | "@typescript-eslint/no-unsafe-argument": "off", 26 | "@typescript-eslint/restrict-template-expressions": "off", 27 | "@typescript-eslint/no-floating-promises": "error", 28 | "@typescript-eslint/ban-ts-comment": "off", 29 | "import/no-unresolved": "error", 30 | "sort-imports": [ 31 | "error", 32 | { 33 | ignoreCase: false, 34 | ignoreDeclarationSort: true, 35 | ignoreMemberSort: false, 36 | memberSyntaxSortOrder: ["none", "all", "multiple", "single"], 37 | allowSeparatedGroups: true, 38 | }, 39 | ], 40 | // https://medium.com/weekly-webtips/how-to-sort-imports-like-a-pro-in-typescript-4ee8afd7258a 41 | "import/order": [ 42 | "error", 43 | { 44 | groups: [ 45 | "builtin", 46 | "external", 47 | "internal", 48 | ["sibling", "parent"], 49 | "index", 50 | "unknown", 51 | ], 52 | "newlines-between": "always", 53 | alphabetize: { 54 | order: "asc", 55 | caseInsensitive: true, 56 | }, 57 | }, 58 | ], 59 | }, 60 | }, 61 | ], 62 | parser: "@typescript-eslint/parser", 63 | plugins: ["@typescript-eslint", "import"], 64 | root: true, 65 | settings: { 66 | "import/resolver": { 67 | typescript: { 68 | project: "./tsconfig.json", 69 | }, 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docker.env 2 | .DS_Store 3 | lib/**/* 4 | node_modules/**/* 5 | *.csv 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | npm run test-silent 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tryretool/retool-cli/3be54676feefec72f4015a603c3cc3552881e3fc/.npmignore -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Retool 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Retool CLI 2 | 3 | A simple command line interface for [Retool](https://retool.com/). Run `retool signup` to create a Retool account in 20 seconds. 4 | 5 | Open an issue in this repository for feature requests. PRs welcome! 6 | 7 | ![Screenshot of the retool help command](https://i.imgur.com/ojYlw0i.png) 8 | 9 | ## Installation & Updating 10 | 11 | 1. `npm install -g retool-cli` 12 | 13 | Node.js is a requirement, it can be installed [here](https://nodejs.org/en/download). See [this guide](https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally) to resolve EACCES permissions errors. 14 | 15 | ## Usage Instructions 16 | 17 | `retool --help` 18 | 19 | `retool --help` 20 | 21 | ## Building from Source 22 | 23 | 1. `git clone https://github.com/tryretool/retool-cli.git` 24 | 2. `cd retool-cli` 25 | 3. `npm i && npm run dev` 26 | 4. `npm install -g .` Installs the `retool` command globally on your machine. 27 | 28 | ## Contribution Guide 29 | 30 | ### Extending an existing command 31 | 32 | 1. Locate the command file in `src/commands/`. 33 | 2. Add a new flag to the `builder` object and provide a clear description. This description will be displayed to the user in the help command. 34 | 3. Handle the new flag by adding an `else if (argv.newFlag)` statement to the handler function. 35 | 36 | ### Adding a new command 37 | 38 | 1. Create a new file in the `src/commands` directory, ensure it exports a `CommandModule`. 39 | 2. `npm run dev` to start TS compiler. 40 | 3. `retool login` to authenticate. 41 | 4. `retool commandName` to test command. 42 | 43 | ### General guidelines 44 | 45 | - Retool CLI adheres to the principles outlined in [this](https://clig.dev/) CLI guide: 46 | - Keep output succinct. 47 | - In help output: 48 | - Use `<>` to indicate required params and `[]` for optional params. 49 | - Provide a usage example if appropriate. 50 | - Errors should be presented in a human-readable format. 51 | - Hide debug output behind a `process.env.DEBUG` check. 52 | - Any files in `src/commands/` directory will become a top-level commands. 53 | - Shared logic should be placed in `src/utils/` directory. 54 | 55 | ### Publishing to NPM (for Retool employees) 56 | 57 | - Bump version in `package.json`, `npm install`, commit changes 58 | - `npm run build` 59 | - `npm publish` 60 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ["**/?(*.)test.js"], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retool-cli", 3 | "version": "1.0.29", 4 | "description": "CLI for Retool", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "bin": { 8 | "retool": "./lib/index.js" 9 | }, 10 | "scripts": { 11 | "dev": "tsc --watch -p .", 12 | "build": "npm run clean && tsc -p . && npm run copy-files && chmod +x lib/index.js", 13 | "clean": "rimraf lib/", 14 | "copy-files": "copyfiles -u 1 src/**/*.html lib/", 15 | "lint": "npx eslint ./src", 16 | "lint-fix": "npm run lint -- --fix", 17 | "test": "jest", 18 | "test-silent": "jest --silent", 19 | "prepare": "husky install" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/tryretool/retool-cli.git" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/tryretool/retool-cli/issues" 27 | }, 28 | "homepage": "https://github.com/tryretool/retool-cli#readme", 29 | "dependencies": { 30 | "@faker-js/faker": "^8.0.2", 31 | "@inquirer/prompts": "^5.2.0", 32 | "@napi-rs/keyring": "^1.1.3", 33 | "axios": "^1.4.0", 34 | "chalk": "^4.1.2", 35 | "connection-string-parser": "^1.0.4", 36 | "csv-parser": "^3.0.0", 37 | "date-fns": "^2.30.0", 38 | "express": "^4.18.2", 39 | "inquirer": "^8.0.0", 40 | "inquirer-tree-prompt": "^1.1.2", 41 | "open": "^7.4.2", 42 | "ora": "^5.4.1", 43 | "pg": "^8.11.3", 44 | "puppeteer": "^20.8.0", 45 | "tar": "^6.1.15", 46 | "untildify": "^4.0.0", 47 | "yargs": "^17.7.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/preset-typescript": "^7.22.5", 51 | "@types/express": "^4.17.17", 52 | "@types/intl": "^1.2.0", 53 | "@types/jest": "^29.5.3", 54 | "@types/node": "^20.2.5", 55 | "@types/tar": "^6.1.5", 56 | "@types/yargs": "^17.0.24", 57 | "@typescript-eslint/eslint-plugin": "^5.61.0", 58 | "@typescript-eslint/parser": "^5.61.0", 59 | "copyfiles": "^2.4.1", 60 | "eslint": "^8.44.0", 61 | "eslint-import-resolver-typescript": "^3.5.5", 62 | "eslint-plugin-import": "^2.27.5", 63 | "husky": "^8.0.3", 64 | "jest": "^29.6.2", 65 | "nodemon": "^2.0.22", 66 | "rimraf": "^5.0.1", 67 | "ts-node": "^10.9.1", 68 | "typescript": "^5.1.6" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/apps.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { 4 | collectAppName, 5 | createApp, 6 | createAppForTable, 7 | deleteApp, 8 | exportApp, 9 | getAppsAndFolders, 10 | } from "../utils/apps"; 11 | import type { App } from "../utils/apps"; 12 | import { getAndVerifyCredentialsWithRetoolDB } from "../utils/credentials"; 13 | import { dateOptions } from "../utils/date"; 14 | import { 15 | collectTableName, 16 | fetchTableInfo, 17 | verifyTableExists, 18 | } from "../utils/table"; 19 | import { logDAU } from "../utils/telemetry"; 20 | 21 | const command = "apps"; 22 | const describe = "Interface with Retool Apps."; 23 | const builder: CommandModule["builder"] = { 24 | create: { 25 | alias: "c", 26 | describe: `Create a new app.`, 27 | }, 28 | "create-from-table": { 29 | alias: "t", 30 | describe: `Create a new app to visualize a Retool DB table.`, 31 | }, 32 | list: { 33 | alias: "l", 34 | describe: `List folders and apps at root level. Optionally provide a folder name to list all apps in that folder. Usage: 35 | retool apps -l [folder-name]`, 36 | }, 37 | "list-recursive": { 38 | alias: "r", 39 | describe: `List all apps and folders.`, 40 | }, 41 | delete: { 42 | alias: "d", 43 | describe: `Delete an app. Usage: 44 | retool apps -d `, 45 | type: "array", 46 | }, 47 | export: { 48 | alias: "e", 49 | describe: `Export an app JSON. Usage: 50 | retool apps -e `, 51 | type: "array", 52 | }, 53 | }; 54 | const handler = async function (argv: any) { 55 | const credentials = await getAndVerifyCredentialsWithRetoolDB(); 56 | // fire and forget 57 | void logDAU(credentials); 58 | 59 | // Handle `retool apps --list [folder-name]` 60 | if (argv.list || argv.r) { 61 | let { apps, folders } = await getAppsAndFolders(credentials); 62 | const rootFolderId = folders?.find( 63 | (folder) => folder.name === "root" && folder.systemFolder === true 64 | )?.id; 65 | 66 | // Only list apps in the specified folder. 67 | if (typeof argv.list === "string") { 68 | const folderId = folders?.find((folder) => folder.name === argv.list)?.id; 69 | if (folderId) { 70 | const appsInFolder = apps?.filter((app) => app.folderId === folderId); 71 | if (appsInFolder && appsInFolder.length > 0) { 72 | printApps(appsInFolder); 73 | } else { 74 | console.log(`No apps found in ${argv.list}.`); 75 | } 76 | } else { 77 | console.log(`No folder named ${argv.list} found.`); 78 | } 79 | } 80 | 81 | // List all folders, then all apps in root folder. 82 | else { 83 | // Filter out undesired folders/apps. 84 | folders = folders?.filter((folder) => folder.systemFolder === false); 85 | if (!argv.r) { 86 | apps = apps?.filter((app) => app.folderId === rootFolderId); 87 | } 88 | 89 | // Sort from oldest to newest. 90 | folders?.sort((a, b) => { 91 | return Date.parse(a.updatedAt) - Date.parse(b.updatedAt); 92 | }); 93 | apps?.sort((a, b) => { 94 | return Date.parse(a.updatedAt) - Date.parse(b.updatedAt); 95 | }); 96 | 97 | if ((!folders || folders.length === 0) && (!apps || apps.length === 0)) { 98 | console.log("No folders or apps found."); 99 | } else { 100 | // List all folders 101 | if (folders && folders?.length > 0) { 102 | folders.forEach((folder) => { 103 | const date = new Date(Date.parse(folder.updatedAt)); 104 | console.log( 105 | `${date.toLocaleString(undefined, dateOptions)} 📂 ${ 106 | folder.name 107 | }/` 108 | ); 109 | }); 110 | } 111 | // List all apps in root folder. 112 | printApps(apps); 113 | } 114 | } 115 | } 116 | 117 | // Handle `retool apps --create-from-table` 118 | else if (argv.t) { 119 | const tableName = await collectTableName(); 120 | await verifyTableExists(tableName, credentials); 121 | const tableInfo = await fetchTableInfo(tableName, credentials); 122 | if (!tableInfo) { 123 | console.error(`Table ${tableName} info not found.`); 124 | process.exit(1); 125 | } 126 | const appName = await collectAppName(); 127 | // Use the first non-pkey column as the search column. 128 | const searchColumnName = tableInfo.fields.find( 129 | (field) => field.name !== tableInfo.primaryKeyColumn 130 | )?.name; 131 | 132 | await createAppForTable( 133 | appName, 134 | tableName, 135 | searchColumnName || tableInfo.primaryKeyColumn, 136 | credentials 137 | ); 138 | } 139 | 140 | // Handle `retool apps --create` 141 | else if (argv.create) { 142 | const appName = await collectAppName(); 143 | await createApp(appName, credentials); 144 | } 145 | 146 | // Handle `retool apps -d ` 147 | else if (argv.delete) { 148 | const appNames = argv.delete; 149 | for (const appName of appNames) { 150 | await deleteApp(appName, credentials, true); 151 | } 152 | } 153 | 154 | // Handle `retool apps -e ` 155 | else if (argv.export) { 156 | const appNames = argv.export; 157 | for (const appName of appNames) { 158 | await exportApp(appName, credentials); 159 | } 160 | } 161 | 162 | // No flag specified. 163 | else { 164 | console.log( 165 | "No flag specified. See `retool apps --help` for available flags." 166 | ); 167 | } 168 | }; 169 | 170 | function printApps(apps: Array | undefined): void { 171 | if (apps && apps?.length > 0) { 172 | apps.forEach((app) => { 173 | const date = new Date(Date.parse(app.updatedAt)); 174 | console.log( 175 | `${date.toLocaleString(undefined, dateOptions)} ${ 176 | app.isGlobalWidget ? "🔧" : "💻" 177 | } ${app.name}` 178 | ); 179 | }); 180 | } 181 | } 182 | 183 | const commandModule: CommandModule = { 184 | command, 185 | describe, 186 | builder, 187 | handler, 188 | }; 189 | 190 | export default commandModule; 191 | -------------------------------------------------------------------------------- /src/commands/custom_component.ts: -------------------------------------------------------------------------------- 1 | import { exec as _exec } from "child_process"; 2 | import util from "util"; 3 | 4 | import ora from "ora"; 5 | import { CommandModule } from "yargs"; 6 | 7 | import { logDAU } from "../utils/telemetry"; 8 | 9 | const exec = util.promisify(_exec); 10 | 11 | const command: CommandModule["command"] = "custom-component"; 12 | const describe: CommandModule["describe"] = "Interface with custom components."; 13 | const builder: CommandModule["builder"] = { 14 | clone: { 15 | alias: "c", 16 | describe: `Clones https://github.com/tryretool/custom-component-guide to the current directory.`, 17 | demandOption: true, 18 | }, 19 | }; 20 | const handler = async function (argv: any) { 21 | // fire and forget 22 | void logDAU(); 23 | 24 | if (argv.clone) { 25 | const spinner = ora("Scaffolding a new custom component").start(); 26 | await exec( 27 | "git clone https://github.com/tryretool/custom-component-guide.git" 28 | ); 29 | spinner.stop(); 30 | console.log( 31 | "Scaffolded a new custom component in the custom-component-guide directory." 32 | ); 33 | } 34 | }; 35 | 36 | const commandModule: CommandModule = { 37 | command, 38 | describe, 39 | builder, 40 | handler, 41 | }; 42 | 43 | export default commandModule; 44 | -------------------------------------------------------------------------------- /src/commands/db.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { getAndVerifyCredentialsWithRetoolDB } from "../utils/credentials"; 4 | import { generateData, promptForDataType } from "../utils/faker"; 5 | import { getRequest, postRequest } from "../utils/networking"; 6 | import { getDataFromPostgres } from "../utils/postgres"; 7 | import { 8 | DBInfoPayload, 9 | collectColumnNames, 10 | collectTableName, 11 | createTable, 12 | createTableFromCSV, 13 | deleteTable, 14 | fetchAllTables, 15 | generateDataWithGPT, 16 | parseDBData, 17 | verifyTableExists, 18 | } from "../utils/table"; 19 | import { logDAU } from "../utils/telemetry"; 20 | 21 | const chalk = require("chalk"); 22 | const inquirer = require("inquirer"); 23 | const ora = require("ora"); 24 | 25 | const command = "db"; 26 | const describe = "Interface with Retool DB."; 27 | const builder: CommandModule["builder"] = { 28 | list: { 29 | alias: "l", 30 | describe: "List all tables in Retool DB.", 31 | }, 32 | create: { 33 | alias: "c", 34 | describe: `Create a new table.`, 35 | }, 36 | upload: { 37 | alias: "u", 38 | describe: `Upload a new table from a CSV file. Usage: 39 | retool db -u `, 40 | type: "array", 41 | }, 42 | delete: { 43 | alias: "d", 44 | describe: `Delete a table. Usage: 45 | retool db -d `, 46 | type: "array", 47 | }, 48 | fromPostgres: { 49 | alias: "f", 50 | describe: `Create tables from a PostgreSQL database. Usage: 51 | retool db -f `, 52 | type: "string", 53 | nargs: 1, 54 | }, 55 | gendata: { 56 | alias: "g", 57 | describe: `Generate data for a table interactively. Usage: 58 | retool db -g `, 59 | type: "string", 60 | nargs: 1, 61 | }, 62 | gpt: { 63 | describe: `A modifier for gendata that uses GPT. Usage: 64 | retool db --gendata --gpt`, 65 | }, 66 | }; 67 | const handler = async function (argv: any) { 68 | const credentials = await getAndVerifyCredentialsWithRetoolDB(); 69 | // fire and forget 70 | void logDAU(credentials); 71 | 72 | // Handle `retool db --upload ` 73 | if (argv.upload) { 74 | const csvFileNames = argv.upload; 75 | for (const csvFileName of csvFileNames) { 76 | await createTableFromCSV(csvFileName, credentials, true, true); 77 | } 78 | } 79 | 80 | // Handle `retool db --fromPostgres ` 81 | else if (argv.fromPostgres) { 82 | const connectionString = argv.fromPostgres; 83 | const data = await getDataFromPostgres(connectionString); 84 | if (data) { 85 | for (const table of data) { 86 | await createTable( 87 | table.name, 88 | table.columns, 89 | table.data, 90 | credentials, 91 | true 92 | ); 93 | } 94 | } 95 | } 96 | 97 | // Handle `retool db --create` 98 | else if (argv.create) { 99 | const tableName = await collectTableName(); 100 | const colNames = await collectColumnNames(); 101 | await createTable(tableName, colNames, undefined, credentials, true); 102 | } 103 | 104 | // Handle `retool db --list` 105 | else if (argv.list) { 106 | const tables = await fetchAllTables(credentials); 107 | if (tables && tables.length > 0) { 108 | tables.forEach((table) => { 109 | console.log(table.name); 110 | }); 111 | } else { 112 | console.log("No tables found."); 113 | } 114 | } 115 | 116 | // Handle `retool db --delete ` 117 | else if (argv.delete) { 118 | const tableNames = argv.delete; 119 | for (const tableName of tableNames) { 120 | await deleteTable(tableName, credentials, true); 121 | } 122 | } 123 | 124 | // Handle `retool db --gendata ` 125 | else if (argv.gendata) { 126 | // Verify that the provided db name exists. 127 | const tableName = argv.gendata; 128 | await verifyTableExists(tableName, credentials); 129 | 130 | // Fetch Retool DB schema and data. 131 | const spinner = ora(`Fetching ${tableName} metadata`).start(); 132 | const infoReq = getRequest( 133 | `${credentials.origin}/api/grid/${credentials.gridId}/table/${tableName}/info` 134 | ); 135 | const dataReq = postRequest( 136 | `${credentials.origin}/api/grid/${credentials.gridId}/table/${tableName}/data`, 137 | { 138 | filters: [], 139 | sorting: [], 140 | } 141 | ); 142 | const [infoRes, dataRes] = await Promise.all([infoReq, dataReq]); 143 | spinner.stop(); 144 | const retoolDBInfo: DBInfoPayload = infoRes.data; 145 | const { fields } = retoolDBInfo.tableInfo; 146 | const retoolDBData: string = dataRes.data; 147 | 148 | // Find the max primary key value. 149 | // 1. Parse the table data. 150 | const parsedDBData = parseDBData(retoolDBData); 151 | // 2. Find the index of the primary key column. 152 | const primaryKeyColIndex = parsedDBData[0].indexOf( 153 | retoolDBInfo.tableInfo.primaryKeyColumn 154 | ); 155 | // 3. Find the max value of the primary key column. 156 | const primaryKeyMaxVal = Math.max( 157 | ...parsedDBData 158 | .slice(1) 159 | .map((row) => row[primaryKeyColIndex]) 160 | .map((id) => parseInt(id)), 161 | 0 162 | ); 163 | 164 | let generatedData: { fields: string[]; data: string[][] }; 165 | 166 | // Generate data using GPT. 167 | if (argv.gpt) { 168 | spinner.start("Generating data using GPT"); 169 | 170 | const gptRes = await generateDataWithGPT( 171 | retoolDBInfo, 172 | fields, 173 | primaryKeyMaxVal, 174 | credentials, 175 | true 176 | ); 177 | //Shouldn't happen, generateDataWithGPT should exit on failure. 178 | if (!gptRes) { 179 | process.exit(1); 180 | } 181 | generatedData = gptRes; 182 | 183 | spinner.stop(); 184 | } 185 | // Generate data using faker. 186 | else { 187 | // Ask how many rows to generate. 188 | const MAX_BATCH_SIZE = 2500; 189 | const { rowCount } = await inquirer.prompt([ 190 | { 191 | name: "rowCount", 192 | message: "How many rows to generate?", 193 | type: "input", 194 | }, 195 | ]); 196 | if (Number.isNaN(parseInt(rowCount))) { 197 | console.log(`Error: Must provide a number.`); 198 | return; 199 | } 200 | if (rowCount < 0) { 201 | console.log(`Error: Cannot generate <1 rows.`); 202 | return; 203 | } 204 | if (rowCount > MAX_BATCH_SIZE) { 205 | console.log( 206 | `Error: Cannot generate more than ${MAX_BATCH_SIZE} rows at a time.` 207 | ); 208 | return; 209 | } 210 | 211 | // Ask what type of data to generate for each column. 212 | for (let i = 0; i < fields.length; i++) { 213 | if (fields[i].name === retoolDBInfo.tableInfo.primaryKeyColumn) 214 | continue; 215 | 216 | fields[i].generatedColumnType = await promptForDataType(fields[i].name); 217 | } 218 | 219 | // Generate mock data. 220 | generatedData = await generateData( 221 | fields, 222 | rowCount, 223 | retoolDBInfo.tableInfo.primaryKeyColumn, 224 | primaryKeyMaxVal 225 | ); 226 | } 227 | 228 | // Insert to Retool DB. 229 | const bulkInsertRes = await postRequest( 230 | `${credentials.origin}/api/grid/${credentials.gridId}/action`, 231 | { 232 | kind: "BulkInsertIntoTable", 233 | tableName: tableName, 234 | additions: generatedData, 235 | } 236 | ); 237 | if (bulkInsertRes.data.success) { 238 | console.log("Successfully inserted data. 🤘🏻"); 239 | console.log( 240 | `\n${chalk.bold("View in browser:")} ${ 241 | credentials.origin 242 | }/resources/data/${ 243 | credentials.retoolDBUuid 244 | }/${tableName}?env=production` 245 | ); 246 | } else { 247 | console.log("Error inserting data."); 248 | console.log(bulkInsertRes.data); 249 | } 250 | } 251 | 252 | // No flag specified. 253 | else { 254 | console.log( 255 | "No flag specified. See `retool db --help` for available flags." 256 | ); 257 | } 258 | }; 259 | 260 | const commandModule: CommandModule = { 261 | command, 262 | describe, 263 | builder, 264 | handler, 265 | }; 266 | 267 | export default commandModule; 268 | -------------------------------------------------------------------------------- /src/commands/login.ts: -------------------------------------------------------------------------------- 1 | import { confirm, input, select } from "@inquirer/prompts"; 2 | import express from "express"; 3 | import { ArgumentsCamelCase, CommandBuilder, CommandModule, InferredOptionTypes } from "yargs"; 4 | 5 | import { accessTokenFromCookies, xsrfTokenFromCookies } from "../utils/cookies"; 6 | import { 7 | askForCookies, 8 | doCredentialsExist, 9 | getCredentials, 10 | persistCredentials, 11 | } from "../utils/credentials"; 12 | import { getRequest, postRequest } from "../utils/networking"; 13 | import { logDAU } from "../utils/telemetry"; 14 | 15 | const path = require("path"); 16 | 17 | const axios = require("axios"); 18 | const chalk = require("chalk"); 19 | const open = require("open"); 20 | 21 | // A helper function to create CommandBuilder without losing the type 22 | // information about defined keys. 23 | function createBuilder(input: T) { return input } 24 | 25 | const command = "login"; 26 | const describe = "Log in to Retool."; 27 | const builder = createBuilder({ 28 | "access-token": { 29 | describe: "Specify access token to use for Cookie login", 30 | type: "string", 31 | }, 32 | email: { 33 | describe: "Specify user email for email / localhost login", 34 | type: "string", 35 | }, 36 | force: { 37 | describe: "Re-authenticate even when already logged in", 38 | type: "boolean", 39 | }, 40 | "login-method": { 41 | describe: "Specify login method", 42 | choices: [ 43 | "browser", 44 | "email", 45 | "cookies", 46 | "localhost", 47 | ], 48 | type: "string", 49 | }, 50 | origin: { 51 | describe: "Specify the login origin host", 52 | type: "string", 53 | }, 54 | password: { 55 | describe: "Specify password for email / localhost login", 56 | type: "string", 57 | }, 58 | "xsrf-token": { 59 | describe: "Specify XSRF token to use for Coookie login", 60 | type: "string", 61 | }, 62 | }); 63 | 64 | type LoginOptionType = InferredOptionTypes 65 | const handler = async function (argv: ArgumentsCamelCase) { 66 | // Ask user if they want to overwrite existing credentials. 67 | if (doCredentialsExist() && !argv.force) { 68 | const overwrite = await confirm({ 69 | message: "You're already logged in. Do you want to re-authenticate?", 70 | }) 71 | if (!overwrite) { 72 | return; 73 | } 74 | } 75 | 76 | // Ask user how they want to login. 77 | let loginMethod = argv.loginMethod 78 | if (!loginMethod) { 79 | loginMethod = await select({ 80 | message: "How would you like to login?", 81 | choices: [ 82 | { 83 | name: "Log in using Google SSO in a web browser", 84 | value: "browser", 85 | }, 86 | { 87 | name: "Log in with email and password", 88 | value: "email", 89 | }, 90 | { 91 | name: "Log in by pasting in cookies", 92 | value: "cookies", 93 | }, 94 | { 95 | name: "Log in to localhost:3000", 96 | value: "localhost", 97 | }, 98 | ], 99 | }); 100 | } 101 | if (loginMethod === "browser") { 102 | await loginViaBrowser(); 103 | } else if (loginMethod === "email") { 104 | await loginViaEmail(false, argv.email, argv.password); 105 | } else if (loginMethod === "cookies") { 106 | await askForCookies({ 107 | origin: argv.origin, 108 | xsrf: argv.xsrfToken, 109 | accessToken: argv.accessToken, 110 | }); 111 | } else if (loginMethod === "localhost") { 112 | await loginViaEmail(true, argv.email, argv.password); 113 | } 114 | 115 | await logDAU(); 116 | }; 117 | 118 | // Ask the user to input their email and password. 119 | // Fire off a request to Retool's login & auth endpoints. 120 | // Persist the credentials. 121 | async function loginViaEmail(localhost = false, email?: string, password?: string) { 122 | if (!email) { 123 | email = await input({ 124 | message: "What is your email?", 125 | }) 126 | } 127 | if (!password) { 128 | password = await input({ 129 | message: "What is your password?", 130 | }); 131 | } 132 | 133 | const loginOrigin = localhost 134 | ? "http://localhost:3000" 135 | : "https://login.retool.com"; 136 | 137 | // Step 1: Hit /api/login with email and password. 138 | const login = await postRequest(`${loginOrigin}/api/login`, { 139 | email, 140 | password, 141 | }); 142 | const { authUrl, authorizationToken } = login.data; 143 | if (!authUrl || !authorizationToken) { 144 | console.log("Error logging in, please try again"); 145 | return; 146 | } 147 | 148 | // Step 2: Hit /auth/saveAuth with authorizationToken. 149 | const authResponse = await postRequest( 150 | localhost ? `${loginOrigin}${authUrl}` : authUrl, 151 | { 152 | authorizationToken, 153 | }, 154 | true, 155 | { 156 | origin: loginOrigin, 157 | } 158 | ); 159 | const { redirectUri } = authResponse.data; 160 | const redirectUrl = localhost 161 | ? new URL(loginOrigin) 162 | : redirectUri 163 | ? new URL(redirectUri) 164 | : undefined; 165 | const accessToken = accessTokenFromCookies( 166 | authResponse.headers["set-cookie"] 167 | ); 168 | const xsrfToken = xsrfTokenFromCookies(authResponse.headers["set-cookie"]); 169 | 170 | // Step 3: Persist the credentials. 171 | if (redirectUrl?.origin && accessToken && xsrfToken) { 172 | persistCredentials({ 173 | origin: redirectUrl.origin, 174 | accessToken, 175 | xsrf: xsrfToken, 176 | firstName: authResponse.data.user?.firstName, 177 | lastName: authResponse.data.user?.lastName, 178 | email: authResponse.data.user?.email, 179 | telemetryEnabled: true, 180 | }); 181 | logSuccess(); 182 | } else { 183 | console.log( 184 | "Error parsing credentials from HTTP Response. Please try again." 185 | ); 186 | } 187 | } 188 | 189 | async function loginViaBrowser() { 190 | // Start a short lived local server to listen for the SSO response. 191 | const app = express(); 192 | 193 | // Step 4: Handle the SSO response. 194 | // Success scenario format: http://localhost:3020/auth?redirect=https://mycompany.retool.com 195 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 196 | app.get("/auth", async function (req, res) { 197 | let url, accessToken, xsrfToken; 198 | 199 | try { 200 | accessToken = decodeURIComponent(req.query.accessToken as string); 201 | xsrfToken = decodeURIComponent(req.query.xsrfToken as string); 202 | url = new URL(decodeURIComponent(req.query.redirect as string)); 203 | } catch (e) { 204 | console.log(e); 205 | } 206 | 207 | if (!accessToken || !xsrfToken || !url) { 208 | console.log("Error: SSO response missing information. Try again."); 209 | res.sendFile(path.join(__dirname, "../loginPages/loginFail.html")); 210 | server_online = false; 211 | return; 212 | } 213 | 214 | axios.defaults.headers["x-xsrf-token"] = xsrfToken; 215 | axios.defaults.headers.cookie = `accessToken=${accessToken};`; 216 | const userRes = await getRequest(`https://${url.hostname}/api/user`); 217 | 218 | persistCredentials({ 219 | origin: url.origin, 220 | accessToken, 221 | xsrf: xsrfToken, 222 | firstName: userRes.data.user?.firstName, 223 | lastName: userRes.data.user?.lastName, 224 | email: userRes.data.user?.email, 225 | telemetryEnabled: true, 226 | }); 227 | logSuccess(); 228 | res.sendFile(path.join(__dirname, "../loginPages/loginSuccess.html")); 229 | server_online = false; 230 | }); 231 | const server = app.listen(3020); 232 | 233 | // Step 1: Open up the google SSO page in the browser. 234 | // Step 2: User accepts the SSO request. 235 | open( 236 | `https://login.retool.com/googlelogin?retoolCliRedirect=true&origin=login` 237 | ); 238 | // For local testing: 239 | // open("http://localhost:3000/googlelogin?retoolCliRedirect=true"); 240 | // open("https://login.retool-qa.com/googlelogin?retoolCliRedirect=true"); 241 | // open("https://admin.retool.dev/googlelogin?retoolCliRedirect=true"); 242 | 243 | // Step 3: Keep the server online until localhost:3020/auth is hit. 244 | let server_online = true; 245 | while (server_online) { 246 | await new Promise((resolve) => setTimeout(resolve, 100)); 247 | } 248 | 249 | server.close(); 250 | } 251 | 252 | export function logSuccess() { 253 | const credentials = getCredentials(); 254 | if (credentials?.firstName && credentials.lastName && credentials.email) { 255 | console.log( 256 | `Logged in as ${chalk.bold(credentials.firstName)} ${chalk.bold( 257 | credentials.lastName 258 | )} (${credentials.email}) ✅` 259 | ); 260 | } else { 261 | console.log("Successfully saved credentials."); 262 | } 263 | } 264 | 265 | const commandModule: CommandModule = { 266 | command, 267 | describe, 268 | builder, 269 | handler, 270 | }; 271 | 272 | export default commandModule; 273 | -------------------------------------------------------------------------------- /src/commands/logout.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { deleteCredentials } from "../utils/credentials"; 4 | 5 | const command = "logout"; 6 | const describe = "Log out of Retool."; 7 | const builder = {}; 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | const handler = function (argv: any) { 10 | deleteCredentials(); 11 | console.log("Successfully logged out. 👋🏻"); 12 | }; 13 | 14 | const commandModule: CommandModule = { 15 | command, 16 | describe, 17 | builder, 18 | handler, 19 | }; 20 | 21 | export default commandModule; 22 | -------------------------------------------------------------------------------- /src/commands/rpc.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | import chalk from "chalk"; 4 | import { format } from "date-fns"; 5 | import ora from "ora"; 6 | import { CommandModule } from "yargs"; 7 | 8 | import { getAndVerifyCredentials } from "../utils/credentials"; 9 | import { 10 | downloadGithubSubfolder, 11 | saveEnvVariablesToFile, 12 | } from "../utils/fileSave"; 13 | import { postRequest } from "../utils/networking"; 14 | import { createPlaygroundQuery } from "../utils/playgroundQuery"; 15 | import { createResource } from "../utils/resources"; 16 | import { logDAU } from "../utils/telemetry"; 17 | 18 | const inquirer = require("inquirer"); 19 | 20 | const command = "rpc"; 21 | const describe = "Interface with Retool RPC."; 22 | const builder = {}; 23 | const handler = async function () { 24 | const credentials = await getAndVerifyCredentials(); 25 | const origin = credentials.origin; 26 | // fire and forget 27 | void logDAU(credentials); 28 | 29 | console.log("\n┛"); 30 | console.log( 31 | ` Retool RPC connects Retool to your codebase, allowing you to register functions to execute from your Retool apps.\n` 32 | ); 33 | console.log(`To start using RetoolRPC, we'll run through three steps:`); 34 | console.log( 35 | `1. ${chalk.bold( 36 | "Creating an RPC resource on Retool" 37 | )} - So Retool can communicate with your instance` 38 | ); 39 | console.log( 40 | `2. ${chalk.bold( 41 | "Generating an access token" 42 | )} - So your server can be authenticated` 43 | ); 44 | console.log( 45 | `3. ${chalk.bold( 46 | "Registering your Retool RPC server" 47 | )} - So Retool can reach your codebase` 48 | ); 49 | console.log("┓\n"); 50 | 51 | console.log("Looking for more information? Visit our docs:"); 52 | console.log("https://docs.retool.com/private/retool-rpc\n"); 53 | 54 | let resourceName = ""; 55 | let resourceId = 0; 56 | 57 | const { resourceDisplayName } = (await inquirer.prompt([ 58 | { 59 | name: "resourceDisplayName", 60 | message: "What would you like to name your Retool RPC resource?", 61 | type: "input", 62 | default: getDefaultRPCResourceName(), 63 | validate: async (displayName: string) => { 64 | try { 65 | const resource = await createResource({ 66 | resourceType: "retoolSdk", 67 | credentials, 68 | resourceOptions: { 69 | requireExplicitVersion: false, 70 | }, 71 | displayName, 72 | }); 73 | resourceName = resource.name; 74 | resourceId = resource.id; 75 | return true; 76 | } catch (error: any) { 77 | return ( 78 | error.response?.data?.message || 79 | error.response?.statusText || 80 | "API call failed creating resource" 81 | ); 82 | } 83 | }, 84 | }, 85 | ])) as { resourceDisplayName: string }; 86 | 87 | console.log( 88 | `'${resourceDisplayName}' resource was created with a resource id of ${resourceName}.\n` 89 | ); 90 | console.log( 91 | `Next, we'll need an access token with RPC scope. You can create a new token here:` 92 | ); 93 | console.log(`${origin}/settings/api\n`); 94 | 95 | const { rpcAccessToken } = (await inquirer.prompt([ 96 | { 97 | name: "rpcAccessToken", 98 | message: `Enter the RPC access token.`, 99 | type: "password", 100 | validate: async (rpcAccessToken: string) => { 101 | try { 102 | const validateResourceAccess = await postRequest( 103 | `${origin}/api/v1/retoolrpc/validateResourceAccess`, 104 | { 105 | resourceId: resourceName, 106 | environmentName: "production", 107 | }, 108 | false, 109 | { 110 | Authorization: `Bearer ${rpcAccessToken}`, 111 | "Content-Type": "application/json", 112 | cookie: "", 113 | "x-xsrf-token": "", 114 | }, 115 | false 116 | ); 117 | return validateResourceAccess.data.success; 118 | } catch (error: any) { 119 | return "Unable to access RPC resource. Did you enter a valid access token?"; 120 | } 121 | }, 122 | }, 123 | ])) as { rpcAccessToken: string }; 124 | console.log(); 125 | 126 | const { languageType } = (await inquirer.prompt([ 127 | { 128 | name: "languageType", 129 | message: 130 | "Which of the following languages would you like to use for your local RPC server?", 131 | type: "list", 132 | choices: [ 133 | { 134 | name: "Typescript", 135 | value: "typescript", 136 | }, 137 | { 138 | name: "Javascript", 139 | value: "javascript", 140 | }, 141 | { 142 | name: "Python", 143 | value: "python", 144 | }, 145 | ], 146 | }, 147 | ])) as { languageType: string }; 148 | 149 | const { destinationPath } = (await inquirer.prompt([ 150 | { 151 | name: "destinationPath", 152 | message: "Where would you like to create your local server?", 153 | type: "input", 154 | default: "./retool_rpc", 155 | }, 156 | ])) as { destinationPath: string }; 157 | 158 | const githubUrl = 159 | "https://api.github.com/repos/tryretool/retool-examples/tarball/main"; 160 | const subfolderPath = "rpc/" + languageType; 161 | await downloadGithubSubfolder(githubUrl, subfolderPath, destinationPath); 162 | 163 | const spinner = ora( 164 | "Installing dependencies and creating starter code to connect to Retool..." 165 | ).start(); 166 | 167 | const queryResult = await createPlaygroundQuery(resourceId, credentials); 168 | const queryUrl = `${origin}/queryLibrary/${queryResult.id}`; 169 | 170 | const cliGeneratedMessage = `'Run the following query in Retool to see how it interacts with your local codebase:\n${queryUrl}'`; 171 | const envVariables = { 172 | RETOOL_RPC_RESOURCE_ID: resourceName, 173 | RETOOL_RPC_HOST: origin, 174 | RETOOL_RPC_API_TOKEN: rpcAccessToken, 175 | CLI_GENERATED_MESSAGE: `${cliGeneratedMessage}`, 176 | }; 177 | saveEnvVariablesToFile(envVariables, destinationPath + "/.env"); 178 | 179 | let runCommand = ""; 180 | let filePath = ""; 181 | if (languageType === "typescript" || languageType === "javascript") { 182 | installYarnDependencies(destinationPath); 183 | runCommand = "yarn example"; 184 | if (languageType === "typescript") { 185 | filePath = "src/index.ts"; 186 | } else { 187 | filePath = "src/index.js"; 188 | } 189 | } 190 | if (languageType === "python") { 191 | installPoetryDependencies(destinationPath); 192 | runCommand = "poetry run python src/example.py"; 193 | filePath = "src/example.py"; 194 | } 195 | 196 | spinner.stop(); 197 | 198 | console.log( 199 | `\nTo help you get started, we've added starter code that spins up a server for you to connect to Retool. The code is located at ${destinationPath}/${filePath}.\n` 200 | ); 201 | console.log(`Start your server by running the following command:`); 202 | console.log(`${chalk.bold(`cd ${destinationPath} && ${runCommand}\n`)}`); 203 | console.log( 204 | "Once your server is running, run the following query in Retool to see how it interacts with your local codebase:" 205 | ); 206 | console.log(`${queryUrl}\n`); 207 | }; 208 | 209 | function installYarnDependencies(destinationPath: string) { 210 | try { 211 | execSync(`cd ${destinationPath} && yarn install`, { stdio: "inherit" }); 212 | } catch (error: any) { 213 | console.error(`Error installing dependencies: ${error.message}`); 214 | process.exit(1); 215 | } 216 | } 217 | 218 | function installPoetryDependencies(destinationPath: string) { 219 | try { 220 | execSync( 221 | `cd ${destinationPath} && curl -sSL https://install.python-poetry.org | python3 - && poetry install`, 222 | { stdio: "inherit" } 223 | ); 224 | } catch (error) { 225 | console.error("Error installing dependencies:", error); 226 | process.exit(1); 227 | } 228 | } 229 | 230 | function getDefaultRPCResourceName() { 231 | const formattedDate = format(new Date(), "MMMM d, yyyy h:mm a'"); 232 | return `CLI Generated Resource [${formattedDate}]`; 233 | } 234 | 235 | const commandModule: CommandModule = { 236 | command, 237 | describe, 238 | builder, 239 | handler, 240 | }; 241 | 242 | export default commandModule; 243 | -------------------------------------------------------------------------------- /src/commands/scaffold.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { createAppForTable, deleteApp } from "../utils/apps"; 4 | import { 5 | Credentials, 6 | getAndVerifyCredentialsWithRetoolDB, 7 | } from "../utils/credentials"; 8 | import { getRequest, postRequest } from "../utils/networking"; 9 | import { 10 | collectColumnNames, 11 | collectTableName, 12 | createTable, 13 | createTableFromCSV, 14 | deleteTable, 15 | generateDataWithGPT, 16 | } from "../utils/table"; 17 | import type { DBInfoPayload } from "../utils/table"; 18 | import { logDAU } from "../utils/telemetry"; 19 | import { deleteWorkflow, generateCRUDWorkflow } from "../utils/workflows"; 20 | 21 | const inquirer = require("inquirer"); 22 | 23 | const command = "scaffold"; 24 | const describe = "Scaffold a Retool DB table, CRUD Workflow, and App."; 25 | const builder: CommandModule["builder"] = { 26 | name: { 27 | alias: "n", 28 | describe: `Name of table to scaffold. Usage: 29 | retool scaffold -n `, 30 | type: "string", 31 | nargs: 1, 32 | }, 33 | columns: { 34 | alias: "c", 35 | describe: `Column names in DB to scaffold. Usage: 36 | retool scaffold -c `, 37 | type: "array", 38 | }, 39 | delete: { 40 | alias: "d", 41 | describe: `Delete a table, Workflow and App created via scaffold. Usage: 42 | retool scaffold -d `, 43 | type: "string", 44 | nargs: 1, 45 | }, 46 | "from-csv": { 47 | alias: "f", 48 | describe: `Create a table, Workflow and App from a CSV file. Usage: 49 | retool scaffold -f `, 50 | type: "array", 51 | }, 52 | "no-workflow": { 53 | describe: `Modifier to avoid generating Workflow. Usage: 54 | retool scaffold --no-workflow`, 55 | type: "boolean", 56 | }, 57 | }; 58 | const handler = async function (argv: any) { 59 | const credentials = await getAndVerifyCredentialsWithRetoolDB(); 60 | // fire and forget 61 | void logDAU(credentials); 62 | 63 | // Handle `retool scaffold -d ` 64 | if (argv.delete) { 65 | const tableName = argv.delete; 66 | const workflowName = `${tableName} CRUD Workflow`; 67 | 68 | // Confirm deletion. 69 | const { confirm } = await inquirer.prompt([ 70 | { 71 | name: "confirm", 72 | message: `Are you sure you want to delete ${tableName} table, CRUD workflow and app?`, 73 | type: "confirm", 74 | }, 75 | ]); 76 | if (!confirm) { 77 | process.exit(0); 78 | } 79 | 80 | //TODO: Could be parallelized. 81 | //TODO: Verify existence before trying to delete. 82 | await deleteTable(tableName, credentials, false); 83 | await deleteWorkflow(workflowName, credentials, false); 84 | await deleteApp(`${tableName} App`, credentials, false); 85 | } 86 | 87 | // Handle `retool scaffold -f ` 88 | else if (argv.f) { 89 | const csvFileNames = argv.f; 90 | 91 | for (const csvFileName of csvFileNames) { 92 | const { tableName, colNames } = await createTableFromCSV( 93 | csvFileName, 94 | credentials, 95 | false, 96 | false 97 | ); 98 | 99 | if (!argv["no-workflow"]) { 100 | console.log("\n"); 101 | await generateCRUDWorkflow(tableName, credentials); 102 | } 103 | 104 | console.log("\n"); 105 | const searchColumnName = colNames.length > 0 ? colNames[0] : "id"; 106 | await createAppForTable( 107 | `${tableName} App`, 108 | tableName, 109 | searchColumnName, 110 | credentials 111 | ); 112 | console.log(""); 113 | } 114 | } 115 | 116 | // Handle `retool scaffold` 117 | else { 118 | let tableName = argv.name; 119 | let colNames = argv.columns; 120 | if (!tableName || tableName.length == 0) { 121 | tableName = await collectTableName(); 122 | } 123 | if (!colNames || colNames.length == 0) { 124 | colNames = await collectColumnNames(); 125 | } 126 | 127 | await createTable(tableName, colNames, undefined, credentials, false); 128 | // Fire and forget 129 | void insertSampleData(tableName, credentials); 130 | 131 | if (!argv["no-workflow"]) { 132 | console.log("\n"); 133 | await generateCRUDWorkflow(tableName, credentials); 134 | } 135 | 136 | console.log("\n"); 137 | const searchColumnName = colNames.length > 0 ? colNames[0] : "id"; 138 | await createAppForTable( 139 | `${tableName} App`, 140 | tableName, 141 | searchColumnName, 142 | credentials 143 | ); 144 | } 145 | }; 146 | 147 | const insertSampleData = async function ( 148 | tableName: string, 149 | credentials: Credentials 150 | ) { 151 | const infoRes = await getRequest( 152 | `${credentials.origin}/api/grid/${credentials.gridId}/table/${tableName}/info`, 153 | false 154 | ); 155 | const retoolDBInfo: DBInfoPayload = infoRes.data; 156 | const { fields } = retoolDBInfo.tableInfo; 157 | 158 | const generatedData = await generateDataWithGPT( 159 | retoolDBInfo, 160 | fields, 161 | 0, 162 | credentials, 163 | false 164 | ); 165 | if (generatedData) { 166 | await postRequest( 167 | `${credentials.origin}/api/grid/${credentials.gridId}/action`, 168 | { 169 | kind: "BulkInsertIntoTable", 170 | tableName: tableName, 171 | additions: generatedData, 172 | } 173 | ); 174 | } 175 | }; 176 | 177 | const commandModule: CommandModule = { 178 | command, 179 | describe, 180 | builder, 181 | handler, 182 | }; 183 | 184 | export default commandModule; 185 | -------------------------------------------------------------------------------- /src/commands/signup.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { logSuccess } from "./login"; 4 | import { accessTokenFromCookies, xsrfTokenFromCookies } from "../utils/cookies"; 5 | import { doCredentialsExist, persistCredentials } from "../utils/credentials"; 6 | import { getRequest, postRequest } from "../utils/networking"; 7 | import { logDAU } from "../utils/telemetry"; 8 | import { isEmailValid } from "../utils/validation"; 9 | 10 | const axios = require("axios"); 11 | const inquirer = require("inquirer"); 12 | const ora = require("ora"); 13 | 14 | const command = "signup"; 15 | const describe = "Create a Retool account."; 16 | const builder = {}; 17 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 | const handler = async function (argv: any) { 19 | // Ask user if they want to overwrite existing credentials. 20 | if (doCredentialsExist()) { 21 | const { overwrite } = await inquirer.prompt([ 22 | { 23 | name: "overwrite", 24 | message: 25 | "You're already logged in. Do you want to log out and create a new account?", 26 | type: "confirm", 27 | }, 28 | ]); 29 | if (!overwrite) { 30 | return; 31 | } 32 | } 33 | 34 | // Step 1: Collect a valid email/password. 35 | let email, password, name, org; 36 | while (!email) { 37 | email = await collectEmail(); 38 | } 39 | while (!password) { 40 | password = await colllectPassword(); 41 | } 42 | 43 | // Step 2: Call signup endpoint, get cookies. 44 | const spinner = ora( 45 | "Verifying that the email and password are valid on the server" 46 | ).start(); 47 | const signupResponse = await postRequest( 48 | `https://login.retool.com/api/signup`, 49 | { 50 | email, 51 | password, 52 | planKey: "free", 53 | } 54 | ); 55 | spinner.stop(); 56 | 57 | const accessToken = accessTokenFromCookies( 58 | signupResponse.headers["set-cookie"] 59 | ); 60 | const xsrfToken = xsrfTokenFromCookies(signupResponse.headers["set-cookie"]); 61 | if (!accessToken || !xsrfToken) { 62 | if (process.env.DEBUG) { 63 | console.log(signupResponse); 64 | } 65 | console.log( 66 | "Error creating account, please try again or signup at https://login.retool.com/auth/signup?plan=free." 67 | ); 68 | return; 69 | } 70 | 71 | axios.defaults.headers["x-xsrf-token"] = xsrfToken; 72 | axios.defaults.headers.cookie = `accessToken=${accessToken};`; 73 | 74 | // Step 3: Collect a valid name/org. 75 | while (!name) { 76 | name = await collectName(); 77 | } 78 | while (!org) { 79 | org = await collectOrg(); 80 | } 81 | 82 | // Step 4: Initialize organization. 83 | await postRequest( 84 | `https://login.retool.com/api/organization/admin/initializeOrganization`, 85 | { 86 | subdomain: org, 87 | } 88 | ); 89 | 90 | // Step 5: Persist credentials 91 | const origin = `https://${org}.retool.com`; 92 | const userRes = await getRequest(`${origin}/api/user`); 93 | persistCredentials({ 94 | origin, 95 | accessToken, 96 | xsrf: xsrfToken, 97 | firstName: userRes.data.user?.firstName, 98 | lastName: userRes.data.user?.lastName, 99 | email: userRes.data.user?.email, 100 | telemetryEnabled: true, 101 | }); 102 | logSuccess(); 103 | await logDAU(); 104 | }; 105 | 106 | async function collectEmail(): Promise { 107 | const { email } = await inquirer.prompt([ 108 | { 109 | name: "email", 110 | message: "What is your email?", 111 | type: "input", 112 | }, 113 | ]); 114 | if (!isEmailValid(email)) { 115 | console.log("Invalid email, try again."); 116 | return; 117 | } 118 | return email; 119 | } 120 | 121 | async function colllectPassword(): Promise { 122 | const { password } = await inquirer.prompt([ 123 | { 124 | name: "password", 125 | message: "Please create a password (min 8 characters):", 126 | type: "password", 127 | }, 128 | ]); 129 | const { confirmedPassword } = await inquirer.prompt([ 130 | { 131 | name: "confirmedPassword", 132 | message: "Please confirm password:", 133 | type: "password", 134 | }, 135 | ]); 136 | if (password.length < 8) { 137 | console.log("Password must be at least 8 characters long, try again."); 138 | return; 139 | } 140 | if (password !== confirmedPassword) { 141 | console.log("Passwords do not match, try again."); 142 | return; 143 | } 144 | return password; 145 | } 146 | 147 | async function collectName(): Promise { 148 | const { name } = await inquirer.prompt([ 149 | { 150 | name: "name", 151 | message: "What is your first and last name?", 152 | type: "input", 153 | }, 154 | ]); 155 | if (!name || name.length === 0) { 156 | console.log("Invalid name, try again."); 157 | return; 158 | } 159 | const parts = name.split(" "); 160 | const changeNameResponse = await postRequest( 161 | `https://login.retool.com/api/user/changeName`, 162 | { 163 | firstName: parts[0], 164 | lastName: parts[1], 165 | }, 166 | false 167 | ); 168 | if (!changeNameResponse) { 169 | return; 170 | } 171 | 172 | return name; 173 | } 174 | 175 | async function collectOrg(): Promise { 176 | let { org } = await inquirer.prompt([ 177 | { 178 | name: "org", 179 | message: 180 | "What is your organization name? Leave blank to generate a random name.", 181 | type: "input", 182 | }, 183 | ]); 184 | if (!org || org.length === 0) { 185 | // Org must start with letter, append a random string after it. 186 | // https://stackoverflow.com/a/8084248 187 | org = "z" + (Math.random() + 1).toString(36).substring(2); 188 | } 189 | 190 | const checkSubdomainAvailabilityResponse = await getRequest( 191 | `https://login.retool.com/api/organization/admin/checkSubdomainAvailability?subdomain=${org}`, 192 | false 193 | ); 194 | 195 | if (!checkSubdomainAvailabilityResponse.status) { 196 | return; 197 | } 198 | 199 | return org; 200 | } 201 | 202 | const commandModule: CommandModule = { command, describe, builder, handler }; 203 | export default commandModule; 204 | -------------------------------------------------------------------------------- /src/commands/telemetry.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { 4 | getAndVerifyCredentials, 5 | persistCredentials, 6 | } from "../utils/credentials"; 7 | 8 | const command = "telemetry"; 9 | const describe = "Configure CLI telemetry."; 10 | const builder = { 11 | disable: { 12 | alias: "d", 13 | describe: `Disable telemetry.`, 14 | }, 15 | enable: { 16 | alias: "e", 17 | describe: `Enable telemetry.`, 18 | }, 19 | }; 20 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 | const handler = async function (argv: any) { 22 | const credentials = await getAndVerifyCredentials(); 23 | 24 | if (argv.disable) { 25 | credentials.telemetryEnabled = false; 26 | persistCredentials(credentials); 27 | console.log("Successfully disabled telemetry. 📉"); 28 | } else if (argv.enable) { 29 | credentials.telemetryEnabled = true; 30 | persistCredentials(credentials); 31 | console.log("Successfully enabled telemetry. 📈"); 32 | } else { 33 | console.log( 34 | "No flag specified. See `retool telemetry --help` for available flags." 35 | ); 36 | } 37 | }; 38 | 39 | const commandModule: CommandModule = { 40 | command, 41 | describe, 42 | builder, 43 | handler, 44 | }; 45 | 46 | export default commandModule; 47 | -------------------------------------------------------------------------------- /src/commands/terraform.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | 3 | import ora from "ora"; 4 | import { CommandModule } from "yargs"; 5 | 6 | import { logDAU } from "../utils/telemetry"; 7 | import { 8 | generateTerraformConfigForFolders, 9 | generateTerraformConfigForGroups, 10 | generateTerraformConfigForPermissions, 11 | generateTerraformConfigForSSO, 12 | generateTerraformConfigForSourceControl, 13 | generateTerraformConfigForSourceControlSettings, 14 | generateTerraformConfigForSpaces, 15 | importRetoolConfig 16 | } from "../utils/terraformGen"; 17 | import type { 18 | TerraformFolderImport, 19 | TerraformGroupImport, 20 | TerraformPermissionsImport, 21 | TerraformSSOImport, 22 | TerraformSourceControlImport, 23 | TerraformSourceControlSettingsImport, 24 | TerraformSpaceImport 25 | } from "../utils/terraformGen"; 26 | 27 | 28 | const command: CommandModule["command"] = "terraform"; 29 | const describe: CommandModule["describe"] = `Generate Terraform configuration for the given Retool organization. 30 | 31 | This command requires the following environment variables to be set: 32 | - RETOOL_ACCESS_TOKEN: Access token for the Retool API. The token must have "source_control:read","groups:read","spaces:read","folders:read","permissions:all:read" scopes. 33 | - RETOOL_HOST: The Retool host domain (e.g. your-org.retool.com). 34 | You can also set the environment variable RETOOL_SCHEME to "http" if you are using HTTP.`; 35 | const builder: CommandModule["builder"] = { 36 | imports: { 37 | alias: "i", 38 | describe: `Path of the output file with "import" blocks in it`, 39 | }, 40 | config: { 41 | alias: "c", 42 | describe: `Path of the output file with Terraform resource configurations in it`, 43 | }, 44 | }; 45 | 46 | const handler = async function (argv: any) { 47 | // fire and forget 48 | void logDAU(); 49 | 50 | if (!process.env.RETOOL_ACCESS_TOKEN || !process.env.RETOOL_HOST) { 51 | console.error("This command requires RETOOL_ACCESS_TOKEN and RETOOL_HOST environment variables to be set."); 52 | process.exit(1); 53 | } 54 | 55 | if (!argv.imports && !argv.config) { 56 | console.error("Please provide an output file path using --imports or --config flag."); 57 | process.exit(1); 58 | } 59 | 60 | const spinner = ora("Reading Retool configuration").start(); 61 | const config = await importRetoolConfig(); 62 | spinner.stop(); 63 | 64 | if (argv.imports) { 65 | // Print everything into a file 66 | const fileContent = config.map((im) => { 67 | return ` 68 | import { 69 | to = ${im.resourceType}.${im.terraformId} 70 | id = "${im.id}" 71 | } 72 | ` 73 | }).join(""); 74 | fs.writeFileSync(argv.imports, fileContent); 75 | 76 | console.log("Generated Terraform file with `import` blocks."); 77 | } 78 | if (argv.config) { 79 | const folderResources = config.filter((resource) => resource.resourceType === "retool_folder") as TerraformFolderImport[]; // not sure why TS is not able to correctly infer the type here 80 | const groupResources = config.filter((resource) => resource.resourceType === "retool_group") as TerraformGroupImport[]; 81 | const permissionResources = config.filter((resource) => resource.resourceType === "retool_permissions") as TerraformPermissionsImport[]; 82 | const ssoResources = config.filter((resource) => resource.resourceType === "retool_sso") as TerraformSSOImport[]; 83 | const sourceControlResources = config.filter((resource) => resource.resourceType === "retool_source_control") as TerraformSourceControlImport[]; 84 | const sourceControlSettingsResources = config.filter((resource) => resource.resourceType === "retool_source_control_settings") as TerraformSourceControlSettingsImport[]; 85 | const spaceResources = config.filter((resource) => resource.resourceType === "retool_space") as TerraformSpaceImport[]; 86 | let lines = await generateTerraformConfigForFolders(folderResources); 87 | lines = lines.concat(generateTerraformConfigForGroups(groupResources)); 88 | lines = lines.concat(await generateTerraformConfigForPermissions(permissionResources, config)); 89 | if (ssoResources.length > 0) { 90 | lines = lines.concat(generateTerraformConfigForSSO(ssoResources[0])); 91 | } 92 | if (sourceControlResources.length > 0) { 93 | lines = lines.concat(generateTerraformConfigForSourceControl(sourceControlResources[0])); 94 | } 95 | if (sourceControlSettingsResources.length > 0) { 96 | lines = lines.concat(generateTerraformConfigForSourceControlSettings(sourceControlSettingsResources[0])); 97 | } 98 | lines = lines.concat(generateTerraformConfigForSpaces(spaceResources)); 99 | // Print everything into a file 100 | fs.writeFileSync(argv.config, lines.join("\n")); 101 | 102 | console.log("Generated Terraform file with resource configurations."); 103 | } 104 | }; 105 | 106 | const commandModule: CommandModule = { 107 | command, 108 | describe, 109 | builder, 110 | handler, 111 | }; 112 | 113 | export default commandModule; 114 | -------------------------------------------------------------------------------- /src/commands/whoami.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsCamelCase, CommandModule } from "yargs"; 2 | 3 | import { getCredentials } from "../utils/credentials"; 4 | import { logDAU } from "../utils/telemetry"; 5 | 6 | const chalk = require("chalk"); 7 | 8 | const command = "whoami"; 9 | const describe = "Show the current Retool user."; 10 | const builder = { 11 | verbose: { 12 | alias: "v", 13 | describe: "Print additional debugging information.", 14 | }, 15 | }; 16 | const handler = function (argv: ArgumentsCamelCase) { 17 | const credentials = getCredentials(); 18 | // fire and forget 19 | void logDAU(credentials); 20 | 21 | if (credentials) { 22 | if ( 23 | !process.env.DEBUG && 24 | !argv.verbose && 25 | credentials.firstName && 26 | credentials.lastName && 27 | credentials.email 28 | ) { 29 | console.log( 30 | `Logged in to ${chalk.bold(credentials.origin)} as ${chalk.bold( 31 | credentials.firstName 32 | )} ${chalk.bold(credentials.lastName)} (${credentials.email}) 🙌🏻` 33 | ); 34 | } else { 35 | console.log("You are logged in with credentials:"); 36 | console.log(credentials); 37 | } 38 | } else { 39 | console.log(`No credentials found. To log in, run: \`retool login\``); 40 | } 41 | }; 42 | 43 | const commandModule: CommandModule = { 44 | command, 45 | describe, 46 | builder, 47 | handler, 48 | }; 49 | 50 | export default commandModule; 51 | -------------------------------------------------------------------------------- /src/commands/workflows.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | import { getAndVerifyCredentialsWithRetoolDB } from "../utils/credentials"; 4 | import { dateOptions } from "../utils/date"; 5 | import { logDAU } from "../utils/telemetry"; 6 | import { 7 | Workflow, 8 | deleteWorkflow, 9 | getWorkflowsAndFolders, 10 | } from "../utils/workflows"; 11 | 12 | const command = "workflows"; 13 | const describe = "Interface with Retool Workflows."; 14 | const builder: CommandModule["builder"] = { 15 | list: { 16 | alias: "l", 17 | describe: `List folders and workflows at root level. Optionally provide a folder name to list all workflows in that folder. Usage: 18 | retool workflows -l [folder-name]`, 19 | }, 20 | "list-recursive": { 21 | alias: "r", 22 | describe: `List all apps and workflows.`, 23 | }, 24 | delete: { 25 | alias: "d", 26 | describe: `Delete a workflow. Usage: 27 | retool workflows -d `, 28 | type: "array", 29 | }, 30 | }; 31 | const handler = async function (argv: any) { 32 | const credentials = await getAndVerifyCredentialsWithRetoolDB(); 33 | // fire and forget 34 | void logDAU(credentials); 35 | 36 | // Handle `retool workflows -l` 37 | if (argv.list || argv.r) { 38 | let { workflows, folders } = await getWorkflowsAndFolders(credentials); 39 | const rootFolderId = folders?.find( 40 | (folder) => folder.name === "root" && folder.systemFolder === true 41 | )?.id; 42 | const trashFolderId = folders?.find( 43 | (folder) => folder.name === "archive" && folder.systemFolder === true 44 | )?.id; 45 | 46 | // Only list workflows in the specified folder. 47 | if (typeof argv.list === "string") { 48 | const folderId = folders?.find((folder) => folder.name === argv.list)?.id; 49 | if (folderId) { 50 | const workflowsInFolder = workflows?.filter( 51 | (w) => w.folderId === folderId 52 | ); 53 | if (workflowsInFolder && workflowsInFolder.length > 0) { 54 | printWorkflows(workflowsInFolder); 55 | } else { 56 | console.log(`No workflows found in ${argv.list}.`); 57 | } 58 | } else { 59 | console.log(`No folder named ${argv.list} found.`); 60 | } 61 | } 62 | 63 | // List all folders, then all workflows in root folder. 64 | else { 65 | // Filter out undesired folders/workflows. 66 | folders = folders?.filter((f) => f.systemFolder === false); 67 | workflows = workflows?.filter((w) => w.folderId !== trashFolderId); 68 | if (!argv.r) { 69 | workflows = workflows?.filter((w) => w.folderId === rootFolderId); 70 | } 71 | 72 | // Sort from oldest to newest. 73 | folders?.sort((a, b) => { 74 | return Date.parse(a.updatedAt) - Date.parse(b.updatedAt); 75 | }); 76 | workflows?.sort((a, b) => { 77 | return Date.parse(a.lastDeployedAt) - Date.parse(b.lastDeployedAt); 78 | }); 79 | 80 | if ( 81 | (!folders || folders.length === 0) && 82 | (!workflows || workflows.length === 0) 83 | ) { 84 | console.log("No folders or workflows found."); 85 | } else { 86 | // List all folders 87 | if (folders && folders?.length > 0) { 88 | folders.forEach((folder) => { 89 | const date = new Date(Date.parse(folder.updatedAt)); 90 | console.log( 91 | `${date.toLocaleString(undefined, dateOptions)} 📂 ${ 92 | folder.name 93 | }/` 94 | ); 95 | }); 96 | } 97 | // List all workflows in root folder. 98 | printWorkflows(workflows); 99 | } 100 | } 101 | } 102 | 103 | // Handle `retool workflows -d ` 104 | else if (argv.delete) { 105 | const workflowNames = argv.delete; 106 | for (const workflowName of workflowNames) { 107 | await deleteWorkflow(workflowName, credentials, true); 108 | } 109 | } 110 | 111 | // No flag specified. 112 | else { 113 | console.log( 114 | "No flag specified. See `retool workflows --help` for available flags." 115 | ); 116 | } 117 | }; 118 | 119 | function printWorkflows(workflows: Array | undefined): void { 120 | if (workflows && workflows.length > 0) { 121 | workflows.forEach((wf) => { 122 | const date = new Date(Date.parse(wf.lastDeployedAt)); 123 | console.log( 124 | `${date.toLocaleString(undefined, dateOptions)} ${ 125 | wf.isEnabled ? "🟢" : "🔴" 126 | } ${wf.name}` 127 | ); 128 | }); 129 | } 130 | } 131 | 132 | const commandModule: CommandModule = { 133 | command, 134 | describe, 135 | builder, 136 | handler, 137 | }; 138 | 139 | export default commandModule; 140 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const axios = require("axios"); 4 | 5 | require("yargs/yargs")(process.argv.slice(2)) 6 | .commandDir("commands", { 7 | visit(commandModule: any) { 8 | return commandModule.default; 9 | }, 10 | }) 11 | .parserConfiguration({ "boolean-negation": false }) 12 | .demandCommand() 13 | .strict() 14 | .usage( 15 | "Work seamlessly with Retool from the command line. For feedback and issues visit https://github.com/tryretool/retool-cli.\n\nUsage: retool [flags]" 16 | ).argv; 17 | 18 | // Setup axios defaults. 19 | axios.defaults.headers.accept = "application/json"; 20 | axios.defaults.headers["content-type"] = "application/json"; 21 | -------------------------------------------------------------------------------- /src/loginPages/loginFail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login Failed 6 | 53 | 54 | 55 |
56 |
57 |

Login Failed 🥲

58 |

Please try again.

59 |
60 |
61 |
62 | 65 |

66 | Quickly 67 | build internal tools and dashboards, using your team's own 68 | data and integrations. 69 |

70 |

Trusted by teams at

71 |
72 | 79 |
80 |
81 | 88 |
89 |
90 |
91 |
92 | 93 | 94 | -------------------------------------------------------------------------------- /src/loginPages/loginSuccess.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Login Success 6 | 53 | 54 | 55 |
56 |
57 |

Login Success 🤩

58 |

Go back to CLI.

59 |
60 |
61 |
62 | 65 |

66 | Quickly 67 | build internal tools and dashboards, using your team's own 68 | data and integrations. 69 |

70 |

Trusted by teams at

71 |
72 | 79 |
80 |
81 | 88 |
89 |
90 |
91 |
92 | 93 | 94 | -------------------------------------------------------------------------------- /src/resources/workflowTemplate.ts: -------------------------------------------------------------------------------- 1 | export type WorkflowTemplateType = typeof workflowTemplate; 2 | export const workflowTemplate = [ 3 | { 4 | block: { 5 | pluginId: "startTrigger", 6 | incomingOnSuccessPlugins: [], 7 | comment: { 8 | body: 'This workflow supports performing CRUD operations on a Retool DB instance. Example valid JSON inputs:\n\n{\n type: "create",\n data:{ col1: "val1", col2: "val2"}\n}\n\n{\n type: "read"\n}\n\n{\n type: "update",\n row: id,\n data:{ col1: "val1", col2: "val2"}\n}\n\n{\n type: "destroy",\n row: id\n}', 9 | visible: true, 10 | pluginId: "startTrigger-comment", 11 | }, 12 | top: 0, 13 | left: -432, 14 | environment: "production", 15 | editorType: "JavascriptQuery", 16 | resourceName: "webhook", 17 | blockType: "webhook", 18 | }, 19 | pluginTemplate: { 20 | id: "startTrigger", 21 | type: "datasource", 22 | subtype: "JavascriptQuery", 23 | resourceName: "JavascriptQuery", 24 | template: { 25 | queryRefreshTime: "", 26 | lastReceivedFromResourceAt: null, 27 | queryDisabledMessage: "", 28 | servedFromCache: false, 29 | offlineUserQueryInputs: "", 30 | successMessage: "", 31 | queryDisabled: "", 32 | playgroundQuerySaveId: "latest", 33 | workflowParams: null, 34 | resourceNameOverride: "", 35 | runWhenModelUpdates: false, 36 | workflowRunId: null, 37 | showFailureToaster: true, 38 | query: 'return {\n type: "read"\n}', 39 | playgroundQueryUuid: "", 40 | playgroundQueryId: null, 41 | error: null, 42 | workflowRunBodyType: "raw", 43 | privateParams: [], 44 | runWhenPageLoadsDelay: "", 45 | data: null, 46 | importedQueryInputs: {}, 47 | _additionalScope: [], 48 | isImported: false, 49 | showSuccessToaster: true, 50 | cacheKeyTtl: "", 51 | requestSentTimestamp: null, 52 | metadata: null, 53 | workflowActionType: null, 54 | queryRunTime: null, 55 | changesetObject: "", 56 | errorTransformer: 57 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 58 | finished: null, 59 | confirmationMessage: null, 60 | isFetching: false, 61 | changeset: "", 62 | rawData: null, 63 | queryTriggerDelay: "0", 64 | watchedParams: [], 65 | enableErrorTransformer: false, 66 | showLatestVersionUpdatedWarning: false, 67 | timestamp: 0, 68 | evalType: "script", 69 | importedQueryDefaults: {}, 70 | enableTransformer: false, 71 | showUpdateSetValueDynamicallyToggle: true, 72 | runWhenPageLoads: false, 73 | transformer: 74 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 75 | events: [], 76 | queryTimeout: "10000", 77 | workflowId: null, 78 | requireConfirmation: false, 79 | queryFailureConditions: "", 80 | changesetIsObject: false, 81 | enableCaching: false, 82 | allowedGroups: [], 83 | workflowBlockPluginId: null, 84 | offlineQueryType: "None", 85 | queryThrottleTime: "750", 86 | updateSetValueDynamically: false, 87 | notificationDuration: "", 88 | }, 89 | }, 90 | }, 91 | { 92 | block: { 93 | pluginId: "createQuery", 94 | incomingOnSuccessPlugins: [], 95 | top: -640, 96 | left: 1040, 97 | environment: "production", 98 | editorType: "SqlQueryUnified", 99 | resourceName: "", 100 | blockType: "default", 101 | incomingPortsPlugins: [ 102 | { 103 | blockPluginId: "switchType", 104 | portId: "a620f4c4-57db-4732-9176-934bcf9b94a8", 105 | }, 106 | ], 107 | }, 108 | pluginTemplate: { 109 | id: "createQuery", 110 | type: "datasource", 111 | subtype: "SqlQueryUnified", 112 | resourceName: null, 113 | template: { 114 | queryRefreshTime: "", 115 | records: "", 116 | lastReceivedFromResourceAt: null, 117 | databasePasswordOverride: "", 118 | queryDisabledMessage: "", 119 | servedFromCache: false, 120 | offlineUserQueryInputs: "", 121 | successMessage: "", 122 | queryDisabled: "", 123 | playgroundQuerySaveId: "latest", 124 | workflowParams: null, 125 | resourceNameOverride: "", 126 | runWhenModelUpdates: false, 127 | workflowRunId: null, 128 | showFailureToaster: true, 129 | query: "", 130 | playgroundQueryUuid: "", 131 | playgroundQueryId: null, 132 | error: null, 133 | workflowRunBodyType: "raw", 134 | privateParams: [], 135 | runWhenPageLoadsDelay: "", 136 | warningCodes: [], 137 | data: null, 138 | recordId: "", 139 | importedQueryInputs: {}, 140 | isImported: false, 141 | showSuccessToaster: true, 142 | dataArray: [], 143 | cacheKeyTtl: "", 144 | filterBy: "", 145 | requestSentTimestamp: null, 146 | databaseHostOverride: "", 147 | metadata: null, 148 | workflowActionType: null, 149 | editorMode: "gui", 150 | queryRunTime: null, 151 | actionType: "INSERT", 152 | changesetObject: "{{startTrigger.data.data}}", 153 | shouldUseLegacySql: false, 154 | errorTransformer: 155 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 156 | finished: null, 157 | databaseNameOverride: "", 158 | confirmationMessage: null, 159 | isFetching: false, 160 | changeset: "", 161 | rawData: null, 162 | queryTriggerDelay: "0", 163 | resourceTypeOverride: "postgresql", 164 | watchedParams: [], 165 | enableErrorTransformer: false, 166 | enableBulkUpdates: false, 167 | showLatestVersionUpdatedWarning: false, 168 | timestamp: 0, 169 | evalType: "script", 170 | importedQueryDefaults: {}, 171 | enableTransformer: false, 172 | showUpdateSetValueDynamicallyToggle: true, 173 | bulkUpdatePrimaryKey: "", 174 | runWhenPageLoads: false, 175 | transformer: 176 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 177 | events: [], 178 | tableName: "name_placeholder", 179 | queryTimeout: "120000", 180 | workflowId: null, 181 | requireConfirmation: false, 182 | queryFailureConditions: "", 183 | changesetIsObject: true, 184 | enableCaching: false, 185 | allowedGroups: [], 186 | workflowBlockPluginId: null, 187 | databaseUsernameOverride: "", 188 | shouldEnableBatchQuerying: false, 189 | doNotThrowOnNoOp: false, 190 | offlineQueryType: "None", 191 | queryThrottleTime: "750", 192 | updateSetValueDynamically: false, 193 | notificationDuration: "", 194 | }, 195 | }, 196 | }, 197 | { 198 | block: { 199 | pluginId: "switchType", 200 | incomingOnSuccessPlugins: [], 201 | top: 0, 202 | left: 528, 203 | environment: "production", 204 | editorType: "JavascriptQuery", 205 | resourceName: "JavascriptQuery", 206 | blockType: "conditional", 207 | options: { 208 | conditions: [ 209 | { 210 | id: "4a38580b-830a-4f7b-9c3f-503bf1a0ed6d", 211 | type: "if", 212 | statement: 'startTrigger.data.type === "create"', 213 | outgoingPortId: "a620f4c4-57db-4732-9176-934bcf9b94a8", 214 | }, 215 | { 216 | id: "5a1e5f9a-ac0e-4b7d-83d9-2ec42d766fb7", 217 | type: "if", 218 | statement: 'startTrigger.data.type === "read"', 219 | outgoingPortId: "75cf13b9-ac7e-47b2-a48d-518ec1094659", 220 | }, 221 | { 222 | id: "42309937-c622-40bc-8d17-7b302e063b23", 223 | type: "if", 224 | statement: 'startTrigger.data.type === "update"', 225 | outgoingPortId: "7d59ff82-a70b-4fe8-a6c8-3253898cf1a6", 226 | }, 227 | { 228 | id: "756ce9dc-9a06-4460-bf83-f4579d255dcc", 229 | type: "if", 230 | statement: 'startTrigger.data.type === "destroy"', 231 | outgoingPortId: "221b4d5d-fb16-4581-9b71-f95ef77c66d0", 232 | }, 233 | { 234 | id: "41fd9758-049c-47b3-8d32-77c4399808c7", 235 | type: "else", 236 | statement: "", 237 | outgoingPortId: "12676e07-8c10-4bb5-914a-98e6608a386e", 238 | }, 239 | ], 240 | }, 241 | outgoingPorts: [ 242 | { 243 | id: "a620f4c4-57db-4732-9176-934bcf9b94a8", 244 | name: "0", 245 | type: "conditional", 246 | }, 247 | { 248 | id: "75cf13b9-ac7e-47b2-a48d-518ec1094659", 249 | name: "1", 250 | type: "conditional", 251 | }, 252 | { 253 | id: "7d59ff82-a70b-4fe8-a6c8-3253898cf1a6", 254 | name: "2", 255 | type: "conditional", 256 | }, 257 | { 258 | id: "221b4d5d-fb16-4581-9b71-f95ef77c66d0", 259 | name: "3", 260 | type: "conditional", 261 | }, 262 | { 263 | id: "12676e07-8c10-4bb5-914a-98e6608a386e", 264 | name: "4", 265 | type: "conditional", 266 | }, 267 | ], 268 | incomingPortsPlugins: [ 269 | { 270 | blockPluginId: "filterBadRequest", 271 | portId: "c7a9d846-836e-4fbd-87af-3d76aa66715c", 272 | }, 273 | ], 274 | }, 275 | pluginTemplate: { 276 | id: "switchType", 277 | type: "datasource", 278 | subtype: "JavascriptQuery", 279 | resourceName: "JavascriptQuery", 280 | template: { 281 | queryRefreshTime: "", 282 | lastReceivedFromResourceAt: null, 283 | queryDisabledMessage: "", 284 | servedFromCache: false, 285 | offlineUserQueryInputs: "", 286 | successMessage: "", 287 | queryDisabled: "", 288 | playgroundQuerySaveId: "latest", 289 | workflowParams: null, 290 | resourceNameOverride: "", 291 | runWhenModelUpdates: false, 292 | workflowRunId: null, 293 | showFailureToaster: true, 294 | query: 295 | "if (startTrigger.data.type === \"create\") {\n executePathAtMostOnce('0')\n}\nelse if (startTrigger.data.type === \"read\") {\n executePathAtMostOnce('1')\n}\nelse if (startTrigger.data.type === \"update\") {\n executePathAtMostOnce('2')\n}\nelse if (startTrigger.data.type === \"destroy\") {\n executePathAtMostOnce('3')\n}\nelse { executePathAtMostOnce('4') }", 296 | playgroundQueryUuid: "", 297 | playgroundQueryId: null, 298 | error: null, 299 | workflowRunBodyType: "raw", 300 | privateParams: [], 301 | runWhenPageLoadsDelay: "", 302 | data: null, 303 | importedQueryInputs: {}, 304 | _additionalScope: [], 305 | isImported: false, 306 | showSuccessToaster: true, 307 | cacheKeyTtl: "", 308 | requestSentTimestamp: null, 309 | metadata: null, 310 | workflowActionType: null, 311 | editorMode: "sql", 312 | queryRunTime: null, 313 | changesetObject: "", 314 | errorTransformer: 315 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 316 | finished: null, 317 | confirmationMessage: null, 318 | isFetching: false, 319 | changeset: "", 320 | rawData: null, 321 | queryTriggerDelay: "0", 322 | watchedParams: [], 323 | enableErrorTransformer: false, 324 | showLatestVersionUpdatedWarning: false, 325 | timestamp: 0, 326 | evalType: "script", 327 | importedQueryDefaults: {}, 328 | enableTransformer: false, 329 | showUpdateSetValueDynamicallyToggle: true, 330 | runWhenPageLoads: false, 331 | transformer: 332 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 333 | events: [], 334 | queryTimeout: "10000", 335 | workflowId: null, 336 | requireConfirmation: false, 337 | queryFailureConditions: "", 338 | changesetIsObject: false, 339 | enableCaching: false, 340 | allowedGroups: [], 341 | workflowBlockPluginId: null, 342 | offlineQueryType: "None", 343 | queryThrottleTime: "750", 344 | updateSetValueDynamically: false, 345 | notificationDuration: "", 346 | }, 347 | }, 348 | }, 349 | { 350 | block: { 351 | pluginId: "readQuery", 352 | incomingOnSuccessPlugins: [], 353 | top: -240, 354 | left: 1040, 355 | environment: "production", 356 | editorType: "SqlQueryUnified", 357 | resourceName: "", 358 | blockType: "default", 359 | incomingPortsPlugins: [ 360 | { 361 | blockPluginId: "switchType", 362 | portId: "75cf13b9-ac7e-47b2-a48d-518ec1094659", 363 | }, 364 | ], 365 | }, 366 | pluginTemplate: { 367 | id: "readQuery", 368 | type: "datasource", 369 | subtype: "SqlQueryUnified", 370 | resourceName: null, 371 | template: { 372 | queryRefreshTime: "", 373 | records: "", 374 | lastReceivedFromResourceAt: null, 375 | databasePasswordOverride: "", 376 | queryDisabledMessage: "", 377 | servedFromCache: false, 378 | offlineUserQueryInputs: "", 379 | successMessage: "", 380 | queryDisabled: "", 381 | playgroundQuerySaveId: "latest", 382 | workflowParams: null, 383 | resourceNameOverride: "", 384 | runWhenModelUpdates: false, 385 | workflowRunId: null, 386 | showFailureToaster: true, 387 | query: "SELECT * from name_placeholder", 388 | playgroundQueryUuid: "", 389 | playgroundQueryId: null, 390 | error: null, 391 | workflowRunBodyType: "raw", 392 | privateParams: [], 393 | runWhenPageLoadsDelay: "", 394 | warningCodes: [], 395 | data: null, 396 | recordId: "", 397 | importedQueryInputs: {}, 398 | isImported: false, 399 | showSuccessToaster: true, 400 | dataArray: [], 401 | cacheKeyTtl: "", 402 | filterBy: "", 403 | requestSentTimestamp: null, 404 | databaseHostOverride: "", 405 | metadata: null, 406 | workflowActionType: null, 407 | editorMode: "sql", 408 | queryRunTime: null, 409 | actionType: "", 410 | changesetObject: "", 411 | shouldUseLegacySql: false, 412 | errorTransformer: 413 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 414 | finished: null, 415 | databaseNameOverride: "", 416 | confirmationMessage: null, 417 | isFetching: false, 418 | changeset: "", 419 | rawData: null, 420 | queryTriggerDelay: "0", 421 | resourceTypeOverride: "postgresql", 422 | watchedParams: [], 423 | enableErrorTransformer: false, 424 | enableBulkUpdates: false, 425 | showLatestVersionUpdatedWarning: false, 426 | timestamp: 0, 427 | evalType: "script", 428 | importedQueryDefaults: {}, 429 | enableTransformer: false, 430 | showUpdateSetValueDynamicallyToggle: true, 431 | bulkUpdatePrimaryKey: "", 432 | runWhenPageLoads: false, 433 | transformer: 434 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 435 | events: [], 436 | tableName: "name_placeholder", 437 | queryTimeout: "120000", 438 | workflowId: null, 439 | requireConfirmation: false, 440 | queryFailureConditions: "", 441 | changesetIsObject: false, 442 | enableCaching: false, 443 | allowedGroups: [], 444 | workflowBlockPluginId: null, 445 | databaseUsernameOverride: "", 446 | shouldEnableBatchQuerying: false, 447 | doNotThrowOnNoOp: false, 448 | offlineQueryType: "None", 449 | queryThrottleTime: "750", 450 | updateSetValueDynamically: false, 451 | notificationDuration: "", 452 | }, 453 | }, 454 | }, 455 | { 456 | block: { 457 | pluginId: "updateQuery", 458 | incomingOnSuccessPlugins: [], 459 | top: 144, 460 | left: 1056, 461 | environment: "production", 462 | editorType: "SqlQueryUnified", 463 | resourceName: "", 464 | blockType: "default", 465 | incomingPortsPlugins: [ 466 | { 467 | blockPluginId: "switchType", 468 | portId: "7d59ff82-a70b-4fe8-a6c8-3253898cf1a6", 469 | }, 470 | ], 471 | }, 472 | pluginTemplate: { 473 | id: "updateQuery", 474 | type: "datasource", 475 | subtype: "SqlQueryUnified", 476 | resourceName: null, 477 | template: { 478 | queryRefreshTime: "", 479 | records: "", 480 | lastReceivedFromResourceAt: null, 481 | databasePasswordOverride: "", 482 | queryDisabledMessage: "", 483 | servedFromCache: false, 484 | offlineUserQueryInputs: "", 485 | successMessage: "", 486 | queryDisabled: "", 487 | playgroundQuerySaveId: "latest", 488 | workflowParams: null, 489 | resourceNameOverride: "", 490 | runWhenModelUpdates: false, 491 | workflowRunId: null, 492 | showFailureToaster: true, 493 | query: "-- Delete query goes here", 494 | playgroundQueryUuid: "", 495 | playgroundQueryId: null, 496 | error: null, 497 | workflowRunBodyType: "raw", 498 | privateParams: [], 499 | runWhenPageLoadsDelay: "", 500 | warningCodes: [], 501 | data: null, 502 | recordId: "", 503 | importedQueryInputs: {}, 504 | isImported: false, 505 | showSuccessToaster: true, 506 | dataArray: [], 507 | cacheKeyTtl: "", 508 | filterBy: 509 | '[{"key":"id","value":"{{startTrigger.data.row}}","operation":"="}]', 510 | requestSentTimestamp: null, 511 | databaseHostOverride: "", 512 | metadata: null, 513 | workflowActionType: null, 514 | editorMode: "gui", 515 | queryRunTime: null, 516 | actionType: "UPDATE_BY", 517 | changesetObject: "{{startTrigger.data.data}}", 518 | shouldUseLegacySql: false, 519 | errorTransformer: 520 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 521 | finished: null, 522 | databaseNameOverride: "", 523 | confirmationMessage: null, 524 | isFetching: false, 525 | changeset: "", 526 | rawData: null, 527 | queryTriggerDelay: "0", 528 | resourceTypeOverride: "postgresql", 529 | watchedParams: [], 530 | enableErrorTransformer: false, 531 | enableBulkUpdates: false, 532 | showLatestVersionUpdatedWarning: false, 533 | timestamp: 0, 534 | evalType: "script", 535 | importedQueryDefaults: {}, 536 | enableTransformer: false, 537 | showUpdateSetValueDynamicallyToggle: true, 538 | bulkUpdatePrimaryKey: "", 539 | runWhenPageLoads: false, 540 | transformer: 541 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 542 | events: [], 543 | tableName: "name_placeholder", 544 | queryTimeout: "120000", 545 | workflowId: null, 546 | requireConfirmation: false, 547 | queryFailureConditions: "", 548 | changesetIsObject: true, 549 | enableCaching: false, 550 | allowedGroups: [], 551 | workflowBlockPluginId: null, 552 | databaseUsernameOverride: "", 553 | shouldEnableBatchQuerying: false, 554 | doNotThrowOnNoOp: false, 555 | offlineQueryType: "None", 556 | queryThrottleTime: "750", 557 | updateSetValueDynamically: false, 558 | notificationDuration: "", 559 | }, 560 | }, 561 | }, 562 | { 563 | block: { 564 | pluginId: "createReturn", 565 | incomingOnSuccessPlugins: ["createQuery"], 566 | top: -640, 567 | left: 1472, 568 | environment: "production", 569 | editorType: "JavascriptQuery", 570 | resourceName: "JavascriptQuery", 571 | blockType: "webhookReturn", 572 | options: { 573 | body: "{\n data: createQuery.data,\n error: createQuery.error\n }", 574 | status: "200", 575 | }, 576 | }, 577 | pluginTemplate: { 578 | id: "createReturn", 579 | type: "datasource", 580 | subtype: "JavascriptQuery", 581 | resourceName: "JavascriptQuery", 582 | template: { 583 | queryRefreshTime: "", 584 | lastReceivedFromResourceAt: null, 585 | queryDisabledMessage: "", 586 | servedFromCache: false, 587 | offlineUserQueryInputs: "", 588 | successMessage: "", 589 | queryDisabled: "", 590 | playgroundQuerySaveId: "latest", 591 | workflowParams: null, 592 | resourceNameOverride: "", 593 | runWhenModelUpdates: false, 594 | workflowRunId: null, 595 | showFailureToaster: true, 596 | query: 597 | "const generateReturn = () => {\n const status = () => {\n try {\n return 200\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n data: createQuery.data,\n error: createQuery.error\n }\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of createReturn'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 598 | playgroundQueryUuid: "", 599 | playgroundQueryId: null, 600 | error: null, 601 | workflowRunBodyType: "raw", 602 | privateParams: [], 603 | runWhenPageLoadsDelay: "", 604 | data: null, 605 | importedQueryInputs: {}, 606 | _additionalScope: [], 607 | isImported: false, 608 | showSuccessToaster: true, 609 | cacheKeyTtl: "", 610 | requestSentTimestamp: null, 611 | metadata: null, 612 | workflowActionType: null, 613 | editorMode: "sql", 614 | queryRunTime: null, 615 | changesetObject: "", 616 | errorTransformer: 617 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 618 | finished: null, 619 | confirmationMessage: null, 620 | isFetching: false, 621 | changeset: "", 622 | rawData: null, 623 | queryTriggerDelay: "0", 624 | watchedParams: [], 625 | enableErrorTransformer: false, 626 | showLatestVersionUpdatedWarning: false, 627 | timestamp: 0, 628 | evalType: "script", 629 | importedQueryDefaults: {}, 630 | enableTransformer: false, 631 | showUpdateSetValueDynamicallyToggle: true, 632 | runWhenPageLoads: false, 633 | transformer: 634 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 635 | events: [], 636 | queryTimeout: "10000", 637 | workflowId: null, 638 | requireConfirmation: false, 639 | queryFailureConditions: "", 640 | changesetIsObject: false, 641 | enableCaching: false, 642 | allowedGroups: [], 643 | workflowBlockPluginId: null, 644 | offlineQueryType: "None", 645 | queryThrottleTime: "750", 646 | updateSetValueDynamically: false, 647 | notificationDuration: "", 648 | }, 649 | }, 650 | }, 651 | { 652 | block: { 653 | pluginId: "readReturn", 654 | incomingOnSuccessPlugins: ["readQuery"], 655 | top: -240, 656 | left: 1488, 657 | environment: "production", 658 | editorType: "JavascriptQuery", 659 | resourceName: "JavascriptQuery", 660 | blockType: "webhookReturn", 661 | options: { 662 | body: "{\n data: readQuery.data,\n error: readQuery.error\n }", 663 | status: "200", 664 | }, 665 | }, 666 | pluginTemplate: { 667 | id: "readReturn", 668 | type: "datasource", 669 | subtype: "JavascriptQuery", 670 | resourceName: "JavascriptQuery", 671 | template: { 672 | queryRefreshTime: "", 673 | lastReceivedFromResourceAt: null, 674 | queryDisabledMessage: "", 675 | servedFromCache: false, 676 | offlineUserQueryInputs: "", 677 | successMessage: "", 678 | queryDisabled: "", 679 | playgroundQuerySaveId: "latest", 680 | workflowParams: null, 681 | resourceNameOverride: "", 682 | runWhenModelUpdates: false, 683 | workflowRunId: null, 684 | showFailureToaster: true, 685 | query: 686 | "const generateReturn = () => {\n const status = () => {\n try {\n return 200\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n data: readQuery.data,\n error: readQuery.error\n }\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of readReturn'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 687 | playgroundQueryUuid: "", 688 | playgroundQueryId: null, 689 | error: null, 690 | workflowRunBodyType: "raw", 691 | privateParams: [], 692 | runWhenPageLoadsDelay: "", 693 | data: null, 694 | importedQueryInputs: {}, 695 | _additionalScope: [], 696 | isImported: false, 697 | showSuccessToaster: true, 698 | cacheKeyTtl: "", 699 | requestSentTimestamp: null, 700 | metadata: null, 701 | workflowActionType: null, 702 | editorMode: "sql", 703 | queryRunTime: null, 704 | changesetObject: "", 705 | errorTransformer: 706 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 707 | finished: null, 708 | confirmationMessage: null, 709 | isFetching: false, 710 | changeset: "", 711 | rawData: null, 712 | queryTriggerDelay: "0", 713 | watchedParams: [], 714 | enableErrorTransformer: false, 715 | showLatestVersionUpdatedWarning: false, 716 | timestamp: 0, 717 | evalType: "script", 718 | importedQueryDefaults: {}, 719 | enableTransformer: false, 720 | showUpdateSetValueDynamicallyToggle: true, 721 | runWhenPageLoads: false, 722 | transformer: 723 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 724 | events: [], 725 | queryTimeout: "10000", 726 | workflowId: null, 727 | requireConfirmation: false, 728 | queryFailureConditions: "", 729 | changesetIsObject: false, 730 | enableCaching: false, 731 | allowedGroups: [], 732 | workflowBlockPluginId: null, 733 | offlineQueryType: "None", 734 | queryThrottleTime: "750", 735 | updateSetValueDynamically: false, 736 | notificationDuration: "", 737 | }, 738 | }, 739 | }, 740 | { 741 | block: { 742 | pluginId: "updateReturn", 743 | incomingOnSuccessPlugins: ["updateQuery"], 744 | top: 144, 745 | left: 1504, 746 | environment: "production", 747 | editorType: "JavascriptQuery", 748 | resourceName: "JavascriptQuery", 749 | blockType: "webhookReturn", 750 | options: { 751 | body: "{\n data: updateQuery.data,\n error: updateQuery.error\n }", 752 | status: "200", 753 | }, 754 | }, 755 | pluginTemplate: { 756 | id: "updateReturn", 757 | type: "datasource", 758 | subtype: "JavascriptQuery", 759 | resourceName: "JavascriptQuery", 760 | template: { 761 | queryRefreshTime: "", 762 | lastReceivedFromResourceAt: null, 763 | queryDisabledMessage: "", 764 | servedFromCache: false, 765 | offlineUserQueryInputs: "", 766 | successMessage: "", 767 | queryDisabled: "", 768 | playgroundQuerySaveId: "latest", 769 | workflowParams: null, 770 | resourceNameOverride: "", 771 | runWhenModelUpdates: false, 772 | workflowRunId: null, 773 | showFailureToaster: true, 774 | query: 775 | "const generateReturn = () => {\n const status = () => {\n try {\n return 200\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n data: updateQuery.data,\n error: updateQuery.error\n }\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of updateReturn'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 776 | playgroundQueryUuid: "", 777 | playgroundQueryId: null, 778 | error: null, 779 | workflowRunBodyType: "raw", 780 | privateParams: [], 781 | runWhenPageLoadsDelay: "", 782 | data: null, 783 | importedQueryInputs: {}, 784 | _additionalScope: [], 785 | isImported: false, 786 | showSuccessToaster: true, 787 | cacheKeyTtl: "", 788 | requestSentTimestamp: null, 789 | metadata: null, 790 | workflowActionType: null, 791 | editorMode: "sql", 792 | queryRunTime: null, 793 | changesetObject: "", 794 | errorTransformer: 795 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 796 | finished: null, 797 | confirmationMessage: null, 798 | isFetching: false, 799 | changeset: "", 800 | rawData: null, 801 | queryTriggerDelay: "0", 802 | watchedParams: [], 803 | enableErrorTransformer: false, 804 | showLatestVersionUpdatedWarning: false, 805 | timestamp: 0, 806 | evalType: "script", 807 | importedQueryDefaults: {}, 808 | enableTransformer: false, 809 | showUpdateSetValueDynamicallyToggle: true, 810 | runWhenPageLoads: false, 811 | transformer: 812 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 813 | events: [], 814 | queryTimeout: "10000", 815 | workflowId: null, 816 | requireConfirmation: false, 817 | queryFailureConditions: "", 818 | changesetIsObject: false, 819 | enableCaching: false, 820 | allowedGroups: [], 821 | workflowBlockPluginId: null, 822 | offlineQueryType: "None", 823 | queryThrottleTime: "750", 824 | updateSetValueDynamically: false, 825 | notificationDuration: "", 826 | }, 827 | }, 828 | }, 829 | { 830 | block: { 831 | pluginId: "invalidType", 832 | incomingOnSuccessPlugins: [], 833 | top: 880, 834 | left: 1056, 835 | environment: "production", 836 | editorType: "JavascriptQuery", 837 | resourceName: "JavascriptQuery", 838 | blockType: "webhookReturn", 839 | options: { 840 | body: '{\n success:false,\n mesage:"Invalid type in request body"\n}', 841 | status: "400", 842 | }, 843 | incomingPortsPlugins: [ 844 | { 845 | blockPluginId: "switchType", 846 | portId: "12676e07-8c10-4bb5-914a-98e6608a386e", 847 | }, 848 | ], 849 | }, 850 | pluginTemplate: { 851 | id: "invalidType", 852 | type: "datasource", 853 | subtype: "JavascriptQuery", 854 | resourceName: "JavascriptQuery", 855 | template: { 856 | queryRefreshTime: "", 857 | lastReceivedFromResourceAt: null, 858 | queryDisabledMessage: "", 859 | servedFromCache: false, 860 | offlineUserQueryInputs: "", 861 | successMessage: "", 862 | queryDisabled: "", 863 | playgroundQuerySaveId: "latest", 864 | workflowParams: null, 865 | resourceNameOverride: "", 866 | runWhenModelUpdates: false, 867 | workflowRunId: null, 868 | showFailureToaster: true, 869 | query: 870 | "const generateReturn = () => {\n const status = () => {\n try {\n return 400\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n success:false,\n mesage:\"Invalid type in request body\"\n}\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of missingTypeReturn'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 871 | playgroundQueryUuid: "", 872 | playgroundQueryId: null, 873 | error: null, 874 | workflowRunBodyType: "raw", 875 | privateParams: [], 876 | runWhenPageLoadsDelay: "", 877 | data: null, 878 | importedQueryInputs: {}, 879 | _additionalScope: [], 880 | isImported: false, 881 | showSuccessToaster: true, 882 | cacheKeyTtl: "", 883 | requestSentTimestamp: null, 884 | metadata: null, 885 | workflowActionType: null, 886 | editorMode: "sql", 887 | queryRunTime: null, 888 | changesetObject: "", 889 | errorTransformer: 890 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 891 | finished: null, 892 | confirmationMessage: null, 893 | isFetching: false, 894 | changeset: "", 895 | rawData: null, 896 | queryTriggerDelay: "0", 897 | watchedParams: [], 898 | enableErrorTransformer: false, 899 | showLatestVersionUpdatedWarning: false, 900 | timestamp: 0, 901 | evalType: "script", 902 | importedQueryDefaults: {}, 903 | enableTransformer: false, 904 | showUpdateSetValueDynamicallyToggle: true, 905 | runWhenPageLoads: false, 906 | transformer: 907 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 908 | events: [], 909 | queryTimeout: "10000", 910 | workflowId: null, 911 | requireConfirmation: false, 912 | queryFailureConditions: "", 913 | changesetIsObject: false, 914 | enableCaching: false, 915 | allowedGroups: [], 916 | workflowBlockPluginId: null, 917 | offlineQueryType: "None", 918 | queryThrottleTime: "750", 919 | updateSetValueDynamically: false, 920 | notificationDuration: "", 921 | }, 922 | }, 923 | }, 924 | { 925 | block: { 926 | pluginId: "destroyQuery", 927 | incomingOnSuccessPlugins: [], 928 | top: 512, 929 | left: 1056, 930 | environment: "production", 931 | editorType: "SqlQueryUnified", 932 | resourceName: "", 933 | blockType: "default", 934 | incomingPortsPlugins: [ 935 | { 936 | blockPluginId: "switchType", 937 | portId: "221b4d5d-fb16-4581-9b71-f95ef77c66d0", 938 | }, 939 | ], 940 | }, 941 | pluginTemplate: { 942 | id: "destroyQuery", 943 | type: "datasource", 944 | subtype: "SqlQueryUnified", 945 | resourceName: null, 946 | template: { 947 | queryRefreshTime: "", 948 | records: "", 949 | lastReceivedFromResourceAt: null, 950 | databasePasswordOverride: "", 951 | queryDisabledMessage: "", 952 | servedFromCache: false, 953 | offlineUserQueryInputs: "", 954 | successMessage: "", 955 | queryDisabled: "", 956 | playgroundQuerySaveId: "latest", 957 | workflowParams: null, 958 | resourceNameOverride: "", 959 | runWhenModelUpdates: false, 960 | workflowRunId: null, 961 | showFailureToaster: true, 962 | query: 963 | 'delete from name_placeholder where "id" = {{startTrigger.data.row}}', 964 | playgroundQueryUuid: "", 965 | playgroundQueryId: null, 966 | error: null, 967 | workflowRunBodyType: "raw", 968 | privateParams: [], 969 | runWhenPageLoadsDelay: "", 970 | warningCodes: [], 971 | data: null, 972 | recordId: "", 973 | importedQueryInputs: {}, 974 | isImported: false, 975 | showSuccessToaster: true, 976 | dataArray: [], 977 | cacheKeyTtl: "", 978 | filterBy: 979 | '[{"key":"id","value":"{{startTrigger.data.row}}","operation":"="}]', 980 | requestSentTimestamp: null, 981 | databaseHostOverride: "", 982 | metadata: null, 983 | workflowActionType: null, 984 | editorMode: "sql", 985 | queryRunTime: null, 986 | actionType: "DELETE_BY", 987 | changesetObject: "", 988 | shouldUseLegacySql: false, 989 | errorTransformer: 990 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 991 | finished: null, 992 | databaseNameOverride: "", 993 | confirmationMessage: null, 994 | isFetching: false, 995 | changeset: "", 996 | rawData: null, 997 | queryTriggerDelay: "0", 998 | resourceTypeOverride: "postgresql", 999 | watchedParams: [], 1000 | enableErrorTransformer: false, 1001 | enableBulkUpdates: false, 1002 | showLatestVersionUpdatedWarning: false, 1003 | timestamp: 0, 1004 | evalType: "script", 1005 | importedQueryDefaults: {}, 1006 | enableTransformer: false, 1007 | showUpdateSetValueDynamicallyToggle: true, 1008 | bulkUpdatePrimaryKey: "", 1009 | runWhenPageLoads: false, 1010 | transformer: 1011 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 1012 | events: [], 1013 | tableName: "name_placeholder", 1014 | queryTimeout: "120000", 1015 | workflowId: null, 1016 | requireConfirmation: false, 1017 | queryFailureConditions: "", 1018 | changesetIsObject: false, 1019 | enableCaching: false, 1020 | allowedGroups: [], 1021 | workflowBlockPluginId: null, 1022 | databaseUsernameOverride: "", 1023 | shouldEnableBatchQuerying: false, 1024 | doNotThrowOnNoOp: true, 1025 | offlineQueryType: "None", 1026 | queryThrottleTime: "750", 1027 | updateSetValueDynamically: false, 1028 | notificationDuration: "", 1029 | }, 1030 | }, 1031 | }, 1032 | { 1033 | block: { 1034 | pluginId: "destroyReturn", 1035 | incomingOnSuccessPlugins: ["destroyQuery"], 1036 | top: 512, 1037 | left: 1504, 1038 | environment: "production", 1039 | editorType: "JavascriptQuery", 1040 | resourceName: "JavascriptQuery", 1041 | blockType: "webhookReturn", 1042 | options: { 1043 | body: "{\n data: destroyQuery.data,\n error: destroyQuery.error\n }", 1044 | status: "200", 1045 | }, 1046 | }, 1047 | pluginTemplate: { 1048 | id: "destroyReturn", 1049 | type: "datasource", 1050 | subtype: "JavascriptQuery", 1051 | resourceName: "JavascriptQuery", 1052 | template: { 1053 | queryRefreshTime: "", 1054 | lastReceivedFromResourceAt: null, 1055 | queryDisabledMessage: "", 1056 | servedFromCache: false, 1057 | offlineUserQueryInputs: "", 1058 | successMessage: "", 1059 | queryDisabled: "", 1060 | playgroundQuerySaveId: "latest", 1061 | workflowParams: null, 1062 | resourceNameOverride: "", 1063 | runWhenModelUpdates: false, 1064 | workflowRunId: null, 1065 | showFailureToaster: true, 1066 | query: 1067 | "const generateReturn = () => {\n const status = () => {\n try {\n return 200\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n data: destroyQuery.data,\n error: destroyQuery.error\n }\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of destroyReturn'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 1068 | playgroundQueryUuid: "", 1069 | playgroundQueryId: null, 1070 | error: null, 1071 | workflowRunBodyType: "raw", 1072 | privateParams: [], 1073 | runWhenPageLoadsDelay: "", 1074 | data: null, 1075 | importedQueryInputs: {}, 1076 | _additionalScope: [], 1077 | isImported: false, 1078 | showSuccessToaster: true, 1079 | cacheKeyTtl: "", 1080 | requestSentTimestamp: null, 1081 | metadata: null, 1082 | workflowActionType: null, 1083 | editorMode: "sql", 1084 | queryRunTime: null, 1085 | changesetObject: "", 1086 | errorTransformer: 1087 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 1088 | finished: null, 1089 | confirmationMessage: null, 1090 | isFetching: false, 1091 | changeset: "", 1092 | rawData: null, 1093 | queryTriggerDelay: "0", 1094 | watchedParams: [], 1095 | enableErrorTransformer: false, 1096 | showLatestVersionUpdatedWarning: false, 1097 | timestamp: 0, 1098 | evalType: "script", 1099 | importedQueryDefaults: {}, 1100 | enableTransformer: false, 1101 | showUpdateSetValueDynamicallyToggle: true, 1102 | runWhenPageLoads: false, 1103 | transformer: 1104 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 1105 | events: [], 1106 | queryTimeout: "10000", 1107 | workflowId: null, 1108 | requireConfirmation: false, 1109 | queryFailureConditions: "", 1110 | changesetIsObject: false, 1111 | enableCaching: false, 1112 | allowedGroups: [], 1113 | workflowBlockPluginId: null, 1114 | offlineQueryType: "None", 1115 | queryThrottleTime: "750", 1116 | updateSetValueDynamically: false, 1117 | notificationDuration: "", 1118 | }, 1119 | }, 1120 | }, 1121 | { 1122 | block: { 1123 | pluginId: "filterBadRequest", 1124 | incomingOnSuccessPlugins: ["startTrigger"], 1125 | top: 0, 1126 | left: 48, 1127 | environment: "production", 1128 | editorType: "JavascriptQuery", 1129 | resourceName: "JavascriptQuery", 1130 | blockType: "conditional", 1131 | options: { 1132 | conditions: [ 1133 | { 1134 | id: "44af1908-d150-49f9-9f9b-340366665a96", 1135 | type: "if", 1136 | statement: "startTrigger.data?.type", 1137 | outgoingPortId: "c7a9d846-836e-4fbd-87af-3d76aa66715c", 1138 | }, 1139 | { 1140 | id: "fde78ed6-12ea-4d47-b0ff-31404aa98db2", 1141 | type: "else", 1142 | statement: "", 1143 | outgoingPortId: "861a427e-f1c4-4ba1-8532-52580fc61da9", 1144 | }, 1145 | ], 1146 | }, 1147 | outgoingPorts: [ 1148 | { 1149 | id: "c7a9d846-836e-4fbd-87af-3d76aa66715c", 1150 | name: "0", 1151 | type: "conditional", 1152 | }, 1153 | { 1154 | id: "861a427e-f1c4-4ba1-8532-52580fc61da9", 1155 | name: "1", 1156 | type: "conditional", 1157 | }, 1158 | ], 1159 | incomingPortsPlugins: [], 1160 | }, 1161 | pluginTemplate: { 1162 | id: "filterBadRequest", 1163 | type: "datasource", 1164 | subtype: "JavascriptQuery", 1165 | resourceName: "JavascriptQuery", 1166 | template: { 1167 | queryRefreshTime: "", 1168 | lastReceivedFromResourceAt: null, 1169 | queryDisabledMessage: "", 1170 | servedFromCache: false, 1171 | offlineUserQueryInputs: "", 1172 | successMessage: "", 1173 | queryDisabled: "", 1174 | playgroundQuerySaveId: "latest", 1175 | workflowParams: null, 1176 | resourceNameOverride: "", 1177 | runWhenModelUpdates: false, 1178 | workflowRunId: null, 1179 | showFailureToaster: true, 1180 | query: 1181 | "if (startTrigger.data?.type) {\n executePathAtMostOnce('0')\n}\nelse { executePathAtMostOnce('1') }", 1182 | playgroundQueryUuid: "", 1183 | playgroundQueryId: null, 1184 | error: null, 1185 | workflowRunBodyType: "raw", 1186 | privateParams: [], 1187 | runWhenPageLoadsDelay: "", 1188 | data: null, 1189 | importedQueryInputs: {}, 1190 | _additionalScope: [], 1191 | isImported: false, 1192 | showSuccessToaster: true, 1193 | cacheKeyTtl: "", 1194 | requestSentTimestamp: null, 1195 | metadata: null, 1196 | workflowActionType: null, 1197 | editorMode: "sql", 1198 | queryRunTime: null, 1199 | changesetObject: "", 1200 | errorTransformer: 1201 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 1202 | finished: null, 1203 | confirmationMessage: null, 1204 | isFetching: false, 1205 | changeset: "", 1206 | rawData: null, 1207 | queryTriggerDelay: "0", 1208 | watchedParams: [], 1209 | enableErrorTransformer: false, 1210 | showLatestVersionUpdatedWarning: false, 1211 | timestamp: 0, 1212 | evalType: "script", 1213 | importedQueryDefaults: {}, 1214 | enableTransformer: false, 1215 | showUpdateSetValueDynamicallyToggle: true, 1216 | runWhenPageLoads: false, 1217 | transformer: 1218 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 1219 | events: [], 1220 | queryTimeout: "10000", 1221 | workflowId: null, 1222 | requireConfirmation: false, 1223 | queryFailureConditions: "", 1224 | changesetIsObject: false, 1225 | enableCaching: false, 1226 | allowedGroups: [], 1227 | workflowBlockPluginId: null, 1228 | offlineQueryType: "None", 1229 | queryThrottleTime: "750", 1230 | updateSetValueDynamically: false, 1231 | notificationDuration: "", 1232 | }, 1233 | }, 1234 | }, 1235 | { 1236 | block: { 1237 | pluginId: "missingType", 1238 | incomingOnSuccessPlugins: [], 1239 | top: 512, 1240 | left: 512, 1241 | environment: "production", 1242 | editorType: "JavascriptQuery", 1243 | resourceName: "JavascriptQuery", 1244 | blockType: "webhookReturn", 1245 | options: { 1246 | body: '{\n success:false,\n mesage:"Missing type in request body"\n}', 1247 | status: "400", 1248 | }, 1249 | incomingPortsPlugins: [ 1250 | { 1251 | blockPluginId: "filterBadRequest", 1252 | portId: "861a427e-f1c4-4ba1-8532-52580fc61da9", 1253 | }, 1254 | ], 1255 | }, 1256 | pluginTemplate: { 1257 | id: "missingType", 1258 | type: "datasource", 1259 | subtype: "JavascriptQuery", 1260 | resourceName: "JavascriptQuery", 1261 | template: { 1262 | queryRefreshTime: "", 1263 | lastReceivedFromResourceAt: null, 1264 | queryDisabledMessage: "", 1265 | servedFromCache: false, 1266 | offlineUserQueryInputs: "", 1267 | successMessage: "", 1268 | queryDisabled: "", 1269 | playgroundQuerySaveId: "latest", 1270 | workflowParams: null, 1271 | resourceNameOverride: "", 1272 | runWhenModelUpdates: false, 1273 | workflowRunId: null, 1274 | showFailureToaster: true, 1275 | query: 1276 | "const generateReturn = () => {\n const status = () => {\n try {\n return 400\n } catch {\n return 200\n }\n }\n const body = () => {\n try {\n return {\n success:false,\n mesage:\"Missing type in request body\"\n}\n } catch {\n return {'error': true, 'messsage': 'there was a problem parsing the JSON body of missingType'}\n }\n }\n return {status: status(), body: body() }\n }\n return generateReturn()\n ", 1277 | playgroundQueryUuid: "", 1278 | playgroundQueryId: null, 1279 | error: null, 1280 | workflowRunBodyType: "raw", 1281 | privateParams: [], 1282 | runWhenPageLoadsDelay: "", 1283 | data: null, 1284 | importedQueryInputs: {}, 1285 | _additionalScope: [], 1286 | isImported: false, 1287 | showSuccessToaster: true, 1288 | cacheKeyTtl: "", 1289 | requestSentTimestamp: null, 1290 | metadata: null, 1291 | workflowActionType: null, 1292 | editorMode: "sql", 1293 | queryRunTime: null, 1294 | changesetObject: "", 1295 | errorTransformer: 1296 | "// The variable 'data' allows you to reference the request's data in the transformer. \n// example: return data.find(element => element.isError)\nreturn data.error", 1297 | finished: null, 1298 | confirmationMessage: null, 1299 | isFetching: false, 1300 | changeset: "", 1301 | rawData: null, 1302 | queryTriggerDelay: "0", 1303 | watchedParams: [], 1304 | enableErrorTransformer: false, 1305 | showLatestVersionUpdatedWarning: false, 1306 | timestamp: 0, 1307 | evalType: "script", 1308 | importedQueryDefaults: {}, 1309 | enableTransformer: false, 1310 | showUpdateSetValueDynamicallyToggle: true, 1311 | runWhenPageLoads: false, 1312 | transformer: 1313 | "// Query results are available as the `data` variable\nreturn formatDataAsArray(data)", 1314 | events: [], 1315 | queryTimeout: "10000", 1316 | workflowId: null, 1317 | requireConfirmation: false, 1318 | queryFailureConditions: "", 1319 | changesetIsObject: false, 1320 | enableCaching: false, 1321 | allowedGroups: [], 1322 | workflowBlockPluginId: null, 1323 | offlineQueryType: "None", 1324 | queryThrottleTime: "750", 1325 | updateSetValueDynamically: false, 1326 | notificationDuration: "", 1327 | }, 1328 | }, 1329 | }, 1330 | ]; 1331 | -------------------------------------------------------------------------------- /src/utils/apps.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | import { Credentials } from "./credentials"; 4 | import { getRequest, postRequest } from "./networking"; 5 | 6 | const fs = require("fs"); 7 | 8 | const axios = require("axios"); 9 | const inquirer = require("inquirer"); 10 | const ora = require("ora"); 11 | 12 | export type App = { 13 | uuid: string; 14 | name: string; 15 | folderId: number; 16 | id: string; 17 | protected: boolean; 18 | updatedAt: string; 19 | createdAt: string; 20 | isGlobalWidget: boolean; // is a module 21 | }; 22 | 23 | type Folder = { 24 | id: number; 25 | parentFolderId: number; 26 | name: string; 27 | systemFolder: boolean; 28 | createdAt: string; 29 | updatedAt: string; 30 | folderType: string; 31 | accessLevel: string; 32 | }; 33 | 34 | export async function createApp( 35 | appName: string, 36 | credentials: Credentials 37 | ): Promise { 38 | const spinner = ora("Creating App").start(); 39 | 40 | const createAppResult = await postRequest( 41 | `${credentials.origin}/api/pages/createPage`, 42 | { 43 | pageName: appName, 44 | isGlobalWidget: false, 45 | isMobileApp: false, 46 | multiScreenMobileApp: false, 47 | } 48 | ); 49 | spinner.stop(); 50 | 51 | const { page } = createAppResult.data; 52 | if (!page?.uuid) { 53 | console.log("Error creating app."); 54 | console.log(createAppResult.data); 55 | process.exit(1); 56 | } else { 57 | console.log("Successfully created an App. 🎉"); 58 | console.log( 59 | `${chalk.bold("View in browser:")} ${credentials.origin}/editor/${ 60 | page.uuid 61 | }` 62 | ); 63 | return page; 64 | } 65 | } 66 | 67 | export async function createAppForTable( 68 | appName: string, 69 | tableName: string, 70 | columnName: string, //The column to use for search bar. 71 | credentials: Credentials 72 | ) { 73 | const spinner = ora("Creating App").start(); 74 | 75 | const createAppResult = await postRequest( 76 | `${credentials.origin}/api/pages/autogeneratePage`, 77 | { 78 | appName, 79 | resourceName: credentials.retoolDBUuid, 80 | tableName, 81 | columnName, 82 | } 83 | ); 84 | spinner.stop(); 85 | 86 | const { pageUuid } = createAppResult.data; 87 | if (!pageUuid) { 88 | console.log("Error creating app."); 89 | console.log(createAppResult.data); 90 | process.exit(1); 91 | } else { 92 | console.log("Successfully created an App. 🎉"); 93 | console.log( 94 | `${chalk.bold("View in browser:")} ${ 95 | credentials.origin 96 | }/editor/${pageUuid}` 97 | ); 98 | } 99 | } 100 | 101 | export async function exportApp(appName: string, credentials: Credentials) { 102 | // Verify that the provided appName exists. 103 | const { apps } = await getAppsAndFolders(credentials); 104 | const app = apps?.filter((app) => { 105 | if (app.name === appName) { 106 | return app; 107 | } 108 | }); 109 | if (app?.length != 1) { 110 | console.log(`0 or >1 Apps named ${appName} found. 😓`); 111 | process.exit(1); 112 | } 113 | 114 | // Export the app. 115 | const spinner = ora("Exporting App").start(); 116 | const response = await axios.post( 117 | `${credentials.origin}/api/pages/uuids/${app[0].uuid}/export`, 118 | {}, 119 | { 120 | responseType: "stream", 121 | } 122 | ); 123 | 124 | // Write the response to a file. 125 | try { 126 | const filePath = `${appName}.json`; 127 | const writer = fs.createWriteStream(filePath); 128 | response.data.pipe(writer); 129 | } catch (error) { 130 | console.error("Error exporting app."); 131 | process.exit(1); 132 | } 133 | 134 | spinner.stop(); 135 | console.log(`Exported ${appName} app. 📦`); 136 | } 137 | 138 | export async function deleteApp( 139 | appName: string, 140 | credentials: Credentials, 141 | confirmDeletion: boolean 142 | ) { 143 | if (confirmDeletion) { 144 | const { confirm } = await inquirer.prompt([ 145 | { 146 | name: "confirm", 147 | message: `Are you sure you want to delete ${appName}?`, 148 | type: "confirm", 149 | }, 150 | ]); 151 | if (!confirm) { 152 | process.exit(0); 153 | } 154 | } 155 | 156 | // Verify that the provided appName exists. 157 | const { apps } = await getAppsAndFolders(credentials); 158 | const app = apps?.filter((app) => { 159 | if (app.name === appName) { 160 | return app; 161 | } 162 | }); 163 | if (app?.length != 1) { 164 | console.log(`0 or >1 Apps named ${appName} found. 😓`); 165 | process.exit(1); 166 | } 167 | 168 | // Delete the app. 169 | const spinner = ora("Deleting App").start(); 170 | await postRequest(`${credentials.origin}/api/folders/deletePage`, { 171 | pageId: app[0].id, 172 | }); 173 | spinner.stop(); 174 | 175 | console.log(`Deleted ${appName} app. 🗑️`); 176 | } 177 | 178 | // Fetch all apps (excluding apps in trash). 179 | export async function getAppsAndFolders( 180 | credentials: Credentials 181 | ): Promise<{ apps?: Array; folders?: Array }> { 182 | const spinner = ora(`Fetching all apps.`).start(); 183 | 184 | const fetchAppsResponse = await getRequest( 185 | `${credentials.origin}/api/pages?mobileAppsOnly=false` 186 | ); 187 | 188 | spinner.stop(); 189 | 190 | const apps: Array | undefined = fetchAppsResponse?.data?.pages; 191 | const folders: Array | undefined = fetchAppsResponse?.data?.folders; 192 | const trashFolderId = folders?.find( 193 | (folder) => folder.name === "archive" && folder.systemFolder === true 194 | )?.id; 195 | 196 | return { 197 | apps: apps?.filter((app) => app.folderId !== trashFolderId), 198 | folders: fetchAppsResponse?.data?.folders, 199 | }; 200 | } 201 | 202 | export async function collectAppName(): Promise { 203 | const { appName } = await inquirer.prompt([ 204 | { 205 | name: "appName", 206 | message: "App name?", 207 | type: "input", 208 | }, 209 | ]); 210 | 211 | if (appName.length === 0) { 212 | console.log("Error: App name cannot be blank."); 213 | process.exit(1); 214 | } 215 | 216 | // Remove spaces from app name. 217 | return appName.replace(/\s/g, "_"); 218 | } 219 | -------------------------------------------------------------------------------- /src/utils/connectionString.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionStringParser } from "connection-string-parser"; 2 | 3 | import { getCredentials } from "./credentials"; 4 | import { getRequest } from "./networking"; 5 | 6 | const chalk = require("chalk"); 7 | 8 | // Print a psql command to connect to the Retool DB. 9 | // Connnection string is never persisted, it's fetched when needed. 10 | // This is done to avoid storing the password in plaintext. 11 | export async function logConnectionStringDetails() { 12 | const connectionString = await getConnectionString(); 13 | if (connectionString) { 14 | const parsed = new ConnectionStringParser({ 15 | scheme: "postgresql", 16 | hosts: [], 17 | }).parse(connectionString); 18 | console.log( 19 | `${chalk.bold("Connect via psql:")} PGPASSWORD=${ 20 | parsed.password 21 | } psql -h ${parsed.hosts[0].host} -U ${parsed.username} ${ 22 | parsed.endpoint 23 | }` 24 | ); 25 | console.log( 26 | `${chalk.bold("Postgres Connection URL:")} ${connectionString}` 27 | ); 28 | } 29 | } 30 | 31 | async function getConnectionString(): Promise { 32 | const credentials = getCredentials(); 33 | if ( 34 | !credentials || 35 | !credentials.retoolDBUuid || 36 | !credentials.hasConnectionString 37 | ) { 38 | return; 39 | } 40 | const grid = await getRequest( 41 | `${credentials.origin}/api/grid/retooldb/${credentials.retoolDBUuid}?env=production`, 42 | false 43 | ); 44 | return grid.data?.gridInfo?.connectionString; 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/cookies.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | 3 | import { accessTokenFromCookies, xsrfTokenFromCookies } from "./cookies"; 4 | 5 | const cookies = [ 6 | "accessToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVPL8.eyJ4c3JmVG9rZW4iOiI0NWQ1ZmQ0MC00ODI5LTQ0ZmYtOWViYy1mYzk0ZGE1ZDkzN2MiLCJ2ZXJzaW9uIjoiMS4yIiwiaWF0IjoxNjkwOTEwODcwfQ.rjtgus6ml0D3wNG7QRZvSEtU0BDU5bGlJZvPc-PU-o0; Max-Age=604800; Path=/; Expires=Tue, 08 Aug 2023 17:27:50 GMT; HttpOnly; Secure; SameSite=None", 7 | "xsrfToken=45d5fd40-4829-44ff-9ebc-fc94da5d654w; Max-Age=604800; Path=/; Expires=Tue, 08 Aug 2023 17:27:50 GMT; Secure; SameSite=None", 8 | "xsrfTokenSameSite=45d5fd40-4829-44ff-9ebc-fc94da5d654w; Max-Age=604800; Path=/; Expires=Tue, 08 Aug 2023 17:27:50 GMT; HttpOnly; Secure; SameSite=Strict", 9 | ] 10 | 11 | describe("accessTokenFromCookies", () => { 12 | test("should return cookie from valid response", () => { 13 | expect(accessTokenFromCookies(cookies)).toBe("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVPL8.eyJ4c3JmVG9rZW4iOiI0NWQ1ZmQ0MC00ODI5LTQ0ZmYtOWViYy1mYzk0ZGE1ZDkzN2MiLCJ2ZXJzaW9uIjoiMS4yIiwiaWF0IjoxNjkwOTEwODcwfQ.rjtgus6ml0D3wNG7QRZvSEtU0BDU5bGlJZvPc-PU-o0"); 14 | }); 15 | 16 | test("should return undefined from invalid response", () => { 17 | expect(accessTokenFromCookies([])).toBe(undefined); 18 | }); 19 | }); 20 | 21 | describe ("xsrfTokenFromCookies", () => { 22 | test("should return cookie from valid response", () => { 23 | expect(xsrfTokenFromCookies(cookies)).toBe("45d5fd40-4829-44ff-9ebc-fc94da5d654w"); 24 | }); 25 | 26 | test("should return undefined from invalid response", () => { 27 | expect(xsrfTokenFromCookies([])).toBe(undefined); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | export function accessTokenFromCookies(cookies: string[]): string | undefined { 2 | for (const cookie of cookies) { 3 | // Matches everything between accessToken= and ; 4 | const matches = cookie.match(/accessToken=([^;]+)/); 5 | if (matches) { 6 | // The first match includes "accessToken=", so we want the second match. 7 | return matches[1]; 8 | } 9 | } 10 | } 11 | 12 | export function xsrfTokenFromCookies(cookies: string[]): string | undefined { 13 | for (const cookie of cookies) { 14 | // Matches everything between xsrfToken= and ; 15 | const matches = cookie.match(/xsrfToken=([^;]+)/); 16 | if (matches) { 17 | // The first match includes "xsrfToken=", so we want the second match. 18 | return matches[1]; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/credentials.ts: -------------------------------------------------------------------------------- 1 | import { input } from "@inquirer/prompts"; 2 | import { Entry } from "@napi-rs/keyring"; 3 | 4 | import { getRequest } from "./networking"; 5 | import { isAccessTokenValid, isOriginValid, isXsrfValid } from "./validation"; 6 | 7 | const axios = require("axios"); 8 | const ora = require("ora"); 9 | 10 | const RETOOL_CLI_SERVICE_NAME = "Retool CLI"; 11 | const RETOOL_CLI_ACCOUNT_NAME = "retool-cli-user"; 12 | 13 | /* 14 | * Credential management using keyring-rs. This is a cross-platform library 15 | * which uses the OS's native credential manager. 16 | * https://github.com/Brooooooklyn/keyring-node 17 | * https://github.com/hwchen/keyring-rs 18 | */ 19 | 20 | export type Credentials = { 21 | origin: string; // The 3 required properties are fetched during login. 22 | xsrf: string; 23 | accessToken: string; 24 | gridId?: string; // The next 3 properties are fetched the first time user interacts with RetoolDB. 25 | retoolDBUuid?: string; 26 | hasConnectionString?: boolean; 27 | firstName?: string; // The next 3 properties are sometimes fetched during login. 28 | lastName?: string; 29 | email?: string; 30 | telemetryEnabled: boolean; // The next 2 properties control telemetry. 31 | telemetryLastSent?: number; 32 | }; 33 | 34 | // A part of credentials that might be passed as the command line arguments. 35 | export type PartialCredentials = Partial> 36 | 37 | // Legacy way of getting credentials. 38 | export async function askForCookies({ origin, xsrf, accessToken }: PartialCredentials) { 39 | if (!origin) { 40 | origin = await input({ 41 | message: "What is your Retool origin? (e.g., https://my-org.retool.com).", 42 | }) 43 | } 44 | //Check if last character is a slash. If so, remove it. 45 | if (origin[origin.length - 1] === "/") { 46 | origin = origin.slice(0, -1); 47 | } 48 | if (!isOriginValid(origin)) { 49 | console.log("Error: Origin is invalid. Remember to include https://."); 50 | process.exit(1); 51 | } 52 | if (!xsrf) { 53 | xsrf = await input({ 54 | message: 55 | "What is your XSRF token? (e.g., 26725f72-8129-47f7-835a-cba0e5dbcfe6) \n Log into Retool, open cookies inspector.\n In Chrome, hit ⌘+⌥+I (Mac) or Ctrl+Shift+I (Windows, Linux) to open dev tools.\n Application tab > your-org.retool.com in Cookies menu > double click cookie value and copy it.", 56 | }); 57 | } 58 | if (!isXsrfValid(xsrf)) { 59 | console.log("Error: XSRF token is invalid."); 60 | process.exit(1); 61 | } 62 | if (!accessToken) { 63 | accessToken = await input({ 64 | message: `What is your access token? It's also found in the cookies inspector.`, 65 | }); 66 | } 67 | if (!isAccessTokenValid(accessToken)) { 68 | console.log("Error: Access token is invalid."); 69 | process.exit(1); 70 | } 71 | 72 | persistCredentials({ 73 | origin, 74 | xsrf, 75 | accessToken, 76 | telemetryEnabled: true, 77 | }); 78 | console.log("Successfully saved credentials."); 79 | } 80 | 81 | export function persistCredentials(credentials: Credentials) { 82 | const entry = new Entry(RETOOL_CLI_SERVICE_NAME, RETOOL_CLI_ACCOUNT_NAME); 83 | entry.setPassword(JSON.stringify(credentials)); 84 | } 85 | 86 | export function getCredentials(): Credentials | undefined { 87 | const entry = new Entry(RETOOL_CLI_SERVICE_NAME, RETOOL_CLI_ACCOUNT_NAME); 88 | const password = entry.getPassword(); 89 | if (password) { 90 | return JSON.parse(password); 91 | } 92 | } 93 | 94 | export function doCredentialsExist(): boolean { 95 | const entry = new Entry(RETOOL_CLI_SERVICE_NAME, RETOOL_CLI_ACCOUNT_NAME); 96 | const password = entry.getPassword(); 97 | if (password) { 98 | return true; 99 | } 100 | return false; 101 | } 102 | 103 | export function deleteCredentials() { 104 | const entry = new Entry(RETOOL_CLI_SERVICE_NAME, RETOOL_CLI_ACCOUNT_NAME); 105 | entry.deletePassword(); 106 | } 107 | 108 | // Fetch gridId and retoolDBUuid from Retool. Persist to keychain. 109 | async function fetchDBCredentials() { 110 | const credentials = getCredentials(); 111 | if (!credentials) { 112 | return; 113 | } 114 | 115 | // 1. Fetch all resources 116 | const resources = await getRequest(`${credentials.origin}/api/resources`); 117 | 118 | // 2. Filter down to Retool DB UUID 119 | const retoolDBs = resources?.data?.resources?.filter( 120 | (resource: any) => resource.displayName === "retool_db" 121 | ); 122 | if (retoolDBs?.length < 1) { 123 | console.log( 124 | `\nError: Retool DB not found. Create one at ${credentials.origin}/resources` 125 | ); 126 | return; 127 | } 128 | 129 | const retoolDBUuid = retoolDBs[0].name; 130 | 131 | // 3. Fetch Grid Info 132 | const grid = await getRequest( 133 | `${credentials.origin}/api/grid/retooldb/${retoolDBUuid}?env=production` 134 | ); 135 | persistCredentials({ 136 | ...credentials, 137 | retoolDBUuid, 138 | gridId: grid?.data?.gridInfo?.id, 139 | hasConnectionString: grid?.data?.gridInfo?.connectionString?.length > 0, 140 | }); 141 | } 142 | 143 | export async function getAndVerifyCredentialsWithRetoolDB() { 144 | const spinner = ora("Verifying Retool DB credentials").start(); 145 | let credentials = getCredentials(); 146 | if (!credentials) { 147 | spinner.stop(); 148 | console.log( 149 | `Error: No credentials found. To log in, run: \`retool login\`` 150 | ); 151 | process.exit(1); 152 | } 153 | axios.defaults.headers["x-xsrf-token"] = credentials.xsrf; 154 | axios.defaults.headers.cookie = `accessToken=${credentials.accessToken};`; 155 | if (!credentials.gridId || !credentials.retoolDBUuid) { 156 | await fetchDBCredentials(); 157 | credentials = getCredentials(); 158 | if (!credentials?.gridId || !credentials?.retoolDBUuid) { 159 | spinner.stop(); 160 | console.log(`Error: No Retool DB credentials found.`); 161 | process.exit(1); 162 | } 163 | } 164 | spinner.stop(); 165 | return credentials; 166 | } 167 | 168 | export async function getAndVerifyCredentials() { 169 | const spinner = ora("Verifying Retool credentials").start(); 170 | const credentials = getCredentials(); 171 | if (!credentials) { 172 | spinner.stop(); 173 | console.log( 174 | `Error: No credentials found. To log in, run: \`retool login\`` 175 | ); 176 | process.exit(1); 177 | } 178 | axios.defaults.headers["x-xsrf-token"] = credentials.xsrf; 179 | axios.defaults.headers.cookie = `accessToken=${credentials.accessToken};`; 180 | const verifyLoggedIn = await getRequest( 181 | `${credentials.origin}/api/user`, 182 | false 183 | ); 184 | spinner.stop(); 185 | if (!verifyLoggedIn) { 186 | console.log("\nError: Credentials are not valid. Please log in again."); 187 | process.exit(1); 188 | } 189 | return credentials; 190 | } 191 | -------------------------------------------------------------------------------- /src/utils/csv.ts: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const csvParser = require("csv-parser"); 4 | 5 | export type ParseResult = 6 | | { 7 | success: true; 8 | headers: string[]; 9 | rows: string[][]; 10 | } 11 | | { success: false; error: string }; 12 | 13 | export async function parseCSV(csvFile: string): Promise { 14 | return new Promise((resolve) => { 15 | const rows: string[][] = []; 16 | let headers: string[] = []; 17 | let firstRow = true; 18 | 19 | fs.createReadStream(csvFile) 20 | .pipe( 21 | csvParser({ 22 | skipEmptyLines: true, // Doesn't seem to work 23 | }) 24 | ) 25 | .on("error", (error: Error) => { 26 | resolve({ success: false, error: error.message }); 27 | }) 28 | .on("data", (row: any) => { 29 | if (Object.keys(row).length > 0) { 30 | if (firstRow) { 31 | headers = Object.keys(row); 32 | firstRow = false; 33 | } 34 | rows.push(Object.values(row)); 35 | } 36 | }) 37 | .on("end", () => { 38 | resolve({ success: true, headers, rows }); 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/date.ts: -------------------------------------------------------------------------------- 1 | import type { DateTimeFormatOptions } from "intl"; 2 | 3 | // 07/19/2023, 09:07:58 PM 4 | export const dateOptions: DateTimeFormatOptions = { 5 | month: "2-digit", 6 | day: "2-digit", 7 | year: "numeric", 8 | hour: "2-digit", 9 | minute: "2-digit", 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/faker.ts: -------------------------------------------------------------------------------- 1 | import { RetoolDBField } from "./table"; 2 | 3 | const inquirer = require("inquirer"); 4 | const TreePrompt = require("inquirer-tree-prompt"); 5 | 6 | // Generate `rowCount` rows for each field in `fields`. 7 | export async function generateData( 8 | fields: Array, 9 | rowCount: number, 10 | primaryKeyColumnName: string, 11 | primaryKeyMaxVal: number 12 | ): Promise<{ 13 | data: string[][]; // rows 14 | fields: string[]; // column names 15 | }> { 16 | const column_names = fields.map((field) => field.name); 17 | const rows: string[][] = []; 18 | // Init rows 19 | for (let j = 0; j < rowCount; j++) { 20 | rows.push([]); 21 | } 22 | 23 | for (let i = 0; i < fields.length; i++) { 24 | for (let j = 0; j < rowCount; j++) { 25 | // Handle primary key column. 26 | if (fields[i].name === primaryKeyColumnName) { 27 | rows[j].push((primaryKeyMaxVal + j + 1).toString()); 28 | } else { 29 | rows[j].push( 30 | await generateDataForColumnType(fields[i].generatedColumnType) 31 | ); 32 | } 33 | } 34 | } 35 | 36 | return { 37 | data: rows, 38 | fields: column_names, 39 | }; 40 | } 41 | 42 | // Each colType string is an option in `promptForDataType()` 43 | async function generateDataForColumnType( 44 | colType: string | undefined 45 | ): Promise { 46 | // Faker is slow, dynamically import it. 47 | return import("@faker-js/faker/locale/en_US").then(({ faker }) => { 48 | if (colType === "First Name") { 49 | return faker.person.firstName(); 50 | } else if (colType === "Last Name") { 51 | return faker.person.lastName(); 52 | } else if (colType === "Full Name" || colType === "Person") { 53 | return faker.person.fullName(); 54 | } else if (colType === "Phone Number") { 55 | return faker.phone.number(); 56 | } else if (colType === "Email") { 57 | return faker.internet.email(); 58 | } else if (colType === "Birthdate") { 59 | return faker.date.birthdate().toString(); 60 | } else if (colType === "Gender") { 61 | return faker.person.gender(); 62 | } else if (colType === "Street Address" || colType === "Location") { 63 | return faker.location.streetAddress(); 64 | } else if (colType === "City") { 65 | return faker.location.city(); 66 | } else if (colType === "State") { 67 | return faker.location.state(); 68 | } else if (colType === "Zip Code") { 69 | return faker.location.zipCode(); 70 | } else if (colType === "Country") { 71 | return faker.location.country(); 72 | } else if (colType === "Country Code") { 73 | return faker.location.countryCode(); 74 | } else if (colType === "Timezone") { 75 | return faker.location.timeZone(); 76 | } else if (colType === "Past" || colType === "Date") { 77 | return faker.date.past().toString(); 78 | } else if (colType === "Future") { 79 | return faker.date.future().toString(); 80 | } else if (colType === "Month") { 81 | return faker.date.month(); 82 | } else if (colType === "Weekday") { 83 | return faker.date.weekday(); 84 | } else if (colType === "Unix Timestamp") { 85 | return faker.date.past().getTime().toString(); 86 | } else if (colType === "Number" || colType === "Random") { 87 | return faker.number.int(10000).toString(); 88 | } else if (colType === "String") { 89 | return faker.string.alpha(5); 90 | } else if (colType === "Boolean") { 91 | return faker.datatype.boolean().toString(); 92 | } else if (colType === "Word") { 93 | return faker.word.words(1); 94 | } else if (colType === "Lorem Ipsum") { 95 | return faker.lorem.sentence(5); 96 | } else if (colType === "Bitcoin Address") { 97 | return faker.finance.bitcoinAddress(); 98 | } else { 99 | return faker.string.alpha(5); 100 | } 101 | }); 102 | } 103 | 104 | // Each option should have a corresponding case in `generateDataForColumnType()` 105 | export async function promptForDataType(fieldName: string): Promise { 106 | inquirer.registerPrompt("tree", TreePrompt); 107 | const { generatedType } = await inquirer.prompt([ 108 | { 109 | type: "tree", 110 | name: "generatedType", 111 | message: `What type of data to generate for ${fieldName}?`, 112 | tree: [ 113 | { 114 | name: "Person", 115 | children: [ 116 | "First Name", 117 | "Last Name", 118 | "Full Name", 119 | "Phone Number", 120 | "Email", 121 | "Birthdate", 122 | "Gender", 123 | ], 124 | }, 125 | { 126 | value: "Location", 127 | children: [ 128 | "Street Address", 129 | "City", 130 | "State", 131 | "Zip Code", 132 | "Country", 133 | "Country Code", 134 | "Timezone", 135 | ], 136 | }, 137 | { 138 | value: "Date", 139 | children: ["Past", "Future", "Month", "Weekday", "Unix Timestamp"], 140 | }, 141 | { 142 | value: "Random", 143 | children: [ 144 | "Number", 145 | "String", 146 | "Boolean", 147 | "Word", 148 | "Lorem Ipsum", 149 | "Bitcoin Address", 150 | ], 151 | }, 152 | ], 153 | }, 154 | ]); 155 | console.log(generatedType); 156 | return generatedType; 157 | } 158 | -------------------------------------------------------------------------------- /src/utils/fileSave.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as path from "path"; 3 | 4 | import * as tar from "tar"; 5 | 6 | const axios = require("axios"); 7 | const inquirer = require("inquirer"); 8 | 9 | // url should be a tarfile link like https://api.github.com/repos/tryretool/retool-examples/tarball/main 10 | // subfolderPath should be a path to a subfolder within the tarfile, e.g. hello_world/typescript 11 | // destinationPath should be a path to a folder where the contents of the github subfolder will be extracted 12 | export async function downloadGithubSubfolder( 13 | githubUrl: string, 14 | subfolderPath: string, 15 | destinationPath: string 16 | ) { 17 | try { 18 | const response = await axios.get(githubUrl, { 19 | responseType: "arraybuffer", 20 | }); 21 | 22 | // Temporary directory to hold the extracted repository 23 | const tempDirPath = "./temp"; 24 | 25 | // Delete the directory if it already exists 26 | if (fs.existsSync(destinationPath)) { 27 | const { proceedWithDirectoryCreation } = (await inquirer.prompt([ 28 | { 29 | name: "proceedWithDirectoryCreation", 30 | message: 31 | "It looks like this directory already exists, can we delete it and continue? (Y/N)", 32 | type: "input", 33 | }, 34 | ])) as { proceedWithDirectoryCreation: string }; 35 | if (proceedWithDirectoryCreation.toLowerCase() !== "y") { 36 | console.log("Aborting..."); 37 | process.exit(1); 38 | } 39 | await fs.promises.rm(destinationPath, { recursive: true }); 40 | } 41 | 42 | fs.mkdirSync(tempDirPath, { recursive: true }); 43 | fs.mkdirSync(destinationPath, { recursive: true }); 44 | 45 | const tarballPath = path.join(tempDirPath, "tarball.tar.gz"); 46 | fs.writeFileSync(tarballPath, response.data); 47 | 48 | await tar.x({ 49 | file: tarballPath, 50 | cwd: tempDirPath, 51 | strip: 1, // remove the top-level directories 52 | }); 53 | 54 | // Copy the specific subfolder to the destination path 55 | const sourceSubfolder = path.join(tempDirPath, subfolderPath); 56 | const destSubfolder = destinationPath; 57 | if (fs.existsSync(sourceSubfolder)) { 58 | fs.renameSync(sourceSubfolder, destSubfolder); 59 | } else { 60 | throw new Error( 61 | `Subfolder "${subfolderPath}" does not exist in the repository.` 62 | ); 63 | } 64 | 65 | fs.unlinkSync(tarballPath); 66 | fs.rm(tempDirPath, { recursive: true, force: true }, (err) => { 67 | if (err) { 68 | console.error("Error removing temporary directory:", err); 69 | } 70 | }); 71 | } catch (error: any) { 72 | console.error("Error:", error.message); 73 | } 74 | } 75 | 76 | export function saveEnvVariablesToFile( 77 | envVariables: Record, 78 | filePath: string 79 | ) { 80 | try { 81 | const envContent = Object.entries(envVariables) 82 | .map(([key, value]) => `${key}=${value}`) 83 | .join("\n"); 84 | 85 | fs.writeFileSync(filePath, envContent); 86 | } catch (error: any) { 87 | console.error("Error saving environment variables:", error.message); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/networking.ts: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | // Convenience function for making network requests. Error handling is centralized here. 4 | // If nothing is returned, the request failed and the process may exit. 5 | export async function postRequest( 6 | url: string, 7 | body: any, 8 | exitOnFailure = true, 9 | headers = {}, 10 | shouldHandleError = true 11 | ) { 12 | if (!shouldHandleError) { 13 | return await axios.post( 14 | url, 15 | { 16 | ...body, 17 | }, 18 | { 19 | headers: { 20 | ...headers, 21 | }, 22 | } 23 | ); 24 | } else { 25 | try { 26 | const response = await axios.post( 27 | url, 28 | { 29 | ...body, 30 | }, 31 | { 32 | headers: { 33 | ...headers, 34 | }, 35 | } 36 | ); 37 | return response; 38 | } catch (error: any) { 39 | handleError(error, exitOnFailure, url); 40 | } 41 | } 42 | } 43 | 44 | export async function getRequest(url: string, exitOnFailure = true, headers = {}) { 45 | try { 46 | const response = await axios.get(url, { headers }); 47 | return response; 48 | } catch (error: any) { 49 | handleError(error, exitOnFailure, url); 50 | } 51 | } 52 | 53 | export async function deleteRequest(url: string, exitOnFailure = true) { 54 | try { 55 | const response = await axios.delete(url); 56 | return response; 57 | } catch (error: any) { 58 | handleError(error, exitOnFailure, url); 59 | } 60 | } 61 | 62 | function handleError(error: any, exitOnFailure = true, url: string) { 63 | if (error.response) { 64 | // The request was made, but the server responded with a status code outside the 2xx range 65 | console.error("\n\nHTTP Request Error:", error.response.data); 66 | } else { 67 | console.error("\n\nNetwork error:", error.toJSON()); 68 | } 69 | if (process.env.DEBUG) { 70 | console.error(error); 71 | } 72 | console.error(`\nFailed to make request to ${url}.`); 73 | if (exitOnFailure) { 74 | process.exit(1); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/playgroundQuery.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from "./credentials"; 2 | import { postRequest } from "./networking"; 3 | 4 | export type PlaygroundQuery = { 5 | id: number; 6 | uuid: string; 7 | name: string; 8 | description: string; 9 | shared: boolean; 10 | createdAt: string; 11 | updatedAt: string; 12 | organizationId: number; 13 | ownerId: number; 14 | saveId: number; 15 | template: Record; 16 | resourceId: number; 17 | resourceUuid: string; 18 | adhocResourceType: string; 19 | }; 20 | 21 | export async function createPlaygroundQuery( 22 | resourceId: number, 23 | credentials: Credentials, 24 | queryName?: string 25 | ): Promise { 26 | const createPlaygroundQueryResult = await postRequest( 27 | `${credentials.origin}/api/playground`, 28 | { 29 | name: queryName || "CLI Generated RPC Query", 30 | description: "", 31 | shared: false, 32 | resourceId, 33 | data: { 34 | // Set default querytimeout to 10 seconds 35 | queryTimeout: "10000", 36 | }, 37 | } 38 | ); 39 | 40 | const { query } = createPlaygroundQueryResult.data; 41 | if (!query?.uuid) { 42 | console.log("Error creating playground query."); 43 | console.log(createPlaygroundQueryResult.data); 44 | process.exit(1); 45 | } else { 46 | return query; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/postgres.ts: -------------------------------------------------------------------------------- 1 | const { Client } = require("pg"); 2 | 3 | export type PostgresTable = { 4 | name: string; 5 | columns: string[]; 6 | data: string[][]; 7 | }; 8 | export type PostresData = PostgresTable[]; 9 | 10 | export async function getDataFromPostgres( 11 | connectionString: string 12 | ): Promise { 13 | // Create a new PostgreSQL client 14 | const client = new Client({ 15 | connectionString: connectionString, 16 | }); 17 | const output = []; 18 | 19 | try { 20 | // Connect to the PostgreSQL database 21 | await client.connect(); 22 | 23 | // Query to get all table names in the current schema 24 | const tableQuery = ` 25 | SELECT table_name 26 | FROM information_schema.tables 27 | WHERE table_schema = 'public' 28 | AND table_type = 'BASE TABLE'; 29 | `; 30 | 31 | // Fetch all table names 32 | const tableResult = await client.query(tableQuery); 33 | const tables = tableResult.rows.map((row: any) => row.table_name); 34 | 35 | // Loop through each table 36 | for (const tableName of tables) { 37 | const selectQuery = `SELECT * FROM ${tableName};`; 38 | 39 | // Fetch data from the table 40 | const dataResult = await client.query(selectQuery); 41 | const tableData = dataResult.rows; 42 | output.push({ 43 | name: tableName, 44 | columns: Object.keys(tableData[0]), 45 | data: tableData.map((row: any) => Object.values(row)), 46 | }); 47 | } 48 | 49 | // Disconnect from the database 50 | await client.end(); 51 | return output; 52 | } catch (error) { 53 | console.error("Error:", error); 54 | await client.end(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/utils/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WorkflowTemplateType, 3 | workflowTemplate, 4 | } from "../resources/workflowTemplate"; 5 | import { getCredentials } from "../utils/credentials"; 6 | 7 | /* 8 | * CAUTION: Puppeteer import takes ~90ms. Prefer to dynamically import this file. 9 | */ 10 | const puppeteer = require("puppeteer"); 11 | 12 | declare global { 13 | interface Window { 14 | generateWorkflowFromTemplateData: any; 15 | } 16 | } 17 | 18 | // https://stackoverflow.com/a/61304202 19 | const waitTillHTMLRendered = async (page: any, timeout = 30000) => { 20 | const checkDurationMsecs = 1000; 21 | const maxChecks = timeout / checkDurationMsecs; 22 | let lastHTMLSize = 0; 23 | let checkCounts = 1; 24 | let countStableSizeIterations = 0; 25 | const minStableSizeIterations = 2; 26 | 27 | while (checkCounts++ <= maxChecks) { 28 | const html = await page.content(); 29 | const currentHTMLSize = html.length; 30 | 31 | // Uncomment for debugging 32 | // const bodyHTMLSize = await page.evaluate( 33 | // () => document.body.innerHTML.length 34 | // ); 35 | // console.log( 36 | // "last: ", 37 | // lastHTMLSize, 38 | // " <> curr: ", 39 | // currentHTMLSize, 40 | // " body html size: ", 41 | // bodyHTMLSize 42 | // ); 43 | 44 | if (lastHTMLSize != 0 && currentHTMLSize == lastHTMLSize) 45 | countStableSizeIterations++; 46 | else countStableSizeIterations = 0; //reset the counter 47 | 48 | // Page rendered fully 49 | if (countStableSizeIterations >= minStableSizeIterations) { 50 | break; 51 | } 52 | 53 | lastHTMLSize = currentHTMLSize; 54 | await page.waitForTimeout(checkDurationMsecs); 55 | } 56 | }; 57 | 58 | export const generateWorkflowMetadata = async (tableName: string) => { 59 | const credentials = getCredentials(); 60 | if (!credentials) { 61 | return; 62 | } 63 | 64 | try { 65 | // Launch Puppeteer and navigate to subdomain.retool.com/workflows 66 | const browser = await puppeteer.launch({ 67 | headless: "new", 68 | // Uncomment this line to see the browser in action 69 | // headless: false, 70 | }); 71 | const page = await browser.newPage(); 72 | const domain = new URL(credentials.origin).hostname; 73 | const cookies = [ 74 | { 75 | domain, 76 | name: "accessToken", 77 | value: credentials.accessToken, 78 | }, 79 | { 80 | domain, 81 | name: "xsrfToken", 82 | value: credentials.xsrf, 83 | }, 84 | ]; 85 | await page.setCookie(...cookies); 86 | await page.goto(`${credentials.origin}/workflows`); 87 | await waitTillHTMLRendered(page); 88 | 89 | // Call window.generateWorkflowFromTemplateData() on the page 90 | const generatedWorkflowMetadata = await page.evaluate( 91 | ( 92 | tableName: string, 93 | workflowTemplate: WorkflowTemplateType, 94 | retoolDBUuid: string 95 | ) => { 96 | // Replaces instances of "name_placeholder" with the newly created table name 97 | workflowTemplate.map((item) => { 98 | if (item.pluginTemplate.template.tableName === "name_placeholder") { 99 | item.pluginTemplate.template.tableName = `\"${tableName}\"`; 100 | } 101 | if (item.pluginTemplate.template.query.includes("name_placeholder")) { 102 | item.pluginTemplate.template.query = 103 | item.pluginTemplate.template.query.replace( 104 | "name_placeholder", 105 | `\"${tableName}\"` 106 | ); 107 | } 108 | 109 | // Inject retool DB UUID 110 | if ( 111 | item.block.pluginId === "createQuery" || 112 | item.block.pluginId === "readQuery" || 113 | item.block.pluginId === "updateQuery" || 114 | item.block.pluginId === "destroyQuery" 115 | ) { 116 | item.block.resourceName = retoolDBUuid; 117 | item.pluginTemplate.resourceName = retoolDBUuid; 118 | } 119 | }); 120 | 121 | const payload = { 122 | name: `${tableName} CRUD Workflow`, 123 | templateData: workflowTemplate, 124 | resources: [], 125 | }; 126 | 127 | const workflow = window.generateWorkflowFromTemplateData(payload); 128 | return workflow; 129 | }, 130 | tableName, 131 | workflowTemplate, 132 | credentials.retoolDBUuid 133 | ); 134 | 135 | await browser.close(); 136 | return generatedWorkflowMetadata; 137 | } catch (error) { 138 | console.error("Error:", error); 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /src/utils/resources.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from "./credentials"; 2 | import { getRequest, postRequest } from "./networking"; 3 | 4 | export type ResourceByEnv = Record; 5 | 6 | export type Resource = { 7 | displayName: string; 8 | id: number; 9 | name: string; 10 | type: string; 11 | environment: string; 12 | environmentId: string; 13 | uuid: string; 14 | organizationId: number; 15 | resourceFolderId: number; 16 | }; 17 | 18 | export async function getResourceByName( 19 | resourceName: string, 20 | credentials: Credentials 21 | ): Promise { 22 | const getResourceResult = await getRequest( 23 | `${credentials.origin}/api/resources/names/${resourceName}` 24 | ); 25 | 26 | const { resourceByEnv } = getResourceResult.data; 27 | if (!resourceByEnv) { 28 | console.log("Error finding resource by that id."); 29 | console.log(getResourceResult.data); 30 | process.exit(1); 31 | } else { 32 | return resourceByEnv; 33 | } 34 | } 35 | 36 | export async function createResource({ 37 | resourceType, 38 | credentials, 39 | displayName, 40 | resourceFolderId, 41 | resourceOptions, 42 | }: { 43 | resourceType: string; 44 | credentials: Credentials; 45 | displayName?: string; 46 | resourceFolderId?: number; 47 | resourceOptions?: Record; 48 | }): Promise { 49 | const createResourceResult = await postRequest( 50 | `${credentials.origin}/api/resources/`, 51 | { 52 | type: resourceType, 53 | displayName, 54 | resourceFolderId, 55 | options: resourceOptions ? resourceOptions : {}, 56 | }, 57 | false, 58 | {}, 59 | false 60 | ); 61 | const resource = createResourceResult.data; 62 | if (!resource) { 63 | throw new Error("Error creating resource."); 64 | } else { 65 | return resource; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/table.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | 3 | import { parseDBData } from "./table"; 4 | 5 | describe("parseDBData", () => { 6 | test("should transform data into expected format", () => { 7 | const input = `["col_1","col_2","col_3"]\n["val_1","val_2","val_3"]`; 8 | const expectedOutput = [["col_1","col_2","col_3"],["val_1","val_2","val_3"]]; 9 | expect(parseDBData(input)).toStrictEqual(expectedOutput); 10 | }); 11 | 12 | test("should preserve brackets and \" in data", () => { 13 | const input = `["col[]_1","col"_2",""col_3]"]`; 14 | const expectedOutput = [["col[]_1",`col"_2`,`"col_3]`]]; 15 | expect(parseDBData(input)).toStrictEqual(expectedOutput); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/utils/table.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import ora from "ora"; 3 | import untildify from "untildify"; 4 | 5 | import { logConnectionStringDetails } from "./connectionString"; 6 | import { Credentials } from "./credentials"; 7 | import { getRequest, postRequest } from "./networking"; 8 | import { parseCSV } from "../utils/csv"; 9 | 10 | const fs = require("fs"); 11 | const path = require("path"); 12 | 13 | const inquirer = require("inquirer"); 14 | 15 | type Table = { 16 | name: string; 17 | }; 18 | 19 | type FieldMapping = Array<{ 20 | csvField: string; 21 | dbField: string | undefined; 22 | ignored: boolean; 23 | dbType?: string; 24 | }>; 25 | 26 | export type DBInfoPayload = { 27 | success: true; 28 | tableInfo: RetoolDBTableInfo; 29 | }; 30 | 31 | // This type is returned from Retool table/info API endpoint. 32 | export type RetoolDBTableInfo = { 33 | fields: Array; 34 | primaryKeyColumn: string; 35 | totalRowCount: number; 36 | }; 37 | 38 | // A "field" is a single Retool DB column. 39 | export type RetoolDBField = { 40 | name: string; 41 | type: any; //GridFieldType 42 | columnDefault: 43 | | { 44 | kind: "NoDefault"; 45 | } 46 | | { 47 | kind: "LiteralDefault"; 48 | value: string; 49 | } 50 | | { 51 | kind: "ExpressionDefault"; 52 | value: string; 53 | }; 54 | generatedColumnType: string | undefined; 55 | }; 56 | 57 | // Verify that the table exists in Retool DB, otherwise exit. 58 | export async function verifyTableExists( 59 | tableName: string, 60 | credentials: Credentials 61 | ) { 62 | const tables = await fetchAllTables(credentials); 63 | if (!tables?.map((table) => table.name).includes(tableName)) { 64 | console.log(`No table named ${tableName} found in Retool DB. 😓`); 65 | console.log(`Use \`retool db --list\` to list all tables.`); 66 | console.log(`Use \`retool db --create\` to create a new table.`); 67 | process.exit(1); 68 | } 69 | } 70 | 71 | // Fetches all existing tables from a Retool DB. 72 | export async function fetchAllTables( 73 | credentials: Credentials 74 | ): Promise | undefined> { 75 | const spinner = ora("Fetching tables from Retool DB").start(); 76 | const fetchDBsResponse = await getRequest( 77 | `${credentials.origin}/api/grid/retooldb/${credentials.retoolDBUuid}?env=production` 78 | ); 79 | spinner.stop(); 80 | 81 | if (fetchDBsResponse.data) { 82 | const { tables } = fetchDBsResponse.data.gridInfo; 83 | return tables; 84 | } 85 | } 86 | 87 | // Fetches the schema of a table from a Retool DB. Assumes the table exists. 88 | export async function fetchTableInfo( 89 | tableName: string, 90 | credentials: Credentials 91 | ): Promise { 92 | const spinner = ora(`Fetching ${tableName} metadata`).start(); 93 | const infoResponse = await getRequest( 94 | `${credentials.origin}/api/grid/${credentials.gridId}/table/${tableName}/info` 95 | ); 96 | spinner.stop(); 97 | 98 | const { tableInfo } = infoResponse.data; 99 | if (tableInfo) { 100 | return tableInfo; 101 | } 102 | } 103 | 104 | export async function deleteTable( 105 | tableName: string, 106 | credentials: Credentials, 107 | confirmDeletion: boolean 108 | ) { 109 | // Verify that the provided table name exists. 110 | await verifyTableExists(tableName, credentials); 111 | 112 | if (confirmDeletion) { 113 | const { confirm } = await inquirer.prompt([ 114 | { 115 | name: "confirm", 116 | message: `Are you sure you want to delete the ${tableName} table?`, 117 | type: "confirm", 118 | }, 119 | ]); 120 | if (!confirm) { 121 | process.exit(0); 122 | } 123 | } 124 | 125 | // Delete the table. 126 | const spinner = ora(`Deleting ${tableName}`).start(); 127 | await postRequest( 128 | `${credentials.origin}/api/grid/${credentials.gridId}/action`, 129 | { 130 | kind: "DeleteTable", 131 | payload: { 132 | table: tableName, 133 | }, 134 | } 135 | ); 136 | spinner.stop(); 137 | 138 | console.log(`Deleted ${tableName} table. 🗑️`); 139 | } 140 | 141 | export async function createTable( 142 | tableName: string, 143 | headers: string[], 144 | rows: string[][] | undefined, 145 | credentials: Credentials, 146 | printConnectionString: boolean 147 | ) { 148 | const spinner = ora("Uploading Table").start(); 149 | const fieldMapping: FieldMapping = headers.map((header) => ({ 150 | csvField: header, 151 | dbField: header, 152 | ignored: false, 153 | })); 154 | 155 | // See NewTable.tsx if implementing more complex logic. 156 | const payload = { 157 | kind: "CreateTable", 158 | payload: { 159 | name: tableName, 160 | fieldMapping, 161 | data: rows, 162 | allowSchemaEditOverride: true, 163 | primaryKey: { 164 | kind: headers.includes("id") ? "CustomColumn" : "IntegerAutoIncrement", 165 | name: "id", 166 | }, 167 | }, 168 | }; 169 | const createTableResult = await postRequest( 170 | `${credentials.origin}/api/grid/${credentials.gridId}/action`, 171 | { 172 | ...payload, 173 | } 174 | ); 175 | spinner.stop(); 176 | 177 | if (!createTableResult.data.success) { 178 | console.log("Error creating table in RetoolDB."); 179 | console.log(createTableResult.data); 180 | process.exit(1); 181 | } else { 182 | console.log( 183 | `Successfully created a table named ${tableName} in RetoolDB. 🎉` 184 | ); 185 | if (printConnectionString) { 186 | console.log(""); 187 | } 188 | console.log( 189 | `${chalk.bold("View in browser:")} ${credentials.origin}/resources/data/${ 190 | credentials.retoolDBUuid 191 | }/${tableName}?env=production` 192 | ); 193 | if (credentials.hasConnectionString && printConnectionString) { 194 | await logConnectionStringDetails(); 195 | } 196 | } 197 | } 198 | 199 | export async function createTableFromCSV( 200 | csvFilePath: string, 201 | credentials: Credentials, 202 | printConnectionString: boolean, 203 | promptForTableName: boolean 204 | ): Promise<{ 205 | tableName: string; 206 | colNames: string[]; 207 | }> { 208 | const filePath = untildify(csvFilePath); 209 | // Verify file exists, is a csv, and is < 18MB. 210 | if ( 211 | !fs.existsSync(filePath) || 212 | !filePath.endsWith(".csv") || 213 | fs.statSync(filePath).size > 18000000 214 | ) { 215 | console.log("The file does not exist, is not a CSV, or is > 18MB."); 216 | process.exit(1); 217 | } 218 | 219 | //Default to csv filename if no table name is provided. 220 | let tableName = path.basename(filePath).slice(0, -4); 221 | if (promptForTableName) { 222 | const { inputName } = await inquirer.prompt([ 223 | { 224 | name: "inputName", 225 | message: "Table name? If blank, defaults to CSV filename.", 226 | type: "input", 227 | }, 228 | ]); 229 | if (inputName.length > 0) { 230 | tableName = inputName; 231 | } 232 | } 233 | // Remove spaces from table name. 234 | tableName = tableName.replace(/\s/g, "_"); 235 | 236 | const spinner = ora("Parsing CSV").start(); 237 | const parseResult = await parseCSV(filePath); 238 | spinner.stop(); 239 | if (!parseResult.success) { 240 | console.log("Failed to parse CSV, error:"); 241 | console.error(parseResult.error); 242 | process.exit(1); 243 | } 244 | 245 | const { headers, rows } = parseResult; 246 | await createTable( 247 | tableName, 248 | headers, 249 | rows, 250 | credentials, 251 | printConnectionString 252 | ); 253 | return { 254 | tableName, 255 | colNames: headers, 256 | }; 257 | } 258 | 259 | // data param is in format: 260 | // ["col_1","col_2","col_3"] 261 | // ["val_1","val_2","val_3"] 262 | // transform to: 263 | // [["col_1","col_2","col_3"],["val_1","val_2","val_3"]] 264 | export function parseDBData(data: string): string[][] { 265 | try { 266 | const rows = data.trim().split("\n"); 267 | rows.forEach( 268 | (row, index, arr) => (arr[index] = row.slice(1, -1)) // Remove [] brackets. 269 | ); 270 | const parsedRows: string[][] = []; 271 | for (let i = 0; i < rows.length; i++) { 272 | const row = rows[i].split(","); 273 | row.forEach( 274 | (val, index, arr) => (arr[index] = val.slice(1, -1)) // Remove "". 275 | ); 276 | parsedRows.push(row); 277 | } 278 | return parsedRows; 279 | } catch (e) { 280 | console.log("Error parsing table data."); 281 | console.log(e); 282 | process.exit(1); 283 | } 284 | } 285 | 286 | export async function generateDataWithGPT( 287 | retoolDBInfo: DBInfoPayload, 288 | fields: RetoolDBField[], 289 | primaryKeyMaxVal: number, 290 | credentials: Credentials, 291 | exitOnFailure: boolean 292 | ): Promise< 293 | | { 294 | fields: string[]; 295 | data: string[][]; 296 | } 297 | | undefined 298 | > { 299 | const genDataRes: { 300 | data: { 301 | data: string[][]; 302 | }; 303 | } = await postRequest( 304 | `${credentials.origin}/api/grid/retooldb/generateData`, 305 | { 306 | fields: retoolDBInfo.tableInfo.fields.map((field) => { 307 | return { 308 | fieldName: field.name, 309 | fieldType: field.type, 310 | isPrimaryKey: field.name === retoolDBInfo.tableInfo.primaryKeyColumn, 311 | }; 312 | }), 313 | }, 314 | exitOnFailure 315 | ); 316 | 317 | const colNames = fields.map((field) => field.name); 318 | const generatedRows: string[][] = []; 319 | if (!genDataRes || colNames.length !== genDataRes?.data?.data[0]?.length) { 320 | if (exitOnFailure) { 321 | console.log("Error: GPT did not generate data with correct schema."); 322 | process.exit(1); 323 | } else { 324 | return; 325 | } 326 | } 327 | 328 | // GPT does not generate primary keys correctly. 329 | // Generate them manually by adding the max primary key value to row #. 330 | for (let i = 0; i < genDataRes.data.data.length; i++) { 331 | const row = genDataRes.data.data[i]; 332 | for (let j = 0; j < row.length; j++) { 333 | if (colNames[j] === retoolDBInfo.tableInfo.primaryKeyColumn) { 334 | row[j] = (primaryKeyMaxVal + i + 1).toString(); 335 | } 336 | } 337 | generatedRows.push(row); 338 | } 339 | return { 340 | fields: colNames, 341 | data: generatedRows, 342 | }; 343 | } 344 | 345 | export async function collectTableName(): Promise { 346 | const { tableName } = await inquirer.prompt([ 347 | { 348 | name: "tableName", 349 | message: "Table name?", 350 | type: "input", 351 | }, 352 | ]); 353 | 354 | if (tableName.length === 0) { 355 | console.log("Error: Table name cannot be blank."); 356 | process.exit(1); 357 | } 358 | 359 | // Remove spaces from table name. 360 | return tableName.replace(/\s/g, "_"); 361 | } 362 | 363 | export async function collectColumnNames(): Promise { 364 | const columnNames: string[] = []; 365 | let columnName = await collectColumnName(); 366 | while (columnName.length > 0) { 367 | columnNames.push(columnName); 368 | columnName = await collectColumnName(); 369 | } 370 | return columnNames; 371 | } 372 | 373 | async function collectColumnName(): Promise { 374 | const { columnName } = await inquirer.prompt([ 375 | { 376 | name: "columnName", 377 | message: "Column name? Leave blank to finish.", 378 | type: "input", 379 | }, 380 | ]); 381 | 382 | // Remove spaces from column name. 383 | return columnName.replace(/\s/g, "_"); 384 | } 385 | -------------------------------------------------------------------------------- /src/utils/telemetry.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, jest, test } from "@jest/globals"; 2 | import axios from "axios"; 3 | 4 | import { logDAU } from "./telemetry"; 5 | 6 | jest.mock("axios"); 7 | 8 | describe("telemetry", () => { 9 | test("should not log anything if telemetryEnabled is false ", async () => { 10 | const creds = { 11 | origin: "", 12 | xsrf: "", 13 | accessToken: "", 14 | telemetryEnabled: false, 15 | }; 16 | expect(await logDAU(creds, false)).toBe(false); 17 | }); 18 | 19 | test("should not log anything if telemetry was sent 1 minute ago ", async () => { 20 | const creds = { 21 | origin: "", 22 | xsrf: "", 23 | accessToken: "", 24 | telemetryEnabled: true, 25 | telemetryLastSent: Date.now() - 60 * 1000, 26 | }; 27 | expect(await logDAU(creds, false)).toBe(false); 28 | }); 29 | 30 | test("should not log anything if telemetry was sent 11 hours ago ", async () => { 31 | const creds = { 32 | origin: "", 33 | xsrf: "", 34 | accessToken: "", 35 | telemetryEnabled: true, 36 | telemetryLastSent: Date.now() - 11 * 60 * 60 * 1000, 37 | }; 38 | expect(await logDAU(creds, false)).toBe(false); 39 | }); 40 | 41 | test("should log telemetry if telemetryEnabled is true and telemetryLastSent is undefined", async () => { 42 | const creds = { 43 | origin: "", 44 | xsrf: "", 45 | accessToken: "", 46 | telemetryEnabled: true, 47 | }; 48 | // @ts-ignore 49 | axios.post = jest.fn().mockReturnValue({ status: 200 }); 50 | expect(await logDAU(creds, false)).toBe(true); 51 | }); 52 | 53 | test("should log telemetry if telemetryEnabled is true and telemetryLastSent is more than 12 hours ago", async () => { 54 | const creds = { 55 | origin: "", 56 | xsrf: "", 57 | accessToken: "", 58 | telemetryEnabled: true, 59 | telemetryLastSent: Date.now() - 13 * 60 * 60 * 1000, 60 | }; 61 | // @ts-ignore 62 | axios.post = jest.fn().mockReturnValue({ status: 200 }); 63 | expect(await logDAU(creds, false)).toBe(true); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/utils/telemetry.ts: -------------------------------------------------------------------------------- 1 | import { Credentials, getCredentials, persistCredentials } from "./credentials"; 2 | import { postRequest } from "./networking"; 3 | // @ts-ignore 4 | import { version } from "../../package.json"; 5 | 6 | export async function logDAU( 7 | credentials?: Credentials, 8 | persistCreds = true 9 | ): Promise { 10 | credentials = credentials || getCredentials(); 11 | const twelveHours = 12 * 60 * 60 * 1000; 12 | 13 | // Don't send telemetry if user has opted out or if we've already sent telemetry in the last 12 hours. 14 | if ( 15 | !credentials || 16 | credentials.telemetryEnabled !== true || 17 | (credentials.telemetryLastSent && 18 | Date.now() - credentials.telemetryLastSent < twelveHours) 19 | ) { 20 | return false; 21 | } 22 | 23 | const payload = { 24 | "CLI Version": version, 25 | email: credentials.email, 26 | origin: credentials.origin, 27 | os: process.platform, 28 | }; 29 | 30 | // Send a POST request to Retool's telemetry endpoint. 31 | const res = await postRequest(`https://p.retool.com/v2/p`, { 32 | event: "CLI DAU", 33 | properties: payload, 34 | }); 35 | 36 | if (res.status === 200) { 37 | // Update the last time we sent telemetry. 38 | credentials.telemetryLastSent = Date.now(); 39 | if (persistCreds) { 40 | persistCredentials(credentials); 41 | } 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/terraformGen.ts: -------------------------------------------------------------------------------- 1 | import { getRequest, postRequest } from "./networking"; 2 | 3 | const sanitizeUnicodeName = (name: string): string => { 4 | return name 5 | .normalize('NFKD') // Normalize Unicode 6 | .replace(/[^\w\s-]/g, '') // Remove non-alphanumeric characters 7 | .replace(/\s+/g, '_') // Replace spaces with underscores 8 | .toLowerCase(); // Convert to lowercase 9 | }; 10 | 11 | const API_URL_PREFIX = `${process.env.RETOOL_SCHEME ?? 'https'}://${process.env.RETOOL_HOST}/api/v2`; 12 | const AUTHORIZATION_HEADER = { Authorization: `Bearer ${process.env.RETOOL_ACCESS_TOKEN}` }; 13 | 14 | const FOLDER_TERRAFORM_IDS = new Set(); 15 | const GROUP_TERRAFORM_IDS = new Set(); 16 | const GROUP_ID_TO_TERRAFORM_ID = new Map(); 17 | const SPACE_TERRAFORM_IDS = new Set(); 18 | 19 | type TerraformResourceImportBase = { 20 | id: string; // The ID of the resource in the Retool database. Can be set to a dummy id for resoures that don't have an ID, like SSO settings. 21 | terraformId: string; 22 | } 23 | 24 | export type TerraformFolderImport = TerraformResourceImportBase & { 25 | resourceType: "retool_folder"; 26 | folder: APIFolder; 27 | } 28 | 29 | export type TerraformGroupImport = TerraformResourceImportBase & { 30 | resourceType: "retool_group"; 31 | group: APIGroup; 32 | } 33 | 34 | export type TerraformPermissionsImport = TerraformResourceImportBase & { 35 | resourceType: "retool_permissions"; 36 | groupId: string; 37 | } 38 | 39 | export type TerraformSSOImport = TerraformResourceImportBase & { 40 | resourceType: "retool_sso"; 41 | ssoConfig: APISSOConfig; 42 | } 43 | 44 | export type TerraformSourceControlImport = TerraformResourceImportBase & { 45 | resourceType: "retool_source_control"; 46 | sourceControlConfig: APISourceControlConfig; 47 | } 48 | 49 | export type TerraformSourceControlSettingsImport = TerraformResourceImportBase & { 50 | resourceType: "retool_source_control_settings"; 51 | settings: APISourceControlSettings; 52 | } 53 | 54 | export type TerraformSpaceImport = TerraformResourceImportBase & { 55 | resourceType: "retool_space"; 56 | space: APISpace; 57 | } 58 | 59 | // This type represents any imported terraform resource 60 | type TerraformResourceImport = 61 | | TerraformFolderImport 62 | | TerraformGroupImport 63 | | TerraformPermissionsImport 64 | | TerraformSSOImport 65 | | TerraformSourceControlImport 66 | | TerraformSourceControlSettingsImport 67 | | TerraformSpaceImport; 68 | 69 | // Ensure that the generated Terraform id is unique - if not, append a sequence number to it 70 | const makeUniqueTerraformId = (terraformId: string, existingIds: Set): string => { 71 | if (existingIds.has(terraformId)) { 72 | let seq = 1; 73 | while (existingIds.has(`${terraformId}_${seq}`)) { 74 | seq++; 75 | } 76 | terraformId = `${terraformId}_${seq}`; 77 | } 78 | existingIds.add(terraformId); 79 | return terraformId; 80 | } 81 | 82 | // Ensure the terraformId starts with a letter or underscore. We also want to avoid single character terraformIds. 83 | const isInvalidTerraformId = (terraformId: string): boolean => { 84 | return terraformId.length <= 1 || !/^[a-z_]/.test(terraformId); 85 | } 86 | 87 | const generateTerraformIdForFolder = (folderType: string, folderName: string): string => { 88 | let terraformId = sanitizeUnicodeName(folderName); 89 | if (isInvalidTerraformId(terraformId)) { 90 | terraformId = `${folderType}_folder_${terraformId}`; 91 | } 92 | return makeUniqueTerraformId(terraformId, FOLDER_TERRAFORM_IDS); 93 | } 94 | 95 | type APIFolder = { 96 | id: string 97 | name: string 98 | folder_type: string 99 | is_system_folder: boolean 100 | parent_folder_id: string 101 | }; 102 | 103 | // Read non-system folders from the Retool API and generate Terraform ids for them 104 | const importFolders = async function (): Promise { 105 | const response = await getRequest( 106 | `${API_URL_PREFIX}/folders`, 107 | false, 108 | AUTHORIZATION_HEADER 109 | ); 110 | const folders: APIFolder[] = response.data.data; 111 | return folders 112 | .sort((a, b) => a.id.localeCompare(b.id)) 113 | .filter((folder) => !folder.is_system_folder) 114 | .map((folder) => ({ 115 | id: folder.id, 116 | terraformId: generateTerraformIdForFolder(folder.folder_type, folder.name), 117 | resourceType: "retool_folder", 118 | folder 119 | })); 120 | } 121 | 122 | type APIGroup = { 123 | id: number 124 | name: string 125 | universal_app_access: string 126 | universal_resource_access: string 127 | universal_workflow_access: string 128 | universal_query_library_access: string 129 | user_list_access: boolean 130 | audit_log_access: boolean 131 | unpublished_release_access: boolean 132 | usage_analytics_access: boolean 133 | account_details_access: boolean 134 | landing_page_app_id: string 135 | }; 136 | 137 | const generateTerraformIdForGroup = (groupName: string, groupId: string): string => { 138 | let terraformId = sanitizeUnicodeName(groupName); 139 | if (isInvalidTerraformId(terraformId)) { 140 | terraformId = `group_${terraformId}`; 141 | } 142 | terraformId = makeUniqueTerraformId(terraformId, GROUP_TERRAFORM_IDS); 143 | GROUP_ID_TO_TERRAFORM_ID.set(groupId, terraformId); 144 | return terraformId; 145 | } 146 | 147 | const importGroups = async function (): Promise { 148 | const response = await getRequest( 149 | `${API_URL_PREFIX}/groups`, 150 | false, 151 | AUTHORIZATION_HEADER 152 | ); 153 | const groups: APIGroup[] = response.data.data; 154 | return groups 155 | .sort((a, b) => a.id - b.id) 156 | .filter((group) => !['admin', 'viewer', 'editor', 'All Users'].includes(group.name)) // filter out predefined groups 157 | .map((group) => ({ 158 | id: group.id.toString(), 159 | terraformId: generateTerraformIdForGroup(group.name, group.id.toString()), 160 | resourceType: "retool_group", 161 | group 162 | })); 163 | } 164 | 165 | const importPermissions = function (groupIds: string[]): TerraformResourceImport[] { 166 | // We'll just generate imports based on the groups we fetched earlier 167 | return groupIds 168 | .map((groupId) => ({ 169 | id: `group|${groupId}`, 170 | terraformId: `${GROUP_ID_TO_TERRAFORM_ID.get(groupId)}_permissions`, 171 | resourceType: "retool_permissions", 172 | groupId 173 | })); 174 | } 175 | 176 | type APISpace = { 177 | id: string 178 | name: string 179 | domain: string 180 | }; 181 | 182 | const generateTerraformIdForSpace = (spaceDomain: string): string => { 183 | // the unfortunate thing here is that users can put whatever they want in the domain field, so we have to guard against that 184 | let terraformId = spaceDomain 185 | .replace(/\./g, '_') 186 | .replace(/[^\w\s-]/g, '') // Remove non-alphanumeric characters 187 | .replace(/\s+/g, '_') // Replace spaces with underscores 188 | .toLowerCase(); // Convert to lowercase 189 | 190 | if (isInvalidTerraformId(terraformId)) { 191 | terraformId = `space_${terraformId}`; 192 | } 193 | return makeUniqueTerraformId(terraformId, SPACE_TERRAFORM_IDS); 194 | } 195 | 196 | const importSpaces = async function (): Promise { 197 | const response = await getRequest( 198 | `${API_URL_PREFIX}/spaces`, 199 | false, 200 | AUTHORIZATION_HEADER 201 | ); 202 | const spaces: APISpace[] = response.data.data; 203 | return spaces 204 | .sort((a, b) => a.id.localeCompare(b.id)) 205 | .map((space) => ({ 206 | id: space.id, 207 | terraformId: generateTerraformIdForSpace(space.domain), 208 | resourceType: "retool_space", 209 | space 210 | })); 211 | 212 | return []; 213 | } 214 | 215 | // Types below are generated based on the zod schema defined here: https://github.com/tryretool/retool_development/blob/c49c49d4bbab4972f7bf28e6348ec97dd2ff5b38/backend/src/server/publicApi/v2/sourceControl/schemas.ts#L161 216 | type CommonGitHubConfig = { 217 | url?: string; 218 | enterprise_api_url?: string; 219 | }; 220 | 221 | type GitHubAppConfig = CommonGitHubConfig & { 222 | type: 'App'; 223 | app_id: string; 224 | installation_id: string; 225 | private_key: string; 226 | }; 227 | 228 | type GitHubPersonalConfig = CommonGitHubConfig & { 229 | type: 'Personal'; 230 | personal_access_token: string; 231 | }; 232 | 233 | type GitHubConfig = GitHubAppConfig | GitHubPersonalConfig; 234 | 235 | type GitLabConfig = { 236 | project_id: number; 237 | url: string; 238 | project_access_token: string; 239 | }; 240 | 241 | type AWSCodeCommitConfig = { 242 | url: string; 243 | region: string; 244 | access_key_id: string; 245 | secret_access_key: string; 246 | https_username: string; 247 | https_password: string; 248 | }; 249 | 250 | type BitbucketConfig = { 251 | username: string; 252 | url?: string; 253 | enterprise_api_url?: string; 254 | app_password: string; 255 | }; 256 | 257 | type AzureReposConfig = { 258 | url: string; 259 | project: string; 260 | user: string; 261 | personal_access_token: string; 262 | use_basic_auth: boolean; 263 | }; 264 | 265 | type SourceControlBaseConfigurationExternal = { 266 | provider: string; 267 | org: string; 268 | repo: string; 269 | default_branch: string; 270 | repo_version?: string; 271 | }; 272 | 273 | type APISourceControlConfig = 274 | | (SourceControlBaseConfigurationExternal & { 275 | config: GitHubConfig; 276 | provider: 'GitHub'; 277 | }) 278 | | (SourceControlBaseConfigurationExternal & { 279 | config: GitLabConfig; 280 | provider: 'GitLab'; 281 | }) 282 | | (SourceControlBaseConfigurationExternal & { 283 | config: AWSCodeCommitConfig; 284 | provider: 'AWS CodeCommit'; 285 | }) 286 | | (SourceControlBaseConfigurationExternal & { 287 | config: BitbucketConfig; 288 | provider: 'Bitbucket'; 289 | }) 290 | | (SourceControlBaseConfigurationExternal & { 291 | config: AzureReposConfig; 292 | provider: 'Azure Repos'; 293 | }); 294 | 295 | const importSourceControl = async function (): Promise { 296 | const response = await getRequest( 297 | `${API_URL_PREFIX}/source_control/config`, 298 | false, 299 | AUTHORIZATION_HEADER 300 | ); 301 | if (!response) { 302 | return []; 303 | } 304 | 305 | return [{ 306 | id: "source_control", 307 | terraformId: "source_control", 308 | resourceType: "retool_source_control", 309 | sourceControlConfig: response.data.data, 310 | }]; 311 | } 312 | 313 | type APISourceControlSettings = { 314 | auto_branch_naming_enabled: boolean; 315 | custom_pull_request_template_enabled: boolean; 316 | custom_pull_request_template: string; 317 | version_control_locked: boolean; 318 | } 319 | 320 | const importSourceControlSettings = async function (): Promise { 321 | const response = await getRequest( 322 | `${API_URL_PREFIX}/source_control/settings`, 323 | false, 324 | AUTHORIZATION_HEADER 325 | ); 326 | if (!response) { 327 | return []; 328 | } 329 | 330 | return [{ 331 | id: "source_control_settings", 332 | terraformId: "source_control_settings", 333 | resourceType: "retool_source_control_settings", 334 | settings: response.data.data, 335 | }]; 336 | } 337 | 338 | // Based on the API schema here: https://github.com/tryretool/retool_development/blob/e3637e2a7471a3875a6c36b496a175c5925540f3/backend/src/server/publicApi/v2/sso/schemas.ts#L192 339 | type APISSOConfig = 340 | | { 341 | config_type: 'google' 342 | google_client_id: string 343 | google_client_secret: string 344 | disable_email_password_login: boolean 345 | jit_enabled: boolean 346 | restricted_domain?: string 347 | trigger_login_automatically: boolean 348 | } 349 | | { 350 | config_type: 'oidc' 351 | oidc_client_id: string 352 | oidc_client_secret: string 353 | oidc_scopes: string 354 | oidc_auth_url: string 355 | oidc_token_url: string 356 | oidc_userinfo_url?: string 357 | oidc_audience?: string 358 | jwt_email_key: string 359 | jwt_roles_key?: string 360 | jwt_first_name_key: string 361 | jwt_last_name_key: string 362 | roles_mapping?: string 363 | jit_enabled: boolean 364 | restricted_domain?: string 365 | trigger_login_automatically: boolean 366 | disable_email_password_login: boolean 367 | } 368 | | { 369 | config_type: 'google & oidc' 370 | google_client_id: string 371 | google_client_secret: string 372 | disable_email_password_login: boolean 373 | oidc_client_id: string 374 | oidc_client_secret: string 375 | oidc_scopes: string 376 | oidc_auth_url: string 377 | oidc_token_url: string 378 | oidc_userinfo_url?: string 379 | oidc_audience?: string 380 | jwt_email_key: string 381 | jwt_roles_key?: string 382 | jwt_first_name_key: string 383 | jwt_last_name_key: string 384 | roles_mapping?: string 385 | jit_enabled: boolean 386 | restricted_domain?: string 387 | trigger_login_automatically: boolean 388 | } 389 | | { 390 | config_type: 'saml' 391 | idp_metadata_xml: string 392 | saml_first_name_attribute: string 393 | saml_last_name_attribute: string 394 | saml_groups_attribute?: string 395 | saml_sync_group_claims: boolean 396 | ldap_sync_group_claims?: boolean 397 | ldap_role_mapping?: string 398 | ldap_server_url?: string 399 | ldap_base_domain_components?: string 400 | ldap_server_name?: string 401 | ldap_server_key?: string 402 | ldap_server_certificate?: string 403 | jit_enabled: boolean 404 | restricted_domain?: string 405 | trigger_login_automatically: boolean 406 | disable_email_password_login: boolean 407 | } 408 | | { 409 | config_type: 'google & saml' 410 | google_client_id: string 411 | google_client_secret: string 412 | disable_email_password_login: boolean 413 | idp_metadata_xml: string 414 | saml_first_name_attribute: string 415 | saml_last_name_attribute: string 416 | saml_groups_attribute?: string 417 | saml_sync_group_claims: boolean 418 | ldap_sync_group_claims?: boolean 419 | ldap_role_mapping?: string 420 | ldap_server_url?: string 421 | ldap_base_domain_components?: string 422 | ldap_server_name?: string 423 | ldap_server_key?: string 424 | ldap_server_certificate?: string 425 | jit_enabled: boolean 426 | restricted_domain?: string 427 | trigger_login_automatically: boolean 428 | } 429 | 430 | const importSSO = async function (): Promise { 431 | const response = await getRequest( 432 | `${API_URL_PREFIX}/sso/config`, 433 | false, 434 | AUTHORIZATION_HEADER 435 | ); 436 | if (!response) { 437 | return []; 438 | } 439 | 440 | return [{ 441 | id: "sso", 442 | terraformId: "sso", 443 | resourceType: "retool_sso", 444 | ssoConfig: response.data.data, 445 | }]; 446 | } 447 | 448 | export const importRetoolConfig = async function (): Promise { 449 | const imports: TerraformResourceImport[] = [] 450 | imports.push(...(await importFolders())); 451 | const groupImports = await importGroups(); 452 | imports.push(...groupImports); 453 | imports.push(...importPermissions(groupImports.map((group) => group.id))); 454 | imports.push(...(await importSpaces())); 455 | imports.push(...(await importSourceControl())); 456 | imports.push(...(await importSourceControlSettings())); 457 | imports.push(...(await importSSO())); 458 | return imports; 459 | } 460 | 461 | const getRootFolderIds = async function (): Promise> { 462 | const response = await getRequest( 463 | `${API_URL_PREFIX}/folders`, 464 | false, 465 | AUTHORIZATION_HEADER 466 | ); 467 | const folders: APIFolder[] = response.data.data; 468 | const rootFolderIdByType = new Map(); 469 | for (const folder of folders) { 470 | if (!folder.parent_folder_id) { 471 | rootFolderIdByType.set(folder.folder_type, folder.id); 472 | } 473 | } 474 | return rootFolderIdByType; 475 | } 476 | 477 | export const generateTerraformConfigForFolders = async function (folders: TerraformFolderImport[]): Promise { 478 | const folderIdToTerraformId = new Map(); 479 | for (const folder of folders) { 480 | // technically, we could've used the folder.id directly, since it includes folder type, but I don't want to depend on this 481 | folderIdToTerraformId.set(`${folder.folder.folder_type}_${folder.id}`, folder.terraformId); 482 | } 483 | 484 | const rootFolderIdByType = await getRootFolderIds(); 485 | let lines: string[] = []; 486 | for (const folder of folders) { 487 | const resourceConfigLines = [ 488 | `resource "retool_folder" "${folder.terraformId}" {`, 489 | ` name = "${folder.folder.name}"`, 490 | ` folder_type = "${folder.folder.folder_type}"`, 491 | ]; 492 | if (folder.folder.parent_folder_id) { 493 | if (folder.folder.parent_folder_id !== rootFolderIdByType.get(folder.folder.folder_type)) { 494 | const parentFolderTerraformId = folderIdToTerraformId.get(`${folder.folder.folder_type}_${folder.folder.parent_folder_id}`); 495 | resourceConfigLines.push(` parent_folder_id = retool_folder.${parentFolderTerraformId}.id`); 496 | } 497 | } 498 | resourceConfigLines.push("}"); 499 | resourceConfigLines.push(""); 500 | lines = lines.concat(resourceConfigLines); 501 | } 502 | return lines; 503 | } 504 | 505 | type APIPermissions = { 506 | type: string 507 | id: string 508 | access_level: string 509 | }; 510 | 511 | const getPermissionsForGroup = async function (groupId: string): Promise { 512 | let permissions: APIPermissions[] = []; 513 | for (const objectType of ["app", "folder", "resource", "resource_configuration"]){ 514 | const response = await postRequest( 515 | `${API_URL_PREFIX}/permissions/listObjects`, 516 | { 517 | subject: { 518 | type: "group", 519 | id: parseInt(groupId), 520 | }, 521 | object_type: objectType, 522 | }, 523 | false, 524 | AUTHORIZATION_HEADER 525 | ); 526 | if (!response || !response.data) { 527 | console.error(`Failed to fetch permissions for group ${groupId}, object type ${objectType}`); 528 | } else { 529 | permissions = permissions.concat(response.data.data); 530 | } 531 | } 532 | return permissions; 533 | } 534 | 535 | export const generateTerraformConfigForGroups = function (groups: TerraformGroupImport[]): string[] { 536 | let lines: string[] = []; 537 | for (const group of groups) { 538 | const resourceConfigLines = [ 539 | `resource "retool_group" "${group.terraformId}" {`, 540 | ` name = "${group.group.name}"`, 541 | ` universal_app_access = "${group.group.universal_app_access}"`, 542 | ` universal_resource_access = "${group.group.universal_resource_access}"`, 543 | ` universal_workflow_access = "${group.group.universal_workflow_access}"`, 544 | ` universal_query_library_access = "${group.group.universal_query_library_access}"`, 545 | ` user_list_access = ${group.group.user_list_access}`, 546 | ` audit_log_access = ${group.group.audit_log_access}`, 547 | ` unpublished_release_access = ${group.group.unpublished_release_access}`, 548 | ` usage_analytics_access = ${group.group.usage_analytics_access}`, 549 | ` account_details_access = ${group.group.account_details_access}`, 550 | ]; 551 | if (group.group.landing_page_app_id) { 552 | resourceConfigLines.push(` landing_page_app_id = "${group.group.landing_page_app_id}"`); 553 | } 554 | resourceConfigLines.push("}"); 555 | resourceConfigLines.push(""); 556 | lines = lines.concat(resourceConfigLines); 557 | } 558 | return lines; 559 | } 560 | 561 | export const generateTerraformConfigForPermissions = async function (permissions: TerraformPermissionsImport[], allResources: TerraformResourceImport[]): Promise { 562 | const folderIdToTerraformId = new Map(); 563 | const groupIdToTerraformId = new Map(); 564 | for (const resource of allResources) { 565 | if (resource.resourceType === "retool_folder") { 566 | folderIdToTerraformId.set(resource.id, resource.terraformId); 567 | } else if (resource.resourceType === "retool_group") { 568 | groupIdToTerraformId.set(resource.id, resource.terraformId); 569 | } 570 | } 571 | let lines: string[] = []; 572 | for (const permission of permissions) { 573 | const groupPermissions = await getPermissionsForGroup(permission.groupId); 574 | const resourceConfigLines = [ 575 | `resource "retool_permissions" "${permission.terraformId}" {`, 576 | ` subject = {`, 577 | ` type = "group"`, 578 | ` id = retool_group.${groupIdToTerraformId.get(permission.groupId)}.id`, 579 | ` }`, 580 | ` permissions = [`, 581 | ]; 582 | for (const groupPermission of groupPermissions) { 583 | resourceConfigLines.push(` {`); 584 | resourceConfigLines.push(` object = {`); 585 | if (groupPermission.type === "folder" && folderIdToTerraformId.has(groupPermission.id)) { 586 | resourceConfigLines.push(` type = "folder"`); 587 | resourceConfigLines.push(` id = retool_folder.${folderIdToTerraformId.get(groupPermission.id)}.id`); 588 | } else { 589 | resourceConfigLines.push(` type = "${groupPermission.type}"`); 590 | resourceConfigLines.push(` id = "${groupPermission.id}"`); 591 | } 592 | resourceConfigLines.push(` }`); 593 | resourceConfigLines.push(` access_level = "${groupPermission.access_level}"`); 594 | resourceConfigLines.push(` },`); 595 | } 596 | resourceConfigLines.push(" ]"); 597 | resourceConfigLines.push("}"); 598 | resourceConfigLines.push(""); 599 | lines = lines.concat(resourceConfigLines); 600 | } 601 | return lines; 602 | } 603 | 604 | // Parses a string like "key1->value1,key2->value2" into a map. 605 | // Copied from https://github.com/tryretool/retool_development/blob/76da02b9538884f27bc4499e742099163a6d3841/packages/common/utils/parseArrowSyntax.ts 606 | const parseArrowSyntax = function (arrowSyntax: string | unknown): { [key: string]: string } { 607 | if (typeof arrowSyntax !== 'string') { 608 | return {} 609 | } 610 | return arrowSyntax.split(',').reduce((acc: Record, arrowSyntaxSubstring) => { 611 | const splitted = arrowSyntaxSubstring.split('->') 612 | if (!splitted || !splitted[0] || !splitted[1]) { 613 | return acc 614 | } 615 | const key = splitted[0].trim() 616 | const value = splitted[1].trim() 617 | acc[key] = value 618 | return acc 619 | }, {}) 620 | } 621 | 622 | // Parses a string like "key1->value, key2-> value, key1->value2" into a map of string arrays. 623 | // input "b -> B, b -> C" will result in output {b: ["B", "C"]} 624 | // Copied from https://github.com/tryretool/retool_development/blob/76da02b9538884f27bc4499e742099163a6d3841/packages/common/utils/parseArrowSyntaxMultiValue.ts#L2-L3 625 | const parseArrowSyntaxMultiValue = function (arrowSyntax: string | unknown): { [key: string]: string[] } { 626 | const result: { [key: string]: string[] } = {} 627 | if (typeof arrowSyntax !== 'string') { 628 | return result 629 | } 630 | arrowSyntax.split(',').forEach((arrowSyntaxSubstring) => { 631 | const splits = arrowSyntaxSubstring.split('->') 632 | if (splits.length !== 2) return 633 | const [key, value] = [splits[0].trim(), splits[1].trim()] 634 | if (!key || !value) return 635 | if (!(key in result)) { 636 | result[key] = [] 637 | } 638 | // check for dup and push 639 | if (!result[key].includes(value)) { 640 | result[key].push(value) 641 | } 642 | }) 643 | return result 644 | } 645 | 646 | 647 | export const generateTerraformConfigForSSO = function (sso: TerraformSSOImport): string[] { 648 | const lines = [ 649 | `resource "retool_sso" "${sso.terraformId}" {`, 650 | ]; 651 | if (sso.ssoConfig.config_type === "google" || sso.ssoConfig.config_type === "google & oidc" || sso.ssoConfig.config_type === "google & saml") { 652 | lines.push(` google = {`); 653 | lines.push(` client_id = "${sso.ssoConfig.google_client_id}"`); 654 | lines.push(` client_secret = null # Replace with your client secret`); 655 | lines.push(` }`); 656 | } 657 | if (sso.ssoConfig.config_type === "oidc" || sso.ssoConfig.config_type === "google & oidc") { 658 | lines.push(` oidc = {`); 659 | lines.push(` client_id = "${sso.ssoConfig.oidc_client_id}"`); 660 | lines.push(` client_secret = null # Replace with your client secret`); 661 | lines.push(` scopes = "${sso.ssoConfig.oidc_scopes}"`); 662 | lines.push(` auth_url = "${sso.ssoConfig.oidc_auth_url}"`); 663 | lines.push(` token_url = "${sso.ssoConfig.oidc_token_url}"`); 664 | if (sso.ssoConfig.oidc_userinfo_url) { 665 | lines.push(` userinfo_url = "${sso.ssoConfig.oidc_userinfo_url}"`); 666 | } 667 | if (sso.ssoConfig.oidc_audience) { 668 | lines.push(` audience = "${sso.ssoConfig.oidc_audience}"`); 669 | } 670 | lines.push(` jwt_email_key = "${sso.ssoConfig.jwt_email_key}"`); 671 | if (sso.ssoConfig.jwt_roles_key) { 672 | lines.push(` jwt_roles_key = "${sso.ssoConfig.jwt_roles_key}"`); 673 | } 674 | lines.push(` jwt_first_name_key = "${sso.ssoConfig.jwt_first_name_key}"`); 675 | lines.push(` jwt_last_name_key = "${sso.ssoConfig.jwt_last_name_key}"`); 676 | if (sso.ssoConfig.roles_mapping) { 677 | // parse -> syntax into string-string map 678 | const rolesMapping = parseArrowSyntax(sso.ssoConfig.roles_mapping); 679 | lines.push(` roles_mapping = {`); 680 | for (const [key, value] of Object.entries(rolesMapping)) { 681 | lines.push(` ${key} = "${value}"`); 682 | } 683 | lines.push(` }`); 684 | } 685 | lines.push(` jit_enabled = ${sso.ssoConfig.jit_enabled}`); 686 | lines.push(` trigger_login_automatically = ${sso.ssoConfig.trigger_login_automatically}`); 687 | if (sso.ssoConfig.restricted_domain) { 688 | lines.push(` restricted_domains = [${sso.ssoConfig.restricted_domain.split(',').map((domain) => `"${domain.trim()}"`).join(", ")}]`); 689 | } 690 | lines.push(` }`); 691 | } 692 | if (sso.ssoConfig.config_type === "saml" || sso.ssoConfig.config_type === "google & saml") { 693 | lines.push(` saml = {`); 694 | lines.push(` idp_metadata_xml = "${sso.ssoConfig.idp_metadata_xml}"`); 695 | lines.push(` first_name_attribute = "${sso.ssoConfig.saml_first_name_attribute}"`); 696 | lines.push(` last_name_attribute = "${sso.ssoConfig.saml_last_name_attribute}"`); 697 | if (sso.ssoConfig.saml_groups_attribute) { 698 | lines.push(` groups_attribute = "${sso.ssoConfig.saml_groups_attribute}"`); 699 | } 700 | lines.push(` sync_group_claims = ${sso.ssoConfig.saml_sync_group_claims}`); 701 | if (sso.ssoConfig.ldap_role_mapping) { 702 | const rolesMapping = parseArrowSyntaxMultiValue(sso.ssoConfig.ldap_role_mapping); 703 | lines.push(` roles_mapping = {`); 704 | for (const [key, value] of Object.entries(rolesMapping)) { 705 | lines.push(` ${key} = [${value.map((v) => `"${v}"`).join(", ")}]`); 706 | } 707 | lines.push(` }`); 708 | } 709 | lines.push(` ldap_sync_group_claims = ${sso.ssoConfig.ldap_sync_group_claims}`); 710 | if (sso.ssoConfig.ldap_server_url || sso.ssoConfig.ldap_base_domain_components || sso.ssoConfig.ldap_server_name || sso.ssoConfig.ldap_server_key || sso.ssoConfig.ldap_server_certificate) { 711 | lines.push(` ldap = {`); 712 | if (sso.ssoConfig.ldap_server_url) { 713 | lines.push(` server_url = "${sso.ssoConfig.ldap_server_url}"`); 714 | } 715 | if (sso.ssoConfig.ldap_base_domain_components) { 716 | lines.push(` base_domain_components = "${sso.ssoConfig.ldap_base_domain_components}"`); 717 | } 718 | if (sso.ssoConfig.ldap_server_name) { 719 | lines.push(` server_name = "${sso.ssoConfig.ldap_server_name}"`); 720 | } 721 | if (sso.ssoConfig.ldap_server_key) { 722 | lines.push(` server_key = null # Replace with your server key`); 723 | } 724 | if (sso.ssoConfig.ldap_server_certificate) { 725 | lines.push(` server_certificate = null # Replace with your server certificate`); 726 | } 727 | lines.push(` }`); 728 | } 729 | lines.push(` jit_enabled = ${sso.ssoConfig.jit_enabled}`); 730 | lines.push(` trigger_login_automatically = ${sso.ssoConfig.trigger_login_automatically}`); 731 | if (sso.ssoConfig.restricted_domain) { 732 | lines.push(` restricted_domains = [${sso.ssoConfig.restricted_domain.split(',').map((domain) => `"${domain.trim()}"`).join(", ")}]`); 733 | } 734 | lines.push(` }`); 735 | } 736 | lines.push(` disable_email_password_login = ${sso.ssoConfig.disable_email_password_login}`); 737 | lines.push("}"); 738 | lines.push(""); 739 | return lines; 740 | } 741 | 742 | export const generateTerraformConfigForSourceControl = function (sourceControl: TerraformSourceControlImport): string[] { 743 | const lines = [ 744 | `resource "retool_source_control" "${sourceControl.terraformId}" {`, 745 | ]; 746 | lines.push(` org = "${sourceControl.sourceControlConfig.org}"`); 747 | lines.push(` repo = "${sourceControl.sourceControlConfig.repo}"`); 748 | lines.push(` default_branch = "${sourceControl.sourceControlConfig.default_branch}"`); 749 | if (sourceControl.sourceControlConfig.repo_version) { 750 | lines.push(` repo_version = "${sourceControl.sourceControlConfig.repo_version}"`); 751 | } 752 | if (sourceControl.sourceControlConfig.provider === "GitHub") { 753 | lines.push(` github = {`); 754 | const config = sourceControl.sourceControlConfig.config; 755 | if (config.type === "App") { 756 | lines.push(` app_authentication = {`); 757 | lines.push(` app_id = "${config.app_id}"`); 758 | lines.push(` installation_id = "${config.installation_id}"`); 759 | lines.push(` private_key = null # Replace with your private key`); 760 | lines.push(` }`); 761 | } else { 762 | lines.push(` personal_access_token = null # Replace with your personal access token`); 763 | } 764 | if (config.url) { 765 | lines.push(` url = "${config.url}"`); 766 | } 767 | if (config.enterprise_api_url) { 768 | lines.push(` enterprise_api_url = "${config.enterprise_api_url}"`); 769 | } 770 | lines.push(" }"); 771 | } else if (sourceControl.sourceControlConfig.provider === "GitLab") { 772 | lines.push(` gitlab = {`); 773 | const config = sourceControl.sourceControlConfig.config; 774 | lines.push(` project_id = ${config.project_id}`); 775 | lines.push(` project_access_token = null # Replace with your project access token`); 776 | lines.push(` url = "${config.url}"`); 777 | lines.push(" }"); 778 | } else if (sourceControl.sourceControlConfig.provider === "AWS CodeCommit") { 779 | lines.push(` aws_codecommit = {`); 780 | const config = sourceControl.sourceControlConfig.config; 781 | lines.push(` region = "${config.region}"`); 782 | lines.push(` access_key_id = null # Replace with your access key id`); 783 | lines.push(` secret_access_key = null # Replace with your secret access key`); 784 | lines.push(` https_username = "${config.https_username}"`); 785 | lines.push(` https_password = null # Replace with your https password`); 786 | lines.push(` url = "${config.url}"`); 787 | lines.push(" }"); 788 | } else if (sourceControl.sourceControlConfig.provider === "Bitbucket") { 789 | lines.push(` bitbucket = {`); 790 | const config = sourceControl.sourceControlConfig.config; 791 | lines.push(` username = "${config.username}"`); 792 | lines.push(` app_password = null # Replace with your app password`); 793 | lines.push(` url = "${config.url}"`); 794 | lines.push(` enterprise_api_url = "${config.enterprise_api_url}"`); 795 | lines.push(" }"); 796 | } else if (sourceControl.sourceControlConfig.provider === "Azure Repos") { 797 | lines.push(` azure_repos = {`); 798 | const config = sourceControl.sourceControlConfig.config; 799 | lines.push(` project = "${config.project}"`); 800 | lines.push(` user = "${config.user}"`); 801 | lines.push(` personal_access_token = null # Replace with your personal access token`); 802 | lines.push(` use_basic_auth = ${config.use_basic_auth}`); 803 | lines.push(` url = "${config.url}"`); 804 | lines.push(" }"); 805 | } 806 | lines.push("}"); 807 | lines.push(""); 808 | return lines; 809 | } 810 | 811 | export const generateTerraformConfigForSourceControlSettings = function (settings: TerraformSourceControlSettingsImport): string[] { 812 | const lines = [ 813 | `resource "retool_source_control_settings" "${settings.terraformId}" {`, 814 | ]; 815 | lines.push(` auto_branch_naming_enabled = ${settings.settings.auto_branch_naming_enabled}`); 816 | lines.push(` custom_pull_request_template_enabled = ${settings.settings.custom_pull_request_template_enabled}`); 817 | if (settings.settings.custom_pull_request_template) { 818 | lines.push(` custom_pull_request_template = "${settings.settings.custom_pull_request_template}"`); 819 | } 820 | lines.push(` version_control_locked = ${settings.settings.version_control_locked}`); 821 | lines.push("}"); 822 | lines.push(""); 823 | return lines; 824 | } 825 | 826 | export const generateTerraformConfigForSpaces = function (spaces: TerraformSpaceImport[]): string[] { 827 | const lines: string[] = [] 828 | for (const spaceResource of spaces) { 829 | lines.push(`resource "retool_space" "${spaceResource.terraformId}" {`); 830 | lines.push(` name = "${spaceResource.space.name}"`); 831 | lines.push(` domain = "${spaceResource.space.domain}"`); 832 | lines.push("}"); 833 | lines.push(""); 834 | } 835 | return lines; 836 | } 837 | -------------------------------------------------------------------------------- /src/utils/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | 3 | import { 4 | isAccessTokenValid, 5 | isEmailValid, 6 | isOriginValid, 7 | isXsrfValid, 8 | } from "./validation"; 9 | 10 | describe("isEmailValid", () => { 11 | test("should return true for valid email", () => { 12 | expect(isEmailValid("hacker@retool.com")).toBe(true); 13 | }); 14 | 15 | test("should return false for missing @", () => { 16 | expect(isEmailValid("hackerretool.com")).toBe(false); 17 | }); 18 | 19 | test("should return false for missing user name", () => { 20 | expect(isEmailValid("@retool.com")).toBe(false); 21 | }); 22 | 23 | test("should return false for missing domain name", () => { 24 | expect(isEmailValid("hacker@com")).toBe(false); 25 | }); 26 | 27 | test("should return false for missing domain", () => { 28 | expect(isEmailValid("hacker@retool")).toBe(false); 29 | }); 30 | }); 31 | 32 | describe("isAccessTokenValid", () => { 33 | test("should return true for valid access token", () => { 34 | expect( 35 | isAccessTokenValid( 36 | "eyJhbGciOiJIUzOOPiIsInR5cCI6IkpXVCJ9.eyJ4c3JmVG9rZW4iOiJjYzRjNmM3MC0wN2ZmLTQzNzktODI5ZS0wZDgyOWE1YjRiZTQiLCJ2ZXJzaW9uIjoiMS4yIiwiaWF0IjoxNjkwODIxMDUxfQ.-BjHNN9N9fDteokZmoIjdL0CbcZnkYKVCYwuzYugTzQ" 37 | ) 38 | ).toBe(true); 39 | }); 40 | 41 | test("should return false for invalid access token", () => { 42 | expect(isAccessTokenValid("asdasdasd")).toBe(false); 43 | }); 44 | }); 45 | 46 | describe("isOriginValid", () => { 47 | test("should return true for valid https origin", () => { 48 | expect(isOriginValid("https://subdomain.retool.com")).toBe(true); 49 | }); 50 | 51 | test("should return true for valid http origin", () => { 52 | expect(isOriginValid("http://subdomain.retool.com")).toBe(true); 53 | }); 54 | 55 | test("should return false for missing http://", () => { 56 | expect(isOriginValid("hacker.retool.com")).toBe(false); 57 | }); 58 | 59 | test("should return false for ending with /", () => { 60 | expect(isOriginValid("http://hacker.retool.com/")).toBe(false); 61 | }); 62 | }); 63 | 64 | describe("isXsrfValid", () => { 65 | test("should return true for valid xsrf", () => { 66 | expect(isXsrfValid("cc4c6c70-07ff-4379-829e-0d829a5b4be4")).toBe(true); 67 | }); 68 | 69 | test("should return false for invalid xsrf", () => { 70 | expect(isXsrfValid("asdasdasd")).toBe(false); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const emailRegex = 3 | /^[-!#$%&'*+\/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; 4 | /* eslint-enable */ 5 | 6 | //https://stackoverflow.com/questions/52456065/how-to-format-and-validate-email-node-js 7 | export function isEmailValid(email: string) { 8 | if (!email) return false; 9 | if (email.length > 254) return false; 10 | if (!emailRegex.test(email)) return false; 11 | 12 | // Further checking of some things regex can't handle 13 | const parts = email.split("@"); 14 | if (parts[0].length > 64) return false; 15 | 16 | const domainParts = parts[1].split("."); 17 | if ( 18 | domainParts.some(function (part) { 19 | return part.length > 63; 20 | }) 21 | ) 22 | return false; 23 | 24 | return true; 25 | } 26 | 27 | export function isOriginValid(origin: string) { 28 | // https://www.regextester.com/23 29 | const hostnameRegEx = 30 | /^(https?:\/\/)(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; 31 | if (origin.match(hostnameRegEx)) { 32 | return true; 33 | } 34 | return false; 35 | } 36 | 37 | export function isXsrfValid(xsrf: string) { 38 | // https://stackoverflow.com/questions/7905929/how-to-test-valid-uuid-guid 39 | const uuidRegEx = 40 | /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; 41 | if (xsrf.match(uuidRegEx)) { 42 | return true; 43 | } 44 | return false; 45 | } 46 | 47 | export function isAccessTokenValid(accessToken: string) { 48 | // https://stackoverflow.com/questions/61802832/regex-to-match-jwt 49 | const jwtRegEx = /^[\w-]+\.[\w-]+\.[\w-]+$/; 50 | if (accessToken.match(jwtRegEx)) { 51 | return true; 52 | } 53 | return false; 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/workflows.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from "./credentials"; 2 | import { deleteRequest, getRequest, postRequest } from "./networking"; 3 | 4 | const chalk = require("chalk"); 5 | const inquirer = require("inquirer"); 6 | const ora = require("ora"); 7 | 8 | export type Workflow = { 9 | id: string; //UUID 10 | name: string; 11 | folderId: number; 12 | isEnabled: boolean; 13 | protected: boolean; 14 | deployedBy: string; 15 | lastDeployedAt: string; 16 | }; 17 | 18 | type WorkflowFolder = { 19 | id: number; 20 | name: string; 21 | systemFolder: boolean; 22 | parentFolderId: number; 23 | createdAt: string; 24 | updatedAt: string; 25 | folderType: string; 26 | accessLevel: string; 27 | }; 28 | 29 | export async function getWorkflowsAndFolders( 30 | credentials: Credentials 31 | ): Promise<{ workflows?: Array; folders?: Array }> { 32 | const spinner = ora("Fetching Workflows").start(); 33 | const fetchWorkflowsResponse = await getRequest( 34 | `${credentials.origin}/api/workflow` 35 | ); 36 | spinner.stop(); 37 | 38 | return { 39 | workflows: fetchWorkflowsResponse?.data?.workflowsMetadata, 40 | folders: fetchWorkflowsResponse?.data?.workflowFolders, 41 | }; 42 | } 43 | 44 | export async function deleteWorkflow( 45 | workflowName: string, 46 | credentials: Credentials, 47 | confirmDeletion: boolean 48 | ) { 49 | if (confirmDeletion) { 50 | const { confirm } = await inquirer.prompt([ 51 | { 52 | name: "confirm", 53 | message: `Are you sure you want to delete ${workflowName}?`, 54 | type: "confirm", 55 | }, 56 | ]); 57 | if (!confirm) { 58 | process.exit(0); 59 | } 60 | } 61 | 62 | // Verify that the provided workflowName exists. 63 | const { workflows } = await getWorkflowsAndFolders(credentials); 64 | const workflow = workflows?.filter((workflow) => { 65 | if (workflow.name === workflowName) { 66 | return workflow; 67 | } 68 | }); 69 | if (workflow?.length != 1) { 70 | console.log(`0 or >1 Workflows named ${workflowName} found. 😓`); 71 | process.exit(1); 72 | } 73 | 74 | // Delete the Workflow. 75 | const spinner = ora(`Deleting ${workflowName}`).start(); 76 | await deleteRequest(`${credentials.origin}/api/workflow/${workflow[0].id}`); 77 | spinner.stop(); 78 | 79 | console.log(`Deleted ${workflowName}. 🗑️`); 80 | } 81 | 82 | // Generates a CRUD workflow for tableName from a template. 83 | export async function generateCRUDWorkflow( 84 | tableName: string, 85 | credentials: Credentials 86 | ) { 87 | let spinner = ora("Creating workflow").start(); 88 | 89 | // Generate workflow metadata via puppeteer. 90 | // Dynamic import b/c puppeteer is slow. 91 | const workflowMeta = await import("./puppeteer").then( 92 | async ({ generateWorkflowMetadata }) => { 93 | return await generateWorkflowMetadata(tableName); 94 | } 95 | ); 96 | const payload = { 97 | name: workflowMeta.name, 98 | crontab: workflowMeta.crontab, 99 | fromTemplate: true, 100 | templateData: workflowMeta.templateData, 101 | timezone: workflowMeta.timezone, 102 | triggerWebhooks: workflowMeta.triggerWebhooks, 103 | blockData: workflowMeta.blockData, 104 | }; 105 | 106 | // Create workflow. 107 | const workflow = await postRequest(`${credentials.origin}/api/workflow`, { 108 | ...payload, 109 | }); 110 | spinner.stop(); 111 | if (workflow.data.id) { 112 | console.log("Successfully created a workflow. 🎉"); 113 | console.log( 114 | `${chalk.bold("View in browser:")} ${credentials.origin}/workflows/${ 115 | workflow.data.id 116 | }` 117 | ); 118 | } else { 119 | console.log("Error creating workflow: "); 120 | console.log(workflow); 121 | return; 122 | } 123 | 124 | // Enable workflow. 125 | spinner = ora("Deploying workflow").start(); 126 | await postRequest(`${credentials.origin}/api/workflow/${workflow.data.id}`, { 127 | isEnabled: true, 128 | }); 129 | spinner.stop(); 130 | console.log("Successfully deployed a workflow. 🚀"); 131 | if (workflow.data.apiKey) { 132 | const curlCommand = `curl -X POST --url "https://api.retool.com/v1/workflows/${workflow.data.id}/startTrigger?workflowApiKey=${workflow.data.apiKey}" --data '{"type":"read"}' -H 'Content-Type: application/json'`; 133 | console.log( 134 | `Retool Cloud users can ${chalk.bold("cURL it:")} ${curlCommand}` 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es6", "es2015", "DOM"], 6 | "declaration": true, 7 | "outDir": "lib", 8 | "rootDir": "src", 9 | "strict": true, 10 | "types": ["node"], 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true 13 | } 14 | } 15 | --------------------------------------------------------------------------------