feat: initial release

This commit is contained in:
Geoffroy Empain 2020-12-02 15:40:43 +01:00
commit 764c62b6b8
45 changed files with 11052 additions and 0 deletions

16
.editorconfig Normal file
View File

@ -0,0 +1,16 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
indent_size = 4

2
.eslintignore Normal file
View File

@ -0,0 +1,2 @@
*.spec.ts
*.spec.js

69
.eslintrc.js Normal file
View File

@ -0,0 +1,69 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'security',
],
extends: [
'eslint:recommended',
'airbnb-base',
'plugin:@typescript-eslint/recommended',
'plugin:security/recommended',
],
settings: {
'import/resolver': {
typescript: {},
},
},
rules: {
'max-len': ['error', {
code: 140,
tabWidth: 2,
ignoreTrailingComments: true,
ignoreComments: true,
}],
'consistent-return': 'off',
'no-nested-ternary': 'off',
'no-plusplus': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
// disable some rules from parents to avoid problems
'import/extensions': [
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
},
],
'no-underscore-dangle': ['error', { allow: ['_id'] }],
'arrow-parens': ['error', 'as-needed'],
'import/prefer-default-export': 'off',
'object-curly-newline': ['error', {
// assignments, params, any js object
"ObjectExpression": { "multiline": true, "minProperties": 1 },
// destructure
"ObjectPattern": { "multiline": true, "minProperties": 3 },
"ImportDeclaration": { "multiline": true, "minProperties": 3 },
"ExportDeclaration": { "multiline": true, "minProperties": 3 },
}],
'object-property-newline': ['error', { allowAllPropertiesOnSameLine: false }],
'no-param-reassign': 'off',
'security/detect-non-literal-fs-filename': 'off',
'import/order': 'off',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': ['error'],
'security/detect-object-injection': 'off',
'security/detect-non-literal-regexp': 'off',
'class-methods-use-this': 'off',
'camelcase': 'off',
'import/no-extraneous-dependencies': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
};

38
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: main
on: [ push ]
env:
GITHUB_REPOSITORY_SSH_URL: "git@github.com:${{ github.repository }}.git"
IS_RELEASE_BRANCH: "${{ github.ref == 'refs/heads/latest' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/beta' }}"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '12'
- name: "install dependencies"
run: npm ci
- name: "Lint"
run: npm run lint
- name: "test"
run: npm run test
- name: "build"
run: npm run build
- name: "release"
if: ${{ env.IS_RELEASE_BRANCH == 'true' }}
run: |
source ./scripts/setup-git.sh "$RELEASE_DEPLOY_KEY"
npx semantic-release -r $GITHUB_REPOSITORY_SSH_URL
env:
RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: "realign next"
if: ${{ env.IS_RELEASE_BRANCH == 'true' }}
run: |
source ./scripts/setup-git.sh "$RELEASE_DEPLOY_KEY"
HEAD_BRANCH=`echo "${GITHUB_REF//refs\/heads\//}" | tr -d '\n'`
./scripts/rebase-git-branch.sh "next" "$HEAD_BRANCH"
env:
RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea/
node_modules/
build/
tmp/
*.log

23
.releaserc.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
branches: [
{ name: 'latest' },
{ name: 'beta', prerelease: true },
{ name: 'next', prerelease: true },
],
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/changelog',
'semantic-release-license',
'@semantic-release/npm',
'@semantic-release/github',
[
'@semantic-release/git',
{ assets: ['CHANGELOG.md', 'package.json', 'LICENSE'] },
],
[
'@semantic-release/exec',
{ generateNotesCmd: 'echo -n ${nextRelease.version} > VERSION' },
],
],
};

103
LICENSE Normal file
View File

