CLI to install project (#164)

* CLI to install project

* CLI fixes

* Update README.md

* Cleanup gitignore
This commit is contained in:
Félix Malfait
2023-06-01 09:19:49 +02:00
committed by GitHub
parent 7d87598953
commit e8f1146ae1
22 changed files with 6639 additions and 0 deletions

View File

@@ -36,6 +36,9 @@ It is meant to be:
- Perfectly in-sync with your data - Perfectly in-sync with your data
- Crafted with care and enjoyable to use - Crafted with care and enjoyable to use
# Quickstart
No need to clone the repo, just run `npx twenty-cli` in your terminal and follow the instructions.
# Progress # Progress
We are currently in the development phase of Twenty's alpha version: We are currently in the development phase of Twenty's alpha version:

26
cli/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js', 'codegen.js', '**/generated/*', '/lib/*'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

36
cli/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Dependency directories
node_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# other
lib/
.DS_Store

4
cli/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

2
cli/README.md Normal file
View File

@@ -0,0 +1,2 @@
# Twenty CLI
A simple CLI to get started with Twenty

8
cli/__tests__/config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { execShell } from '../src/config';
export {};
test('execShell runs a shell command', async () => {
let response = await execShell('echo "hello"');
expect(response).toEqual('hello\n');
});

5
cli/jest.config.cjs Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

5872
cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
cli/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "twenty-cli",
"version": "1.0.5",
"type": "module",
"description": "A simple CLI to install and interact with Twenty",
"files": [
"!lib/__tests__/**/*",
"lib/**/*",
"bin/**/*"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"@types/gradient-string": "^1.1.2",
"chalk": "^5.2.0",
"commander": "^10.0.1",
"dotenv": "^16.1.1",
"gradient-string": "^2.0.2",
"open": "^9.1.0",
"pg": "^8.11.0",
"prompts": "^2.4.2"
},
"bin": {
"twenty": "./lib/index.js"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^20.2.5",
"@types/pg": "^8.10.1",
"@types/prompts": "^2.4.4",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"typescript": "^5.0.4"
}
}

17
cli/src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { exec } from 'child_process';
export function execShell(cmd: string): Promise<string> {
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (error) {
console.warn(`Error: ${error.message}`);
console.warn(`stderr: ${stderr}`);
reject(error);
}
resolve(stdout ? stdout : stderr);
});
});
}
export const REPO_URL = 'https://github.com/twentyhq/twenty.git';
export const CLONE_DIR = 'twenty';

27
cli/src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env node
import { program } from 'commander';
import { showWelcomeScreen, firstQuestion } from './install/index.js';
import prompts from 'prompts';
import { askContributeQuestions } from './install/contribute/index.js';
import { askDemoQuestions } from './install/demo/index.js';
import { askSelfhostQuestions } from './install/selfhost/index.js';
program;
showWelcomeScreen();
(async () => {
const response = await prompts(firstQuestion);
switch (response.install_type) {
case 'contribute':
askContributeQuestions();
break;
case 'demo':
askDemoQuestions();
break;
case 'selfhost':
askSelfhostQuestions();
break;
}
})();

View File

@@ -0,0 +1,35 @@
import prompts, { PromptObject } from 'prompts';
import { askContributeLocalQuestions } from './local/index.js';
import { askContributeRemoteQuestions } from './remote.js';
export const contributeQuestions: PromptObject<string>[] = [
{
type: 'select',
name: 'contribute_type',
message: 'Where do you want to setup your development environment?',
choices: [
{
title: 'Local',
description: 'I want to setup a development environment on my machine',
value: 'local',
},
{
title: 'Remote (via Github Codespaces)',
description: 'A simple pre-configured remote environment',
value: 'remote',
},
],
},
];
export const askContributeQuestions: () => Promise<void> = async () => {
const response = await prompts(contributeQuestions);
switch (response.contribute_type) {
case 'local':
await askContributeLocalQuestions();
break;
case 'remote':
await askContributeRemoteQuestions();
break;
}
};

View File

