UNPKG

22.5 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const cli_framework_1 = require("@ionic/cli-framework");
4const cli_framework_prompts_1 = require("@ionic/cli-framework-prompts");
5const format_1 = require("@ionic/cli-framework/utils/format");
6const Debug = require("debug");
7const constants_1 = require("../constants");
8const guards_1 = require("../guards");
9const color_1 = require("../lib/color");
10const command_1 = require("../lib/command");
11const errors_1 = require("../lib/errors");
12const executor_1 = require("../lib/executor");
13const open_1 = require("../lib/open");
14const debug = Debug('ionic:commands:link');
15const CHOICE_CREATE_NEW_APP = 'createNewApp';
16const CHOICE_NEVERMIND = 'nevermind';
17const CHOICE_RELINK = 'relink';
18const CHOICE_LINK_EXISTING_APP = 'linkExistingApp';
19const CHOICE_IONIC = 'ionic';
20const CHOICE_GITHUB = 'github';
21const CHOICE_MASTER_ONLY = 'master';
22const CHOICE_SPECIFIC_BRANCHES = 'specific';
23class LinkCommand extends command_1.Command {
24 async getMetadata() {
25 const projectFile = this.project ? format_1.prettyPath(this.project.filePath) : constants_1.PROJECT_FILE;
26 return {
27 name: 'link',
28 type: 'project',
29 summary: 'Connect local apps to Ionic',
30 description: `
31Link apps on Ionic Appflow to local Ionic projects with this command.
32
33If the ${color_1.input('id')} argument is excluded, this command will prompt you to select an app from Ionic Appflow.
34
35Ionic Appflow uses a git-based workflow to manage app updates. During the linking process, select ${color_1.strong('GitHub')} (recommended) or ${color_1.strong('Ionic Appflow')} as a git host. See our documentation[^appflow-git-basics] for more information.
36
37Ultimately, this command sets the ${color_1.strong('id')} property in ${color_1.strong(format_1.prettyPath(projectFile))}, which marks this app as linked.
38
39If you are having issues linking, please get in touch with our Support[^support-request].
40 `,
41 footnotes: [
42 {
43 id: 'appflow-git-basics',
44 url: 'https://ionicframework.com/docs/appflow/basics/git',
45 shortUrl: 'https://ion.link/appflow-git-basics',
46 },
47 {
48 id: 'support-request',
49 url: 'https://ion.link/support-request',
50 },
51 ],
52 exampleCommands: ['', 'a1b2c3d4'],
53 inputs: [
54 {
55 name: 'id',
56 summary: `The Ionic Appflow ID of the app to link (e.g. ${color_1.input('a1b2c3d4')})`,
57 },
58 ],
59 options: [
60 {
61 name: 'name',
62 summary: 'The app name to use during the linking of a new app',
63 groups: ["hidden" /* HIDDEN */],
64 },
65 {
66 name: 'create',
67 summary: 'Create a new app on Ionic Appflow and link it with this local Ionic project',
68 type: Boolean,
69 groups: ["hidden" /* HIDDEN */],
70 },
71 {
72 name: 'pro-id',
73 summary: 'Specify an app ID from the Ionic Appflow to link',
74 groups: ["deprecated" /* DEPRECATED */, "hidden" /* HIDDEN */],
75 spec: { value: 'id' },
76 },
77 ],
78 };
79 }
80 async preRun(inputs, options) {
81 const { create } = options;
82 if (inputs[0] && create) {
83 throw new errors_1.FatalException(`Sorry--cannot use both ${color_1.input('id')} and ${color_1.input('--create')}. You must either link an existing app or create a new one.`);
84 }
85 const id = options['pro-id'] ? String(options['pro-id']) : undefined;
86 if (id) {
87 inputs[0] = id;
88 }
89 }
90 async run(inputs, options, runinfo) {
91 const { promptToLogin } = await Promise.resolve().then(() => require('../lib/session'));
92 if (!this.project) {
93 throw new errors_1.FatalException(`Cannot run ${color_1.input('ionic link')} outside a project directory.`);
94 }
95 let id = inputs[0];
96 let { create } = options;
97 const idFromConfig = this.project.config.get('id');
98 if (idFromConfig) {
99 if (id && idFromConfig === id) {
100 this.env.log.msg(`Already linked with app ${color_1.input(id)}.`);
101 return;
102 }
103 const msg = id ?
104 `Are you sure you want to link it to ${color_1.input(id)} instead?` :
105 `Would you like to run link again?`;
106 const confirm = await this.env.prompt({
107 type: 'confirm',
108 name: 'confirm',
109 message: `App ID ${color_1.input(idFromConfig)} is already set up with this app. ${msg}`,
110 });
111 if (!confirm) {
112 this.env.log.msg('Not linking.');
113 return;
114 }
115 }
116 if (!this.env.session.isLoggedIn()) {
117 await promptToLogin(this.env);
118 }
119 if (!id && !create) {
120 const choices = [
121 {
122 name: `Link ${idFromConfig ? 'a different' : 'an existing'} app on Ionic Appflow`,
123 value: CHOICE_LINK_EXISTING_APP,
124 },
125 {
126 name: 'Create a new app on Ionic Appflow',
127 value: CHOICE_CREATE_NEW_APP,
128 },
129 ];
130 if (idFromConfig) {
131 choices.unshift({
132 name: `Relink ${color_1.input(idFromConfig)}`,
133 value: CHOICE_RELINK,
134 });
135 }
136 const result = await this.env.prompt({
137 type: 'list',
138 name: 'whatToDo',
139 message: 'What would you like to do?',
140 choices,
141 });
142 if (result === CHOICE_CREATE_NEW_APP) {
143 create = true;
144 id = undefined;
145 }
146 else if (result === CHOICE_LINK_EXISTING_APP) {
147 const tasks = this.createTaskChain();
148 tasks.next(`Looking up your apps`);
149 const apps = [];
150 const appClient = await this.getAppClient();
151 const paginator = appClient.paginate();
152 for (const r of paginator) {
153 const res = await r;
154 apps.push(...res.data);
155 }
156 tasks.end();
157 if (apps.length === 0) {
158 const confirm = await this.env.prompt({
159 type: 'confirm',
160 name: 'confirm',
161 message: `No apps found. Would you like to create a new app on Ionic Appflow?`,
162 });
163 if (!confirm) {
164 throw new errors_1.FatalException(`Cannot link without an app selected.`);
165 }
166 create = true;
167 id = undefined;
168 }
169 else {
170 const choice = await this.chooseApp(apps);
171 if (choice === CHOICE_NEVERMIND) {
172 this.env.log.info('Not linking app.');
173 id = undefined;
174 }
175 else {
176 id = choice;
177 }
178 }
179 }
180 else if (result === CHOICE_RELINK) {
181 id = idFromConfig;
182 }
183 }
184 if (create) {
185 let name = options['name'] ? String(options['name']) : undefined;
186 if (!name) {
187 name = await this.env.prompt({
188 type: 'input',
189 name: 'name',
190 message: 'Please enter a name for your new app:',
191 validate: v => cli_framework_1.validators.required(v),
192 });
193 }
194 id = await this.createApp({ name }, runinfo);
195 }
196 else if (id) {
197 const app = await this.lookUpApp(id);
198 await this.linkApp(app, runinfo);
199 }
200 }
201 async getAppClient() {
202 const { AppClient } = await Promise.resolve().then(() => require('../lib/app'));
203 const token = this.env.session.getUserToken();
204 return new AppClient(token, this.env);
205 }
206 async getUserClient() {
207 const { UserClient } = await Promise.resolve().then(() => require('../lib/user'));
208 const token = this.env.session.getUserToken();
209 return new UserClient(token, this.env);
210 }
211 async lookUpApp(id) {
212 const tasks = this.createTaskChain();
213 tasks.next(`Looking up app ${color_1.input(id)}`);
214 const appClient = await this.getAppClient();
215 const app = await appClient.load(id); // Make sure the user has access to the app
216 tasks.end();
217 return app;
218 }
219 async createApp({ name }, runinfo) {
220 const appClient = await this.getAppClient();
221 const org_id = this.env.config.get('org.id');
222 const app = await appClient.create({ name, org_id });
223 await this.linkApp(app, runinfo);
224 return app.id;
225 }
226 async linkApp(app, runinfo) {
227 // TODO: load connections
228 // TODO: check for git availability before this
229 this.env.log.nl();
230 this.env.log.info(`Ionic Appflow uses a git-based workflow to manage app updates.\n` +
231 `You will be prompted to set up the git host and repository for this new app. See the docs${color_1.ancillary('[1]')} for more information.\n\n` +
232 `${color_1.ancillary('[1]')}: ${color_1.strong('https://ion.link/appflow-git-basics')}`);
233 this.env.log.nl();
234 const service = await this.env.prompt({
235 type: 'list',
236 name: 'gitService',
237 message: 'Which git host would you like to use?',
238 choices: [
239 {
240 name: 'GitHub',
241 value: CHOICE_GITHUB,
242 },
243 {
244 name: 'Ionic Appflow',
245 value: CHOICE_IONIC,
246 },
247 ],
248 });
249 let githubUrl;
250 if (service === CHOICE_IONIC) {
251 if (!this.env.config.get('git.setup')) {
252 await executor_1.runCommand(runinfo, ['ssh', 'setup']);
253 }
254 await executor_1.runCommand(runinfo, ['config', 'set', 'id', `"${app.id}"`, '--json']);
255 await executor_1.runCommand(runinfo, ['git', 'remote']);
256 }
257 else {
258 if (service === CHOICE_GITHUB) {
259 githubUrl = await this.linkGithub(app);
260 }
261 await executor_1.runCommand(runinfo, ['config', 'set', 'id', `"${app.id}"`, '--json']);
262 }
263 this.env.log.ok(`Project linked with app ${color_1.input(app.id)}!`);
264 if (service === CHOICE_GITHUB) {
265 this.env.log.info(`Here are some additional links that can help you with you first push to GitHub:\n` +
266 `${color_1.strong('Adding GitHub as a remote')}:\n\t${color_1.strong('https://help.github.com/articles/adding-a-remote/')}\n\n` +
267 `${color_1.strong('Pushing to a remote')}:\n\t${color_1.strong('https://help.github.com/articles/pushing-to-a-remote/')}\n\n` +
268 `${color_1.strong('Working with branches')}:\n\t${color_1.strong('https://guides.github.com/introduction/flow/')}\n\n` +
269 `${color_1.strong('More comfortable with a GUI? Try GitHub Desktop!')}\n\t${color_1.strong('https://desktop.github.com/')}`);
270 if (githubUrl) {
271 this.env.log.info(`You can now push to one of your branches on GitHub to trigger a build in Ionic Appflow!\n` +
272 `If you haven't added GitHub as your origin you can do so by running:\n\n` +
273 `${color_1.input('git remote add origin ' + githubUrl)}\n\n` +
274 `You can find additional links above to help if you're having issues.`);
275 }
276 }
277 }
278 async linkGithub(app) {
279 const { id } = this.env.session.getUser();
280 const userClient = await this.getUserClient();
281 const user = await userClient.load(id, { fields: ['oauth_identities'] });
282 if (!user.oauth_identities || !user.oauth_identities.github) {
283 await this.oAuthProcess(id);
284 }
285 if (await this.needsAssociation(app, user.id)) {
286 await this.confirmGithubRepoExists();
287 const repoId = await this.selectGithubRepo();
288 const branches = await this.selectGithubBranches(repoId);
289 return this.connectGithub(app, repoId, branches);
290 }
291 }
292 async confirmGithubRepoExists() {
293 let confirm = false;
294 this.env.log.nl();
295 this.env.log.info(color_1.strong(`In order to link to a GitHub repository the repository must already exist on GitHub.`));
296 this.env.log.info(`${color_1.strong('If the repository does not exist please create one now before continuing.')}\n` +
297 `If you're not familiar with Git you can learn how to set it up with GitHub here:\n\n` +
298 color_1.strong(`https://help.github.com/articles/set-up-git/ \n\n`) +
299 `You can find documentation on how to create a repository on GitHub and push to it here:\n\n` +
300 color_1.strong(`https://help.github.com/articles/create-a-repo/`));
301 confirm = await this.env.prompt({
302 type: 'confirm',
303 name: 'confirm',
304 message: 'Does the repository exist on GitHub?',
305 });
306 if (!confirm) {
307 throw new errors_1.FatalException(`Repo must exist on GitHub in order to link. Please create the repo and run ${color_1.input('ionic link')} again.`);
308 }
309 }
310 async oAuthProcess(userId) {
311 const userClient = await this.getUserClient();
312 let confirm = false;
313 this.env.log.nl();
314 this.env.log.info(`GitHub OAuth setup required.\n` +
315 `To continue, we need you to authorize Ionic Appflow with your GitHub account. ` +
316 `A browser will open and prompt you to complete the authorization request. ` +
317 `When finished, please return to the CLI to continue linking your app.`);
318 confirm = await this.env.prompt({
319 type: 'confirm',
320 name: 'ready',
321 message: 'Open browser:',
322 });
323 if (!confirm) {
324 throw new errors_1.FatalException(`GitHub OAuth setup is required to link to GitHub repository. Please run ${color_1.input('ionic link')} again when ready.`);
325 }
326 const url = await userClient.oAuthGithubLogin(userId);
327 await open_1.openUrl(url, { encode: false });
328 confirm = await this.env.prompt({
329 type: 'confirm',
330 name: 'ready',
331 message: 'Authorized and ready to continue:',
332 });
333 if (!confirm) {
334 throw new errors_1.FatalException(`GitHub OAuth setup is required to link to GitHub repository. Please run ${color_1.input('ionic link')} again when ready.`);
335 }
336 }
337 async needsAssociation(app, userId) {
338 const appClient = await this.getAppClient();
339 if (app.association && app.association.repository.html_url) {
340 this.env.log.msg(`App ${color_1.input(app.id)} already connected to ${color_1.strong(app.association.repository.html_url)}`);
341 const confirm = await this.env.prompt({
342 type: 'confirm',
343 name: 'confirm',
344 message: 'Would you like to connect a different repo?',
345 });
346 if (!confirm) {
347 return false;
348 }
349 try {
350 // TODO: maybe we can use a PUT instead of DELETE now + POST later?
351 await appClient.deleteAssociation(app.id);
352 }
353 catch (e) {
354 if (guards_1.isSuperAgentError(e)) {
355 if (e.response.status === 401) {
356 await this.oAuthProcess(userId);
357 await appClient.deleteAssociation(app.id);
358 return true;
359 }
360 else if (e.response.status === 404) {
361 debug(`DELETE ${app.id} GitHub association not found`);
362 return true;
363 }
364 }
365 throw e;
366 }
367 }
368 return true;
369 }
370 async connectGithub(app, repoId, branches) {
371 const appClient = await this.getAppClient();
372 try {
373 const association = await appClient.createAssociation(app.id, { repoId, type: 'github', branches });
374 this.env.log.ok(`App ${color_1.input(app.id)} connected to ${color_1.strong(association.repository.html_url)}`);
375 return association.repository.html_url;
376 }
377 catch (e) {
378 if (guards_1.isSuperAgentError(e) && e.response.status === 403) {
379 throw new errors_1.FatalException(e.response.body.error.message);
380 }
381 }
382 }
383 formatRepoName(fullName) {
384 const [org, name] = fullName.split('/');
385 return `${color_1.weak(`${org} /`)} ${name}`;
386 }
387 async chooseApp(apps) {
388 const { formatName } = await Promise.resolve().then(() => require('../lib/app'));
389 const neverMindChoice = {
390 name: color_1.strong('Nevermind'),
391 id: CHOICE_NEVERMIND,
392 value: CHOICE_NEVERMIND,
393 org: null,
394 };
395 const linkedApp = await this.env.prompt({
396 type: 'list',
397 name: 'linkedApp',
398 message: 'Which app would you like to link',
399 choices: [
400 ...apps.map(app => ({
401 name: `${formatName(app)} ${color_1.weak(`(${app.id})`)}`,
402 value: app.id,
403 })),
404 cli_framework_prompts_1.createPromptChoiceSeparator(),
405 neverMindChoice,
406 cli_framework_prompts_1.createPromptChoiceSeparator(),
407 ],
408 });
409 return linkedApp;
410 }
411 async selectGithubRepo() {
412 const user = this.env.session.getUser();
413 const userClient = await this.getUserClient();
414 const tasks = this.createTaskChain();
415 const task = tasks.next('Looking up your GitHub repositories');
416 const paginator = userClient.paginateGithubRepositories(user.id);
417 const repos = [];
418 try {
419 for (const r of paginator) {
420 const res = await r;
421 repos.push(...res.data);
422 task.msg = `Looking up your GitHub repositories: ${color_1.strong(String(repos.length))} found`;
423 }
424 }
425 catch (e) {
426 tasks.fail();
427 if (guards_1.isSuperAgentError(e) && e.response.status === 401) {
428 await this.oAuthProcess(user.id);
429 return this.selectGithubRepo();
430 }
431 throw e;
432 }
433 tasks.end();
434 const repoId = await this.env.prompt({
435 type: 'list',
436 name: 'githubRepo',
437 message: 'Which GitHub repository would you like to link?',
438 choices: repos.map(repo => ({
439 name: this.formatRepoName(repo.full_name),
440 value: String(repo.id),
441 })),
442 });
443 return Number(repoId);
444 }
445 async selectGithubBranches(repoId) {
446 this.env.log.nl();
447 this.env.log.info(color_1.strong(`By default Ionic Appflow links only to the ${color_1.input('master')} branch.`));
448 this.env.log.info(`${color_1.strong('If you\'d like to link to another branch or multiple branches you\'ll need to select each branch to connect to.')}\n` +
449 `If you're not familiar with on working with branches in GitHub you can read about them here:\n\n` +
450 color_1.strong(`https://guides.github.com/introduction/flow/ \n\n`));
451 const choice = await this.env.prompt({
452 type: 'list',
453 name: 'githubMultipleBranches',
454 message: 'Which would you like to do?',
455 choices: [
456 {
457 name: `Link to master branch only`,
458 value: CHOICE_MASTER_ONLY,
459 },
460 {
461 name: `Link to specific branches`,
462 value: CHOICE_SPECIFIC_BRANCHES,
463 },
464 ],
465 });
466 switch (choice) {
467 case CHOICE_MASTER_ONLY:
468 return ['master'];
469 case CHOICE_SPECIFIC_BRANCHES:
470 // fall through and begin prompting to choose branches
471 break;
472 default:
473 throw new errors_1.FatalException('Aborting. No branch choice specified.');
474 }
475 const user = this.env.session.getUser();
476 const userClient = await this.getUserClient();
477 const paginator = userClient.paginateGithubBranches(user.id, repoId);
478 const tasks = this.createTaskChain();
479 const task = tasks.next('Looking for available branches');
480 const availableBranches = [];
481 try {
482 for (const r of paginator) {
483 const res = await r;
484 availableBranches.push(...res.data);
485 task.msg = `Looking up the available branches on your GitHub repository: ${color_1.strong(String(availableBranches.length))} found`;
486 }
487 }
488 catch (e) {
489 tasks.fail();
490 throw e;
491 }
492 tasks.end();
493 const choices = availableBranches.map(branch => ({
494 name: branch.name,
495 value: branch.name,
496 checked: branch.name === 'master',
497 }));
498 if (choices.length === 0) {
499 this.env.log.warn(`No branches found for the repository. Linking to ${color_1.input('master')} branch.`);
500 return ['master'];
501 }
502 const selectedBranches = await this.env.prompt({
503 type: 'checkbox',
504 name: 'githubBranches',
505 message: 'Which branch would you like to link?',
506 choices,
507 default: ['master'],
508 });
509 return selectedBranches;
510 }
511}
512exports.LinkCommand = LinkCommand;