@ -0,0 +1,103 @@
Business Source License 1.1
Parameters
Licensor: Charlie Bravo SRL
Licensed Work: Meli 1.0.0-next.23
The Licensed Work is (c) 2020 Charlie Bravo SRL
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Static Site
Hosting Service.
A “Static Site Hosting Service” is a commercial offering that
allows third parties (other than your employees and
contractors) to access the functionality of the
Licensed Work by creating organizations, teams or sites
controlled by such third parties.
Change Date: 2020-11-28
Change License: Apache License, Version 2.0
For information about alternative licensing arrangements for the Software,
please visit: https://www.getoutline.com
Notice
The Business Source License (this document, or the “License”) is not an Open
Source license. However, the Licensed Work will eventually be made available
under an Open Source License, as stated in this License.
License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
“Business Source License” is a trademark of MariaDB Corporation Ab.
-----------------------------------------------------------------------------
Business Source License 1.1
Terms
The Licensor hereby grants you the right to copy, modify, create derivative
works, redistribute, and make non-production use of the Licensed Work. The
Licensor may make an Additional Use Grant, above, permitting limited
production use.
Effective on the Change Date, or the fourth anniversary of the first publicly
available distribution of a specific version of the Licensed Work under this
License, whichever comes first, the Licensor hereby grants you rights under
the terms of the Change License, and the rights granted in the paragraph
above terminate.
If your use of the Licensed Work does not comply with the requirements
currently in effect as described in this License, you must purchase a
commercial license from the Licensor, its affiliated entities, or authorized
resellers, or you must refrain from using the Licensed Work.
All copies of the original and modified Licensed Work, and derivative works
of the Licensed Work, are subject to this License. This License applies
separately for each version of the Licensed Work and the Change Date may vary
for each version of the Licensed Work released by Licensor.
You must conspicuously display this License on each original or modified copy
of the Licensed Work. If you receive the Licensed Work in original or
modified form from a third party, the terms and conditions set forth in this
License apply to your use of that work.
Any use of the Licensed Work in violation of this License will automatically
terminate your rights under this License for the current and all other
versions of the Licensed Work.
This License does not grant you any right in any trademark or logo of
Licensor or its affiliates (provided that you may use a trademark or logo of
Licensor as expressly required by this License).
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
TITLE.
MariaDB hereby grants you permission to use this Licenses text to license
your works, and to refer to it using the trademark “Business Source License”,
as long as you comply with the Covenants of Licensor below.
Covenants of Licensor
In consideration of the right to use this Licenses text and the “Business
Source License” name and trademark, Licensor covenants to MariaDB, and to all
other recipients of the licensed work to be provided by Licensor:
1. To specify as the Change License the GPL Version 2.0 or any later version,
or a license that is compatible with GPL Version 2.0 or a later version,
where “compatible” means that software provided under the Change License can
be included in a program with software provided under GPL Version 2.0 or a
later version. Licensor may specify additional Change Licenses without
limitation.
2. To either: (a) specify an additional grant of rights to use that does not
impose any additional restriction on the right granted in this License, as
the Additional Use Grant; or (b) insert the text “None”.
3. To specify a Change Date.
4. Not to modify this License in any other way.

19
README.md Normal file
View File

@ -0,0 +1,19 @@
<p align="center">
<img alt="Meli Logo" src="https://raw.githubusercontent.com/meli-io/meli-brand/master/logo/logo-withot-text.svg" width="100" />
</p>
<h1 align="center">
Meli
</h1>
Self-hosted and (soon) cloud-hosted static site deployment server built with Caddy, NodeJS, React and MongoDB.
## Development
```
npm start
```
Test the CLI:
```
./build/index.js --help
```

11
SECURITY.md Normal file
View File

@ -0,0 +1,11 @@
# Security Guidelines for this Project
We take security seriously and do our best to keep our code secure. However, if you have found or think you may have found a security vulnerability, you should open a security advisory [here](./security/advisories/new) or contact us at info@charie-bravo.be.
Examples of things you should **not** do:
- open a normal issue
- disclose sensitive information publicly
- use, distribute or disclose information exploited from a security vulnerability
- do any harm to systems or persons impacted by the security vulnerability
- ...

31
jest.config.js Normal file
View File

@ -0,0 +1,31 @@
module.exports = {
roots: [
'<rootDir>/src',
],
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'src/**/*.tsx',
'!src/**/*.spec.ts',
'!src/**/*.spec.tsx',
],
// https://github.com/istanbuljs/istanbuljs/tree/master/packages/istanbul-reports/lib
coverageReporters: [
'text-summary',
'html',
'lcovonly',
'json-summary',
],
globals: {
// normally set by webpack.DefinePlugin
BUILD_INFO: {
version: '0.1.0',
buildDate: new Date().toJSON(),
commitHash: 'babb2a47d9f3849ff0f697b2df7f44cc9f3b121f',
},
},
}
;