@@ -0,0 +1,128 @@
import prompts, { PromptObject } from 'prompts';
import { spawn } from 'child_process';
import { execShell, REPO_URL } from '../../../config.js';
import { join } from 'path';
import * as fs from 'fs';
export const dockerQuestions: PromptObject<string>[] = [
{
type: 'text',
name: 'folder_name',
initial: 'twenty',
message: 'Name of folder where we will clone the repo?',
},
];
export const askDockerQuestions: () => Promise<void> = async () => {
let folderResponse = await prompts(dockerQuestions);
let folderExists = fs.existsSync(folderResponse.folder_name);
while (folderExists) {
try {
folderResponse = await prompts({
type: 'text',
name: 'folder_name',
message:
'Folder already exists. Please choose another name and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
folderExists = fs.existsSync(folderResponse.folder_name);
}
let git_is_installed = false;
while (!git_is_installed) {
try {
await execShell('git --version');
git_is_installed = true;
} catch (error) {
try {
await prompts({
type: 'text',
name: 'git_install',
message:
'Git does not appear to be installed. Please install it and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
let docker_is_installed = false;
while (!docker_is_installed) {
try {
await execShell('docker --version');
docker_is_installed = true;
} catch (error) {
try {
await prompts({
type: 'text',
name: 'docker_install',
message:
'Docker does not appear to be installed. Please install it and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
let docker_daemon_running = false;
while (!docker_daemon_running) {
try {
await execShell('docker info');
docker_daemon_running = true;
} catch (error) {
try {
await prompts({
type: 'text',
name: 'docker_install',
message:
'Docker daemon does not appear to be running. Please start it manually and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
console.log('Cloning the Twenty repo. This can take a little while.');
await execShell(`git clone ${REPO_URL} ${folderResponse.folder_name}`);
console.log('Build the docker images. (cd infra/dev then make build)');
const makeBuild = spawn('make', ['build'], {
cwd: join(folderResponse.folder_name, 'infra', 'dev'),
});
makeBuild.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
makeBuild.stderr.on('data', (data) => {
console.log(`stderr: ${data}`);
});
makeBuild.on('error', (error) => {
console.log(`error: ${error.message}`);
});
makeBuild.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
};

View File

@@ -0,0 +1,35 @@
import prompts, { PromptObject } from 'prompts';
import { askDockerQuestions } from './docker.js';
import { askNoDockerQuestions } from './no-docker.js';
export const contributeLocalQuestions: PromptObject<string>[] = [
{
type: 'select',
name: 'local_setup_type',
message: 'What is your prefered setup?',
choices: [
{
title: 'Docker',
description: 'A managed development environment with Postgres included',
value: 'docker',
},
{
title: 'Without docker',
description: "You'll need to setup a Postgres instance on your own",
value: 'no-docker',
},
],
},
];
export const askContributeLocalQuestions: () => Promise<void> = async () => {
const response = await prompts(contributeLocalQuestions);
switch (response.local_setup_type) {
case 'docker':
await askDockerQuestions();
break;
case 'no-docker':
await askNoDockerQuestions();
break;
}
};

View File

@@ -0,0 +1,213 @@
import prompts, { PromptObject } from 'prompts';
import { spawn } from 'child_process';
import { execShell, REPO_URL } from '../../../config.js';
import { join } from 'path';
import * as fs from 'fs';
import * as path from 'path';
import pkg from 'pg';
const { Client } = pkg;
export const noDockerQuestion1: PromptObject<string>[] = [
{
type: 'text',
name: 'folder_name',
initial: 'twenty',
message: 'Name of folder where we will clone the repo?',
},
];
export const noDockerQuestion2: PromptObject<string>[] = [
{
type: 'text',
name: 'postgres_string',
initial: 'postgres://postgres:postgrespassword@postgres:5432/default',
message:
'Since you are not using Docker, you need to bring your own database, please enter your postgres connection string.',
},
];
export const askNoDockerQuestions: () => Promise<void> = async () => {
let folderResponse = await prompts(noDockerQuestion1);
let folderExists = fs.existsSync(folderResponse.folder_name);
while (folderExists) {
try {
folderResponse = await prompts({
type: 'text',
name: 'folder_name',
message:
'Folder already exists. Please choose another name and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
folderExists = fs.existsSync(folderResponse.folder_name);
}
let connectionStringResponse = await prompts(noDockerQuestion2);
let postgres_connection_valid = false;
while (!postgres_connection_valid) {
const client = new Client({
connectionString: connectionStringResponse.postgres_string,
});
try {
await client.connect();
await client.end();
postgres_connection_valid = true;
} catch (error) {
console.log(error);
postgres_connection_valid = false;
}
if (!postgres_connection_valid) {
try {
connectionStringResponse = await prompts({
type: 'text',
name: 'postgres_string',
initial: 'postgres://postgres:postgrespassword@postgres:5432/default',
message:
'Connection to Postgres failed. Please enter the string again',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
let git_is_installed = false;
while (!git_is_installed) {
try {
await execShell('git --version');
git_is_installed = true;
} catch (error) {
try {
await prompts({
type: 'text',
name: 'git_install',
message:
'Git does not appear to be installed. Please install it and restart.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
let npm_is_installed = false;
while (!npm_is_installed) {
try {
await execShell('npm --version');
npm_is_installed = true;
} catch (error) {
try {
await prompts({
type: 'text',
name: 'git_install',
message:
'Npm does not appear to be installed. Please install it and press enter.',
});
} catch (error) {
if ((error as NodeJS.Signals) === 'SIGINT') {
process.exit(0);
} else {
throw error;
}
}
}
}
console.log('Cloning the Twenty repo. This can take a little while.');
await execShell(`git clone ${REPO_URL} ${folderResponse.folder_name}`);
await execShell(
`cp ${folderResponse.folder_name}/front/.env.example ${folderResponse.folder_name}/front/.env`,
);
await execShell(
`cp ${folderResponse.folder_name}/server/.env.example ${folderResponse.folder_name}/server/.env`,
);
const envFile = path.resolve(
join(folderResponse.folder_name, 'server', '.env'),
);
let envFileLines = fs.readFileSync(envFile, 'utf-8').split('\n');
envFileLines = envFileLines.map((line) =>
line.startsWith('PG_DATABASE_URL=')
? `PG_DATABASE_URL=${connectionStringResponse.postgres_string}`
: line,
);
// write the updated content back to the .env file
fs.writeFileSync(envFile, envFileLines.join('\n'));
console.log('Building the frontend (running npm install on frontend folder)');
const buildFront = spawn('npm', ['install'], {
cwd: join(folderResponse.folder_name, 'front'),
});
buildFront.stdout.on('data', (data) => {
console.log(`${data}`);
});
buildFront.stderr.on('data', (data) => {
console.log(`${data}`);
});
buildFront.on('error', (error) => {
console.log(`error: ${error.message}`);
});
buildFront.on('close', () => {
console.log('Building the server (running npm install on server folder)');
const buildServer = spawn('npm', ['install'], {
cwd: join(folderResponse.folder_name, 'server'),
});
buildServer.stdout.on('data', (data) => {
console.log(`${data}`);
});
buildServer.stderr.on('data', (data) => {
console.log(`${data}`);
});
buildServer.on('error', (error) => {
console.log(`error: ${error.message}`);
});
buildServer.on('close', () => {
console.log(
'Running the frontend (running npm start on frontend folder)',
);
const runFrontend = spawn('npm', ['run', 'start'], {
cwd: join(folderResponse.folder_name, 'server'),
});
runFrontend.stdout.on('data', (data) => {
console.log(`${data}`);
});
runFrontend.stderr.on('data', (data) => {
console.log(`${data}`);
});
runFrontend.on('error', (error) => {
console.log(`error: ${error.message}`);
});
console.log('Running the server (running npm start on server folder)');
const runServer = spawn('npm', ['run', 'start'], {
cwd: join(folderResponse.folder_name, 'server'),
});
runServer.stdout.on('data', (data) => {
console.log(`${data}`);
});
runServer.stderr.on('data', (data) => {
console.log(`${data}`);
});
runServer.on('error', (error) => {
console.log(`error: ${error.message}`);
});
});
});
};

View File

@@ -0,0 +1,16 @@
import prompts, { PromptObject } from 'prompts';
import open from 'open';
export const contributeRemoteQuestions: PromptObject<string>[] = [
{
type: 'text',
name: 'local_setup_type',
message:
"We'll be redirecting you to a dedicated Github Codespace. Press enter to continue.",
},
];
export const askContributeRemoteQuestions: () => Promise<void> = async () => {
await prompts(contributeRemoteQuestions);
await open('https://codespaces.new/twentyhq/twenty');
};

View File

@@ -0,0 +1,43 @@
import prompts, { PromptObject } from 'prompts';
import open from 'open';
export const demoCloudQuestions: PromptObject<string>[] = [
{
type: 'text',
name: 'continue_cloud',
message: 'We will redirect your to the cloud app. Press enter to continue.',
},
/*
In the future we can let user signup from CLI directly before redirecting:
{
type: 'select',
name: 'signup_type',
message: 'How do you want to signup?',
choices: [
{ title: 'Google Sign-in', value: 'google' },
{ title: 'Email with magic link', value: 'magic_link' },
{ title: 'Email with password', value: 'password' },
{ title: 'No-email, demo account with seeds', value: 'seeded_demo' },
],
},
{
type: (rep) => (rep == 'google' ? 'text' : null),
name: 'google_signup',
message:
'A new browser window will open to sign you up with Google. Press enter to continue.',
},
{
type: (rep) => {
if (rep == 'magic_link' || rep == 'password') {
return 'text';
}
},
name: 'email_signup',
message: 'Please enter your email',
}, */
];
export const askDemoCloudQuestions: () => Promise<void> = async () => {
await prompts(demoCloudQuestions);
open('https://app.twenty.com');
};

View File

@@ -0,0 +1,14 @@
import prompts, { PromptObject } from 'prompts';
export const demoDockerQuestions: PromptObject<string>[] = [
{
type: 'text',
name: 'not_ready_yet',
message: 'Not yeady yet',
choices: [{ title: 'XXX', value: 'XXX' }],
},
];
export const askDemoDockerQuestions: () => Promise<void> = async () => {
await prompts(demoDockerQuestions);
};

View File

@@ -0,0 +1,27 @@
import prompts, { PromptObject } from 'prompts';
import { askDemoCloudQuestions } from './cloud.js';
import { askDemoDockerQuestions } from './docker.js';
export const demoQuestions: PromptObject<string>[] = [
{
type: 'select',
name: 'demo_type',
message: 'How do you want to try the app?',
choices: [
{ title: 'Cloud demo', value: 'cloud' },
{ title: 'Local docker image', value: 'docker', disabled: true },
],
},
];
export const askDemoQuestions: () => Promise<void> = async () => {
const response = await prompts(demoQuestions);
switch (response.demo_type) {
case 'cloud':
await askDemoCloudQuestions();
break;
case 'docker':
await askDemoDockerQuestions();
break;
}
};

57
cli/src/install/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import gradient from 'gradient-string';
import chalk from 'chalk';
import { PromptObject } from 'prompts';
export function showWelcomeScreen() {
const logo = `
&&&&&&&&&&&&& &&&&&&&&&&&&&
&&&&&&&&&&&&&& &&&&&&&&&&&&&&&&
&&&& &&&&& &&&&
&&&& &&&&&& &&&&
&&&& &&&&&& && &&&&
&&&&& &&&& &&&&
&&&&&& &&&& &&&&
&&&&&& &&&& &&&&
&&&&& &&&& &&&&
&&&&&&&&&&&&&&&& &&&&&&&&&&&&&&&
&&&&&&&&&&&&&&&& &&&&&&&&&&&&
`;
const items = logo.split('\n').map((row) => gradient.mind(row));
/* eslint-disable no-console */
console.log(chalk.bold(items.join('\n')));
console.log(chalk.bold(`Welcome to Twenty!`));
console.log(
chalk.bold(
gradient.mind(`We're building a modern alternative to Salesforce\n`),
),
);
console.log(chalk.bold(`Let's get you started!\n\n`));
/* eslint-enable no-console */
}
export const firstQuestion: PromptObject = {
type: 'select',
name: 'install_type',
message: 'What do you want to do?',
choices: [
{
title: 'Contribute to the code',
description: 'I want to setup a development environment',
value: 'contribute',
},
{
title: 'Quickly try the product',
description: 'I want to play with a demo version',
value: 'demo',
},
{
title: 'Self-host on a server',
description: 'I want to host the app on a distant server',
value: 'selfhost',
},
],
};

View File

@@ -0,0 +1,16 @@
import prompts, { PromptObject } from 'prompts';
import open from 'open';
export const selfhostQuestions: PromptObject<string>[] = [
{
type: 'text',
name: 'docker',
message:
'The options to self-host are documented in the doc. Click enter to open the relevant help page.',
},
];
export const askSelfhostQuestions: () => Promise<void> = async () => {
await prompts(selfhostQuestions);
await open('https://docs.twenty.com/dev-docs/getting-started/self-hosting');
};

14
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es5",
"module": "es2020",
"strict": true,
"outDir": "lib/",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}