Unverified Commit aef99a0f authored by ritwizsinha's avatar ritwizsinha Committed by GitHub

GSoC2020/Hot-reload (#64)

* Experimental HOT-RELOAD

* Error correction

* Corrected some errors

* Added description for command help

* Removed 'watching your app' message after every update

* Added filewatcher event ready to run deploy first time without any change

* Added ability to wait before filewatcher event is emitted and removed cli prompt with cli log

* Fix unhandled promise error

* Allow user to add an remove files from the watchlist

* Fixed alerts

* Separate deploy and package logic from deploy command

* Used deploy functions in watch command

* Removed deploy helpers class and added singular functions instead

* Added config to store server variables

* Added the correction made in another pr

* Add config file to store and retrieve the serverInfo

* Code improvements in watch command

* Added serverInfo.json to the ignored list

* Added userid and token to config

* Added listr to deploy command and added error message instead of object

* Refactored the code

* Added test for watch command

* Removed Listr package and its implementationfrom deploy and watch

* Remove asking for serverinfo in the create command

* Code improvements

* Added instructions to upload to Readme

* Improve code and cli functionality for better user experience

1. Corrected grammar for flag descriptions
2. Removed irrelevant try catch blocks
3. Removed error message when updating of app fails
4. Remove serverInfo.json and add .rcappconfig.json for storing the apps configuration
5. Remove hardcoded ignored files and added them as configuration for the app

* Update readme

* Added check before uploading or updating

* Review improvements

1. Removed addFiles and remFiles flags in watch
2. Removed the .json file extension from .rcappsconfig, now reading it as normal string and erroring out if format is not json
3. All login variables added as flags in both watch and deploy commands
4. Made .rcappsconfig optional
5. The login credentials presented in flags overrides those in the config file(if present)
6. Made required checks for acheiving this

* Better error handling messages

* Added review changes
1. Added double slash in url
2. Run checkReport dicreetly before package and zip
3. Converted hardcoded unicode to readable strings
4. Updated Readme

* Removed create command error by removing packaging from creation

* Fix edge cases in fetching server Info
parent 6187b023
......@@ -68,3 +68,26 @@ export class TodoListApp extends App {
### Packaging the app
Currently the Rocket.Chat servers and Marketplace allow submission of zip files, these files can be created by running `rc-apps package` which packages your app and creates the zip file under `dist` folder.
### Uploading the app
For uploading the app you need add to the required parameters in the .rcappsconfig already created in the apps directory. It accepts two types of objects:-
1. Upload using username, password
```
{
url: string;
username: string;
password: string;
}
```
2. Upload using personal access token and userId
```
{
url: string;
userId: string;
token: string;
}
```
......@@ -1031,6 +1031,16 @@
"resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz",
"integrity": "sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk="
},
"anymatch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
"integrity": "sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"append-transform": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz",
......@@ -1121,6 +1131,12 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
},
"binary-extensions": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
"integrity": "sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==",
"dev": true
},
"bl": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-3.0.0.tgz",
......@@ -1279,6 +1295,33 @@
"integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=",
"dev": true
},
"chokidar": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.1.tgz",
"integrity": "sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==",
"dev": true,
"requires": {
"anymatch": "~3.1.1",
"braces": "~3.0.2",
"fsevents": "~2.1.2",
"glob-parent": "~5.1.0",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.3.0"
},
"dependencies": {
"glob-parent": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.0.tgz",
"integrity": "sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
}
}
},
"chownr": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.2.tgz",
......@@ -2073,6 +2116,13 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"fsevents": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz",
"integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==",
"dev": true,
"optional": true
},
"fuzzy": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz",
......@@ -2433,6 +2483,15 @@
"integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-buffer": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz",
......@@ -2997,6 +3056,12 @@
}
}
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"npm-run-path": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
......@@ -3561,6 +3626,15 @@
"util-deprecate": "^1.0.1"
}
},
"readdirp": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.3.0.tgz",
"integrity": "sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==",
"dev": true,
"requires": {
"picomatch": "^2.0.7"
}
},
"redeyed": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz",
......
......@@ -8,9 +8,7 @@ import * as semver from 'semver';
import * as uuid from 'uuid';
import {
AppCompiler,
AppCreator,
AppPackager,
FolderDetails,
VariousUtils,
} from '../misc';
......@@ -20,6 +18,11 @@ export default class Create extends Command {
public static flags = {
help: flags.help({ char: 'h' }),
name: flags.string({char: 'n', description: 'Name of the app'}),
description: flags.string({char: 'd', description: 'Description of the app'}),
author: flags.string({char: 'a', description: 'Author\'s name'}),
homepage: flags.string({char: 'H', description: 'Author\'s or app\'s home page'}),
support: flags.string({char: 's', description: 'URL or email address to get support for the app'}),
};
public async run() {
......@@ -40,16 +43,15 @@ export default class Create extends Command {
this.log('We need some information first:');
this.log('');
info.name = await cli.prompt(chalk.bold(' App Name'));
const { flags } = this.parse(Create);
info.name = flags.name ? flags.name : await cli.prompt(chalk.bold(' App Name'));
info.nameSlug = VariousUtils.slugify(info.name);
info.classFile = `${ pascalCase(info.name) }App.ts`;
info.description = await cli.prompt(chalk.bold(' App Description'));
info.author.name = await cli.prompt(chalk.bold(' Author\'s Name'));
info.author.homepage = await cli.prompt(chalk.bold(' Author\'s Home Page'));
info.author.support = await cli.prompt(chalk.bold(' Author\'s Support Page'));
this.log('');
info.description = flags.description ? flags.description : await cli.prompt(chalk.bold(' App Description'));
info.author.name = flags.author ? flags.author : await cli.prompt(chalk.bold(' Author\'s Name'));
info.author.homepage = flags.homepage ? flags.homepage : await cli.prompt(chalk.bold(' Author\'s Home Page'));
info.author.support = flags.support ? flags.support : await cli.prompt(chalk.bold(' Author\'s Support Page'));
const folder = path.join(process.cwd(), info.nameSlug);
......@@ -69,16 +71,6 @@ export default class Create extends Command {
return;
}
const compiler = new AppCompiler(this, fd);
const report = compiler.logDiagnostics();
if (!report.isValid) {
throw new Error('invalid.');
}
const packager = new AppPackager(this, fd);
await packager.zipItUp();
cli.action.stop(chalk.cyan('done!'));
}
}
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import cli from 'cli-ux';
import * as FormData from 'form-data';
import * as fs from 'fs';
import fetch from 'node-fetch';
import { Response } from 'node-fetch';
import { AppCompiler, AppPackager, FolderDetails } from '../misc';
import { FolderDetails, unicodeSymbols } from '../misc';
import { checkReport, getServerInfo, packageAndZip, uploadApp } from '../misc/deployHelpers';
export default class Deploy extends Command {
public static description = 'allows deploying an App to a server';
public static flags = {
help: flags.help({ char: 'h' }),
// flag with no value (-f, --force)
force: flags.boolean({ char: 'f', description: 'forcefully deploy the App, ignores lint & TypeScript errors' }),
update: flags.boolean({ description: 'updates the app, instead of creating' }),
url: flags.string({ description: 'where the App should be deployed to' }),
username: flags.string({ char: 'u', dependsOn: ['url'], description: 'username to authenticate with' }),
password: flags.string({ char: 'p', dependsOn: ['username'], description: 'password of the user' }),
code: flags.string({ char: 'c', dependsOn: ['username'], description: '2FA code of the user' }),
i2fa: flags.boolean({ description: 'interactively ask for 2FA code' }),
url: flags.string({
description: 'where the app should be deployed to',
}),
username: flags.string({
char: 'u',
description: 'username to authenticate with',
}),
password: flags.string({
char: 'p',
description: 'password for the user',
}),
token: flags.string({
char: 't', dependsOn: ['userid'],
char: 't',
description: 'API token to use with UserID (instead of username & password)',
}),
userid: flags.string({
char: 'i', dependsOn: ['token'],
char: 'i',
description: 'UserID to use with API token (instead of username & password)',
}),
// flag with no value (-f, --force)
force: flags.boolean({ char: 'f', description: 'forcefully deploy the App, ignores lint & TypeScript errors' }),
update: flags.boolean({ description: 'updates the app, instead of creating' }),
code: flags.string({ char: 'c', dependsOn: ['username'], description: '2FA code of the user' }),
i2fa: flags.boolean({ description: 'interactively ask for 2FA code' }),
};
public async run() {
const { flags } = this.parse(Deploy);
cli.action.start(`${ chalk.green('packaging') } your app`);
const fd = new FolderDetails(this);
try {
await fd.readInfoFile();
} catch (e) {
this.error(e && e.message ? e.message : e);
return;
}
const compiler = new AppCompiler(this, fd);
const report = compiler.logDiagnostics();
if (!report.isValid && !flags.force) {
this.error('TypeScript compiler error(s) occurred');
this.exit(1);
return;
}
const packager = new AppPackager(this, fd);
const zipName = await packager.zipItUp();
cli.action.stop('packaged!');
if (!flags.url) {
flags.url = await cli.prompt('What is the server\'s url (include https)?');
}
if (!flags.username && !flags.token) {
flags.username = await cli.prompt('What is the username?');
this.error(e && e.message ? e.message : e, {exit: 2});
}
if (!flags.password && !flags.token) {
flags.password = await cli.prompt('And, what is the password?', { type: 'hide' });
}
if (flags.i2fa) {
flags.code = await cli.prompt('2FA code', { type: 'hide' });
}
cli.action.start(`${ chalk.green('deploying') } your app`);
const data = new FormData();
data.append('app', fs.createReadStream(fd.mergeWithFolder(zipName)));
await this.asyncSubmitData(data, flags, fd);
cli.action.stop('deployed!');
}
// tslint:disable:promise-function-async
private async asyncSubmitData(data: FormData, flags: { [key: string]: any }, fd: FolderDetails): Promise<void> {
let authResult;
if (!flags.token) {
let credentials: { username: string, password: string, code?: string };
credentials = { username: flags.username, password: flags.password };
if (flags.code) {
credentials.code = flags.code;
}
authResult = await fetch(this.normalizeUrl(flags.url, '/api/v1/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
}).then((res: Response) => res.json());
if (authResult.status === 'error' || !authResult.data) {
throw new Error('Invalid username and password or missing 2FA code (if active)');
}
} else {
const verificationResult = await fetch(this.normalizeUrl(flags.url, '/api/v1/me'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Auth-Token': flags.token,
'X-User-Id': flags.userid,
},
}).then((res: Response) => res.json());
if (!verificationResult.success) {
throw new Error('Invalid API token');
}
authResult = { data: { authToken: flags.token, userId: flags.userid } };
}
let endpoint = '/api/apps';
if (flags.update) {
endpoint += `/${fd.info.id}`;
}
const deployResult = await fetch(this.normalizeUrl(flags.url, endpoint), {
method: 'POST',
headers: {
'X-Auth-Token': authResult.data.authToken,
'X-User-Id': authResult.data.userId,
},
body: data,
}).then((res: Response) => res.json());
if (deployResult.status === 'error') {
throw new Error(`Unknown error occurred while deploying ${JSON.stringify(deployResult)}`);
} else if (!deployResult.success) {
if (deployResult.status === 'compiler_error') {
throw new Error(`Deployment compiler errors: \n${ JSON.stringify(deployResult.messages, null, 2) }`);
}
throw new Error(`Deployment error: ${ deployResult }`);
let serverInfo;
let zipName;
cli.log(chalk.bold.greenBright(' Starting App Deployment to Server\n'));
try {
cli.action.start(chalk.bold.greenBright(' Getting Server Info'));
serverInfo = await getServerInfo(fd, flags);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
cli.action.start(chalk.bold.greenBright(' Packaging the app'));
checkReport(this, fd, flags);
zipName = await packageAndZip(this, fd);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
cli.action.start(chalk.bold.greenBright(' Uploading App'));
await uploadApp(serverInfo, fd, zipName);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
} catch (e) {
cli.action.stop(chalk.red(unicodeSymbols.get('heavyMultiplicationX')));
this.log(chalk.bold.redBright(
` ${unicodeSymbols.get('longRightwardsSquiggleArrow')} ${e && e.message ? e.message : e}`));
}
}
// expects the `path` to start with the /
private normalizeUrl(url: string, path: string): string {
return url.replace(/\/$/, '') + path;
}
}
import { Command, flags } from '@oclif/command';
import chalk from 'chalk';
import * as chokidar from 'chokidar';
import cli from 'cli-ux';
import { FolderDetails, unicodeSymbols } from '../misc';
import { checkReport, checkUpload, getIgnoredFiles, getServerInfo,
packageAndZip, uploadApp } from '../misc/deployHelpers';
export default class Watch extends Command {
public static description = 'watches for changes in the app and redeploys to the server';
public static flags = {
help: flags.help({ char: 'h' }),
url: flags.string({
description: 'where the app should be deployed to',
}),
username: flags.string({
char: 'u',
description: 'username to authenticate with',
}),
password: flags.string({
char: 'p',
description: 'password for the user',
}),
token: flags.string({
char: 't',
description: 'API token to use with UserID (instead of username & password)',
}),
userid: flags.string({
char: 'i',
description: 'UserID to use with API token (instead of username & password)',
}),
// flag with no value (-f, --force)
force: flags.boolean({ char: 'f', description: 'forcefully deploy the App, ignores lint & TypeScript errors' }),
code: flags.string({ char: 'c', dependsOn: ['username'], description: '2FA code of the user' }),
i2fa: flags.boolean({ description: 'interactively ask for 2FA code' }),
};
public async run() {
const { flags } = this.parse(Watch);
const fd = new FolderDetails(this);
try {
await fd.readInfoFile();
} catch (e) {
this.error(chalk.bold.red(e && e.message ? e.message : e), {exit: 2});
}
if (flags.i2fa) {
flags.code = await cli.prompt('2FA code', { type: 'hide' });
}
let ignoredFiles: Array<string>;
try {
ignoredFiles = await getIgnoredFiles(fd);
} catch (e) {
this.error(chalk.bold.red(e && e.message ? e.message : e));
}
const watcher = chokidar.watch(fd.folder, {
ignored: ignoredFiles,
awaitWriteFinish: true,
persistent: true,
interval: 300,
});
watcher
.on('change', async () => {
tasks(this, fd, flags)
.catch((e) => {
this.log(chalk.bold.redBright(
` ${unicodeSymbols.get('longRightwardsSquiggleArrow')} ${e && e.message ? e.message : e}`));
});
})
.on('ready', async () => {
tasks(this, fd, flags)
.catch((e) => {
this.log(chalk.bold.redBright(
` ${unicodeSymbols.get('longRightwardsSquiggleArrow')} ${e && e.message ? e.message : e}`));
});
});
}
}
const tasks = async (command: Command, fd: FolderDetails, flags: { [key: string]: any }): Promise<void> => {
let serverInfo;
let zipName;
try {
command.log('\n');
cli.action.start(chalk.bold.greenBright(' Packaging the app'));
checkReport(command, fd, flags);
zipName = await packageAndZip(command, fd);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
cli.action.start(chalk.bold.greenBright(' Getting Server Info'));
serverInfo = await getServerInfo(fd, flags);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
const status = await checkUpload({...flags, ...serverInfo}, fd);
if (status) {
cli.action.start(chalk.bold.greenBright(' Updating App'));
await uploadApp({...serverInfo, update: true}, fd, zipName);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
} else {
cli.action.start(chalk.bold.greenBright(' Uploading App'));
await uploadApp(serverInfo, fd, zipName);
cli.action.stop(chalk.bold.greenBright(unicodeSymbols.get('checkMark')));
}
} catch (e) {
cli.action.stop(chalk.red(unicodeSymbols.get('heavyMultiplicationX')));
throw new Error(e);
}
};
......@@ -11,6 +11,7 @@ export class AppCreator {
public async writeFiles(): Promise<void> {
fs.mkdirSync(this.fd.folder);
this.createAppJson();
this.createServerInfoJson();
this.createMainTypeScriptFile();
this.createBlankIcon();
this.createdReadme();
......@@ -28,6 +29,30 @@ export class AppCreator {
fs.writeFileSync(this.fd.mergeWithFolder('app.json'), JSON.stringify(this.fd.info, undefined, 4), 'utf8');
}
private createServerInfoJson(): void {
const toWrite = {
url: 'http://localhost:3000',
username: '',
password: '',
ignoredFiles: [
'**/README.md',
'**/package-lock.json',
'**/package.json',
'**/tslint.json',
'**/tsconfig.json',
'**/*.js',
'**/*.js.map',
'**/*.d.ts',
'**/*.spec.ts',
'**/*.test.ts',
'**/dist/**',
'**/.*',
],
};
fs.writeFileSync(this.fd.mergeWithFolder('.rcappsconfig'), JSON.stringify(toWrite, null, 4) , 'utf8');
}
private createMainTypeScriptFile(): void {
const toWrite =
`import {
......
import Command from '@oclif/command';
import * as FormData from 'form-data';
import * as fs from 'fs';
import fetch from 'node-fetch';
import { Response } from 'node-fetch';
import { AppCompiler, AppPackager, FolderDetails } from '.';
export const checkReport = (command: Command, fd: FolderDetails, flags: { [key: string]: any }): void => {
const compiler = new AppCompiler(command, fd);
const report = compiler.logDiagnostics();
if (!report.isValid && !flags.force) {
throw new Error('TypeScript compiler error(s) occurred');
}
return;
};
export const getServerInfo = async (fd: FolderDetails, flags: {[key: string]: any}):
Promise<{[key: string]: any}> => {
let loginInfo = flags;
try {
if (await fd.doesFileExist(fd.mergeWithFolder('.rcappsconfig'))) {
const data = JSON.parse(await fs.promises.readFile(fd.mergeWithFolder('.rcappsconfig'), 'utf-8'));
loginInfo = { ...data, ...loginInfo};
}
} catch (e) {
throw new Error(e && e.message ? e.message : e);
}
// tslint:disable-next-line:max-line-length
const providedLoginArguments = ((loginInfo.username && loginInfo.password) || (loginInfo.userId && loginInfo.token));
if (loginInfo.url && providedLoginArguments) {
return loginInfo;
} else if (!loginInfo.url && providedLoginArguments) {
throw new Error(`
No url found.
Consider adding url with the flag --url
or create a .rcappsconfig file and add the url as
{
"url": "your-server-url"
}
`);
} else {
if (loginInfo.password || loginInfo.username) {
if (!loginInfo.password) {
throw new Error(`
No password found for username.
Consider adding password as a flag with -p="your-password"
or create a .rcappsconfig file and add the password as
{
"password":"your-password"