9668
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "@melihq/cli",
"version": "0.1.0",
"description": "Meli CLI",
"main": "build/index.js",
"bin": {
"meli": "./build/index.js"
},
"scripts": {
"start": "npm run build:watch --mode=development",
"build": "rimraf build && webpack --mode=production",
"build:watch": "webpack --watch --mode=development",
"test": "jest --config jest.config.js --runInBand --no-cache --coverage --silent",
"test:debug": "jest --config jest.config.js --runInBand --no-cache --coverage",
"test:watch": "npm run test -t --watch",
"analyze:bundle": "export ANALYZE_BUNDLE=1 && webpack --mode=production",
"lint": "eslint ./src --ext .ts,.tsx",
"lint:fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix"
},
"repository": {
"type": "git",
"url": "git@gitlab.charlie-bravo.be:meli/meli-cli.git"
},
"author": "Charlie Bravo SRL",
"license": "UNLICENSED",
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/jest": "^25.2.3",
"@types/node": "^12.19.4",
"@types/tar": "^4.0.3",
"@typescript-eslint/eslint-plugin": "^3.9.0",
"@typescript-eslint/parser": "^3.9.0",
"axios": "^0.21.0",
"chalk": "^4.1.0",
"debug": "^4.2.0",
"eslint": "^7.7.0",
"eslint-config-airbnb-base": "^14.2.0",
"eslint-import-resolver-typescript": "^2.3.0",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-security": "^1.4.0",
"form-data": "^3.0.0",
"jest": "^26.6.3",
"semantic-release-license": "^1.0.0",
"tar": "^6.0.5",
"terser-webpack-plugin": "^2.3.8",
"ts-jest": "^25.5.1",
"ts-loader": "^6.2.2",
"typescript": "^4.0.5",
"webpack": "^4.44.2",
"webpack-bundle-analyzer": "^3.9.0",
"webpack-cli": "^4.2.0",
"webpack-node-externals": "^2.5.2",
"winston": "^3.3.3",
"yargs": "^15.4.1"
},
"dependencies": {}
}

30
scripts/rebase-git-branch.sh Executable file
View File

@ -0,0 +1,30 @@
[[ "$1" == "" ]] && echo 'missing base branch ($1)' && exit 1;
BASE_BRANCH="$1"
[[ "$2" == "" ]] && echo 'missing head branch ($2)' && exit 1;
HEAD_BRANCH="$2"
# save head to come back to it after we're done rebasing
HEAD=$(git rev-parse HEAD | tr -d '\n')
# reduce log verbosity
git config advice.detachedHead false
# cleanup workspace
git reset --hard
# update everything
git fetch
# move to head branch and update
git checkout $HEAD_BRANCH
git pull --rebase
# move to base branch
git checkout $BASE_BRANCH
git branch -u origin/$BASE_BRANCH
git pull --rebase
#git push --set-upstream origin $BASE_BRANCH
git merge --ff -m "chore: realign $BASE_BRANCH on $HEAD_BRANCH [ci skip]" $HEAD_BRANCH
git push
git checkout $HEAD

12
scripts/setup-git.sh Executable file
View File

@ -0,0 +1,12 @@
[[ "$1" == "" ]] && echo 'missing deploy key ($1)' && exit 1;
DEPLOY_KEY="$1"
# install ssh and add ssh key for semantic-release
which ssh-agent || apk add openssh
eval $(ssh-agent -s)
echo "$DEPLOY_KEY" | tr -d '\r' | ssh-add -
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# setup git
git config user.name "semantic-release"
git config user.email "release@meli.sh"

View File

@ -0,0 +1,19 @@
import { promises as fs } from 'fs';
import tar from 'tar';
import { Logger } from '../../commons/logger/logger';
const logger = new Logger('meli.cli:upload');
export async function archiveFiles(path: string, archivePath: string): Promise<void> {
const files = await fs.readdir(path);
logger.debug(`${files.length} files/directories (from ${path}) will be added to the archive (${archivePath})`);
const fileList = files.map(file => (file.startsWith('@') ? `./${file}` : file));
return tar.create(
{
gzip: true,
file: archivePath,
cwd: path,
},
fileList,
);
}

View File

@ -0,0 +1,48 @@
import 'reflect-metadata';
import { getBranch } from './get-branch';
describe('getCurrentBranchNameInCiEnvironment', () => {
const workdir = process.cwd();
afterEach(() => {
jest.restoreAllMocks();
delete process.env.CI_COMMIT_REF_NAME;
delete process.env.CIRCLE_BRANCH;
delete process.env.DRONE_BRANCH;
delete process.env.GITHUB_REF;
});
it('should use env vars when running in Gitlab CI', async () => {
process.env['CI_COMMIT_REF_NAME'] = 'master';
const branch = await getBranch();
expect(branch).toEqual('master');
});
it('should use env vars when running in Circle CI', async () => {
process.env['CIRCLE_BRANCH'] = 'master';
const branch = await getBranch();
expect(branch).toEqual('master');
});
it('should use env vars when running in Drone CI', async () => {
process.env['DRONE_BRANCH'] = 'master';
const branch = await getBranch();
expect(branch).toEqual('master');
});
it('should use env vars when running in Github Actions', async () => {
process.env['GITHUB_REF'] = 'refs/heads/master';
const branch = await getBranch();
expect(branch).toEqual('master');
});
});

