diff --git a/README.md b/README.md index 749147bfad21..a7270a228223 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ with two sub-routes. The file structure will be as follows: ... ``` -By default the cli will add the import statements for HeroList and HeroDetail to +By default the cli will add the import statements for HeroList and HeroDetail to `hero-root.component.ts`: ``` @@ -148,7 +148,7 @@ export const CliRouteConfig = [ Visiting `http://localhost:4200/hero` will show the hero list. -There is an optional flag for `skip-router-generation` which will not add the route to the `CliRouteConfig` for the application. +There is an optional flag for `skip-router-generation` which will not add the route to the `CliRouteConfig` for the application. ### Creating a build @@ -166,7 +166,7 @@ ng test Tests will execute after a build is executed via [Karma](http://karma-runner.github.io/0.13/index.html) -If run with the watch argument `--watch` (shorthand `-w`) builds will run when source files have changed +If run with the watch argument `--watch` (shorthand `-w`) builds will run when source files have changed and tests will run after each successful build @@ -183,16 +183,25 @@ End-to-end tests are ran via [Protractor](https://angular.github.io/protractor/) ### Deploying the app via GitHub Pages -The CLI currently comes bundled with [angular-cli-github-pages addon](https://github.com/IgorMinar/angular-cli-github-pages). - -This means that you can deploy your apps quickly via: +You can deploy your apps quickly via: ``` -git commit -a -m "final tweaks before deployment - what could go wrong?" -ng github-pages:deploy +ng github-pages:deploy --message "Optional commit message" ``` -Checkout [angular-cli-github-pages addon](https://github.com/IgorMinar/angular-cli-github-pages) docs for more info. +This will do the following: + +- creates GitHub repo for the current project if one doesn't exist +- rebuilds the app at the current `HEAD` +- creates a local `gh-pages` branch if one doesn't exist +- moves your app to the `gh-pages` branch and creates a commit +- edit the base tag in index.html to support github pages +- pushes the `gh-pages` branch to github +- returns back to the original `HEAD` + +Creating the repo requires a token from github, and the remaining functionality +relies on ssh authentication for all git operations that communicate with github.com. +To simplify the authentication, be sure to [setup your ssh keys](https://help.github.com/articles/generating-ssh-keys/). ### Linting and formatting code @@ -201,15 +210,6 @@ This will use the `lint`/`format` npm script that in generated projects uses `ts You can modify the these scripts in `package.json` to run whatever tool you prefer. - -### Formatting code - -You can format your app code by running `ng format`. -This will use the `format` npm script that in generated projects uses `clang-format`. - -You can modify the `format` script in `package.json` to run whatever formatting tool -you prefer and `ng format` will still run it. - ### Support for offline applications By default a file `manifest.appcache` will be generated which lists all files included in diff --git a/addon/ng2/blueprints/ng2/files/package.json b/addon/ng2/blueprints/ng2/files/package.json index 18cd77f60041..78276eb02e1a 100644 --- a/addon/ng2/blueprints/ng2/files/package.json +++ b/addon/ng2/blueprints/ng2/files/package.json @@ -22,7 +22,6 @@ }, "devDependencies": { "angular-cli": "0.0.*", - "angular-cli-github-pages": "^0.2.0", "clang-format": "^1.0.35", "codelyzer": "0.0.12", "ember-cli-inject-live-reload": "^1.4.0", diff --git a/addon/ng2/commands/github-pages-deploy.ts b/addon/ng2/commands/github-pages-deploy.ts new file mode 100644 index 000000000000..76b036f04e15 --- /dev/null +++ b/addon/ng2/commands/github-pages-deploy.ts @@ -0,0 +1,184 @@ +import * as Command from 'ember-cli/lib/models/command'; +import * as SilentError from 'silent-error'; +import { exec } from 'child_process'; +import * as Promise from 'ember-cli/lib/ext/promise'; +import * as chalk from 'chalk'; +import * as fs from 'fs'; +import * as fse from 'fs-extra'; +import * as path from 'path'; +import * as BuildTask from 'ember-cli/lib/tasks/build'; +import * as win from 'ember-cli/lib/utilities/windows-admin'; +import * as CreateGithubRepo from '../tasks/create-github-repo'; + +const fsReadFile = Promise.denodeify(fs.readFile); +const fsWriteFile = Promise.denodeify(fs.writeFile); +const fsReadDir = Promise.denodeify(fs.readdir); +const fsCopy = Promise.denodeify(fse.copy); + +module.exports = Command.extend({ + name: 'github-pages:deploy', + aliases: ['gh-pages:deploy'], + description: 'Build the test app for production, commit it into a git branch, setup GitHub repo and push to it', + works: 'insideProject', + + availableOptions: [ + { + name: 'message', + type: String, + default: 'new gh-pages version', + description: 'The commit message to include with the build, must be wrapped in quotes.' + }, { + name: 'environment', + type: String, + default: 'production', + description: 'The Angular environment to create a build for' + }, { + name: 'branch', + type: String, + default: 'gh-pages', + description: 'The git branch to push your pages to' + }, { + name: 'skip-build', + type: Boolean, + default: false, + description: 'Skip building the project before deploying' + }, { + name: 'gh-token', + type: String, + default: '', + description: 'Github token' + }, { + name: 'gh-username', + type: String, + default: '', + description: 'Github username' + }], + + run: function(options, rawArgs) { + var ui = this.ui; + var root = this.project.root; + var execOptions = { + cwd: root + }; + var projectName = this.project.pkg.name; + + let initialBranch; + + // declared here so that tests can stub exec + const execPromise = Promise.denodeify(exec); + + var buildTask = new BuildTask({ + ui: this.ui, + analytics: this.analytics, + project: this.project + }); + + var buildOptions = { + environment: options.environment, + outputPath: 'dist/' + }; + + var createGithubRepoTask = new CreateGithubRepo({ + ui: this.ui, + analytics: this.analytics, + project: this.project + }); + + var createGithubRepoOptions = { + projectName, + ghUsername: options.ghUsername, + ghToken: options.ghToken + }; + + return checkForPendingChanges() + .then(build) + .then(saveStartingBranchName) + .then(createGitHubRepoIfNeeded) + .then(checkoutGhPages) + .then(copyFiles) + .then(updateBaseHref) + .then(addAndCommit) + .then(returnStartingBranch) + .then(pushToGitRepo) + .then(printProjectUrl); + + function checkForPendingChanges() { + return execPromise('git status --porcelain') + .then(stdout => { + if (/\w+/m.test(stdout)) { + let msg = 'Uncommitted file changes found! Please commit all changes before deploying.'; + return Promise.reject(new SilentError(msg)); + } + }); + } + + function build() { + if (options.skipBuild) return Promise.resolve(); + return win.checkWindowsElevation(ui) + .then(() => buildTask.run(buildOptions)); + } + + function saveStartingBranchName() { + return execPromise('git rev-parse --abbrev-ref HEAD') + .then((stdout) => initialBranch = stdout); + } + + function createGitHubRepoIfNeeded() { + return execPromise('git remote -v') + .then(function(stdout) { + if (!/origin\s+(https:\/\/|git@)github\.com/m.test(stdout)) { + return createGithubRepoTask.run(createGithubRepoOptions); + } + }); + } + + function checkoutGhPages() { + return execPromise(`git checkout ${options.branch}`) + .catch(createGhPagesBranch) + } + + function createGhPagesBranch() { + return execPromise(`git checkout --orphan ${options.branch}`) + .then(() => execPromise('git rm --cached -r .', execOptions)) + .then(() => execPromise('git add .gitignore', execOptions)) + .then(() => execPromise('git clean -f -d', execOptions)) + .then(() => execPromise(`git commit -m \"initial ${options.branch} commit\"`)); + } + + function copyFiles() { + return fsReadDir('dist') + .then((files) => Promise.all(files.map((file) => fsCopy(path.join('dist', file), path.join('.', file))))) + } + + function updateBaseHref() { + let indexHtml = path.join(root, 'index.html'); + return fsReadFile(indexHtml, 'utf8') + .then((data) => data.replace(//g, ` fsWriteFile(indexHtml, data, 'utf8')); + } + + function addAndCommit() { + return execPromise('git add .', execOptions) + .then(() => execPromise(`git commit -m "${options.message}"`)) + .catch(() => Promise.reject(new SilentError('No changes found. Deployment skipped.'))); + } + + function returnStartingBranch() { + return execPromise(`git checkout ${initialBranch}`); + } + + function pushToGitRepo(committed) { + return execPromise(`git push origin ${options.branch}`); + } + + function printProjectUrl() { + return execPromise('git remote -v') + .then((stdout) => { + + let userName = stdout.match(/origin\s+(?:https:\/\/|git@)github\.com(?:\:|\/)([^\/]+)/m)[1].toLowerCase(); + ui.writeLine(chalk.green(`Deployed! Visit https://${userName}.github.io/${projectName}/`)); + ui.writeLine('Github pages might take a few minutes to show the deployed site.'); + }); + } + } +}); diff --git a/addon/ng2/index.js b/addon/ng2/index.js index d9c98fd81adc..54a87033aacb 100644 --- a/addon/ng2/index.js +++ b/addon/ng2/index.js @@ -14,7 +14,8 @@ module.exports = { 'format': require('./commands/format'), 'version': require('./commands/version'), 'completion': require('./commands/completion'), - 'doc': require('./commands/doc') + 'doc': require('./commands/doc'), + 'github-pages-deploy': require('./commands/github-pages-deploy') }; } }; diff --git a/addon/ng2/tasks/create-github-repo.ts b/addon/ng2/tasks/create-github-repo.ts new file mode 100644 index 000000000000..046c65694e88 --- /dev/null +++ b/addon/ng2/tasks/create-github-repo.ts @@ -0,0 +1,77 @@ +import * as Promise from 'ember-cli/lib/ext/promise'; +import * as Task from 'ember-cli/lib/models/task'; +import * as SilentError from 'silent-error'; +import { exec } from 'child_process'; +import * as https from 'https'; + +module.exports = Task.extend({ + run: function(commandOptions) { + var ui = this.ui; + let promise; + + // declared here so that tests can stub exec + const execPromise = Promise.denodeify(exec); + + if (/.+/.test(commandOptions.ghToken) && /\w+/.test(commandOptions.ghUsername)) { + promise = Promise.resolve({ + ghToken: commandOptions.ghToken, + ghUsername: commandOptions.ghUsername + }); + } else { + ui.writeLine("\nIn order to deploy this project via GitHub Pages, we must first create a repository for it."); + ui.writeLine("It's safer to use a token than to use a password, so you will need to create one.\n"); + ui.writeLine("Go to the following page and click 'Generate new token'."); + ui.writeLine("https://github.com/settings/tokens\n"); + ui.writeLine("Choose 'public_repo' as scope and then click 'Generate token'.\n"); + promise = ui.prompt([ + { + name: 'ghToken', + type: 'input', + message: 'Please enter GitHub token you just created (used only once to create the repo):', + validate: function(token) { + return /.+/.test(token); + } + }, { + name: 'ghUsername', + type: 'input', + message: 'and your GitHub user name:', + validate: function(userName) { + return /\w+/.test(userName); + } + }]); + } + + return promise + .then((answers) => { + return new Promise(function(resolve, reject) { + var postData = JSON.stringify({ + 'name': commandOptions.projectName + }); + + var req = https.request({ + hostname: 'api.github.com', + port: 443, + path: '/user/repos', + method: 'POST', + headers: { + 'Authorization': `token ${answers.ghToken}`, + 'Content-Type': 'application/json', + 'Content-Length': postData.length, + 'User-Agent': 'angular-cli-github-pages' + } + }); + + req.on('response', function(response) { + if (response.statusCode === 201) { + resolve(execPromise(`git remote add origin git@github.com:${answers.ghUsername}/${commandOptions.projectName}.git`)) + } else { + reject(new SilentError(`Failed to create GitHub repo. Error: ${response.statusCode} ${response.statusMessage}`)); + } + }); + + req.write(postData); + req.end(); + }); + }); + } +}); diff --git a/package.json b/package.json index 51ede5aca5f1..1c5a1c858328 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "mocha": "^2.4.5", "object-assign": "^4.0.1", "rewire": "^2.5.1", + "sinon": "^1.17.3", "through": "^2.3.8", "tslint": "^3.6.0", "walk-sync": "^0.2.6" diff --git a/tests/acceptance/github-pages-deploy.spec.js b/tests/acceptance/github-pages-deploy.spec.js new file mode 100644 index 000000000000..09bdc06dfc44 --- /dev/null +++ b/tests/acceptance/github-pages-deploy.spec.js @@ -0,0 +1,232 @@ +/*eslint-disable no-console */ +'use strict'; + +var ng = require('../helpers/ng'); +var tmp = require('../helpers/tmp'); +var conf = require('ember-cli/tests/helpers/conf'); +var Promise = require('ember-cli/lib/ext/promise'); +var fs = require('fs'); +var path = require('path'); +var chai = require('chai'); +var sinon = require('sinon'); +var ExecStub = require('../helpers/exec-stub'); +var https = require('https'); + +const expect = chai.expect; +const fsReadFile = Promise.denodeify(fs.readFile); +const fsWriteFile = Promise.denodeify(fs.writeFile); +const fsMkdir = Promise.denodeify(fs.mkdir); + +describe('Acceptance: ng github-pages:deploy', function() { + let execStub; + let project = 'foo', + initialBranch = 'master', + branch = 'gh-pages', + message = 'new gh-pages version', + remote = 'origin git@github.com:username/project.git (fetch)'; + + function setupDist() { + return fsMkdir('./dist') + .then(() => { + let indexHtml = path.join(process.cwd(), 'dist', 'index.html'); + let indexData = `project\n`; + return fsWriteFile(indexHtml, indexData, 'utf8'); + }); + } + + before(conf.setup); + + after(conf.restore); + + beforeEach(function() { + this.timeout(10000); + return tmp.setup('./tmp') + .then(() => process.chdir('./tmp')) + .then(() => ng(['new', project, '--skip-npm', '--skip-bower'])) + .then(() => setupDist()) + .then(() => execStub = new ExecStub()); + }); + + afterEach(function() { + this.timeout(10000); + return tmp.teardown('./tmp') + .then(() => expect(execStub.hasFailed()).to.be.false) + .then(() => expect(execStub.hasEmptyStack()).to.be.true) + .then(() => execStub.restore()); + }); + + it('should fail with uncommited changes', function() { + execStub.addExecSuccess('git status --porcelain', 'M dir/file.ext'); + return ng(['github-pages:deploy', '--skip-build']) + .then((ret) => expect(ret).to.equal(1)) + }); + + it('should deploy with defaults to existing remote', function() { + execStub.addExecSuccess('git status --porcelain') + .addExecSuccess('git rev-parse --abbrev-ref HEAD', initialBranch) + .addExecSuccess('git remote -v', remote) + .addExecSuccess(`git checkout ${branch}`) + .addExecSuccess('git add .') + .addExecSuccess(`git commit -m "${message}"`) + .addExecSuccess(`git checkout ${initialBranch}`) + .addExecSuccess(`git push origin ${branch}`) + .addExecSuccess('git remote -v', remote); + + return ng(['github-pages:deploy', '--skip-build']) + .then(() => { + let indexHtml = path.join(process.cwd(), 'index.html'); + return fsReadFile(indexHtml, 'utf8'); + }) + .then((data) => expect(data.search(` { + let indexHtml = path.join(process.cwd(), 'index.html'); + return fsReadFile(indexHtml, 'utf8'); + }) + .then((data) => expect(data.search(` { + let indexHtml = path.join(process.cwd(), 'index.html'); + return fsReadFile(indexHtml, 'utf8'); + }) + .then((data) => expect(data.search(` responseCb = cb, + write: (postData) => expect(postData).to.eql(expectedPostData), + end: () => responseCb({ statusCode: 201 }) + } + } + + return ng(['github-pages:deploy', '--skip-build', `--gh-token=${token}`, + `--gh-username=${username}`]) + .then(() => { + let indexHtml = path.join(process.cwd(), 'index.html'); + return fsReadFile(indexHtml, 'utf8'); + }) + .then((data) => expect(data.search(` httpsStub.restore()); + }); + + it('should stop deploy if create branch fails', function() { + let noRemote = '', + token = 'token', + username = 'username'; + + execStub.addExecSuccess('git status --porcelain') + .addExecSuccess('git rev-parse --abbrev-ref HEAD', initialBranch) + .addExecSuccess('git remote -v', noRemote); + + var httpsStub = sinon.stub(https, 'request', httpsRequestStubFunc); + + function httpsRequestStubFunc(req) { + let responseCb; + + let expectedPostData = JSON.stringify({ + 'name': project + }); + + let expectedReq = { + hostname: 'api.github.com', + port: 443, + path: '/user/repos', + method: 'POST', + headers: { + 'Authorization': `token ${token}`, + 'Content-Type': 'application/json', + 'Content-Length': expectedPostData.length, + 'User-Agent': 'angular-cli-github-pages' + } + } + + expect(req).to.eql(expectedReq); + + return { + on: (event, cb) => responseCb = cb, + write: (postData) => expect(postData).to.eql(expectedPostData), + end: () => responseCb({ statusCode: 401, statusMessage: 'Unauthorized' }) + } + } + + return ng(['github-pages:deploy', '--skip-build', `--gh-token=${token}`, + `--gh-username=${username}`]) + .then((ret) => expect(ret).to.equal(1)) + .then(() => httpsStub.restore()); + }); +}); diff --git a/tests/helpers/exec-stub.js b/tests/helpers/exec-stub.js new file mode 100644 index 000000000000..42e7f0e8ec46 --- /dev/null +++ b/tests/helpers/exec-stub.js @@ -0,0 +1,71 @@ +'use strict'; +var child_process = require('child_process'); +var sinon = require('sinon'); + +class ExecStub { + constructor() { + this.execOrig = child_process.exec; + this.stub = sinon.stub(child_process, 'exec', this.execStubFunc.bind(this)); + this.stack = []; + this.failed = false; + } + execStubFunc(cmd) { + let resp; + + // console.log('####running', cmd); + + if (this.failed) { + resp = this.failedExec('ExecStub - in fail mode'); + return resp.apply(null, arguments); + } + + if (this.stack.length === 0) { + this.failed = true; + resp = this.failedExec('ExecStub - expected stack size exceeded'); + return resp.apply(null, arguments); + } + + let item = this.stack.shift(); + + // console.log('####expected', item.cmd); + + if (cmd !== item.cmd) { + this.failed = true; + resp = this.failedExec(`ExecStub - unexpected command: ${cmd}`); + return resp.apply(null, arguments); + } + + return item.resp.apply(null, arguments); + } + hasFailed() { + return this.failed; + } + hasEmptyStack() { + return this.stack.length === 0; + } + restore() { + this.stub.restore(); + return this; + } + addExecSuccess(cmd, sdout) { + sdout = sdout || ''; + this.stack.push({ + cmd, + resp: (cmd, opt, cb) => (cb ? cb : opt)(null, sdout, null) + }); + return this; + } + addExecError(cmd, stderr) { + stderr = stderr || ''; + this.stack.push({ + cmd, + resp: (cmd, opt, cb) => (cb ? cb : opt)(new Error(stderr), null, stderr) + }); + return this; + } + failedExec(reason) { + return (cmd, opt, cb) => (cb ? cb : opt)(new Error(reason), null, reason) + } +} + +module.exports = ExecStub;