UNPKG

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