View File

@ -0,0 +1,12 @@
export function getBranch(): string | undefined {
return (
// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#variables-reference
process.env.CI_COMMIT_REF_NAME as string
// https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
|| process.env.CIRCLE_BRANCH as string
// https://docs.drone.io/pipeline/environment/reference
|| process.env.DRONE_BRANCH as string
// https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables
|| (process.env.GITHUB_REF ? process.env.GITHUB_REF!.replace('refs/heads/', '') : undefined)
);
}

View File

@ -0,0 +1,12 @@
export function getCommitHash(): string | undefined {
return (
// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#variables-reference
process.env.CI_COMMIT_SHA
// https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
|| process.env.CIRCLE_SHA1
// https://docs.drone.io/pipeline/environment/reference
|| process.env.DRONE_COMMIT
// https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables
|| process.env.GITHUB_SHA
);
}

View File

@ -0,0 +1,12 @@
export function getRepoId(): string | undefined {
return (
// https://docs.gitlab.com/ee/ci/variables/predefined_variables.html#variables-reference
process.env.CI_PROJECT_ID
// https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables
|| process.env.CIRCLE_PROJECT_REPONAME
// https://docs.drone.io/pipeline/environment/reference
|| process.env.DRONE_REPO
// https://docs.github.com/en/free-pro-team@latest/actions/reference/environment-variables#default-environment-variables
|| process.env.GITHUB_REPOSITORY
);
}

View File

@ -0,0 +1,51 @@
import axios, { AxiosInstance } from 'axios';
import { ensureStackTrace } from '../../../commons/axios/ensure-stack-trace';
// https://github.com/go-gitea/gitea/blob/9ce4d89e9922cc87bdb13d122339ae165a080c3d/templates/repo/commit_status.tmpl
export type GiteaCommitStatus =
// orange filled circle
| 'pending'
// green check
| 'success'
// red exclamation mark
| 'error'
// red cross
| 'failure'
// yellow filled triangle with white exclamation mark inside
| 'warning';
export class Gitea {
private axios: AxiosInstance;
constructor(
readonly token: string,
private readonly url: string,
) {
this.axios = axios.create({
baseURL: this.url,
headers: {
Authorization: `bearer ${this.token}`,
},
});
ensureStackTrace(this.axios);
}
async setCommitStatus(
repoId: string,
sha: string,
options: {
context: string;
description: string;
state: GiteaCommitStatus;
url?: string;
},
): Promise<void> {
// https://try.gitea.io/api/v1/swagger#/repository/repoCreateStatus
await this.axios.post(`/api/v1/repos/${repoId}/statuses/${sha}`, {
context: options.context,
description: options.description,
state: options.state,
target_url: options.url,
});
}
}

View File

@ -0,0 +1,48 @@
import { ensureStackTrace } from '../../../commons/axios/ensure-stack-trace';
import axios, { AxiosInstance } from 'axios';
type GithubCommitStatus =
| 'error'
| 'failure'
| 'pending'
| 'success';
const githubUrl = 'https://github.com';
export class Github {
private axios: AxiosInstance;
constructor(
private readonly token: string,
private readonly url = githubUrl,
) {
this.axios = axios.create({
baseURL: this.url === githubUrl ? 'https://api.github.com' : `${url}/api/v3`,
headers: {
// https://developer.github.com/v3/#current-version
Authorization: `token ${this.token}`,
// Accept: 'application/vnd.github.v3+json',
},
});
ensureStackTrace(this.axios);
}
async setCommitStatus(
repoId: string,
sha: string,
options: {
context: string;
description: string;
status: GithubCommitStatus;
url?: string;
},
): Promise<void> {
// https://developer.github.com/v3/repos/statuses/#create-a-commit-status
await this.axios.post(`/repos/${repoId}/statuses/${sha}`, {
context: options.context,
description: options.description,
state: options.status,
target_url: options.url,
});
}
}

View File

@ -0,0 +1,52 @@
import { ensureStackTrace } from '../../../commons/axios/ensure-stack-trace';
import axios, { AxiosInstance } from 'axios';
// https://docs.gitlab.com/ee/api/commits.html#post-the-build-status-to-a-commit
// TODO how does gitlab display the "warning" state when allow_failure is true ?
export type GitlabCommitStatus =
// orange filled circle with paused sign inside (but "external" bubble displayed as "running")
| 'pending'
// circle partially filled with blue
| 'running'
// green filled circle with check
| 'success'
// red filled circle with red cross
| 'failed'
// back circle with backslash
| 'canceled';
export class Gitlab {
private axios: AxiosInstance;
constructor(
private readonly token: string,
private readonly url = 'https://gitlab.com',
) {
this.axios = axios.create({
baseURL: this.url,
headers: {
Authorization: `Bearer ${this.token}`,
},
});
ensureStackTrace(this.axios);
}
async setCommitStatus(
projectId: string,
sha: string,
options: {
name: string;
description: string;
state: GitlabCommitStatus,
url?: string;
},
): Promise<void> {
// https://docs.gitlab.com/ee/api/commits.html#post-the-build-status-to-a-commit
await this.axios.post(`/api/v4/projects/${projectId}/statuses/${sha}`, {
name: options.name,
description: options.description,
state: options.state,
target_url: options.url,
});
}
}

