diff --git a/.gitignore b/.gitignore index 8fd0115..f145fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ *-debug.log *-error.log -/lib /node_modules /tmp -lib/ node_modules/ package-lock.json diff --git a/lib/api-client.d.ts b/lib/api-client.d.ts new file mode 100644 index 0000000..da8a4c4 --- /dev/null +++ b/lib/api-client.d.ts @@ -0,0 +1,57 @@ +import { Interfaces } from '@oclif/core'; +import { CLIError } from '@oclif/core/lib/errors'; +import { HTTP, HTTPError, HTTPRequestOptions } from 'http-call'; +import { Login } from './login'; +import { Mutex } from './mutex'; +export declare namespace APIClient { + interface Options extends HTTPRequestOptions { + retryAuth?: boolean; + } +} +export interface IOptions { + required?: boolean; + preauth?: boolean; +} +export interface IHerokuAPIErrorOptions { + resource?: string; + app?: { + id: string; + name: string; + }; + id?: string; + message?: string; + url?: string; +} +export declare class HerokuAPIError extends CLIError { + http: HTTPError; + body: IHerokuAPIErrorOptions; + constructor(httpError: HTTPError); +} +export declare class APIClient { + protected config: Interfaces.Config; + options: IOptions; + preauthPromises: { + [k: string]: Promise>; + }; + authPromise?: Promise>; + http: typeof HTTP; + private readonly _login; + private _twoFactorMutex; + private _auth?; + constructor(config: Interfaces.Config, options?: IOptions); + get twoFactorMutex(): Mutex; + get auth(): string | undefined; + set auth(token: string | undefined); + twoFactorPrompt(): Promise; + preauth(app: string, factor: string): Promise>; + get(url: string, options?: APIClient.Options): Promise>; + post(url: string, options?: APIClient.Options): Promise>; + put(url: string, options?: APIClient.Options): Promise>; + patch(url: string, options?: APIClient.Options): Promise>; + delete(url: string, options?: APIClient.Options): Promise>; + stream(url: string, options?: APIClient.Options): Promise>; + request(url: string, options?: APIClient.Options): Promise>; + login(opts?: Login.Options): Promise; + logout(): Promise; + get defaults(): typeof HTTP.defaults; +} diff --git a/lib/api-client.js b/lib/api-client.js new file mode 100644 index 0000000..f08ec28 --- /dev/null +++ b/lib/api-client.js @@ -0,0 +1,194 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.APIClient = exports.HerokuAPIError = void 0; +const tslib_1 = require("tslib"); +const errors_1 = require("@oclif/core/lib/errors"); +const netrc_parser_1 = tslib_1.__importDefault(require("netrc-parser")); +const url = tslib_1.__importStar(require("url")); +const deps_1 = tslib_1.__importDefault(require("./deps")); +const login_1 = require("./login"); +const request_id_1 = require("./request-id"); +const vars_1 = require("./vars"); +class HerokuAPIError extends errors_1.CLIError { + constructor(httpError) { + if (!httpError) + throw new Error('invalid error'); + const options = httpError.body; + if (!options || !options.message) + throw httpError; + const info = []; + if (options.id) + info.push(`Error ID: ${options.id}`); + if (options.app && options.app.name) + info.push(`App: ${options.app.name}`); + if (options.url) + info.push(`See ${options.url} for more information.`); + if (info.length > 0) + super([options.message, '', ...info].join('\n')); + else + super(options.message); + this.http = httpError; + this.body = options; + } +} +exports.HerokuAPIError = HerokuAPIError; +class APIClient { + constructor(config, options = {}) { + this.config = config; + this.options = options; + this._login = new login_1.Login(this.config, this); + this.config = config; + if (options.required === undefined) + options.required = true; + options.preauth = options.preauth !== false; + this.options = options; + const apiUrl = url.URL ? new url.URL(vars_1.vars.apiUrl) : url.parse(vars_1.vars.apiUrl); + const envHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}'); + this.preauthPromises = {}; + const self = this; + const opts = { + host: apiUrl.hostname, + port: apiUrl.port, + protocol: apiUrl.protocol, + headers: Object.assign({ accept: 'application/vnd.heroku+json; version=3', 'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}` }, envHeaders), + }; + this.http = class APIHTTPClient extends deps_1.default.HTTP.HTTP.create(opts) { + static async twoFactorRetry(err, url, opts = {}, retries = 3) { + const app = err.body.app ? err.body.app.name : null; + if (!app || !options.preauth) { + opts.headers = opts.headers || {}; + opts.headers['Heroku-Two-Factor-Code'] = await self.twoFactorPrompt(); + return this.request(url, opts, retries); + } + // if multiple requests are run in parallel for the same app, we should + // only preauth for the first so save the fact we already preauthed + if (!self.preauthPromises[app]) { + self.preauthPromises[app] = self.twoFactorPrompt().then((factor) => self.preauth(app, factor)); + } + await self.preauthPromises[app]; + return this.request(url, opts, retries); + } + static trackRequestIds(response) { + const responseRequestIdHeader = response.headers[request_id_1.requestIdHeader]; + if (responseRequestIdHeader) { + const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(','); + request_id_1.RequestId.track(...requestIds); + } + } + static async request(url, opts = {}, retries = 3) { + opts.headers = opts.headers || {}; + opts.headers[request_id_1.requestIdHeader] = request_id_1.RequestId.create() && request_id_1.RequestId.headerValue; + if (!Object.keys(opts.headers).find(h => h.toLowerCase() === 'authorization')) { + opts.headers.authorization = `Bearer ${self.auth}`; + } + retries--; + try { + const response = await super.request(url, opts); + this.trackRequestIds(response); + return response; + } + catch (error) { + if (!(error instanceof deps_1.default.HTTP.HTTPError)) + throw error; + if (retries > 0) { + if (opts.retryAuth !== false && error.http.statusCode === 401 && error.body.id === 'unauthorized') { + if (process.env.HEROKU_API_KEY) { + throw new Error('The token provided to HEROKU_API_KEY is invalid. Please double-check that you have the correct token, or run `heroku login` without HEROKU_API_KEY set.'); + } + if (!self.authPromise) + self.authPromise = self.login(); + await self.authPromise; + opts.headers.authorization = `Bearer ${self.auth}`; + return this.request(url, opts, retries); + } + if (error.http.statusCode === 403 && error.body.id === 'two_factor') { + return this.twoFactorRetry(error, url, opts, retries); + } + } + throw new HerokuAPIError(error); + } + } + }; + } + get twoFactorMutex() { + if (!this._twoFactorMutex) { + this._twoFactorMutex = new deps_1.default.Mutex(); + } + return this._twoFactorMutex; + } + get auth() { + if (!this._auth) { + if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY) + deps_1.default.cli.warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY'); + this._auth = process.env.HEROKU_API_KEY; + if (!this._auth) { + deps_1.default.netrc.loadSync(); + this._auth = deps_1.default.netrc.machines[vars_1.vars.apiHost] && deps_1.default.netrc.machines[vars_1.vars.apiHost].password; + } + } + return this._auth; + } + set auth(token) { + delete this.authPromise; + this._auth = token; + } + twoFactorPrompt() { + deps_1.default.yubikey.enable(); + return this.twoFactorMutex.synchronize(async () => { + try { + const factor = await deps_1.default.cli.prompt('Two-factor code', { type: 'mask' }); + deps_1.default.yubikey.disable(); + return factor; + } + catch (error) { + deps_1.default.yubikey.disable(); + throw error; + } + }); + } + preauth(app, factor) { + return this.put(`/apps/${app}/pre-authorizations`, { + headers: { 'Heroku-Two-Factor-Code': factor }, + }); + } + get(url, options = {}) { + return this.http.get(url, options); + } + post(url, options = {}) { + return this.http.post(url, options); + } + put(url, options = {}) { + return this.http.put(url, options); + } + patch(url, options = {}) { + return this.http.patch(url, options); + } + delete(url, options = {}) { + return this.http.delete(url, options); + } + stream(url, options = {}) { + return this.http.stream(url, options); + } + request(url, options = {}) { + return this.http.request(url, options); + } + login(opts = {}) { + return this._login.login(opts); + } + async logout() { + try { + await this._login.logout(); + } + catch (error) { + if (error instanceof errors_1.CLIError) + (0, errors_1.warn)(error); + } + delete netrc_parser_1.default.machines['api.heroku.com']; + delete netrc_parser_1.default.machines['git.heroku.com']; + await netrc_parser_1.default.save(); + } + get defaults() { + return this.http.defaults; + } +} +exports.APIClient = APIClient; diff --git a/lib/command.d.ts b/lib/command.d.ts new file mode 100644 index 0000000..f0ed1c1 --- /dev/null +++ b/lib/command.d.ts @@ -0,0 +1,11 @@ +import { Command as Base } from '@oclif/core'; +import { APIClient } from './api-client'; +export declare abstract class Command extends Base { + base: string; + _heroku: APIClient; + _legacyHerokuClient: any; + get heroku(): APIClient; + get legacyHerokuClient(): any; + get cli(): any; + get out(): any; +} diff --git a/lib/command.js b/lib/command.js new file mode 100644 index 0000000..7f43995 --- /dev/null +++ b/lib/command.js @@ -0,0 +1,44 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Command = void 0; +const tslib_1 = require("tslib"); +const core_1 = require("@oclif/core"); +const util_1 = require("util"); +const pjson = require('../package.json'); +const deps_1 = tslib_1.__importDefault(require("./deps")); +const deprecatedCLI = (0, util_1.deprecate)(() => { + return require('cli-ux').cli; +}, 'this.out and this.cli is deprecated. Please import "CliUx" from the @oclif/core module directly instead.'); +class Command extends core_1.Command { + constructor() { + super(...arguments); + this.base = `${pjson.name}@${pjson.version}`; + } + get heroku() { + if (this._heroku) + return this._heroku; + this._heroku = new deps_1.default.APIClient(this.config); + return this._heroku; + } + get legacyHerokuClient() { + if (this._legacyHerokuClient) + return this._legacyHerokuClient; + const HerokuClient = require('heroku-client'); + const options = { + debug: this.config.debug, + host: `${this.heroku.defaults.protocol || 'https:'}//${this.heroku.defaults.host || + 'api.heroku.com'}`, + token: this.heroku.auth, + userAgent: this.heroku.defaults.headers['user-agent'], + }; + this._legacyHerokuClient = new HerokuClient(options); + return this._legacyHerokuClient; + } + get cli() { + return deprecatedCLI(); + } + get out() { + return deprecatedCLI(); + } +} +exports.Command = Command; diff --git a/lib/completions.d.ts b/lib/completions.d.ts new file mode 100644 index 0000000..343cb01 --- /dev/null +++ b/lib/completions.d.ts @@ -0,0 +1,38 @@ +import { Interfaces } from '@oclif/core'; +declare type CompletionContext = { + args?: { + [name: string]: string; + }; + flags?: { + [name: string]: string; + }; + argv?: string[]; + config: Interfaces.Config; +}; +declare type Completion = { + skipCache?: boolean; + cacheDuration?: number; + cacheKey?(ctx: CompletionContext): Promise; + options(ctx: CompletionContext): Promise; +}; +export declare const oneDay: number; +export declare const herokuGet: (resource: string, ctx: { + config: Interfaces.Config; +}) => Promise; +export declare const AppCompletion: Completion; +export declare const AppAddonCompletion: Completion; +export declare const AppDynoCompletion: Completion; +export declare const BuildpackCompletion: Completion; +export declare const DynoSizeCompletion: Completion; +export declare const FileCompletion: Completion; +export declare const PipelineCompletion: Completion; +export declare const ProcessTypeCompletion: Completion; +export declare const RegionCompletion: Completion; +export declare const RemoteCompletion: Completion; +export declare const RoleCompletion: Completion; +export declare const ScopeCompletion: Completion; +export declare const SpaceCompletion: Completion; +export declare const StackCompletion: Completion; +export declare const StageCompletion: Completion; +export declare const TeamCompletion: Completion; +export {}; diff --git a/lib/completions.js b/lib/completions.js new file mode 100644 index 0000000..8eaef6b --- /dev/null +++ b/lib/completions.js @@ -0,0 +1,159 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TeamCompletion = exports.StageCompletion = exports.StackCompletion = exports.SpaceCompletion = exports.ScopeCompletion = exports.RoleCompletion = exports.RemoteCompletion = exports.RegionCompletion = exports.ProcessTypeCompletion = exports.PipelineCompletion = exports.FileCompletion = exports.DynoSizeCompletion = exports.BuildpackCompletion = exports.AppDynoCompletion = exports.AppAddonCompletion = exports.AppCompletion = exports.herokuGet = exports.oneDay = void 0; +const tslib_1 = require("tslib"); +const errors_1 = require("@oclif/core/lib/errors"); +const path = tslib_1.__importStar(require("path")); +const deps_1 = tslib_1.__importDefault(require("./deps")); +const git_1 = require("./git"); +exports.oneDay = 60 * 60 * 24; +const herokuGet = async (resource, ctx) => { + const heroku = new deps_1.default.APIClient(ctx.config); + let { body: resources } = await heroku.get(`/${resource}`); + if (typeof resources === 'string') + resources = JSON.parse(resources); + return resources.map((a) => a.name).sort(); +}; +exports.herokuGet = herokuGet; +exports.AppCompletion = { + cacheDuration: exports.oneDay, + options: async (ctx) => { + const apps = await (0, exports.herokuGet)('apps', ctx); + return apps; + }, +}; +exports.AppAddonCompletion = { + cacheDuration: exports.oneDay, + cacheKey: async (ctx) => { + return ctx.flags && ctx.flags.app ? `${ctx.flags.app}_addons` : ''; + }, + options: async (ctx) => { + const addons = ctx.flags && ctx.flags.app ? await (0, exports.herokuGet)(`apps/${ctx.flags.app}/addons`, ctx) : []; + return addons; + }, +}; +exports.AppDynoCompletion = { + cacheDuration: exports.oneDay, + cacheKey: async (ctx) => { + return ctx.flags && ctx.flags.app ? `${ctx.flags.app}_dynos` : ''; + }, + options: async (ctx) => { + const dynos = ctx.flags && ctx.flags.app ? await (0, exports.herokuGet)(`apps/${ctx.flags.app}/dynos`, ctx) : []; + return dynos; + }, +}; +exports.BuildpackCompletion = { + skipCache: true, + options: async () => { + return [ + 'heroku/ruby', + 'heroku/nodejs', + 'heroku/clojure', + 'heroku/python', + 'heroku/java', + 'heroku/gradle', + 'heroku/scala', + 'heroku/php', + 'heroku/go', + ]; + }, +}; +exports.DynoSizeCompletion = { + cacheDuration: exports.oneDay * 90, + options: async (ctx) => { + const sizes = await (0, exports.herokuGet)('dyno-sizes', ctx); + return sizes; + }, +}; +exports.FileCompletion = { + skipCache: true, + options: async () => { + const files = await deps_1.default.file.readdir(process.cwd()); + return files; + }, +}; +exports.PipelineCompletion = { + cacheDuration: exports.oneDay, + options: async (ctx) => { + const pipelines = await (0, exports.herokuGet)('pipelines', ctx); + return pipelines; + }, +}; +exports.ProcessTypeCompletion = { + skipCache: true, + options: async () => { + let types = []; + const procfile = path.join(process.cwd(), 'Procfile'); + try { + const buff = await deps_1.default.file.readFile(procfile); + types = buff + .toString() + .split('\n') + .map((s) => { + if (!s) + return false; + const m = s.match(/^([\w-]+)/); + return m ? m[0] : false; + }) + .filter((t) => t); + } + catch (error) { + if (error instanceof errors_1.CLIError && error.code !== 'ENOENT') + throw error; + } + return types; + }, +}; +exports.RegionCompletion = { + cacheDuration: exports.oneDay * 7, + options: async (ctx) => { + const regions = await (0, exports.herokuGet)('regions', ctx); + return regions; + }, +}; +exports.RemoteCompletion = { + skipCache: true, + options: async () => { + const remotes = (0, git_1.getGitRemotes)((0, git_1.configRemote)()); + return remotes.map(r => r.remote); + }, +}; +exports.RoleCompletion = { + skipCache: true, + options: async () => { + return ['admin', 'collaborator', 'member', 'owner']; + }, +}; +exports.ScopeCompletion = { + skipCache: true, + options: async () => { + return ['global', 'identity', 'read', 'write', 'read-protected', 'write-protected']; + }, +}; +exports.SpaceCompletion = { + cacheDuration: exports.oneDay, + options: async (ctx) => { + const spaces = await (0, exports.herokuGet)('spaces', ctx); + return spaces; + }, +}; +exports.StackCompletion = { + cacheDuration: exports.oneDay, + options: async (ctx) => { + const stacks = await (0, exports.herokuGet)('stacks', ctx); + return stacks; + }, +}; +exports.StageCompletion = { + skipCache: true, + options: async () => { + return ['test', 'review', 'development', 'staging', 'production']; + }, +}; +exports.TeamCompletion = { + cacheDuration: exports.oneDay, + options: async (ctx) => { + const teams = await (0, exports.herokuGet)('teams', ctx); + return teams; + }, +}; diff --git a/lib/deps.d.ts b/lib/deps.d.ts new file mode 100644 index 0000000..e8a004f --- /dev/null +++ b/lib/deps.d.ts @@ -0,0 +1,25 @@ +/// +import oclif = require('@oclif/core'); +import HTTP = require('http-call'); +import netrc = require('netrc-parser'); +import apiClient = require('./api-client'); +import file = require('./file'); +import flags = require('./flags'); +import git = require('./git'); +import mutex = require('./mutex'); +export declare const deps: { + readonly cli: typeof oclif.ux; + readonly HTTP: typeof HTTP; + readonly netrc: netrc.Netrc; + readonly Mutex: typeof mutex.Mutex; + readonly yubikey: { + disable: () => void; + enable: () => void; + platform: NodeJS.Platform; + }; + readonly APIClient: typeof apiClient.APIClient; + readonly file: typeof file; + readonly flags: typeof flags; + readonly Git: typeof git.Git; +}; +export default deps; diff --git a/lib/deps.js b/lib/deps.js new file mode 100644 index 0000000..be9ffd7 --- /dev/null +++ b/lib/deps.js @@ -0,0 +1,45 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.deps = void 0; +// remote +const oclif = require("@oclif/core"); +const { ux } = oclif; +exports.deps = { + // remote + get cli() { + return fetch('@oclif/core').ux; + }, + get HTTP() { + return fetch('http-call'); + }, + get netrc() { + return fetch('netrc-parser').default; + }, + // local + get Mutex() { + return fetch('./mutex').Mutex; + }, + get yubikey() { + return fetch('./yubikey').yubikey; + }, + get APIClient() { + return fetch('./api-client').APIClient; + }, + get file() { + return fetch('./file'); + }, + get flags() { + return fetch('./flags'); + }, + get Git() { + return fetch('./git').Git; + }, +}; +const cache = {}; +function fetch(s) { + if (!cache[s]) { + cache[s] = require(s); + } + return cache[s]; +} +exports.default = exports.deps; diff --git a/lib/file.d.ts b/lib/file.d.ts new file mode 100644 index 0000000..2e18712 --- /dev/null +++ b/lib/file.d.ts @@ -0,0 +1,4 @@ +/// +export declare function exists(f: string): Promise; +export declare function readdir(f: string): Promise; +export declare function readFile(f: string): Promise; diff --git a/lib/file.js b/lib/file.js new file mode 100644 index 0000000..5bef31f --- /dev/null +++ b/lib/file.js @@ -0,0 +1,27 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.readFile = exports.readdir = exports.exists = void 0; +const tslib_1 = require("tslib"); +const fs = tslib_1.__importStar(require("fs")); +const util_1 = require("util"); +let _debug; +function debug(...args) { + if (_debug) + _debug = require('debug')('@heroku-cli/command:file'); + _debug(...args); +} +function exists(f) { + // tslint:disable-next-line + return (0, util_1.promisify)(fs.exists)(f); +} +exports.exists = exists; +function readdir(f) { + debug('readdir', f); + return (0, util_1.promisify)(fs.readdir)(f); +} +exports.readdir = readdir; +function readFile(f) { + debug('readFile', f); + return (0, util_1.promisify)(fs.readFile)(f); +} +exports.readFile = readFile; diff --git a/lib/flags/app.d.ts b/lib/flags/app.d.ts new file mode 100644 index 0000000..49ff983 --- /dev/null +++ b/lib/flags/app.d.ts @@ -0,0 +1,8 @@ +export declare const app: import("@oclif/core/lib/interfaces").FlagDefinition; +export declare const remote: import("@oclif/core/lib/interfaces").FlagDefinition; diff --git a/lib/flags/app.js b/lib/flags/app.js new file mode 100644 index 0000000..7b9a7bb --- /dev/null +++ b/lib/flags/app.js @@ -0,0 +1,42 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.remote = exports.app = void 0; +const core_1 = require("@oclif/core"); +const errors_1 = require("@oclif/core/lib/errors"); +const git_1 = require("../git"); +class MultipleRemotesError extends errors_1.CLIError { + constructor(gitRemotes) { + super(`Multiple apps in git remotes + Usage: --remote ${gitRemotes[1].remote} + or: --app ${gitRemotes[1].app} + Your local git repository has more than 1 app referenced in git remotes. + Because of this, we can't determine which app you want to run this command against. + Specify the app you want with --app or --remote. + Heroku remotes in repo: + ${gitRemotes.map(r => `${r.app} (${r.remote})`).join('\n')} + + https://devcenter.heroku.com/articles/multiple-environments`); + } +} +exports.app = core_1.Flags.custom({ + char: 'a', + description: 'app to run command against', + default: async ({ options, flags }) => { + const envApp = process.env.HEROKU_APP; + if (envApp) + return envApp; + const gitRemotes = (0, git_1.getGitRemotes)(flags.remote || (0, git_1.configRemote)()); + if (gitRemotes.length === 1) + return gitRemotes[0].app; + if (flags.remote && gitRemotes.length === 0) { + (0, errors_1.error)(`remote ${flags.remote} not found in git remotes`); + } + if (gitRemotes.length > 1 && options.required) { + throw new MultipleRemotesError(gitRemotes); + } + }, +}); +exports.remote = core_1.Flags.custom({ + char: 'r', + description: 'git remote of app to use', +}); diff --git a/lib/flags/index.d.ts b/lib/flags/index.d.ts new file mode 100644 index 0000000..7855899 --- /dev/null +++ b/lib/flags/index.d.ts @@ -0,0 +1,5 @@ +export * from '@oclif/core/lib/flags'; +export { app, remote } from './app'; +export { org } from './org'; +export { team } from './team'; +export { pipeline } from './pipeline'; diff --git a/lib/flags/index.js b/lib/flags/index.js new file mode 100644 index 0000000..7ff473a --- /dev/null +++ b/lib/flags/index.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pipeline = exports.team = exports.org = exports.remote = exports.app = void 0; +const tslib_1 = require("tslib"); +tslib_1.__exportStar(require("@oclif/core/lib/flags"), exports); +var app_1 = require("./app"); +Object.defineProperty(exports, "app", { enumerable: true, get: function () { return app_1.app; } }); +Object.defineProperty(exports, "remote", { enumerable: true, get: function () { return app_1.remote; } }); +var org_1 = require("./org"); +Object.defineProperty(exports, "org", { enumerable: true, get: function () { return org_1.org; } }); +var team_1 = require("./team"); +Object.defineProperty(exports, "team", { enumerable: true, get: function () { return team_1.team; } }); +var pipeline_1 = require("./pipeline"); +Object.defineProperty(exports, "pipeline", { enumerable: true, get: function () { return pipeline_1.pipeline; } }); diff --git a/lib/flags/org.d.ts b/lib/flags/org.d.ts new file mode 100644 index 0000000..edb94ce --- /dev/null +++ b/lib/flags/org.d.ts @@ -0,0 +1,4 @@ +export declare const org: import("@oclif/core/lib/interfaces").FlagDefinition<() => string | undefined, import("@oclif/core/lib/interfaces").CustomOptions, { + multiple: false; + requiredOrDefaulted: true; +}>; diff --git a/lib/flags/org.js b/lib/flags/org.js new file mode 100644 index 0000000..9d4c7eb --- /dev/null +++ b/lib/flags/org.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.org = void 0; +const core_1 = require("@oclif/core"); +exports.org = core_1.Flags.custom({ + char: 'o', + default: () => process.env.HEROKU_ORGANIZATION, + description: 'name of org', + hidden: true, +}); diff --git a/lib/flags/pipeline.d.ts b/lib/flags/pipeline.d.ts new file mode 100644 index 0000000..1196ce0 --- /dev/null +++ b/lib/flags/pipeline.d.ts @@ -0,0 +1,4 @@ +export declare const pipeline: import("@oclif/core/lib/interfaces").FlagDefinition; diff --git a/lib/flags/pipeline.js b/lib/flags/pipeline.js new file mode 100644 index 0000000..c269075 --- /dev/null +++ b/lib/flags/pipeline.js @@ -0,0 +1,8 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.pipeline = void 0; +const core_1 = require("@oclif/core"); +exports.pipeline = core_1.Flags.custom({ + char: 'p', + description: 'name of pipeline', +}); diff --git a/lib/flags/team.d.ts b/lib/flags/team.d.ts new file mode 100644 index 0000000..d8bcb5a --- /dev/null +++ b/lib/flags/team.d.ts @@ -0,0 +1,4 @@ +export declare const team: import("@oclif/core/lib/interfaces").FlagDefinition; diff --git a/lib/flags/team.js b/lib/flags/team.js new file mode 100644 index 0000000..c67a14a --- /dev/null +++ b/lib/flags/team.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.team = void 0; +const core_1 = require("@oclif/core"); +exports.team = core_1.Flags.custom({ + char: 't', + description: 'team to use', + default: async ({ flags }) => { + const { HEROKU_ORGANIZATION: org, HEROKU_TEAM: team } = process.env; + if (flags.org) + return flags.org; + if (team) + return team; + if (org) + return org; + }, +}); diff --git a/lib/git.d.ts b/lib/git.d.ts new file mode 100644 index 0000000..8cd2027 --- /dev/null +++ b/lib/git.d.ts @@ -0,0 +1,14 @@ +export interface IGitRemote { + name: string; + url: string; +} +export declare class Git { + get remotes(): IGitRemote[]; + exec(cmd: string): string; +} +export declare function configRemote(): string | undefined; +export interface IGitRemotes { + remote: string; + app: string; +} +export declare function getGitRemotes(onlyRemote: string | undefined): IGitRemotes[]; diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 0000000..86c93a7 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,67 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getGitRemotes = exports.configRemote = exports.Git = void 0; +const errors_1 = require("@oclif/core/lib/errors"); +const vars_1 = require("./vars"); +class Git { + get remotes() { + return this.exec('remote -v') + .split('\n') + .filter(l => l.endsWith('(fetch)')) + .map(l => { + const [name, url] = l.split('\t'); + return { name, url: url.split(' ')[0] }; + }); + } + exec(cmd) { + const { execSync: exec } = require('child_process'); + try { + return exec(`git ${cmd}`, { + encoding: 'utf8', + stdio: [null, 'pipe', null], + }); + } + catch (error) { + if (error.code === 'ENOENT') { + throw new errors_1.CLIError('Git must be installed to use the Heroku CLI. See instructions here: http://git-scm.com'); + } + throw error; + } + } +} +exports.Git = Git; +function configRemote() { + const git = new Git(); + try { + return git.exec('config heroku.remote').trim(); + } + catch (_a) { } +} +exports.configRemote = configRemote; +function getGitRemotes(onlyRemote) { + const git = new Git(); + const appRemotes = []; + let remotes; + try { + remotes = git.remotes; + } + catch (_a) { + return []; + } + for (const remote of remotes) { + if (onlyRemote && remote.name !== onlyRemote) + continue; + for (const prefix of vars_1.vars.gitPrefixes) { + const suffix = '.git'; + const match = remote.url.match(`${prefix}(.*)${suffix}`); + if (!match) + continue; + appRemotes.push({ + app: match[1], + remote: remote.name, + }); + } + } + return appRemotes; +} +exports.getGitRemotes = getGitRemotes; diff --git a/lib/index.d.ts b/lib/index.d.ts new file mode 100644 index 0000000..c75ff2d --- /dev/null +++ b/lib/index.d.ts @@ -0,0 +1,8 @@ +import { Command } from './command'; +import * as completions from './completions'; +import * as flags from './flags'; +export { APIClient } from './api-client'; +export { vars } from './vars'; +export { flags, completions }; +export { Command }; +export default Command; diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..0ed9bee --- /dev/null +++ b/lib/index.js @@ -0,0 +1,15 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Command = exports.completions = exports.flags = exports.vars = exports.APIClient = void 0; +const tslib_1 = require("tslib"); +const command_1 = require("./command"); +Object.defineProperty(exports, "Command", { enumerable: true, get: function () { return command_1.Command; } }); +const completions = tslib_1.__importStar(require("./completions")); +exports.completions = completions; +const flags = tslib_1.__importStar(require("./flags")); +exports.flags = flags; +var api_client_1 = require("./api-client"); +Object.defineProperty(exports, "APIClient", { enumerable: true, get: function () { return api_client_1.APIClient; } }); +var vars_1 = require("./vars"); +Object.defineProperty(exports, "vars", { enumerable: true, get: function () { return vars_1.vars; } }); +exports.default = command_1.Command; diff --git a/lib/login.d.ts b/lib/login.d.ts new file mode 100644 index 0000000..d769e3d --- /dev/null +++ b/lib/login.d.ts @@ -0,0 +1,23 @@ +import { Interfaces } from '@oclif/core'; +import { APIClient } from './api-client'; +export declare namespace Login { + interface Options { + expiresIn?: number; + method?: 'interactive' | 'sso' | 'browser'; + browser?: string; + } +} +export declare class Login { + private readonly config; + private readonly heroku; + loginHost: string; + constructor(config: Interfaces.Config, heroku: APIClient); + login(opts?: Login.Options): Promise; + logout(token?: string | undefined): Promise; + private browser; + private interactive; + private createOAuthToken; + private saveToken; + private defaultToken; + private sso; +} diff --git a/lib/login.js b/lib/login.js new file mode 100644 index 0000000..e82079c --- /dev/null +++ b/lib/login.js @@ -0,0 +1,277 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Login = void 0; +const tslib_1 = require("tslib"); +const color_1 = tslib_1.__importDefault(require("@heroku-cli/color")); +const core_1 = require("@oclif/core"); +const http_call_1 = tslib_1.__importDefault(require("http-call")); +const netrc_parser_1 = tslib_1.__importDefault(require("netrc-parser")); +const open = require("open"); +const os = tslib_1.__importStar(require("os")); +const api_client_1 = require("./api-client"); +const vars_1 = require("./vars"); +const debug = require('debug')('heroku-cli-command'); +const hostname = os.hostname(); +const thirtyDays = 60 * 60 * 24 * 30; +const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } }); +class Login { + constructor(config, heroku) { + this.config = config; + this.heroku = heroku; + this.loginHost = process.env.HEROKU_LOGIN_HOST || 'https://cli-auth.heroku.com'; + } + async login(opts = {}) { + let loggedIn = false; + try { + // timeout after 10 minutes + setTimeout(() => { + if (!loggedIn) + core_1.ux.error('timed out'); + }, 1000 * 60 * 10).unref(); + if (process.env.HEROKU_API_KEY) + core_1.ux.error('Cannot log in with HEROKU_API_KEY set'); + if (opts.expiresIn && opts.expiresIn > thirtyDays) + core_1.ux.error('Cannot set an expiration longer than thirty days'); + await netrc_parser_1.default.load(); + const previousEntry = netrc_parser_1.default.machines['api.heroku.com']; + let input = opts.method; + if (!input) { + if (opts.expiresIn) { + // can't use browser with --expires-in + input = 'interactive'; + } + else if (process.env.HEROKU_LEGACY_SSO === '1') { + input = 'sso'; + } + else { + await core_1.ux.anykey(`heroku: Press any key to open up the browser to login or ${color_1.default.yellow('q')} to exit`); + input = 'browser'; + } + } + try { + if (previousEntry && previousEntry.password) + await this.logout(previousEntry.password); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + core_1.ux.warn(message); + } + let auth; + switch (input) { + case 'b': + case 'browser': + auth = await this.browser(opts.browser); + break; + case 'i': + case 'interactive': + auth = await this.interactive(previousEntry && previousEntry.login, opts.expiresIn); + break; + case 's': + case 'sso': + auth = await this.sso(); + break; + default: + return this.login(opts); + } + await this.saveToken(auth); + } + catch (error) { + throw new api_client_1.HerokuAPIError(error); + } + finally { + loggedIn = true; + } + } + async logout(token = this.heroku.auth) { + if (!token) + return debug('no credentials to logout'); + const requests = []; + // for SSO logins we delete the session since those do not show up in + // authorizations because they are created a trusted client + requests.push(http_call_1.default.delete(`${vars_1.vars.apiUrl}/oauth/sessions/~`, headers(token)) + .catch(error => { + if (!error.http) + throw error; + if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'session') { + return; + } + if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { + return; + } + throw error; + })); + // grab all the authorizations so that we can delete the token they are + // using in the CLI. we have to do this rather than delete ~ because + // the ~ is the API Key, not the authorization that is currently requesting + requests.push(http_call_1.default.get(`${vars_1.vars.apiUrl}/oauth/authorizations`, headers(token)) + .then(async ({ body: authorizations }) => { + // grab the default authorization because that is the token shown in the + // dashboard as API Key and they may be using it for something else and we + // would unwittingly break an integration that they are depending on + const d = await this.defaultToken(); + if (d === token) + return; + return Promise.all(authorizations + .filter(a => a.access_token && a.access_token.token === this.heroku.auth) + .map(a => http_call_1.default.delete(`${vars_1.vars.apiUrl}/oauth/authorizations/${a.id}`, headers(token)))); + }) + .catch(error => { + if (!error.http) + throw error; + if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { + return []; + } + throw error; + })); + await Promise.all(requests); + } + async browser(browser) { + const { body: urls } = await http_call_1.default.post(`${this.loginHost}/auth`, { + body: { description: `Heroku CLI login from ${hostname}` }, + }); + const url = `${this.loginHost}${urls.browser_url}`; + process.stderr.write(`Opening browser to ${url}\n`); + let urlDisplayed = false; + const showUrl = () => { + if (!urlDisplayed) + core_1.ux.warn('Cannot open browser.'); + urlDisplayed = true; + }; + // ux.warn(`If browser does not open, visit ${color.greenBright(url)}`) + const cp = await open(url, { app: browser, wait: false }); + cp.on('error', err => { + core_1.ux.warn(err); + showUrl(); + }); + if (process.env.HEROKU_TESTING_HEADLESS_LOGIN === '1') + showUrl(); + cp.on('close', code => { + if (code !== 0) + showUrl(); + }); + core_1.ux.action.start('heroku: Waiting for login'); + const fetchAuth = async (retries = 3) => { + try { + const { body: auth } = await http_call_1.default.get(`${this.loginHost}${urls.cli_url}`, { + headers: { authorization: `Bearer ${urls.token}` }, + }); + return auth; + } + catch (error) { + if (retries > 0 && error.http && error.http.statusCode > 500) + return fetchAuth(retries - 1); + throw error; + } + }; + const auth = await fetchAuth(); + if (auth.error) + core_1.ux.error(auth.error); + this.heroku.auth = auth.access_token; + core_1.ux.action.start('Logging in'); + const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(auth.access_token)); + core_1.ux.action.stop(); + return { + login: account.email, + password: auth.access_token, + }; + } + async interactive(login, expiresIn) { + process.stderr.write('heroku: Enter your login credentials\n'); + login = await core_1.ux.prompt('Email', { default: login }); + const password = await core_1.ux.prompt('Password', { type: 'hide' }); + let auth; + try { + auth = await this.createOAuthToken(login, password, { expiresIn }); + } + catch (error) { + if (error.body && error.body.id === 'device_trust_required') { + error.body.message = 'The interactive flag requires Two-Factor Authentication to be enabled on your account. Please use heroku login.'; + throw error; + } + if (!error.body || error.body.id !== 'two_factor') { + throw error; + } + const secondFactor = await core_1.ux.prompt('Two-factor code', { type: 'mask' }); + auth = await this.createOAuthToken(login, password, { expiresIn, secondFactor }); + } + this.heroku.auth = auth.password; + return auth; + } + async createOAuthToken(username, password, opts = {}) { + function basicAuth(username, password) { + let auth = [username, password].join(':'); + auth = Buffer.from(auth).toString('base64'); + return `Basic ${auth}`; + } + const headers = { + accept: 'application/vnd.heroku+json; version=3', + authorization: basicAuth(username, password), + }; + if (opts.secondFactor) + headers['Heroku-Two-Factor-Code'] = opts.secondFactor; + const { body: auth } = await http_call_1.default.post(`${vars_1.vars.apiUrl}/oauth/authorizations`, { + headers, + body: { + scope: ['global'], + description: `Heroku CLI login from ${hostname}`, + expires_in: opts.expiresIn || thirtyDays, + }, + }); + return { password: auth.access_token.token, login: auth.user.email }; + } + async saveToken(entry) { + const hosts = [vars_1.vars.apiHost, vars_1.vars.httpGitHost]; + hosts.forEach(host => { + if (!netrc_parser_1.default.machines[host]) + netrc_parser_1.default.machines[host] = {}; + netrc_parser_1.default.machines[host].login = entry.login; + netrc_parser_1.default.machines[host].password = entry.password; + delete netrc_parser_1.default.machines[host].method; + delete netrc_parser_1.default.machines[host].org; + }); + if (netrc_parser_1.default.machines._tokens) { + netrc_parser_1.default.machines._tokens.forEach((token) => { + if (hosts.includes(token.host)) { + token.internalWhitespace = '\n '; + } + }); + } + await netrc_parser_1.default.save(); + } + async defaultToken() { + try { + const { body: authorization } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/oauth/authorizations/~`, headers(this.heroku.auth)); + return authorization.access_token && authorization.access_token.token; + } + catch (error) { + if (!error.http) + throw error; + if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.body.resource === 'authorization') + return; + if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') + return; + throw error; + } + } + async sso() { + let url = process.env.SSO_URL; + let org = process.env.HEROKU_ORGANIZATION; + if (!url) { + org = await (org ? core_1.ux.prompt('Organization name', { default: org }) : core_1.ux.prompt('Organization name')); + url = `https://sso.heroku.com/saml/${encodeURIComponent(org)}/init?cli=true`; + } + // TODO: handle browser + debug(`opening browser to ${url}`); + process.stderr.write(`Opening browser to:\n${url}\n`); + process.stderr.write(color_1.default.gray('If the browser fails to open or you’re authenticating on a ' + + 'remote machine, please manually open the URL above in your ' + + 'browser.\n')); + await open(url, { wait: false }); + const password = await core_1.ux.prompt('Access token', { type: 'mask' }); + core_1.ux.action.start('Validating token'); + this.heroku.auth = password; + const { body: account } = await http_call_1.default.get(`${vars_1.vars.apiUrl}/account`, headers(password)); + return { password, login: account.email }; + } +} +exports.Login = Login; diff --git a/lib/mutex.d.ts b/lib/mutex.d.ts new file mode 100644 index 0000000..215a750 --- /dev/null +++ b/lib/mutex.d.ts @@ -0,0 +1,11 @@ +export declare type PromiseResolve = (value: T | PromiseLike) => void; +export declare type PromiseReject = (reason?: any) => void; +export declare type Task = () => Promise; +export declare type Record = [Task, PromiseResolve, PromiseReject]; +export declare class Mutex { + private busy; + private readonly queue; + synchronize(task: Task): Promise; + dequeue(): Promise | undefined; + execute(record: Record): Promise; +} diff --git a/lib/mutex.js b/lib/mutex.js new file mode 100644 index 0000000..bdc2748 --- /dev/null +++ b/lib/mutex.js @@ -0,0 +1,34 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Mutex = void 0; +class Mutex { + constructor() { + this.busy = false; + this.queue = []; + } + synchronize(task) { + return new Promise((resolve, reject) => { + this.queue.push([task, resolve, reject]); + if (!this.busy) { + this.dequeue(); + } + }); + } + dequeue() { + this.busy = true; + const next = this.queue.shift(); + if (next) { + return this.execute(next); + } + this.busy = false; + } + execute(record) { + const [task, resolve, reject] = record; + return task() + .then(resolve, reject) + .then(() => { + this.dequeue(); + }); + } +} +exports.Mutex = Mutex; diff --git a/lib/request-id.d.ts b/lib/request-id.d.ts new file mode 100644 index 0000000..5ac5ec2 --- /dev/null +++ b/lib/request-id.d.ts @@ -0,0 +1,9 @@ +export declare const requestIdHeader = "Request-Id"; +export declare class RequestId { + static ids: string[]; + static track(...ids: string[]): string[]; + static create(): string[]; + static empty(): void; + static get headerValue(): string; + static _generate(): string; +} diff --git a/lib/request-id.js b/lib/request-id.js new file mode 100644 index 0000000..6ab5ede --- /dev/null +++ b/lib/request-id.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RequestId = exports.requestIdHeader = void 0; +const tslib_1 = require("tslib"); +const uuid = tslib_1.__importStar(require("uuid")); +exports.requestIdHeader = 'Request-Id'; +// tslint:disable-next-line: no-unnecessary-class +class RequestId { + static track(...ids) { + const tracked = RequestId.ids; + ids = ids.filter(id => !(tracked.includes(id))); + RequestId.ids = [...ids, ...tracked]; + return RequestId.ids; + } + static create() { + const tracked = RequestId.ids; + const generatedId = RequestId._generate(); + RequestId.ids = [generatedId, ...tracked]; + return RequestId.ids; + } + static empty() { + RequestId.ids = []; + } + static get headerValue() { + return RequestId.ids.join(','); + } + static _generate() { + return uuid.v4(); + } +} +exports.RequestId = RequestId; +RequestId.ids = []; diff --git a/lib/vars.d.ts b/lib/vars.d.ts new file mode 100644 index 0000000..7f748ac --- /dev/null +++ b/lib/vars.d.ts @@ -0,0 +1,11 @@ +export declare class Vars { + get host(): string; + get apiUrl(): string; + get apiHost(): string; + get envHost(): string | undefined; + get envGitHost(): string | undefined; + get gitHost(): string; + get httpGitHost(): string; + get gitPrefixes(): string[]; +} +export declare const vars: Vars; diff --git a/lib/vars.js b/lib/vars.js new file mode 100644 index 0000000..d4454c8 --- /dev/null +++ b/lib/vars.js @@ -0,0 +1,52 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.vars = exports.Vars = void 0; +const tslib_1 = require("tslib"); +const url = tslib_1.__importStar(require("url")); +class Vars { + get host() { + return this.envHost || 'heroku.com'; + } + get apiUrl() { + return this.host.startsWith('http') ? this.host : `https://api.${this.host}`; + } + get apiHost() { + if (this.host.startsWith('http')) { + const u = url.parse(this.host); + if (u.host) + return u.host; + } + return `api.${this.host}`; + } + get envHost() { + return process.env.HEROKU_HOST; + } + get envGitHost() { + return process.env.HEROKU_GIT_HOST; + } + get gitHost() { + if (this.envGitHost) + return this.envGitHost; + if (this.host.startsWith('http')) { + const u = url.parse(this.host); + if (u.host) + return u.host; + } + return this.host; + } + get httpGitHost() { + if (this.envGitHost) + return this.envGitHost; + if (this.host.startsWith('http')) { + const u = url.parse(this.host); + if (u.host) + return u.host; + } + return `git.${this.host}`; + } + get gitPrefixes() { + return [`git@${this.gitHost}:`, `ssh://git@${this.gitHost}/`, `https://${this.httpGitHost}/`]; + } +} +exports.Vars = Vars; +exports.vars = new Vars(); diff --git a/lib/yubikey.d.ts b/lib/yubikey.d.ts new file mode 100644 index 0000000..2799218 --- /dev/null +++ b/lib/yubikey.d.ts @@ -0,0 +1,6 @@ +/// +export declare const yubikey: { + disable: () => void; + enable: () => void; + platform: NodeJS.Platform; +}; diff --git a/lib/yubikey.js b/lib/yubikey.js new file mode 100644 index 0000000..59add83 --- /dev/null +++ b/lib/yubikey.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.yubikey = void 0; +function toggle(onoff) { + const cp = require('child_process'); + if (exports.yubikey.platform !== 'darwin') + return; + try { + cp.execSync(`osascript -e 'if application "yubiswitch" is running then tell application "yubiswitch" to ${onoff}'`, { stdio: 'inherit' }); + } + catch (_a) { } +} +exports.yubikey = { + disable: () => toggle('KeyOff'), + enable: () => toggle('KeyOn'), + platform: process.platform, +};