Skip to content

Commit 7406627

Browse files
authored
feat: ACNA-4061 - aio app pack should include a lockfile to ensure reproducibility and security (#885)
* add `aio app install --[no]-allow-scripts` flag
1 parent 26b9e6b commit 7406627

5 files changed

Lines changed: 221 additions & 15 deletions

File tree

src/commands/app/install.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const execa = require('execa')
1919
const unzipper = require('unzipper')
2020
const { validateJsonWithSchema } = require('../../lib/install-helper')
2121
const jsYaml = require('js-yaml')
22-
const { USER_CONFIG_FILE, DEPLOY_CONFIG_FILE } = require('../../lib/defaults')
22+
const { USER_CONFIG_FILE, DEPLOY_CONFIG_FILE, PACKAGE_LOCK_FILE } = require('../../lib/defaults')
2323
const ora = require('ora')
2424

2525
// eslint-disable-next-line node/no-missing-require
@@ -51,7 +51,14 @@ class InstallCommand extends BaseCommand {
5151
await this.unzipFile(args.path, outputPath)
5252
await this.validateAppConfig(outputPath, USER_CONFIG_FILE)
5353
await this.validateDeployConfig(outputPath, DEPLOY_CONFIG_FILE)
54-
await this.npmInstall(flags.verbose)
54+
55+
const packageLockPath = path.join(outputPath, PACKAGE_LOCK_FILE)
56+
if (fs.existsSync(packageLockPath)) {
57+
await this.npmCI(flags.verbose, flags['allow-scripts'])
58+
} else {
59+
this.warn('No lockfile found, running standard npm install. It is recommended that you include a lockfile with your app bundle.')
60+
await this.npmInstall(flags.verbose, flags['allow-scripts'])
61+
}
5562
if (flags.tests) {
5663
await this.runTests()
5764
}
@@ -131,15 +138,26 @@ class InstallCommand extends BaseCommand {
131138
}
132139
}
133140

134-
async npmInstall (isVerbose) {
141+
async npmInstall (isVerbose = false, allowScripts = true) {
142+
const ignoreScripts = allowScripts ? undefined : '--ignore-scripts'
135143
this.spinner.start('Running npm install...')
136144
const stdio = isVerbose ? 'inherit' : 'ignore'
137-
return execa('npm', ['install'], { stdio })
145+
return execa('npm', ['install', ignoreScripts], { stdio })
138146
.then(() => {
139147
this.spinner.succeed('Ran npm install')
140148
})
141149
}
142150

151+
async npmCI (isVerbose = false, allowScripts = true) {
152+
const ignoreScripts = allowScripts ? undefined : '--ignore-scripts'
153+
this.spinner.start('Running npm ci...')
154+
const stdio = isVerbose ? 'inherit' : 'ignore'
155+
return execa('npm', ['ci', ignoreScripts], { stdio })
156+
.then(() => {
157+
this.spinner.succeed('Ran npm ci')
158+
})
159+
}
160+
143161
async runTests (isVerbose) {
144162
this.spinner.start('Running app tests...')
145163
return this.config.runCommand('app:test').then((result) => {
@@ -157,6 +175,11 @@ InstallCommand.description = `This command will support installing apps packaged
157175

158176
InstallCommand.flags = {
159177
...BaseCommand.flags,
178+
'allow-scripts': Flags.boolean({
179+
description: 'Allow post and preinstall scripts during npm install/npm ci',
180+
default: true,
181+
allowNo: true
182+
}),
160183
output: Flags.string({
161184
description: 'The packaged app output folder path',
162185
char: 'o',

src/commands/app/pack.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,13 @@ class Pack extends BaseCommand {
7171
this.error(hookResults.failures.map(f => `${f.plugin.name} : ${f.error.message}`).join('\nError: '), { exit: 1 })
7272
}
7373

74+
const filesToInclude = flags['lock-file'] ? ['package-lock.json'] : []
75+
7476
// 1a. Get file list to pack
75-
const fileList = await this.filesToPack({ filesToExclude: [flags.output, DEFAULTS.DIST_FOLDER, ...distLocations] })
77+
const fileList = await this.filesToPack({
78+
filesToExclude: [flags.output, DEFAULTS.DIST_FOLDER, ...distLocations],
79+
filesToInclude
80+
})
7681
this.log('=== Files to pack ===')
7782
fileList.forEach((file) => {
7883
this.log(` ${file}`)
@@ -265,10 +270,11 @@ class Pack extends BaseCommand {
265270
*
266271
* @param {object} options the options for the method
267272
* @param {Array<string>} options.filesToExclude a list of files to exclude
273+
* @param {Array<string>} options.filesToInclude a list of files to include (will always be included)
268274
* @param {string} options.workingDirectory the working directory to run `npm pack` in
269275
* @returns {Array<string>} a list of files that are to be packed
270276
*/
271-
async filesToPack ({ filesToExclude = [], workingDirectory = process.cwd() } = {}) {
277+
async filesToPack ({ filesToExclude = [], filesToInclude = [], workingDirectory = process.cwd() } = {}) {
272278
const { stdout } = await execa('npm', ['pack', '--dry-run', '--json'], { cwd: workingDirectory })
273279

274280
const noJunkFiles = (file) => {
@@ -290,11 +296,14 @@ class Pack extends BaseCommand {
290296
}
291297

292298
const { files } = JSON.parse(stdout)[0]
293-
return files
294-
.map(file => file.path)
295-
.filter(file => !filesToExclude.includes(file))
296-
.filter(noJunkFiles) // no junk files like .DS_Store
297-
.filter(noDotFiles) // no files that start with a '.'
299+
return [
300+
...files
301+
.map(file => file.path)
302+
.filter(file => !filesToExclude.includes(file))
303+
.filter(noJunkFiles) // no junk files like .DS_Store
304+
.filter(noDotFiles), // no files that start with a '.'
305+
...filesToInclude
306+
]
298307
}
299308

300309
/**
@@ -361,6 +370,11 @@ Pack.description = `This command will support packaging apps for redistribution.
361370

362371
Pack.flags = {
363372
...BaseCommand.flags,
373+
'lock-file': Flags.boolean({
374+
description: 'Include the package-lock.json file in the packaged app',
375+
default: true,
376+
allowNo: true
377+
}),
364378
output: Flags.string({
365379
description: 'The packaged app output file path',
366380
char: 'o',

src/lib/defaults.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ module.exports = {
3333
IMPORT_CONFIG_FILE: 'config.json',
3434
USER_CONFIG_FILE: 'app.config.yaml',
3535
DEPLOY_CONFIG_FILE: 'deploy.yaml',
36+
PACKAGE_LOCK_FILE: 'package-lock.json',
3637
LEGACY_RUNTIME_MANIFEST: 'manifest.yml',
3738
INCLUDE_DIRECTIVE: '$include',
3839
APPLICATION_CONFIG_KEY: 'application',

test/commands/app/install.test.js

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ governing permissions and limitations under the License.
1313
const TheCommand = require('../../../src/commands/app/install.js')
1414
const BaseCommand = require('../../../src/BaseCommand.js')
1515
const fs = require('fs-extra')
16+
const { PACKAGE_LOCK_FILE } = require('../../../src/lib/defaults.js')
1617
const unzipper = require('unzipper')
1718
const execa = require('execa')
1819
const installHelper = require('../../../src/lib/install-helper')
@@ -221,16 +222,15 @@ describe('npmInstall', () => {
221222
command = new TheCommand()
222223
})
223224

224-
test('success', async () => {
225+
test('success (defaults)', async () => {
225226
execa.mockImplementationOnce((cmd, args, options) => {
226227
expect(cmd).toEqual('npm')
227228
expect(args).toEqual(['install'])
228229
expect(options.stdio).toEqual('ignore')
229230
return Promise.resolve({ stdout: '' })
230231
})
231232

232-
const isVerbose = false
233-
await expect(command.npmInstall(isVerbose)).resolves.toEqual(undefined)
233+
await expect(command.npmInstall()).resolves.toEqual(undefined)
234234
})
235235

236236
test('success --verbose', async () => {
@@ -245,6 +245,19 @@ describe('npmInstall', () => {
245245
await expect(command.npmInstall(isVerbose)).resolves.toEqual(undefined)
246246
})
247247

248+
test('success --verbose --no-allow-scripts', async () => {
249+
execa.mockImplementationOnce((cmd, args, options) => {
250+
expect(cmd).toEqual('npm')
251+
expect(args).toEqual(['install', '--ignore-scripts'])
252+
expect(options.stdio).toEqual('inherit')
253+
return Promise.resolve({ stdout: '' })
254+
})
255+
256+
const isVerbose = true
257+
const allowScripts = false
258+
await expect(command.npmInstall(isVerbose, allowScripts)).resolves.toEqual(undefined)
259+
})
260+
248261
test('failure', async () => {
249262
const errorMessage = 'npm install error'
250263

@@ -258,6 +271,63 @@ describe('npmInstall', () => {
258271
})
259272
})
260273

274+
describe('npmCI', () => {
275+
let command
276+
277+
beforeEach(() => {
278+
execa.mockReset()
279+
command = new TheCommand()
280+
})
281+
282+
test('success (defaults)', async () => {
283+
execa.mockImplementationOnce((cmd, args, options) => {
284+
expect(cmd).toEqual('npm')
285+
expect(args).toEqual(['ci'])
286+
expect(options.stdio).toEqual('ignore')
287+
return Promise.resolve({ stdout: '' })
288+
})
289+
290+
await expect(command.npmCI()).resolves.toEqual(undefined)
291+
})
292+
293+
test('success --verbose', async () => {
294+
execa.mockImplementationOnce((cmd, args, options) => {
295+
expect(cmd).toEqual('npm')
296+
expect(args).toEqual(['ci'])
297+
expect(options.stdio).toEqual('inherit')
298+
return Promise.resolve({ stdout: '' })
299+
})
300+
301+
const isVerbose = true
302+
await expect(command.npmCI(isVerbose)).resolves.toEqual(undefined)
303+
})
304+
305+
test('success --verbose --no-allow-scripts', async () => {
306+
execa.mockImplementationOnce((cmd, args, options) => {
307+
expect(cmd).toEqual('npm')
308+
expect(args).toEqual(['ci', '--ignore-scripts'])
309+
expect(options.stdio).toEqual('inherit')
310+
return Promise.resolve({ stdout: '' })
311+
})
312+
313+
const isVerbose = true
314+
const allowScripts = false
315+
await expect(command.npmCI(isVerbose, allowScripts)).resolves.toEqual(undefined)
316+
})
317+
318+
test('failure', async () => {
319+
const errorMessage = 'npm ci error'
320+
321+
execa.mockImplementationOnce((cmd, args) => {
322+
expect(cmd).toEqual('npm')
323+
expect(args).toEqual(['ci'])
324+
throw new Error(errorMessage)
325+
})
326+
327+
await expect(command.npmCI()).rejects.toThrow(errorMessage)
328+
})
329+
})
330+
261331
describe('runTests', () => {
262332
let command
263333

@@ -284,7 +354,11 @@ describe('runTests', () => {
284354
})
285355

286356
describe('run', () => {
287-
test('no flags', async () => {
357+
beforeEach(() => {
358+
fs.existsSync.mockReset()
359+
})
360+
361+
test('no flags, no lockfile', async () => {
288362
const command = new TheCommand()
289363
command.argv = ['my-app.zip']
290364

@@ -295,6 +369,7 @@ describe('run', () => {
295369
command.validateDeployConfig = jest.fn()
296370
command.runTests = jest.fn()
297371
command.npmInstall = jest.fn()
372+
command.npmCI = jest.fn()
298373
command.error = jest.fn()
299374
await command.run()
300375

@@ -305,6 +380,36 @@ describe('run', () => {
305380
expect(command.validateDeployConfig).toHaveBeenCalledTimes(1)
306381
expect(command.runTests).toHaveBeenCalledTimes(1)
307382
expect(command.npmInstall).toHaveBeenCalledTimes(1)
383+
expect(command.npmCI).toHaveBeenCalledTimes(0)
384+
expect(command.error).toHaveBeenCalledTimes(0)
385+
})
386+
387+
test('no flags, has lockfile', async () => {
388+
const command = new TheCommand()
389+
command.argv = ['my-app.zip']
390+
391+
// since we already unit test the methods above, we mock it here
392+
command.validateZipDirectoryStructure = jest.fn()
393+
command.unzipFile = jest.fn()
394+
command.addCodeDownloadAnnotation = jest.fn()
395+
command.validateDeployConfig = jest.fn()
396+
command.runTests = jest.fn()
397+
command.npmInstall = jest.fn()
398+
command.npmCI = jest.fn()
399+
command.error = jest.fn()
400+
401+
fs.existsSync.mockImplementation((filePath) => filePath === PACKAGE_LOCK_FILE)
402+
403+
await command.run()
404+
405+
expect(command.validateZipDirectoryStructure).toHaveBeenCalledTimes(1)
406+
expect(command.unzipFile).toHaveBeenCalledTimes(1)
407+
expect(libConfig.coalesce).toHaveBeenCalledTimes(1)
408+
expect(libConfig.validate).toHaveBeenCalledTimes(1)
409+
expect(command.validateDeployConfig).toHaveBeenCalledTimes(1)
410+
expect(command.runTests).toHaveBeenCalledTimes(1)
411+
expect(command.npmInstall).toHaveBeenCalledTimes(0)
412+
expect(command.npmCI).toHaveBeenCalledTimes(1)
308413
expect(command.error).toHaveBeenCalledTimes(0)
309414
})
310415

@@ -321,6 +426,7 @@ describe('run', () => {
321426
command.addCodeDownloadAnnotation = jest.fn()
322427
command.validateDeployConfig = jest.fn()
323428
command.npmInstall = jest.fn()
429+
command.npmCI = jest.fn()
324430
command.error = jest.fn()
325431
command.runTests = jest.fn(() => { throw errorObject })
326432

@@ -351,6 +457,7 @@ describe('run', () => {
351457
command.addCodeDownloadAnnotation = jest.fn()
352458
command.validateDeployConfig = jest.fn()
353459
command.npmInstall = jest.fn()
460+
command.npmCI = jest.fn()
354461
command.error = jest.fn()
355462
command.runTests = jest.fn(() => { throw new Error(errorMessage) })
356463

@@ -379,6 +486,7 @@ describe('run', () => {
379486
command.validateDeployConfig = jest.fn()
380487
command.runTests = jest.fn()
381488
command.npmInstall = jest.fn()
489+
command.npmCI = jest.fn()
382490
command.error = jest.fn()
383491

384492
await command.run()
@@ -405,6 +513,7 @@ describe('run', () => {
405513
command.validateDeployConfig = jest.fn()
406514
command.runTests = jest.fn()
407515
command.npmInstall = jest.fn()
516+
command.npmCI = jest.fn()
408517
command.error = jest.fn()
409518

410519
const err = new Error('fake validation error')
@@ -452,6 +561,7 @@ describe('run', () => {
452561
command.validateDeployConfig = jest.fn()
453562
command.runTests = jest.fn()
454563
command.npmInstall = jest.fn()
564+
command.npmCI = jest.fn()
455565
command.error = jest.fn()
456566

457567
await command.run()

0 commit comments

Comments
 (0)