View File

@ -0,0 +1,69 @@
import { getRepoId } from '../ci/get-repo-id';
import { Github } from './github';
import { getCommitHash } from '../ci/get-commit-hash';
import { Logger } from '../../../commons/logger/logger';
import { Gitlab } from './gitlab';
import { Gitea } from './gitea';
import { UploadResponse } from '../upload-response';
const logger = new Logger('meli.server:setCommitStatus');
export async function setCommitStatus(data: UploadResponse, release?: string): Promise<void> {
const commitHash = getCommitHash();
const repoId = getRepoId();
if (!repoId) {
logger.warn('Repo id not detected, cannot set commit status');
return;
}
if (!commitHash) {
logger.warn('Commit hash not detected, cannot set commit status');
return;
}
const context = 'meli';
const description = release ? `Release ${release} deployed to Meli` : 'Site deployed to Meli';
if (process.env.GITHUB_TOKEN) {
logger.info('Setting Github commit status');
const github = new Github(process.env.GITHUB_TOKEN, process.env.GITHUB_SERVER_URL);
await github.setCommitStatus(repoId, commitHash, {
context,
description,
status: data ? 'success' : 'failure',
url: data?.urls[0],
});
} else {
logger.debug('Github token not found, will not set commit status');
}
if (process.env.GITLAB_TOKEN) {
logger.info('Setting Gitlab commit status');
const gitlab = new Gitlab(process.env.GITLAB_TOKEN, process.env.GITLAB_URL);
await gitlab.setCommitStatus(repoId, commitHash, {
name: context,
description,
state: data ? 'success' : 'failed',
url: data?.urls[0],
});
} else {
logger.debug('Gitlab token not found, will not set commit status');
}
if (process.env.GITEA_TOKEN && process.env.GITEA_URL) {
logger.info('Setting Gitea commit status');
const gitea = new Gitea(process.env.GITEA_TOKEN, process.env.GITEA_URL);
await gitea.setCommitStatus(repoId, commitHash, {
context,
description,
state: data ? 'success' : 'failure',
url: data?.urls[0],
});
} else {
logger.debug('Gitea token/url not found, will not set commit status');
}
}

View File

@ -0,0 +1,50 @@
import FormData from 'form-data';
import { createReadStream } from 'fs';
import { Logger } from '../../commons/logger/logger';
import { API_TOKEN_HEADER } from '../../global-options';
import { UploadOptions } from './upload-options';
import { getBranch } from './ci/get-branch';
import { axios } from '../../commons/axios/axios';
import { setCommitStatus } from './notify-git/set-commit-status';
import { UploadResponse } from './upload-response';
const logger = new Logger('meli.cli:uploadArchive');
function getBranches(): string[] {
const branch = getBranch();
return branch ? [branch] : [];
}
export async function uploadArchive(archivePath: string, options: UploadOptions) {
const form = new FormData();
form.append('file', createReadStream(archivePath));
if (options.release) {
form.append('release', options.release);
}
const branches = options.branch || getBranches();
if (!branches || branches.length === 0) {
throw new Error('Could not detect branch, please provide --branch option');
}
form.append('branches', branches.join(','));
logger.debug('adding branch to form', branches);
const url = `${options.url}/api/v1/sites/${options.site}/releases`;
logger.debug(`[POST] ${url}`);
const { data } = await axios.post<UploadResponse>(url, form, {
headers: {
[API_TOKEN_HEADER]: options.token,
...form.getHeaders(),
},
});
logger.info('Your site has been deployed at:');
data.urls.forEach(deployUrl => {
logger.info(`- ${deployUrl}`);
});
await setCommitStatus(data, options.release);
}

View File

