@raisenow/tamaro-cli 1.0.8 → 1.0.11

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.
@@ -0,0 +1,163 @@
1
+ import {existsSync} from 'fs'
2
+ import {
3
+ AWS_CLOUDFRONT_DISTRIBUTION_ID,
4
+ AWS_S3_BUCKET,
5
+ CORE_CONFIG_NAME,
6
+ } from 'lib/constants'
7
+ import {
8
+ assertProfileValid,
9
+ AwsOptions,
10
+ withAwsAuthenticate,
11
+ prepareFlags as prepareAwsFlags,
12
+ } from 'lib/aws'
13
+ import {getIfCoreFns, getPaths, getWidgetUuid} from 'lib/resolve'
14
+ import {logCommand, logDataTable, logTitle} from 'lib/logging'
15
+ import {notify} from 'lib/notifier'
16
+ import {fail, runCommandSync} from 'lib/command'
17
+
18
+ ///////////////////////////////////////////////////////////////////////////////
19
+
20
+ export type DeployOptions = AwsOptions & {
21
+ tag: string
22
+ }
23
+
24
+ ///////////////////////////////////////////////////////////////////////////////
25
+
26
+ export const deploy = async (options: DeployOptions): Promise<void> => {
27
+ const {ifCore} = getIfCoreFns()
28
+ const paths = getPaths(ifCore)
29
+
30
+ assertOptionsValid(options)
31
+ assertDistPathExists(paths.appDist)
32
+
33
+ const flags = prepareFlags([], options).join(' ')
34
+ const {tag} = options
35
+ const configName = ifCore(CORE_CONFIG_NAME, getWidgetUuid())
36
+ const deployUrl = `s3://${AWS_S3_BUCKET}/${configName}/${tag}`
37
+ const entryFilename = ifCore('index.js', 'widget.js')
38
+
39
+ /////////////////////////////////////////////////////////////////////////////
40
+ // Sync all files except entrypoint (without deleting old files)
41
+ /////////////////////////////////////////////////////////////////////////////
42
+
43
+ const cmdSyncAll = `
44
+ aws s3 sync ${paths.appDist} ${deployUrl}
45
+ --acl public-read
46
+ --size-only
47
+ --exclude ${paths.appDist}/${entryFilename}
48
+ --cache-control max-age=31536000
49
+ ${flags}
50
+ `
51
+ logTitle('Deploying to AWS S3 …')
52
+ logCommand(cmdSyncAll)
53
+
54
+ try {
55
+ withAwsAuthenticate(
56
+ () => runCommandSync(cmdSyncAll, {stdout: 'inherit'}),
57
+ options,
58
+ )
59
+ } catch (error: any) {
60
+ fail(error.stderr)
61
+ }
62
+
63
+ /////////////////////////////////////////////////////////////////////////////
64
+ // Copy entrypoint
65
+ /////////////////////////////////////////////////////////////////////////////
66
+
67
+ const cmdCpEntry = `
68
+ aws s3 cp ${paths.appDist}/${entryFilename} ${deployUrl}/${entryFilename}
69
+ --acl public-read
70
+ --cache-control max-age=64800
71
+ ${flags}
72
+ `
73
+ logCommand(cmdCpEntry)
74
+
75
+ try {
76
+ withAwsAuthenticate(
77
+ () => runCommandSync(cmdCpEntry, {stdout: 'inherit'}),
78
+ options,
79
+ )
80
+ } catch (error: any) {
81
+ fail(error.stderr)
82
+ }
83
+
84
+ /////////////////////////////////////////////////////////////////////////////
85
+ // Invalidate cloudfront cache
86
+ /////////////////////////////////////////////////////////////////////////////
87
+
88
+ const cmdInvalidateCache = `
89
+ aws cloudfront create-invalidation
90
+ --distribution-id ${AWS_CLOUDFRONT_DISTRIBUTION_ID}
91
+ --paths /${configName}/${tag}/*
92
+ ${flags}
93
+ `
94
+ logTitle('\nInvalidating edge cache …')
95
+ logCommand(cmdInvalidateCache)
96
+
97
+ try {
98
+ // ignore output
99
+ withAwsAuthenticate(() => runCommandSync(cmdInvalidateCache), options)
100
+ } catch (error: any) {
101
+ fail(error.stderr)
102
+ }
103
+
104
+ /////////////////////////////////////////////////////////////////////////////
105
+
106
+ const demoPage = `https://${AWS_S3_BUCKET}/${configName}/${tag}/index.html`
107
+ const entryPoint = `https://${AWS_S3_BUCKET}/${configName}/${tag}/${entryFilename}`
108
+
109
+ logTitle(`\nBundle for “${configName}” is deployed with tag “${tag}”.`)
110
+ logDataTable({
111
+ 'Demo page:': demoPage,
112
+ 'Entry point:': entryPoint,
113
+ })
114
+
115
+ /////////////////////////////////////////////////////////////////////////////
116
+
117
+ process.on('exit', () => {
118
+ notify({
119
+ title: 'deploy',
120
+ message: `
121
+ Bundle for “${configName}” is deployed with tag “${tag}”.
122
+ Demo page: ${demoPage}
123
+ Entry point: ${entryPoint}
124
+ `,
125
+ })
126
+ })
127
+ }
128
+
129
+ ///////////////////////////////////////////////////////////////////////////////
130
+
131
+ const assertOptionsValid = (options: DeployOptions) => {
132
+ const {tag, profile} = options
133
+
134
+ assertTagValid(tag)
135
+ assertProfileValid(profile)
136
+ }
137
+
138
+ ///////////////////////////////////////////////////////////////////////////////
139
+
140
+ const assertDistPathExists = (distPath: string) => {
141
+ if (!existsSync(distPath)) {
142
+ fail(`
143
+ Dist folder does not exists.
144
+ Make sure you have built the bundle before trying to deploy it.
145
+ `)
146
+ }
147
+ }
148
+
149
+ ///////////////////////////////////////////////////////////////////////////////
150
+
151
+ const assertTagValid = (tag: string) => {
152
+ const regex = /^[a-zA-Z0-9-_.]+$/
153
+
154
+ if (!regex.test(tag)) {
155
+ fail(`Flag "--tag" has forbidden format. Allowed format: ${regex}.`)
156
+ }
157
+ }
158
+
159
+ ///////////////////////////////////////////////////////////////////////////////
160
+
161
+ const prepareFlags = (flags: string[], options: DeployOptions): string[] => {
162
+ return prepareAwsFlags(flags, options)
163
+ }
@@ -0,0 +1,68 @@
1
+ import {getPortPromise} from 'portfinder'
2
+ import {getIfCoreFns, resolveBin, resolveOwn} from 'lib/resolve'
3
+ import {logCommand, logTitle} from 'lib/logging'
4
+ import {fail, runCommandSync} from 'lib/command'
5
+
6
+ ///////////////////////////////////////////////////////////////////////////////
7
+
8
+ export type DevOptions = {
9
+ localCore: boolean
10
+ port: string
11
+ }
12
+
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+
15
+ export const dev = async (options: DevOptions): Promise<void> => {
16
+ assertOptionsValid(options)
17
+
18
+ const flags = (await prepareFlags([], options)).join(' ')
19
+ const {ifCore} = getIfCoreFns()
20
+ const title = ifCore(
21
+ 'Running dev web-server for Tamaro Core …',
22
+ 'Running dev web-server for customer configuration …',
23
+ )
24
+ const wpBin = resolveBin('webpack')
25
+ const wpConfig = resolveOwn('dist/webpack.config.js')
26
+ const cmd = `
27
+ ${wpBin} serve
28
+ --config ${wpConfig}
29
+ ${flags}
30
+ `
31
+
32
+ logTitle(title)
33
+ logCommand(cmd)
34
+ runCommandSync(cmd, {stdio: 'inherit'})
35
+ }
36
+
37
+ ///////////////////////////////////////////////////////////////////////////////
38
+
39
+ const assertOptionsValid = (options: DevOptions) => {
40
+ const {localCore, port} = options
41
+ const {ifCore} = getIfCoreFns()
42
+
43
+ if (ifCore() && localCore) {
44
+ fail('Flag "--local-core" is redundant if running in Tamaro Core context.')
45
+ }
46
+
47
+ if (isNaN(Number(port))) {
48
+ fail('Flag "--port" should be a number.')
49
+ }
50
+ }
51
+
52
+ ///////////////////////////////////////////////////////////////////////////////
53
+
54
+ const prepareFlags = async (
55
+ flags: string[],
56
+ options: DevOptions,
57
+ ): Promise<string[]> => {
58
+ const {localCore, port: defaultPort} = options
59
+ const port = await getPortPromise({port: Number(defaultPort)})
60
+
61
+ if (localCore) {
62
+ flags.push('--env localCore')
63
+ }
64
+
65
+ flags.push(`--port ${port}`)
66
+
67
+ return flags
68
+ }
@@ -0,0 +1,112 @@
1
+ import {AWS_S3_BUCKET, CORE_CONFIG_NAME} from 'lib/constants'
2
+ import {
3
+ assertProfileValid,
4
+ AwsOptions,
5
+ withAwsAuthenticate,
6
+ prepareFlags as prepareAwsFlags,
7
+ } from 'lib/aws'
8
+ import {getIfCoreFns, getWidgetUuid} from 'lib/resolve'
9
+ import {logCommand, logTitle} from 'lib/logging'
10
+ import {fail, runCommandSync} from 'lib/command'
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ export type ListDeployedOptions = AwsOptions & {
15
+ config?: string
16
+ }
17
+
18
+ ///////////////////////////////////////////////////////////////////////////////
19
+
20
+ export const listDeployed = async (
21
+ options: ListDeployedOptions,
22
+ ): Promise<void> => {
23
+ assertOptionsValid(options)
24
+
25
+ const flags = prepareFlags([], options).join(' ')
26
+ const {config} = options
27
+ const {ifCore} = getIfCoreFns()
28
+ const configName = config ?? ifCore(CORE_CONFIG_NAME, getWidgetUuid())
29
+ const title =
30
+ !config && ifCore()
31
+ ? `Listing deployments of Tamaro Core …`
32
+ : `Listing deployments of “${configName}” customer configuration …`
33
+
34
+ const deployUrl = `s3://${AWS_S3_BUCKET}/${configName}/`
35
+ const cmd = `aws s3 ls ${deployUrl} ${flags}`
36
+ let out = ''
37
+
38
+ logTitle(title)
39
+ logCommand(cmd)
40
+
41
+ // If customer configuration folder does not exist on AWS S3,
42
+ // command fails with blank stderr, so we need to handle this case.
43
+ try {
44
+ const result = withAwsAuthenticate(() => runCommandSync(cmd), options)
45
+ out = result.stdout
46
+ } catch (error: any) {
47
+ out = error.stdout
48
+
49
+ if (error.stderr) {
50
+ fail(error.stderr)
51
+ }
52
+ }
53
+
54
+ const lines = out.split('\n')
55
+ const tags = parseTags(lines)
56
+ let text = ''
57
+
58
+ if (tags.length === 0) {
59
+ text = 'No deployments found.'
60
+ } else {
61
+ text = tags
62
+ .map((tag, idx) => {
63
+ // prettier-ignore
64
+ return `${idx + 1}. https://${AWS_S3_BUCKET}/${configName}/${tag}/index.html`
65
+ })
66
+ .join('\n')
67
+ }
68
+
69
+ console.log(`${text}\n`)
70
+ }
71
+
72
+ ///////////////////////////////////////////////////////////////////////////////
73
+
74
+ const assertOptionsValid = (options: ListDeployedOptions) => {
75
+ const {config, profile} = options
76
+
77
+ assertConfigValid(config)
78
+ assertProfileValid(profile)
79
+ }
80
+
81
+ ///////////////////////////////////////////////////////////////////////////////
82
+
83
+ const assertConfigValid = (config: string | undefined) => {
84
+ if (!config) {
85
+ return
86
+ }
87
+
88
+ const regex = /^[a-zA-Z0-9-_]+$/
89
+
90
+ if (!regex.test(config)) {
91
+ fail(`Flag "--config" has forbidden format. Allowed format: ${regex}.`)
92
+ }
93
+ }
94
+
95
+ ///////////////////////////////////////////////////////////////////////////////
96
+
97
+ const prepareFlags = (
98
+ flags: string[],
99
+ options: ListDeployedOptions,
100
+ ): string[] => {
101
+ return prepareAwsFlags(flags, options)
102
+ }
103
+
104
+ ///////////////////////////////////////////////////////////////////////////////
105
+
106
+ const parseTags = (lines: string[]): string[] => {
107
+ const regex = /^\s*PRE\s*/
108
+
109
+ return lines
110
+ .filter((line) => regex.test(line))
111
+ .map((line) => line.replace(regex, '').replace(/\/$/, ''))
112
+ }
@@ -0,0 +1,53 @@
1
+ import {getPortPromise} from 'portfinder'
2
+ import {getIfCoreFns, getPaths} from 'lib/resolve'
3
+ import {logCommand, logTitle} from 'lib/logging'
4
+ import {fail, runCommandSync} from 'lib/command'
5
+
6
+ ///////////////////////////////////////////////////////////////////////////////
7
+
8
+ export type ServeOptions = {
9
+ port: string
10
+ }
11
+
12
+ ///////////////////////////////////////////////////////////////////////////////
13
+
14
+ export const serve = async (options: ServeOptions): Promise<void> => {
15
+ assertOptionsValid(options)
16
+
17
+ const flags = (await prepareFlags([], options)).join(' ')
18
+ const {ifCore} = getIfCoreFns()
19
+ const paths = getPaths(ifCore)
20
+ const cmd = `
21
+ npx -y serve ${paths.appDist}
22
+ --cors
23
+ ${flags}
24
+ `
25
+
26
+ logTitle(`Running web-server for pre-built bundle …`)
27
+ logCommand(cmd)
28
+ runCommandSync(cmd, {stdio: 'inherit'})
29
+ }
30
+
31
+ ///////////////////////////////////////////////////////////////////////////////
32
+
33
+ const assertOptionsValid = (options: ServeOptions) => {
34
+ const {port} = options
35
+
36
+ if (isNaN(Number(port))) {
37
+ fail('Flag "--port" should be a number.')
38
+ }
39
+ }
40
+
41
+ ///////////////////////////////////////////////////////////////////////////////
42
+
43
+ const prepareFlags = async (
44
+ flags: string[],
45
+ options: ServeOptions,
46
+ ): Promise<string[]> => {
47
+ const {port: defaultPort} = options
48
+ const port = await getPortPromise({port: Number(defaultPort)})
49
+
50
+ flags.push(`-p ${port}`)
51
+
52
+ return flags
53
+ }
@@ -1,14 +1,18 @@
1
- import HtmlWebpackPlugin from 'html-webpack-plugin'
2
1
  import {Compiler} from 'webpack'
