From 19c916eeb14670f9c579b6d5eab1ee6a37442cfb Mon Sep 17 00:00:00 2001 From: Moritz Raho Date: Mon, 13 Nov 2023 22:11:53 +0100 Subject: [PATCH] Add support for quickstart apps (#679) * Add support for quickstart apps * move quick-start to repo * nit: download and install repo first * use path.relative, specific error handling * clean up, linting, coverage 100% * use ora for repo download status, test/mocks, todos --------- Co-authored-by: Jesse MacFadyen --- package.json | 1 + src/commands/app/init.js | 153 +++++++++++++++++++++++++-------- test/commands/app/init.test.js | 115 +++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index b35e1dbc..d7d996d6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@adobe/generator-aio-app": "^6.0.0", "@adobe/generator-app-common-lib": "^1.0.0", "@adobe/inquirer-table-checkbox": "^1.2.0", + "@octokit/rest": "^19.0.11", "@oclif/core": "^2.11.6", "@parcel/core": "^2.7.0", "@parcel/reporter-cli": "^2.7.0", diff --git a/src/commands/app/init.js b/src/commands/app/init.js index ddb3c7f6..22cf325f 100644 --- a/src/commands/app/init.js +++ b/src/commands/app/init.js @@ -20,9 +20,10 @@ const generators = require('@adobe/generator-aio-app') const TemplateRegistryAPI = require('@adobe/aio-lib-templates') const inquirer = require('inquirer') const hyperlinker = require('hyperlinker') - const { importConsoleConfig } = require('../../lib/import') const { loadAndValidateConfigFile } = require('../../lib/import-helper') +const { Octokit } = require('@octokit/rest') +const aioLogger = require('@adobe/aio-lib-core-logging')('@adobe/aio-cli-plugin-app:init', { provider: 'debug' }) const DEFAULT_WORKSPACE = 'Stage' @@ -102,24 +103,28 @@ class InitCommand extends TemplatesCommand { this.log(chalk.green(`Loaded Adobe Developer Console configuration file for the Project '${consoleConfig.project.title}' in the Organization '${consoleConfig.project.org.name}'`)) } - // 2. prompt for templates to be installed - const templates = await this.getTemplatesForFlags(flags) - // If no templates selected, init a standalone app - if (templates.length <= 0) { - flags['standalone-app'] = true - } + if (flags.repo) { + await this.withQuickstart(flags.repo) + } else { + // 2. prompt for templates to be installed + const templates = await this.getTemplatesForFlags(flags) + // If no templates selected, init a standalone app + if (templates.length <= 0) { + flags['standalone-app'] = true + } - // 3. run base code generators - const projectName = (consoleConfig && consoleConfig.project.name) || path.basename(process.cwd()) - await this.runCodeGenerators(this.getInitialGenerators(flags), flags.yes, projectName) + // 3. run base code generators + const projectName = (consoleConfig && consoleConfig.project.name) || path.basename(process.cwd()) + await this.runCodeGenerators(this.getInitialGenerators(flags), flags.yes, projectName) - // 4. install templates - await this.installTemplates({ - useDefaultValues: flags.yes, - installNpm: flags.install, - installConfig: flags.login, - templates - }) + // 4. install templates + await this.installTemplates({ + useDefaultValues: flags.yes, + installNpm: flags.install, + installConfig: flags.login, + templates + }) + } // 5. import config - if any if (flags.import) { @@ -128,40 +133,50 @@ class InitCommand extends TemplatesCommand { } async initWithLogin (flags) { + if (flags.repo) { + await this.withQuickstart(flags.repo) + } // this will trigger a login const consoleCLI = await this.getLibConsoleCLI() // 1. select org - const org = await this.selectConsoleOrg(consoleCLI) + const org = await this.selectConsoleOrg(consoleCLI, flags) // 2. get supported services const orgSupportedServices = await consoleCLI.getEnabledServicesForOrg(org.id) // 3. select or create project - const project = await this.selectOrCreateConsoleProject(consoleCLI, org) + const project = await this.selectOrCreateConsoleProject(consoleCLI, org, flags) // 4. retrieve workspace details, defaults to Stage const workspace = await this.retrieveWorkspaceFromName(consoleCLI, org, project, flags) - // 5. get list of templates to install - const templates = await this.getTemplatesForFlags(flags, orgSupportedServices) - // If no templates selected, init a standalone app - if (templates.length <= 0) { - flags['standalone-app'] = true + let templates + if (!flags.repo) { + // 5. get list of templates to install + templates = await this.getTemplatesForFlags(flags, orgSupportedServices) + // If no templates selected, init a standalone app + if (templates.length <= 0) { + flags['standalone-app'] = true + } } // 6. download workspace config const consoleConfig = await consoleCLI.getWorkspaceConfig(org.id, project.id, workspace.id, orgSupportedServices) // 7. run base code generators - await this.runCodeGenerators(this.getInitialGenerators(flags), flags.yes, consoleConfig.project.name) + if (!flags.repo) { + await this.runCodeGenerators(this.getInitialGenerators(flags), flags.yes, consoleConfig.project.name) + } // 8. import config await this.importConsoleConfig(consoleConfig, flags) // 9. install templates - await this.installTemplates({ - useDefaultValues: flags.yes, - installNpm: flags.install, - templates - }) + if (!flags.repo) { + await this.installTemplates({ + useDefaultValues: flags.yes, + installNpm: flags.install, + templates + }) + } this.log(chalk.blue(chalk.bold(`Project initialized for Workspace ${workspace.name}, you can run 'aio app use -w ' to switch workspace.`))) } @@ -275,21 +290,24 @@ class InitCommand extends TemplatesCommand { return [searchCriteria, orderByCriteria, selection, selectionLabel] } - async selectConsoleOrg (consoleCLI) { + async selectConsoleOrg (consoleCLI, flags) { const organizations = await consoleCLI.getOrganizations() - const selectedOrg = await consoleCLI.promptForSelectOrganization(organizations) + const selectedOrg = await consoleCLI.promptForSelectOrganization(organizations, { orgId: flags.org, orgCode: flags.org }) await this.ensureDevTermAccepted(consoleCLI, selectedOrg.id) return selectedOrg } - async selectOrCreateConsoleProject (consoleCLI, org) { + async selectOrCreateConsoleProject (consoleCLI, org, flags) { const projects = await consoleCLI.getProjects(org.id) let project = await consoleCLI.promptForSelectProject( projects, - {}, + { projectId: flags.project, projectName: flags.project }, { allowCreate: true } ) if (!project) { + if (flags.project) { + this.error(`--project ${flags.project} not found`) + } // user has escaped project selection prompt, let's create a new one const projectDetails = await consoleCLI.promptForCreateProjectDetails() project = await consoleCLI.createProject(org.id, projectDetails) @@ -348,7 +366,56 @@ class InitCommand extends TemplatesCommand { } ) } + + async withQuickstart (fullRepo) { + const octokit = new Octokit({ + auth: '' + }) + const spinner = ora('Downloading quickstart repo').start() + /** @private */ + async function downloadRepoDirRecursive (owner, repo, filePath, basePath) { + const { data } = await octokit.repos.getContent({ owner, repo, path: filePath }) + for (const fileOrDir of data) { + if (fileOrDir.type === 'dir') { + const destDir = path.relative(basePath, fileOrDir.path) + fs.ensureDirSync(destDir) + await downloadRepoDirRecursive(owner, repo, fileOrDir.path, basePath) + } else { + // todo: use a spinner + spinner.text = `Downloading ${fileOrDir.path}` + const response = await fetch(fileOrDir.download_url) + const jsonResponse = await response.text() + fs.writeFileSync(path.relative(basePath, fileOrDir.path), jsonResponse) + } + } + } + // we need to handle n-deep paths, /// + const [owner, repo, ...restOfPath] = fullRepo.split('/') + const basePath = restOfPath.join('/') + try { + const response = await octokit.repos.getContent({ owner, repo, path: `${basePath}/app.config.yaml` }) + aioLogger.debug(`github headers: ${JSON.stringify(response.headers, 0, 2)}`) + await downloadRepoDirRecursive(owner, repo, basePath, basePath) + spinner.succeed('Downloaded quickstart repo') + } catch (e) { + if (e.status === 404) { + spinner.fail('Quickstart repo not found') + this.error('--repo does not point to a valid Adobe App Builder app') + } + if (e.status === 403) { + // This is helpful for debugging, but by default we don't show it + // github rate limit is 60 requests per hour for unauthenticated users + const resetTime = new Date(e.response.headers['x-ratelimit-reset'] * 1000) + aioLogger.debug(`too many requests, resetTime : ${resetTime.toLocaleTimeString()}`) + spinner.fail() + this.error('too many requests, please try again later') + } else { + this.error(e) + } + } + } } + InitCommand.description = `Create a new Adobe I/O App ` @@ -372,18 +439,28 @@ InitCommand.flags = { description: 'Extension point(s) to implement', char: 'e', multiple: true, - exclusive: ['template'] + exclusive: ['template', 'repo'] }), 'standalone-app': Flags.boolean({ description: 'Create a stand-alone application', default: false, - exclusive: ['template'] + exclusive: ['template', 'repo'] }), template: Flags.string({ description: 'Specify a link to a template that will be installed', char: 't', multiple: true }), + org: Flags.string({ + description: 'Specify the Adobe Developer Console Org to init from', + hidden: true, + exclusive: ['import'] // also no-login + }), + project: Flags.string({ + description: 'Specify the Adobe Developer Console Project to init from', + hidden: true, + exclusive: ['import'] // also no-login + }), workspace: Flags.string({ description: 'Specify the Adobe Developer Console Workspace to init from, defaults to Stage', default: DEFAULT_WORKSPACE, @@ -394,6 +471,10 @@ InitCommand.flags = { description: 'Skip and confirm prompt for creating a new workspace', default: false }), + repo: Flags.string({ + description: 'Init from gh quick-start repo. Expected to be of the form //', + exclusive: ['template', 'extension', 'standalone-app'] + }), 'use-jwt': Flags.boolean({ description: 'if the config has both jwt and OAuth Server to Server Credentials (while migrating), prefer the JWT credentials', default: false diff --git a/test/commands/app/init.test.js b/test/commands/app/init.test.js index b031ee30..95e75c07 100644 --- a/test/commands/app/init.test.js +++ b/test/commands/app/init.test.js @@ -17,6 +17,7 @@ const importHelperLib = require('../../../src/lib/import-helper') const inquirer = require('inquirer') const savedDataDir = process.env.XDG_DATA_HOME const yeoman = require('yeoman-environment') +const { Octokit } = require('@octokit/rest') jest.mock('@adobe/aio-lib-core-config') jest.mock('fs-extra') @@ -27,6 +28,22 @@ jest.mock('inquirer', () => ({ createPromptModule: jest.fn() })) +// mock ora +jest.mock('ora', () => { + const mockOra = { + start: jest.fn(() => mockOra), + stop: jest.fn(() => mockOra), + succeed: jest.fn(() => mockOra), + fail: jest.fn(() => mockOra), + info: jest.fn(() => mockOra), + warn: jest.fn(() => mockOra), + stopAndPersist: jest.fn(() => mockOra), + clear: jest.fn(() => mockOra), + promise: jest.fn(() => Promise.resolve(mockOra)) + } + return jest.fn(() => mockOra) +}) + // mock login jest.mock('@adobe/aio-lib-ims') @@ -72,6 +89,8 @@ yeoman.createEnv.mockReturnValue({ runGenerator: jest.fn() }) +jest.mock('@octokit/rest') + // FAKE DATA /////////////////////// // // some fake data @@ -166,6 +185,8 @@ beforeEach(() => { importHelperLib.importConfigJson.mockReset() importHelperLib.loadConfigFile.mockReturnValue({ values: fakeConfig }) + + Octokit.mockReset() }) afterAll(() => { @@ -231,6 +252,17 @@ describe('bad args/flags', () => { }) }) +describe('--project', () => { + test('no value', async () => { + command.argv = ['--project'] + await expect(command.run()).rejects.toThrow('Flag --project expects a value') + }) + test('non-existent', async () => { + command.argv = ['--project=non-existent'] + await expect(command.run()).rejects.toThrow('--project non-existent not found') + }) +}) + describe('--no-login', () => { test('select excshell, arg: /otherdir', async () => { const installOptions = { @@ -285,6 +317,89 @@ describe('--no-login', () => { expect(importHelperLib.importConfigJson).not.toHaveBeenCalled() }) + test('--repo --no-login', async () => { + const getContent = () => new Promise((resolve, reject) => { + resolve({ headers: [], status: 302, data: [] }) + }) + Octokit.mockImplementation(() => ({ repos: { getContent } })) + + command.argv = ['--no-login', '--repo=adobe/appbuilder-quickstarts/qr-code'] + await command.run() + + expect(command.installTemplates).not.toHaveBeenCalled() + expect(LibConsoleCLI.init).not.toHaveBeenCalled() + expect(importHelperLib.importConfigJson).not.toHaveBeenCalled() + }) + + test('--repo --login', async () => { + const getContent = ({ owner, repo, path }) => new Promise((resolve, reject) => { + // console.log('args = ', owner, repo, path) + if (path === 'src') { + resolve({ data: [] }) + } else { + resolve({ + data: [{ + type: 'file', + path: '.gitignore', + download_url: 'https://raw.githubusercontent.com/adobe/appbuilder-quickstarts/master/qr-code/.gitignore' + }, { + type: 'dir', + path: 'src' + }] + }) + } + }) + Octokit.mockImplementation(() => ({ repos: { getContent } })) + + command.argv = ['--login', '--repo=adobe/appbuilder-quickstarts/qr-code'] + await command.run() + + expect(command.installTemplates).not.toHaveBeenCalled() + expect(LibConsoleCLI.init).toHaveBeenCalled() + expect(importHelperLib.importConfigJson).toHaveBeenCalled() + }) + + test('--repo not valid 404', async () => { + const getContent = () => new Promise((resolve, reject) => { + // console.log('rejecting with 404') + const error = new Error('the error message is not checked, just the status code') + error.status = 404 + reject(error) + }) + Octokit.mockImplementation(() => ({ repos: { getContent } })) + + command.error = jest.fn() + command.argv = ['--no-login', '--repo=adobe/appbuilder-quickstarts/dne'] + + await command.run() + + expect(command.error).toHaveBeenCalledWith('--repo does not point to a valid Adobe App Builder app') + expect(command.installTemplates).not.toHaveBeenCalled() + expect(LibConsoleCLI.init).not.toHaveBeenCalled() + expect(importHelperLib.importConfigJson).not.toHaveBeenCalled() + }) + + test('--repo not reachable 403', async () => { + const getContent = () => new Promise((resolve, reject) => { + // console.log('rejecting with 403') + const error = new Error('the error message is not checked, just the status code') + error.response = { headers: { 'x-ratelimit-reset': 99999999999 } } + error.status = 403 + reject(error) + }) + Octokit.mockImplementation(() => ({ repos: { getContent } })) + + command.error = jest.fn() + command.argv = ['--no-login', '--repo=adobe/appbuilder-quickstarts/dne'] + + await command.run() + + expect(command.error).toHaveBeenCalledWith('too many requests, please try again later') + expect(command.installTemplates).not.toHaveBeenCalled() + expect(LibConsoleCLI.init).not.toHaveBeenCalled() + expect(importHelperLib.importConfigJson).not.toHaveBeenCalled() + }) + test('--yes --no-install, select excshell', async () => { const installOptions = { useDefaultValues: true,