@ -0,0 +1,52 @@
import { Options } from 'yargs';
export interface UploadOptions {
// API call config
token: string;
site: string;
url: string;
// release config
directory: string;
release: string;
branch: string[];
}
export const uploadOptions: { [key: string]: Options } = {
// API call config
token: {
alias: 't',
type: 'string',
describe: 'API token, available in site settings in Meli UI',
demandOption: true,
},
site: {
alias: 's',
type: 'string',
describe: 'Site ID, available in Meli UI',
demandOption: true,
},
url: {
alias: 'u',
type: 'string',
describe: 'URL of the Meli API (e.g. https://meli.sh or https://meli.company.com)',
demandOption: true,
},
// release config
directory: {
alias: 'd',
type: 'string',
describe: 'Path of the directory containing the assets to upload',
default: './build',
},
release: {
alias: 'r',
type: 'string',
describe: 'The version of your site (if none is given, a random one will be generated)',
},
branch: {
alias: 'b',
type: 'string',
array: true,
describe: 'A branch on which this release will be published (multiple branches: --branch branch1 --branch branch2)',
},
};

View File

@ -0,0 +1,10 @@
export interface UploadResponse {
release: {
_id: string;
date: Date;
name: string;
siteId: string;
branches: string[];
};
urls: string[];
}

View File

@ -0,0 +1,20 @@
import { tmpdir } from 'os';
import { join } from 'path';
import { Logger } from '../../commons/logger/logger';
import { UploadOptions } from './upload-options';
import { uploadArchive } from './upload-archive';
import { archiveFiles } from './archive-files';
const logger = new Logger('meli.cli:upload');
export async function upload(options: UploadOptions): Promise<void> {
const archivePath = join(tmpdir(), `${options.site}-${options.release ?? 'latest'}-${options.branch}.tar.gz`);
logger.info(`Compressing files from ${options.directory}....`);
await archiveFiles(options.directory, archivePath);
logger.info(`Uploading release to ${options.url}...`);
await uploadArchive(archivePath, options);
logger.info('Done');
}

View File

@ -0,0 +1,19 @@
import {AppError} from './app-error';
describe('AppError', () => {
describe('toString', () => {
it('should print error', async () => {
const error = {
toString: jest.fn().mockReturnValue('{}'),
};
const appError = new AppError('whoops', error as any);
const str = appError.toString();
expect(str).toEqual('whoops (caused by: {})');
expect(error.toString).toHaveBeenCalled();
});
});
});

22
src/commons/app-error.ts Normal file
View File

@ -0,0 +1,22 @@
import { BaseError } from 'make-error';
export class AppError<T extends Error = any> extends BaseError {
constructor(message: string, public readonly cause?: T) {
super(message);
this.addCauseToStack();
}
toString(): string {
let str = this.message;
if (this.cause) {
str += ` (caused by: ${this.cause.toString()})`;
}
return str;
}
private addCauseToStack(): void {
if (this.cause) {
this.stack += `\nCaused by: ${this.cause.stack}`;
}
}
}

View File

@ -0,0 +1,62 @@
import {AxiosError} from './axios-error';
describe('AxiosError', () => {
const originalAxiosError = {
toJSON: () => ({}),
response: {
status: 'status',
statusText: 'statusText',
headers: 'headers',
data: 'data',
}
}
describe('toJSON', () => {
it('should format error to json', async () => {
const error = new AxiosError('message', originalAxiosError);
expect(error.toJSON()).toEqual({
errorObject: {},
response: {
status: 'status',
statusText: 'statusText',
headers: 'headers',
data: 'data',
},
})
});
it('should accept empty error', async () => {
const axiosError = new AxiosError('message', null);
expect(axiosError.toJSON()).toEqual(undefined);
});
it('should accept empty error response', async () => {
const axiosError = new AxiosError('message', {
response: undefined,
toJSON: () => '',
});
expect(axiosError.toJSON()).toEqual({
errorObject: '',
response: {
status: undefined,
statusText: undefined,
headers: undefined,
data: undefined,
}
});
});
});
describe('toString', () => {
it('should format error', async () => {
const error = new AxiosError('message', originalAxiosError);
expect(error.toString()).toEqual('message {\"errorObject\":{},\"response\":{\"status\":\"status\",\"statusText\":\"statusText\",\"headers\":\"headers\",\"data\":\"data\"}}');
});
});
});

View File

@ -0,0 +1,24 @@
export class AxiosError extends Error {
constructor(message: string, public error?: any) {
super(message);
}
toJSON(): any {
if (!this.error) {
return undefined;
}
return {
errorObject: this.error?.toJSON(),
response: {
status: this.error.response?.status,
statusText: this.error.response?.statusText,
headers: this.error.response?.headers,
data: this.error.response?.data,
},
};
}
toString(): string {
return `${this.message} ${JSON.stringify(this.toJSON())}`;
}
}

View File

@ -0,0 +1,6 @@
import axiosModule from 'axios';
import { ensureStackTrace } from './ensure-stack-trace';
export const axios = axiosModule.create();
ensureStackTrace(axios);

