publish-microfrontend 0.15.6-beta.5100 → 0.15.6-beta.5105

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "publish-microfrontend",
3
- "version": "0.15.6-beta.5100",
3
+ "version": "0.15.6-beta.5105",
4
4
  "description": "A CLI for publishing micro frontends to a feed service.",
5
5
  "keywords": [
6
6
  "modules",
@@ -69,5 +69,5 @@
69
69
  "typescript": "^4.0.0",
70
70
  "yargs": "^15.0.0"
71
71
  },
72
- "gitHead": "e3037bccd6f8bf036f49a057a9b92ca79e4b5442"
72
+ "gitHead": "fe9f4af00df8a9281ba22e253e4ace0171160768"
73
73
  }
package/src/browser.ts ADDED
@@ -0,0 +1,10 @@
1
+ import open from 'open';
2
+ import { logFail } from './log';
3
+
4
+ export async function openBrowserAt(address: string) {
5
+ try {
6
+ await open(address, undefined);
7
+ } catch (err) {
8
+ logFail('Failed to open the browser: %s', err);
9
+ }
10
+ }
package/src/common.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { platform } from 'os';
2
+
3
+ const os = platform();
4
+
5
+ export const standardHeaders = {
6
+ 'user-agent': `publish-microfrontend/http.node-${os}`,
7
+ };
8
+
9
+ export const isWindows = process.platform === 'win32';
package/src/http.ts ADDED
@@ -0,0 +1,192 @@
1
+ import axios from 'axios';
2
+ import FormData from 'form-data';
3
+ import { Agent } from 'https';
4
+ import { Stream } from 'stream';
5
+ import { createWriteStream } from 'fs';
6
+ import { tmpdir } from 'os';
7
+ import { join } from 'path';
8
+ import { logWarn } from './log';
9
+ import { standardHeaders } from './common';
10
+ import { getTokenInteractively } from './interactive';
11
+
12
+ function getMessage(body: string | { message?: string }) {
13
+ if (typeof body === 'string') {
14
+ try {
15
+ const content = JSON.parse(body);
16
+ return content.message;
17
+ } catch (ex) {
18
+ return body;
19
+ }
20
+ } else if (body && typeof body === 'object') {
21
+ if ('message' in body) {
22
+ return body.message;
23
+ } else {
24
+ return JSON.stringify(body);
25
+ }
26
+ }
27
+
28
+ return '';
29
+ }
30
+
31
+ export interface PostFormResult {
32
+ status: number;
33
+ success: boolean;
34
+ response?: object;
35
+ }
36
+
37
+ export type FormDataObj = Record<string, string | [Buffer, string]>;
38
+
39
+ function streamToFile(source: Stream, target: string) {
40
+ const dest = createWriteStream(target);
41
+ return new Promise<Array<string>>((resolve, reject) => {
42
+ source.pipe(dest);
43
+ source.on('error', (err) => reject(err));
44
+ dest.on('finish', () => resolve([target]));
45
+ });
46
+ }
47
+
48
+ export async function downloadFile(url: string, ca?: Buffer): Promise<Array<string>> {
49
+ const httpsAgent = ca ? new Agent({ ca }) : undefined;
50
+
51
+ try {
52
+ const res = await axios.get<Stream>(url, {
53
+ responseType: 'stream',
54
+ headers: standardHeaders,
55
+ httpsAgent,
56
+ });
57
+ const rid = Math.random().toString(36).split('.').pop();
58
+ const target = join(tmpdir(), `microfrontend_${rid}.tgz`);
59
+ return streamToFile(res.data, target);
60
+ } catch (error) {
61
+ logWarn('Failed HTTP GET requested: %s', error.message);
62
+ return [];
63
+ }
64
+ }
65
+
66
+ export function postForm(
67
+ target: string,
68
+ scheme: string,
69
+ key: string,
70
+ formData: FormDataObj,
71
+ customHeaders: Record<string, string> = {},
72
+ ca?: Buffer,
73
+ interactive = false,
74
+ ): Promise<PostFormResult> {
75
+ const httpsAgent = ca ? new Agent({ ca }) : undefined;
76
+ const form = new FormData();
77
+
78
+ Object.keys(formData).forEach((key) => {
79
+ const value = formData[key];
80
+
81
+ if (typeof value === 'string') {
82
+ form.append(key, value);
83
+ } else {
84
+ form.append(key, value[0], value[1]);
85
+ }
86
+ });
87
+
88
+ const headers: Record<string, string> = {
89
+ ...form.getHeaders(),
90
+ ...standardHeaders,
91
+ ...customHeaders,
92
+ };
93
+
94
+ if (key) {
95
+ switch (scheme) {
96
+ case 'basic':
97
+ headers.authorization = `Basic ${key}`;
98
+ break;
99
+ case 'bearer':
100
+ headers.authorization = `Bearer ${key}`;
101
+ break;
102
+ case 'digest':
103
+ headers.authorization = `Digest ${key}`;
104
+ break;
105
+ case 'none':
106
+ default:
107
+ headers.authorization = key;
108
+ break;
109
+ }
110
+ }
111
+
112
+ return axios
113
+ .post(target, form, {
114
+ headers,
115
+ httpsAgent,
116
+ maxContentLength: Infinity,
117
+ maxBodyLength: Infinity,
118
+ })
119
+ .then(
120
+ (res) => {
121
+ return {
122
+ status: res.status,
123
+ success: true,
124
+ response: res.data,
125
+ };
126
+ },
127
+ (error) => {
128
+ if (error.response) {
129
+ // The request was made and the server responded with a status code
130
+ // that falls out of the range of 2xx
131
+ const { data, statusText, status } = error.response;
132
+
133
+ if (interactive && 'interactiveAuth' in data) {
134
+ const { interactiveAuth } = data;
135
+
136
+ if (typeof interactiveAuth === 'string') {
137
+ return getTokenInteractively(interactiveAuth, httpsAgent).then(({ mode, token }) =>
138
+ postForm(target, mode, token, formData, customHeaders, ca, false),
139
+ );
140
+ }
141
+ }
142
+
143
+ const message = getMessage(data) || '';
144
+ logWarn('The HTTP Post request failed with status %s (%s): %s', status, statusText, message);
145
+ return {
146
+ status,
147
+ success: false,
148
+ response: message,
149
+ };
150
+ } else if (error.isAxiosError) {
151
+ // axios initiated error: try to parse message from error object
152
+ let errorMessage: string = error.errno || 'Unknown Axios Error';
153
+
154
+ if (typeof error.toJSON === 'function') {
155
+ const errorObj: { message?: string } = error.toJSON();
156
+ errorMessage = errorObj?.message ?? errorMessage;
157
+ }
158
+
159
+ logWarn('The HTTP Post request failed with error: %s', errorMessage);
160
+ return {
161
+ status: 500,
162
+ success: false,
163
+ response: errorMessage,
164
+ };
165
+ } else if (error.request) {
166
+ logWarn('The HTTP Post request failed unexpectedly.');
167
+ } else {
168
+ logWarn('The HTTP Post request failed with error: %s', error.message);
169
+ }
170
+
171
+ return {
172
+ status: 500,
173
+ success: false,
174
+ response: undefined,
175
+ };
176
+ },
177
+ );
178
+ }
179
+
180
+ export function postFile(
181
+ target: string,
182
+ scheme: string,
183
+ key: string,
184
+ file: Buffer,
185
+ customFields: Record<string, string> = {},
186
+ customHeaders: Record<string, string> = {},
187
+ ca?: Buffer,
188
+ interactive = false,
189
+ ): Promise<PostFormResult> {
190
+ const data: FormDataObj = { ...customFields, file: [file, 'microfrontend.tgz'] };
191
+ return postForm(target, scheme, key, data, customHeaders, ca, interactive);
192
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { fromKeys, publishModeKeys } from 'piral-cli/src/helpers';
4
3
  import * as yargs from 'yargs';
4
+ import rc from 'rc';
5
+ import { fromKeys, publishModeKeys } from 'piral-cli/src/helpers';
6
+ import { basename } from 'path';
7
+ import { readFile } from 'fs/promises';
8
+ import { progress, fail, logDone, logFail, logInfo } from './log';
9
+ import { getCa, getFiles } from './utils';
10
+ import { postFile } from './http';
5
11
 
6
- const defaultArgs = {
12
+ const current = process.cwd();
13
+ const defaultArgs = rc('microfrontend', {
7
14
  url: undefined,
8
15
  apiKey: undefined,
9
16
  cert: undefined,
@@ -12,22 +19,21 @@ const defaultArgs = {
12
19
  fields: {},
13
20
  headers: {},
14
21
  interactive: false,
15
- };
22
+ });
16
23
 
17
24
  const args = yargs
18
- .positional('source', {
19
- type: 'string',
20
- describe: 'Sets the source of either the previously packed *.tgz bundle or the directory to publish.',
21
- })
25
+ .string('source')
26
+ .describe('source', 'Sets the source of either the previously packed *.tgz bundle or the directory to publish.')
27
+ .default('source', current)
22
28
  .string('url')
23
29
  .describe('url', 'Sets the explicit URL where to publish the micro frontend to.')
24
30
  .default('url', defaultArgs.url)
25
31
  .string('api-key')
26
32
  .describe('api-key', 'Sets the potential API key to send to the service.')
27
33
  .default('api-key', defaultArgs.apiKey)
28
- .string('ca-cert')
29
- .describe('ca-cert', 'Sets a custom certificate authority to use, if any.')
30
- .default('ca-cert', defaultArgs.cert)
34
+ .string('cert')
35
+ .describe('cert', 'Sets a custom certificate authority to use, if any.')
36
+ .default('cert', defaultArgs.cert)
31
37
  .choices('mode', publishModeKeys)
32
38
  .describe('mode', 'Sets the authorization mode to use.')
33
39
  .default('mode', defaultArgs.mode)
@@ -45,4 +51,64 @@ const args = yargs
45
51
  .describe('interactive', 'Defines if authorization tokens can be retrieved interactively.')
46
52
  .default('interactive', defaultArgs.interactive).argv;
47
53
 
48
- console.log('First release.', args.source);
54
+ async function run() {
55
+ const { cert, source, from, url, 'api-key': apiKey, headers, fields, interactive, mode } = args;
56
+ const sources = Array.isArray(source) ? source : [source];
57
+ const ca = await getCa(cert);
58
+ const files = await getFiles(current, sources, from, ca);
59
+ const successfulUploads: Array<string> = [];
60
+
61
+ if (files.length === 0) {
62
+ fail('No micro frontends for publishing found: %s.', sources.join(', '));
63
+ }
64
+
65
+ for (const file of files) {
66
+ const fileName = basename(file);
67
+ const content = await readFile(file);
68
+
69
+ if (content) {
70
+ progress(`Publishing "%s" ...`, file, url);
71
+
72
+ const { success, status, response } = await postFile(
73
+ url,
74
+ mode,
75
+ apiKey,
76
+ content,
77
+ fields,
78
+ headers,
79
+ ca,
80
+ interactive,
81
+ );
82
+
83
+ const result = typeof response !== 'string' ? JSON.stringify(response, undefined, 2) : response;
84
+
85
+ if (success) {
86
+ successfulUploads.push(file);
87
+
88
+ if (response) {
89
+ logInfo('Response from server: %s', result);
90
+ }
91
+
92
+ progress(`Published successfully!`);
93
+ } else if (status === 402) {
94
+ logFail('Payment required to upload the micro frontend: %s', result);
95
+ } else if (status === 409) {
96
+ logFail('Version of the micro frontend already exists: %s"', result);
97
+ } else if (status === 413) {
98
+ logFail('Size too large for uploading the micro frontend: %s"', result);
99
+ } else {
100
+ logFail('Failed to upload micro frontend "%s".', fileName);
101
+ }
102
+ } else {
103
+ logFail('Failed to read Micro Frontend.');
104
+ }
105
+ }
106
+
107
+ if (files.length === successfulUploads.length) {
108
+ logDone(`The micro frontends have been published successfully!`);
109
+ } else {
110
+ fail('Failed to publish the micro frontends.');
111
+ }
112
+ }
113
+
114
+ run();
@@ -0,0 +1,68 @@
1
+ import axios from 'axios';
2
+ import { Agent } from 'https';
3
+ import { openBrowserAt } from './browser';
4
+ import { standardHeaders } from './common';
5
+ import { logSuspend, logInfo } from './log';
6
+
7
+ type TokenResult = Promise<{ mode: string; token: string }>;
8
+
9
+ const tokenRetrievers: Record<string, TokenResult> = {};
10
+
11
+ export function getTokenInteractively(url: string, httpsAgent: Agent): TokenResult {
12
+ if (!(url in tokenRetrievers)) {
13
+ const logResume = logSuspend();
14
+
15
+ tokenRetrievers[url] = axios
16
+ .post(
17
+ url,
18
+ {
19
+ clientId: 'publish-microfrontend',
20
+ clientName: 'Publish Micro Frontend CLI',
21
+ description: 'Authorize the Publish Micro Frontend CLI temporarily to perform actions in your name.',
22
+ },
23
+ {
24
+ headers: {
25
+ ...standardHeaders,
26
+ 'content-type': 'application/json',
27
+ },
28
+ httpsAgent,
29
+ },
30
+ )
31
+ .then(async (res) => {
32
+ const { loginUrl, callbackUrl, expires } = res.data;
33
+ const now = new Date();
34
+ const then = new Date(expires);
35
+ const diff = ~~((then.valueOf() - now.valueOf()) / (60 * 1000));
36
+
37
+ logInfo(`Use the URL below to complete the login. The link expires in ${diff} minutes (${then}).`);
38
+ logInfo('===');
39
+ logInfo(loginUrl);
40
+ logInfo('===');
41
+
42
+ openBrowserAt(loginUrl);
43
+
44
+ try {
45
+ while (true) {
46
+ const { data, status } = await axios.get(callbackUrl);
47
+
48
+ if (status === 202) {
49
+ await new Promise((resolve) => setTimeout(resolve, 5000));
50
+ continue;
51
+ }
52
+
53
+ if (status === 200) {
54
+ return { ...data };
55
+ }
56
+
57
+ throw new Error(`Could not get status from interactive login endpoint.`);
58
+ }
59
+ } catch (ex) {
60
+ throw ex;
61
+ } finally {
62
+ logResume();
63
+ }
64
+ });
65
+ }
66
+
67
+ return tokenRetrievers[url];
68
+ }
package/src/log.ts ADDED
@@ -0,0 +1,51 @@
1
+ import ora from 'ora';
2
+ import { format } from 'util';
3
+
4
+ const instance = ora();
5
+
6
+ let currentProgress: string = undefined;
7
+
8
+ export function logInfo(message: string, ...args: Array<string | number | boolean>) {
9
+ const msg = format(message, ...args);
10
+ instance.info(msg);
11
+ return msg;
12
+ }
13
+
14
+ export function logDone(message: string, ...args: Array<string | number | boolean>) {
15
+ const msg = format(message, ...args);
16
+ instance.succeed(msg);
17
+ return msg;
18
+ }
19
+
20
+ export function logWarn(message: string, ...args: Array<string | number | boolean>) {
21
+ const msg = format(message, ...args);
22
+ instance.warn(msg);
23
+ return msg;
24
+ }
25
+
26
+ export function logFail(message: string, ...args: Array<string | number | boolean>) {
27
+ const msg = format(message, ...args);
28
+ instance.fail(msg);
29
+ return msg;
30
+ }
31
+
32
+ export function progress(message: string, ...args: Array<string | number | boolean>) {
33
+ const msg = format(message, ...args);
34
+ instance.start(msg);
35
+ currentProgress = msg;
36
+ }
37
+
38
+ export function logReset() {
39
+ instance.stop();
40
+ }
41
+
42
+ export function logSuspend() {
43
+ logReset();
44
+
45
+ return () => instance.start(currentProgress);
46
+ }
47
+
48
+ export function fail(message: string, ...args: Array<string | number | boolean>): never {
49
+ logFail(message, ...args);
50
+ process.exit(1);
51
+ }
package/src/scripts.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { exec } from 'child_process';
2
+ import { resolve } from 'path';
3
+ import { MemoryStream } from 'piral-cli/src/common/MemoryStream';
4
+ import { isWindows } from './common';
5
+
6
+ function resolveWinPath(specialFolder: string, subPath: string): string | undefined {
7
+ const basePath = process.env[specialFolder];
8
+
9
+ if (basePath) {
10
+ return resolve(basePath, subPath);
11
+ }
12
+
13
+ return undefined;
14
+ }
15
+
16
+ export function runScript(script: string, cwd = process.cwd(), output: NodeJS.WritableStream = process.stdout) {
17
+ const bin = resolve(cwd, './node_modules/.bin');
18
+ const sep = isWindows ? ';' : ':';
19
+ const env = Object.assign({}, process.env);
20
+
21
+ if (isWindows) {
22
+ // on windows we sometimes may see a strange behavior,
23
+ // see https://github.com/smapiot/piral/issues/192
24
+ const newPaths = [
25
+ resolveWinPath('AppData', 'npm'),
26
+ resolveWinPath('ProgramFiles', 'nodejs'),
27
+ resolveWinPath('ProgramFiles(x86)', 'nodejs'),
28
+ ...(env.Path || env.PATH || '').split(';'),
29
+ ];
30
+ env.PATH = newPaths.filter((path) => path && path.length > 0).join(sep);
31
+ }
32
+
33
+ env.PATH = `${bin}${sep}${env.PATH}`;
34
+
35
+ return new Promise<void>((resolve, reject) => {
36
+ const error = new MemoryStream();
37
+ const opt = { end: false };
38
+ const cp = exec(script, {
39
+ cwd,
40
+ env,
41
+ });
42
+
43
+ cp.stdout.pipe(output, opt);
44
+ cp.stderr.pipe(error, opt);
45
+
46
+ cp.on('error', () => reject(new Error(error.value)));
47
+ cp.on('close', (code) => (code === 0 ? resolve() : reject(new Error(error.value))));
48
+ });
49
+ }
50
+
51
+ export function runCommand(exe: string, args: Array<string>, cwd: string, output?: NodeJS.WritableStream) {
52
+ const npmCommand = isWindows ? `${exe}.cmd` : exe;
53
+ const sanitizedArgs = sanitizeCmdArgs(args);
54
+ const cmd = [npmCommand, ...sanitizedArgs].join(' ');
55
+ return runScript(cmd, cwd, output);
56
+ }
57
+
58
+ function sanitizeCmdArgs(args: Array<string>) {
59
+ // Introduced for fixing https://github.com/smapiot/piral/issues/259.
60
+ // If an arg contains a whitespace, it can be incorrectly interpreted as two separate arguments.
61
+ // For the moment, it's fixed by simply wrapping each arg in OS specific quotation marks.
62
+ const quote = isWindows ? '"' : "'";
63
+
64
+ return args.map((arg) => {
65
+ let result = arg.trim();
66
+
67
+ if (/\s/.test(result)) {
68
+ if (!result.startsWith(quote)) {
69
+ result = `${quote}${result}`;
70
+ }
71
+
72
+ if (!result.endsWith(quote)) {
73
+ result = `${result}${quote}`;
74
+ }
75
+ }
76
+
77
+ return result;
78
+ });
79
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,74 @@
1
+ import glob from 'glob';
2
+ import { stat, readFile } from 'fs/promises';
3
+ import { dirname, basename, resolve } from 'path';
4
+ import { MemoryStream } from 'piral-cli/src/common/MemoryStream';
5
+ import { downloadFile } from './http';
6
+ import { runCommand } from './scripts';
7
+
8
+ function runNpmProcess(args: Array<string>, target: string, output?: NodeJS.WritableStream) {
9
+ const cwd = resolve(process.cwd(), target);
10
+ return runCommand('npm', args, cwd, output);
11
+ }
12
+
13
+ async function findTarball(packageRef: string, target = '.', ...flags: Array<string>) {
14
+ const ms = new MemoryStream();
15
+ await runNpmProcess(['view', packageRef, 'dist.tarball', ...flags], target, ms);
16
+ return ms.value;
17
+ }
18
+
19
+ export async function getCa(cert: string | undefined): Promise<Buffer | undefined> {
20
+ if (cert && typeof cert === 'string') {
21
+ const statCert = await stat(cert).catch(() => undefined);
22
+
23
+ if (statCert?.isFile()) {
24
+ const dir = dirname(cert);
25
+ const file = basename(cert);
26
+ return await readFile(resolve(dir, file));
27
+ }
28
+ }
29
+
30
+ return undefined;
31
+ }
32
+
33
+ export function matchFiles(baseDir: string, pattern: string) {
34
+ return new Promise<Array<string>>((resolve, reject) => {
35
+ glob(
36
+ pattern,
37
+ {
38
+ cwd: baseDir,
39
+ absolute: true,
40
+ dot: true,
41
+ },
42
+ (err, files) => {
43
+ if (err) {
44
+ reject(err);
45
+ } else {
46
+ resolve(files);
47
+ }
48
+ },
49
+ );
50
+ });
51
+ }
52
+
53
+ export async function getFiles(baseDir: string, sources: Array<string>, from: string, ca: Buffer): Promise<Array<string>> {
54
+ switch (from) {
55
+ case 'local': {
56
+ const allFiles = await Promise.all(sources.map((s) => matchFiles(baseDir, s)));
57
+ // TODO:
58
+ // - Reduced files should be unique.
59
+ // - can be a mix of directories and files - if files are matched take those, otherwise take directories
60
+ // - for all matched directories look up if a `package.json` exists - if yes, run "npm pack" and replace the directory by the created npm package
61
+ // Finally return these files (created npm packages should be taken from a tmp directory, following the download approach below)
62
+ return allFiles.reduce((result, files) => [...result, ...files], []);
63
+ }
64
+ case 'remote': {
65
+ const allFiles = await Promise.all(sources.map((s) => downloadFile(s, ca)));
66
+ return allFiles.reduce((result, files) => [...result, ...files], []);
67
+ }
68
+ case 'npm': {
69
+ const allUrls = await Promise.all(sources.map((s) => findTarball(s)));
70
+ const allFiles = await Promise.all(allUrls.map((url) => downloadFile(url, ca)));
71
+ return allFiles.reduce((result, files) => [...result, ...files], []);
72
+ }
73
+ }
74
+ }