2
+ import HtmlWebpackPlugin from 'html-webpack-plugin'
3
3
  import escapeStringRegexp from 'escape-string-regexp'
4
4
 
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
 
7
- type Replacements = {[key: string]: any}
7
+ export type Replacements = {[key: string]: any}
8
+
9
+ ///////////////////////////////////////////////////////////////////////////////
10
+
11
+ const PLUGIN_NAME = 'InterpolateHtmlPlugin'
8
12
 
9
13
  ///////////////////////////////////////////////////////////////////////////////
10
14
 
11
- class InterpolateHtmlPlugin {
15
+ export class InterpolateHtmlPlugin {
12
16
  replacements: Replacements
13
17
 
14
18
  constructor(replacements: Replacements) {
@@ -16,16 +20,16 @@ class InterpolateHtmlPlugin {
16
20
  }
17
21
 
18
22
  apply(compiler: Compiler): void {
19
- compiler.hooks.compilation.tap('InterpolateHtmlPlugin', (compilation) => {
23
+ compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
20
24
  const hooks = HtmlWebpackPlugin.getHooks(compilation)
21
25
 
22
- hooks.alterAssetTagGroups.tap('InterpolateHtmlPlugin', (assets) => {
26
+ hooks.alterAssetTagGroups.tap(PLUGIN_NAME, (assets) => {
23
27
  this.replacements['ENTRY'] = assets.headTags[0].attributes['src']
24
28
 
25
29
  return assets
26
30
  })
27
31
 
28
- hooks.afterTemplateExecution.tap('InterpolateHtmlPlugin', (data) => {
32
+ hooks.afterTemplateExecution.tap(PLUGIN_NAME, (data) => {
29
33
  // Run HTML through a series of user-specified string replacements.
30
34
  Object.entries(this.replacements).forEach(([key, value]) => {
31
35
  data.html = data.html.replace(
@@ -39,5 +43,3 @@ class InterpolateHtmlPlugin {
39
43
  })
40
44
  }
41
45
  }
42
-
43
- export default InterpolateHtmlPlugin
package/src/lib/aws.ts ADDED
@@ -0,0 +1,127 @@
1
+ import {execaCommandSync} from 'execa'
2
+ import stripIndent from 'strip-indent'
3
+ import {fail} from 'lib/command'
4
+
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+
7
+ export type AwsOptions = {
8
+ profile: string
9
+ }
10
+
11
+ ///////////////////////////////////////////////////////////////////////////////
12
+
13
+ export const withAwsAuthenticate = <Result = any>(
14
+ fn: () => Result,
15
+ options: AwsOptions,
16
+ ): Result => {
17
+ awsAuthenticate(options)
18
+
19
+ return fn()
20
+ }
21
+
22
+ ///////////////////////////////////////////////////////////////////////////////
23
+
24
+ export const awsAuthenticate = (options: AwsOptions) => {
25
+ if (isSetEnv()) {
26
+ // Don't try to authenticate the user.
27
+ // This may be useful if CLI is running within an automated system like pipeline,
28
+ // where "AWS_ACCESS_KEY_ID" and "AWS_SECRET_ACCESS_KEY" are used instead of "AWS_PROFILE".
29
+ return
30
+ }
31
+
32
+ try {
33
+ checkIdentity(options)
34
+ } catch (error) {
35
+ login(options)
36
+ }
37
+ }
38
+
39
+ ///////////////////////////////////////////////////////////////////////////////
40
+
41
+ // Returns true if "AWS_ACCESS_KEY_ID" and "AWS_SECRET_ACCESS_KEY" environment variables are set.
42
+ export const isSetEnv = () => {
43
+ return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY)
44
+ }
45
+
46
+ ///////////////////////////////////////////////////////////////////////////////
47
+
48
+ export const assertProfileValid = (profile: string) => {
49
+ if (isSetEnv()) {
50
+ return
51
+ }
52
+
53
+ const profiles = getAvailableProfiles()
54
+
55
+ if (!profiles.includes(profile)) {
56
+ console.log(`Using AWS profile: “${profile}”`)
57
+
58
+ let message
59
+
60
+ if (profiles.length === 0) {
61
+ message = stripIndent(`
62
+ No AWS profiles found.
63
+ `)
64
+ } else {
65
+ message = stripIndent(`
66
+ AWS profile “${profile}” has not been found.
67
+ Available profiles are: ${profiles.map((v) => `“${v}”`).join(', ')}.
68
+ `)
69
+ }
70
+
71
+ message += stripIndent(`
72
+ Run “aws configure sso” to set up SSO-enabled profile.
73
+ Check the wiki for more information:
74
+ https://raisenow.atlassian.net/wiki/x/lIrWvg
75
+ `)
76
+
77
+ fail(message)
78
+ }
79
+ }
80
+
81
+ ///////////////////////////////////////////////////////////////////////////////
82
+
83
+ const getAvailableProfiles = (): string[] => {
84
+ const {stdout} = execaCommandSync('aws configure list-profiles')
85
+
86
+ return stdout.split('\n')
87
+ }
88
+
89
+ ///////////////////////////////////////////////////////////////////////////////
90
+
91
+ // Returns caller identity data (output is ignored here).
92
+ // Fails in case of expired SSO session.
93
+ const checkIdentity = (options: AwsOptions) => {
94
+ const flags = prepareFlags([], options).join(' ')
95
+
96
+ execaCommandSync(`aws sts get-caller-identity ${flags}`)
97
+ }
98
+
99
+ ///////////////////////////////////////////////////////////////////////////////
100
+
101
+ // Opens SSO authentication page in the browser.
102
+ // Fails if user canceled authentication process.
103
+ const login = (options: AwsOptions) => {
104
+ const flags = prepareFlags([], options).join(' ')
105
+
106
+ try {
107
+ execaCommandSync(`aws sso login ${flags}`, {stdio: 'inherit'})
108
+ console.log('')
109
+ } catch (error) {
110
+ fail('Login failed.')
111
+ }
112
+ }
113
+
114
+ ///////////////////////////////////////////////////////////////////////////////
115
+
116
+ export const prepareFlags = (
117
+ flags: string[],
118
+ options: AwsOptions,
119
+ ): string[] => {
120
+ const {profile} = options
121
+
122
+ if (!isSetEnv()) {
123
+ flags.push(`--profile ${profile}`)
124
+ }
125
+
126
+ return flags
127
+ }
@@ -0,0 +1,27 @@
1
+ import {execaCommandSync, ExecaSyncReturnValue, SyncOptions} from 'execa'
2
+ import {logError} from 'lib/logging'
3
+
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+
6
+ export const fail = (message?: string) => {
7
+ logError(message)
8
+ process.exit(1)
9
+ }
10
+
11
+ ///////////////////////////////////////////////////////////////////////////////
12
+
13
+ export const prepareCommand = (cmd: string): string => {
14
+ return cmd
15
+ .replace(/\n/gm, ' ')
16
+ .replace(/[ \t]{2,}/gm, ' ')
17
+ .trim()
18
+ }
19
+
20
+ ///////////////////////////////////////////////////////////////////////////////
21
+
22
+ export const runCommandSync = (
23
+ cmd: string,
24
+ options?: SyncOptions,
25
+ ): ExecaSyncReturnValue => {
26
+ return execaCommandSync(prepareCommand(cmd), options)
27
+ }
@@ -0,0 +1,7 @@
1
+ export const DEFAULT_TAG = 'latest'
2
+ export const DEFAULT_PORT = 1234
3
+ export const DEFAULT_AWS_PROFILE = 'payments-prod-cs-deployer'
4
+ export const DEFAULT_AWS_S3_EMAIL_CONFIG_BUCKET = 'rnw-email-service'
5
+ export const AWS_S3_BUCKET = 'tamaro.raisenow.com'
6
+ export const CORE_CONFIG_NAME = 'tamaro-core'
7
+ export const AWS_CLOUDFRONT_DISTRIBUTION_ID = 'EHJ1OM458YQ0I'