View File

@ -0,0 +1,19 @@
import { AxiosInstance } from 'axios';
import { AxiosError } from './axios-error';
// https://github.com/axios/axios/issues/2387#issuecomment-652242713
export function ensureStackTrace(instance: AxiosInstance): AxiosInstance {
instance.interceptors.request.use(config => {
(config as any).errorContext = new Error('Thrown at:');
return config;
});
instance.interceptors.response.use(undefined, async error => {
const err = error.isAxiosError ? new AxiosError(error.message, error) : error;
const originalStackTrace = error.config?.errorContext?.stack;
if (originalStackTrace) {
error.stack = `${error.stack}\n${originalStackTrace}`;
}
throw err;
});
return instance;
}

5
src/commons/brand.ts Normal file
View File

@ -0,0 +1,5 @@
const buildInfo = BUILD_INFO;
export const BRAND = `
Meli CLI v${buildInfo.version} - ${buildInfo.buildDate} - ${buildInfo.commitHash}
`;

22
src/commons/exec-async.ts Normal file
View File

@ -0,0 +1,22 @@
import { exec, ExecOptions } from 'child_process';
export class ExecError extends Error {
constructor(
private readonly error: any,
private readonly stdout: string,
private readonly stderr: string,
) {
super();
}
}
export function execAsync(cmd: string, options?: ExecOptions): Promise<string> {
return new Promise((resolve, reject) => {
exec(cmd, options, (error, stdout, stderr) => {
if (error) {
reject(new ExecError(error, stdout.toString(), stderr.toString()));
}
resolve(stdout.toString());
});
});
}

View File

@ -0,0 +1,61 @@
import chalk from 'chalk';
import { hostname } from 'os';
import * as winston from 'winston';
// https://github.com/winstonjs/winston#logging-levels
type LogLevel =
| 'error'
| 'warning'
| 'info'
| 'debug'
;
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context: string;
meta: {
args: any[];
};
}
const chalkColorMap = new Map<LogLevel, any>([
['info', chalk.bold.green],
['debug', chalk.bold.gray],
['warning', chalk.bold.yellow],
['error', chalk.bold.red],
]);
const processId = process.pid;
const host = hostname();
function winstonFormat() {
return winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.printf((info: any) => {
const {
timestamp, level, message, context, meta: { args },
} = info;
const chalkFn = chalkColorMap.get(level);
const contextPart = context ? ` ${chalk.cyan(context)}` : undefined;
const levelPart = chalkFn ? chalkFn(level) : level;
const argsPart = args ? ` ${args.join(' ')}` : '';
return `${host} ${processId} ${timestamp} ${levelPart} ${contextPart} ${message}${argsPart}`;
}),
);
}
export function buildWinstonLogger(level: LogLevel = 'info'): winston.Logger {
return winston.createLogger({
level,
format: winston.format.json(),
transports: [
new winston.transports.Console({
format: winstonFormat(),
level,
}),
],
});
}

View File

@ -0,0 +1,32 @@
import { Logger } from './logger';
// TODO test log level and all methods
describe('Logger', () => {
afterEach(() => jest.restoreAllMocks());
describe('info', () => {
// TODO not working
it('should log meta.error', async () => {
const consoleLog = jest.spyOn(global.console, 'log');
const logger = new Logger();
await logger.info('message', { error: 'hello' });
// expect(consoleLog).toHaveBeenCalledWith('');
});
// TODO not working
it('should log meta.context', async () => {
const consoleLog = jest.spyOn(global.console, 'log');
const logger = new Logger('meli.bot:context');
logger.info('message', { error: 'hello' });
// expect(consoleLog).toHaveBeenCalledWith('');
});
});
});

View File

@ -0,0 +1,50 @@
import * as winston from 'winston';
import debug from 'debug';
import { buildWinstonLogger } from './build-winston-logger';
export class Logger {
private static winstonLogger: winston.Logger = buildWinstonLogger();
private debugger: any;
constructor(private readonly context?: string) {
this.debugger = debug(context || '');
}
info(message: string, ...args: any[]): void {
Logger.winstonLogger.info(message, {
context: this.context,
meta: {
args,
},
});
}
warn(message: string, ...args: any[]): void {
Logger.winstonLogger.warn(message, {
context: this.context,
meta: {
args,
},
});
}
error(error: any, ...args: any[]): void {
Logger.winstonLogger.error(error, {
context: this.context,
meta: {
args: [
...args,
...(error.stack ? [error.stack] : []),
...args
.filter(arg => !!arg.stack)
.map(arg => arg.stack),
],
},
});
}
debug(...args: any[]): void {
this.debugger(...args);
}
}

