diff --git a/bin/glitch-deploy.js b/bin/glitch-deploy.js index eee50ae..c2940aa 100755 --- a/bin/glitch-deploy.js +++ b/bin/glitch-deploy.js @@ -1,238 +1,110 @@ #!/usr/bin/env node -const pathResolve = require('path').resolve - -const axios = require('axios') const debug = require('debug')('glitch-deploy') -const getProperty = require('lodash/get') -const githubFromGit = require('github-url-from-git') -const githubNameRegex = require('github-username-regex') -const inquirer = require('inquirer') + +if (process.env.CI) { + debug('Running on CI, checking for environment varibales') + require('../lib/env.js') +} + const puppeteer = require('puppeteer') -const passwordStorage = require('../lib/password-storage')('github') +const authorizeGithubAccess = require('../lib/authorize-github-access') +const authorizeGithubRepoAccess = require('../lib/authorize-github-repo-access') +const createGlitchProject = require('../lib/create-glitch-project') +const getGithubCredentials = require('../lib/get-github-credentials') +const getGlitchApp = require('../lib/get-glitch-app') +const getGlitchNameAndRepo = require('../lib/get-glitch-name-and-repo') +const getPackageDefaults = require('../lib/get-package-defaults') +const handleGithub2faAuthentication = require('../lib/handle-github-2fa-authentication') +const importGitHubRepo = require('../lib/import-github-repo') ;(async () => { - const pkgDefaults = getPackageDefaults() - - const browser = await puppeteer.launch({ - // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions - headless: !process.env.SHOW_BROWSER - }) + const state = { + debug: debug, + defaults: getPackageDefaults(), + browser: await puppeteer.launch({ + // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions + headless: !process.env.SHOW_BROWSER + }) + } process.on('unhandledRejection', async (error) => { console.log(error) - await browser.close() + await state.browser.close() process.exit(1) }) - const page = await browser.newPage() - - // await page.goto('https://glitch.com') - await page.goto('https://glitch.com', {waitUntil: 'networkidle'}) - + state.page = await state.browser.newPage() + await state.page.goto('https://glitch.com', {waitUntil: 'networkidle'}) debug('glitch.com loaded') // click on login with GitHub // page.click does not work with given selector - // await page.click('[href^="https://github.com/login/oauth/authorize"]') - await page.evaluate(() => { + // await state.page.click('[href^="https://github.com/login/oauth/authorize"]') + await state.page.evaluate(() => { document.querySelector('[href^="https://github.com/login/oauth/authorize"]').click() }) - debug('GitHub login clicked') - - await page.waitForNavigation() - - const githubLoginAnswers = await inquirer.prompt([{ - type: 'input', - name: 'username', - default: pkgDefaults.username, - message: 'What is your GitHub username?', - validate: (username) => String(username).length > 0 - }, { - type: 'password', - name: 'password', - message: 'What is your GitHub password?', - validate: (password) => String(password).length > 0, - when: answers => passwordStorage.get(answers.username).then(result => result === null) - }]) - - const passwordFromKeychain = await passwordStorage.get(githubLoginAnswers.username) - - if (passwordFromKeychain) { - debug('Loaded password from keychain') - githubLoginAnswers.password = passwordFromKeychain - } - - await page.focus('[name=login]') - await page.type(githubLoginAnswers.username) - - await page.focus('[name=password]') - await page.type(githubLoginAnswers.password) - - await page.click('[type=submit]') - await page.waitForNavigation() - - const twoFactorTokenInput = await page.$('[name=otp]') + debug('Login with GitHub') + await state.page.waitForNavigation() - if (twoFactorTokenInput) { - debug('github.com: Two Factor Authentication prompt') - const tokenAnswer = await inquirer.prompt([{ - type: 'input', - name: 'code', - message: 'What is your GitHub two-factor authentication code?' - }]) + const githubCredentials = await getGithubCredentials(state) + state.githubUsername = githubCredentials.username - await page.focus('[name=otp]') - await page.type(tokenAnswer.code) + await state.page.focus('[name=login]') + await state.page.type(githubCredentials.username) + await state.page.focus('[name=password]') + await state.page.type(githubCredentials.password) + await state.page.click('[type=submit]') + await state.page.waitForNavigation() - await page.click('[type=submit]') - await page.waitForNavigation() - debug('2Fa code submitted') - } - - const oauthAuthorizeButton = await page.$('form[action="/login/oauth/authorize"]') + await handleGithub2faAuthentication(state) + await authorizeGithubAccess(state) - if (oauthAuthorizeButton) { - debug('github.com: Oauth request page for glitch.com') - await page.waitForSelector('button[type=submit]:not([disabled])') - await page.click('button[type=submit]') - await page.waitForNavigation() - debug('github.com: access granted to glitch.com') - } + const glitchInfo = await getGlitchNameAndRepo(state) + state.glitchDomain = glitchInfo.appName + state.repoName = glitchInfo.repoName - const glitchAnswers = await inquirer.prompt([{ - type: 'input', - name: 'appName', - default: pkgDefaults.name, - message: 'To what Glitch app do you want to deploy (https://.glitch.com)?', - validate: (appName) => String(appName).length > 0 - }, { - type: 'confirm', - name: 'doCreate', - message: 'App does not yet exist, do you want to create it', - when: answers => glitchAppDoesNotExists(answers.appName) - }, { - type: 'input', - name: 'repoName', - default: pkgDefaults.repo, - message: 'What repository do you want to deploy (https://github.com/, e.g. "octocat/Hello-World")', - validate: (name) => { - const [owner, repoName] = name.split('/') - return githubNameRegex.test(owner) && githubNameRegex.test(repoName) && name === `${owner}/${repoName}` - }, - when: answers => !answers.hasOwnProperty('doCreate') || answers.doCreate - }]) - - let glitchApp = await getGlitchApp(glitchAnswers.appName) + let glitchApp = await getGlitchApp(state, glitchInfo.appName) // abort if app does not exist and user does not want to create it - if (glitchApp === null && !glitchAnswers.doCreate) { + if (glitchApp === null && !glitchInfo.doCreate) { debug('aborting') - return browser.close() + return state.browser.close() } - const glitchAuthToken = await page.evaluate(() => JSON.parse(window.localStorage.getItem('cachedUser')).persistentToken) - debug(`glitchAuthToken: ${glitchAuthToken}`) + const glitchAuthToken = await state.page.evaluate(() => JSON.parse(window.localStorage.getItem('cachedUser')).persistentToken) + debug(`glitchAuthToken: ${glitchAuthToken.substr(0, 5)}***`) - if (glitchAnswers.doCreate) { - debug(`creating ${glitchAnswers.appName}`) - glitchApp = await createGlitchProject({ + if (glitchApp === null && glitchInfo.doCreate) { + debug(`creating ${glitchInfo.appName}`) + glitchApp = await createGlitchProject(state, { authToken: glitchAuthToken, - domain: glitchAnswers.appName + domain: glitchInfo.appName }) debug(`${glitchApp.domain} created (id: ${glitchApp.id})`) } - debug(`Opening https://glitch.com/edit/#!/${glitchAnswers.appName}`) - await page.goto(`https://glitch.com/edit/#!/${glitchAnswers.appName}`, {waitUntil: 'networkidle'}) - await page.waitFor(() => /\?path=/.test(window.location.hash)) - debug(`${glitchAnswers.appName} opened`) - - // check if repo access granted yet - const hasReadAccess = await page.evaluate((repoName) => { - console.log('repoName', repoName) - return window.application.checkGitHubReadPermissions(repoName) - }, glitchAnswers.repoName) - - if (!hasReadAccess) { - debug(`Repo access not yet granted to Glitch. Granting now `) - await page.evaluate(() => window.application.ensureGitHubReadPermissions()) - await page.waitForNavigation() - await page.waitForSelector('button[type=submit]:not([disabled])') - await page.click('button[type=submit]') - await page.waitForNavigation() - debug('github.com: repo access granted to glitch.com') - } + debug(`Opening https://glitch.com/edit/#!/${glitchInfo.appName}`) + await state.page.goto(`https://glitch.com/edit/#!/${glitchInfo.appName}`, {waitUntil: 'networkidle'}) + await state.page.waitFor(() => /\?path=/.test(window.location.hash)) + debug(`${glitchInfo.appName} opened`) + + await authorizeGithubRepoAccess(state) - await importGitHubRepo({ + await importGitHubRepo(state, { authToken: glitchAuthToken, appId: glitchApp.id, - repoName: glitchAnswers.repoName + repoName: glitchInfo.repoName }) debug('Waiting for server to start ...') - await page.waitForSelector('.status.success') + await state.page.waitForSelector('.status.success') debug(`Opening https://${glitchApp.domain}.glitch.me ...`) - await page.goto(`https://${glitchApp.domain}.glitch.me`, {waitUntil: 'networkidle'}) + await state.page.goto(`https://${glitchApp.domain}.glitch.me`, {waitUntil: 'networkidle'}) - console.log(`${glitchAnswers.repoName} deployed to https://${glitchApp.domain}.glitch.me`) - browser.close() + console.log(`${glitchInfo.repoName} deployed to https://${glitchApp.domain}.glitch.me`) + state.browser.close() })() - -async function getGlitchApp (appName) { - const url = `https://api.glitch.com/projects/${appName}` - debug(`GET ${url}`) - const response = await axios.get(url) - return response.data -} -async function glitchAppDoesNotExists (appName) { - const app = await getGlitchApp(appName) - return app === null -} - -function getPackageDefaults () { - try { - const pkg = require(pathResolve(process.cwd(), 'package.json')) - const url = githubFromGit(getProperty(pkg, 'repository.url')) - const [owner, repoName] = url.substr('https://github.com/'.length).split('/') - return { - name: pkg.name, - username: owner, - repo: `${owner}/${repoName}` - } - } catch (error) { - return {} - } -} - -async function createGlitchProject ({authToken, domain}) { - const data = { - domain - } - - debug(`creating Glitch app with ${JSON.stringify(data)}`) - - const response = await axios({ - method: 'post', - url: `https://api.glitch.com/projects?authorization=${authToken}`, - data: data - }) - - return response.data -} - -async function importGitHubRepo ({authToken, appId, repoName}) { - debug(`Importing ${repoName} ...`) - - const response = await axios({ - method: 'post', - url: `https://api.glitch.com/project/githubImport?token=${authToken}&projectId=${appId}&repo=${encodeURIComponent(repoName)}` - }) - - debug(`${repoName} imported`) - - return response.status === 200 -} diff --git a/lib/authorize-github-access.js b/lib/authorize-github-access.js new file mode 100644 index 0000000..19a051e --- /dev/null +++ b/lib/authorize-github-access.js @@ -0,0 +1,13 @@ +module.exports = authorizeGithubAccess + +async function authorizeGithubAccess (state) { + const oauthAuthorizeButton = await state.page.$('form[action="/login/oauth/authorize"]') + + if (oauthAuthorizeButton) { + state.debug('github.com: Oauth request page for glitch.com') + await state.page.waitForSelector('button[type=submit]:not([disabled])') + await state.page.click('button[type=submit]') + await state.page.waitForNavigation() + state.debug('github.com: access granted to glitch.com') + } +} diff --git a/lib/authorize-github-repo-access.js b/lib/authorize-github-repo-access.js new file mode 100644 index 0000000..bcc2ffb --- /dev/null +++ b/lib/authorize-github-repo-access.js @@ -0,0 +1,18 @@ +module.exports = authorizeGithubRepoAccess + +async function authorizeGithubRepoAccess (state) { + const hasReadAccess = await state.page.evaluate((repoName) => { + console.log('repoName', repoName) + return window.application.checkGitHubReadPermissions(repoName) + }, state.repoName) + + if (!hasReadAccess) { + state.debug(`Repo access not yet granted to Glitch. Granting now `) + await state.page.evaluate(() => window.application.ensureGitHubReadPermissions()) + await state.page.waitForNavigation() + await state.page.waitForSelector('button[type=submit]:not([disabled])') + await state.page.click('button[type=submit]') + await state.page.waitForNavigation() + state.debug('github.com: repo access granted to glitch.com') + } +} diff --git a/lib/create-glitch-project.js b/lib/create-glitch-project.js new file mode 100644 index 0000000..d0f9a3e --- /dev/null +++ b/lib/create-glitch-project.js @@ -0,0 +1,19 @@ +module.exports = createGlitchProject + +const axios = require('axios') + +async function createGlitchProject (state, {authToken, domain}) { + const data = { + domain + } + + state.debug(`creating Glitch app with ${JSON.stringify(data)}`) + + const response = await axios({ + method: 'post', + url: `https://api.glitch.com/projects?authorization=${authToken}`, + data: data + }) + + return response.data +} diff --git a/lib/env.js b/lib/env.js new file mode 100644 index 0000000..e9d29ca --- /dev/null +++ b/lib/env.js @@ -0,0 +1,8 @@ +const envalid = require('envalid') + +module.exports = envalid.cleanEnv(process.env, { + GITHUB_USERNAME: envalid.str(), + GITHUB_PASSWORD: envalid.str(), + GITHUB_REPO: envalid.str(), + GLITCH_DOMAIN: envalid.str() +}) diff --git a/lib/get-github-credentials.js b/lib/get-github-credentials.js new file mode 100644 index 0000000..8443fd8 --- /dev/null +++ b/lib/get-github-credentials.js @@ -0,0 +1,35 @@ +module.exports = getGithubCredentials + +async function getGithubCredentials (state) { + if (process.env.CI) { + return { + username: process.env.GITHUB_USERNAME, + password: process.env.GITHUB_PASSWORD + } + } + + const inquirer = require('inquirer') + const passwordStorage = require('./password-storage')('github') + const githubLoginAnswers = await inquirer.prompt([{ + type: 'input', + name: 'username', + default: state.defaults.username, + message: 'What is your GitHub username?', + validate: (username) => String(username).length > 0 + }, { + type: 'password', + name: 'password', + message: 'What is your GitHub password?', + validate: (password) => String(password).length > 0, + when: answers => passwordStorage.get(answers.username).then(result => result === null) + }]) + + const passwordFromKeychain = await passwordStorage.get(githubLoginAnswers.username) + + if (passwordFromKeychain) { + state.debug('Loaded password from keychain') + githubLoginAnswers.password = passwordFromKeychain + } + + return githubLoginAnswers +} diff --git a/lib/get-glitch-app.js b/lib/get-glitch-app.js new file mode 100644 index 0000000..faaac45 --- /dev/null +++ b/lib/get-glitch-app.js @@ -0,0 +1,10 @@ +module.exports = getGlitchApp + +const axios = require('axios') + +async function getGlitchApp (state, appName) { + const url = `https://api.glitch.com/projects/${appName}` + state.debug(`GET ${url}`) + const response = await axios.get(url) + return response.data +} diff --git a/lib/get-glitch-name-and-repo.js b/lib/get-glitch-name-and-repo.js new file mode 100644 index 0000000..7b9b9a2 --- /dev/null +++ b/lib/get-glitch-name-and-repo.js @@ -0,0 +1,39 @@ +module.exports = getGlitchNameAndRepo + +const githubNameRegex = require('github-username-regex') + +const glitchAppDoesNotExists = require('./glitch-app-does-not-exist') + +async function getGlitchNameAndRepo (state) { + if (process.env.CI) { + return { + appName: process.env.GLITCH_DOMAIN, + repoName: process.env.GITHUB_REPO, + doCreate: true + } + } + + const inquirer = require('inquirer') + return inquirer.prompt([{ + type: 'input', + name: 'appName', + default: state.defaults.name, + message: 'To what Glitch app do you want to deploy (https://.glitch.com)?', + validate: (appName) => String(appName).length > 0 + }, { + type: 'confirm', + name: 'doCreate', + message: 'App does not yet exist, do you want to create it', + when: answers => glitchAppDoesNotExists(state, answers.appName) + }, { + type: 'input', + name: 'repoName', + default: state.defaults.repo, + message: 'What repository do you want to deploy (https://github.com/, e.g. "octocat/Hello-World")', + validate: (name) => { + const [owner, repoName] = name.split('/') + return githubNameRegex.test(owner) && githubNameRegex.test(repoName) && name === `${owner}/${repoName}` + }, + when: answers => !answers.hasOwnProperty('doCreate') || answers.doCreate + }]) +} diff --git a/lib/get-package-defaults.js b/lib/get-package-defaults.js new file mode 100644 index 0000000..caefa7c --- /dev/null +++ b/lib/get-package-defaults.js @@ -0,0 +1,21 @@ +module.exports = getPackageDefaults + +const pathResolve = require('path').resolve + +const getProperty = require('lodash/get') +const githubFromGit = require('github-url-from-git') + +function getPackageDefaults () { + try { + const pkg = require(pathResolve(process.cwd(), 'package.json')) + const url = githubFromGit(getProperty(pkg, 'repository.url')) + const [owner, repoName] = url.substr('https://github.com/'.length).split('/') + return { + name: pkg.name, + username: owner, + repo: `${owner}/${repoName}` + } + } catch (error) { + return {} + } +} diff --git a/lib/glitch-app-does-not-exist.js b/lib/glitch-app-does-not-exist.js new file mode 100644 index 0000000..5b1efb0 --- /dev/null +++ b/lib/glitch-app-does-not-exist.js @@ -0,0 +1,8 @@ +module.exports = glitchAppDoesNotExists + +const getGlitchApp = require('./get-glitch-app') + +async function glitchAppDoesNotExists (state, appName) { + const app = await getGlitchApp(state, appName) + return app === null +} diff --git a/lib/handle-github-2fa-authentication.js b/lib/handle-github-2fa-authentication.js new file mode 100644 index 0000000..fba7592 --- /dev/null +++ b/lib/handle-github-2fa-authentication.js @@ -0,0 +1,28 @@ +module.exports = handleGithub2faAuthentication + +async function handleGithub2faAuthentication (state) { + const twoFactorTokenInput = await state.page.$('[name=otp]') + + if (!twoFactorTokenInput) { + return + } + + if (process.env.CI) { + throw new Error(`GitHub user ${state.githubUsername} requires Two Factor Authentication which is not possible when run on CI`) + } + + const inquirer = require('inquirer') + state.debug('github.com: Two Factor Authentication prompt') + const tokenAnswer = await inquirer.prompt([{ + type: 'input', + name: 'code', + message: 'What is your GitHub two-factor authentication code?' + }]) + + await state.page.focus('[name=otp]') + await state.page.type(tokenAnswer.code) + + await state.page.click('[type=submit]') + await state.page.waitForNavigation() + state.debug('2Fa code submitted') +} diff --git a/lib/import-github-repo.js b/lib/import-github-repo.js new file mode 100644 index 0000000..db08ab4 --- /dev/null +++ b/lib/import-github-repo.js @@ -0,0 +1,16 @@ +module.exports = importGitHubRepo + +const axios = require('axios') + +async function importGitHubRepo (state, {authToken, appId, repoName}) { + state.debug(`Importing ${repoName} ...`) + + const response = await axios({ + method: 'post', + url: `https://api.glitch.com/project/githubImport?token=${authToken}&projectId=${appId}&repo=${encodeURIComponent(repoName)}` + }) + + state.debug(`${repoName} imported`) + + return response.status === 200 +}