2
src/global-options.ts Normal file
View File

@ -0,0 +1,2 @@
export const CLI_PREFIX = 'MELI';
export const API_TOKEN_HEADER = 'x-meli-token';

11
src/handle-error.ts Normal file
View File

@ -0,0 +1,11 @@
import { AppError } from './commons/app-error';
import { AxiosError } from './commons/axios/axios-error';
/* eslint-disable no-console */
export function handleError(err: Error | AppError | any) {
console.error(err);
if (err instanceof AxiosError) {
console.log(JSON.stringify(err.toJSON(), null, 2));
}
process.exit(1);
}

48
src/index.ts Normal file
View File

@ -0,0 +1,48 @@
// Enable NodeJS sourcemap support
import 'source-map-support/register';
import yargs, {
Arguments, Argv, Options,
} from 'yargs';
import { upload } from './commands/upload/upload';
import { uploadOptions } from './commands/upload/upload-options';
import { handleError } from './handle-error';
import { CLI_PREFIX } from './global-options';
import chalk from 'chalk';
import { BRAND } from './commons/brand';
// force chalk colors
// process.env.FORCE_COLOR = '1';
function configureArgv(argv: Argv, options: { [optionName: string]: Options }): Argv {
Object.keys(options).forEach((optionName: string) => {
argv.option(optionName, options[optionName]);
});
return argv;
}
// eslint-disable-next-line no-console
console.log(chalk.bold.blue(BRAND));
yargs
.scriptName('meli')
.version(BUILD_INFO.version)
.env(CLI_PREFIX)
.command({
command: 'upload <directory>',
describe: 'Upload a release',
builder: (args: Argv) => configureArgv(args, uploadOptions),
handler: (args: Arguments) => {
upload(args as any).catch(handleError);
},
})
.command({
command: '*',
handler: args => {
// eslint-disable-next-line no-console
console.error(`Unknown command "${args._ && args._.length > 0 ? args._[0] : ''}", see --help`);
process.exit(1);
},
})
// .recommendCommands() // not working with '*'
.help()
.parse(process.argv.slice(2));

10
src/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"exclude": [
"**/*.spec.ts"
],
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}

11
src/typings.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export interface BuildInfo {
version: string;
buildDate: string;
commitHash: string;
}
declare global {
const BUILD_INFO: BuildInfo;
// eslint-disable-next-line no-underscore-dangle, camelcase
const __non_webpack_require__: (id: string) => any;
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"incremental": false,
"target": "es2018",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["es2018"],
"sourceMap": true,
"removeComments": true,
"strictPropertyInitialization": false,
"strict": false,
"noImplicitAny": false,
"baseUrl": "./",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}

89
webpack.config.js Normal file
View File

@ -0,0 +1,89 @@
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const nodeExternals = require('webpack-node-externals');
const buildInfo = {
version: require('./package.json').version,
buildDate: new Date().toISOString(),
commitHash: require('child_process').execSync('git rev-parse HEAD').toString().trim(),
};
const definedVariables = {
BUILD_INFO: JSON.stringify(buildInfo),
};
console.log('definedVariables', definedVariables);
// https://webpack.js.org/guides/typescript/
module.exports = {
target: 'node',
node: {
__dirname: false,
},
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'build'),
filename: 'index.js',
devtoolModuleFilenameTemplate: '../[resource-path]',
},
mode: 'production',
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
// https://webpack.js.org/guides/typescript/#source-maps
devtool: 'source-map',
optimization: {
minimize: true,
// https://webpack.js.org/plugins/uglifyjs-webpack-plugin/
minimizer: [
// https://stackoverflow.com/questions/47439067/uglifyjs-throws-unexpected-token-keyword-const-with-node-modules
new TerserPlugin({
sourceMap: true,
// https://webpack.js.org/plugins/terser-webpack-plugin/#extractcomments
extractComments: false,
// https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions
terserOptions: {
ecma: 6,
// warnings: false,
mangle: {
toplevel: true,
// https://github.com/terser/terser#mangle-properties-options
// properties: true
},
},
}),
],
},
stats: {
// https://github.com/yargs/yargs/blob/master/docs/webpack.md#webpack-configuration
warningsFilter: [
/node_modules\/yargs/,
/.*plugin-loader.ts*/,
],
},
plugins: [
new webpack.DefinePlugin(definedVariables),
...(process.env.ANALYZE_BUNDLE ? [new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
})] : []),
...(!process.env.ANALYZE_BUNDLE ? [new webpack.BannerPlugin({
banner: '#!/usr/bin/env node',
raw: true,
})] : []),
],
};