1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const cli_framework_1 = require("@ionic/cli-framework");
|
4 | const cli_framework_prompts_1 = require("@ionic/cli-framework-prompts");
|
5 | const format_1 = require("@ionic/cli-framework/utils/format");
|
6 | const Debug = require("debug");
|
7 | const constants_1 = require("../constants");
|
8 | const guards_1 = require("../guards");
|
9 | const color_1 = require("../lib/color");
|
10 | const command_1 = require("../lib/command");
|
11 | const errors_1 = require("../lib/errors");
|
12 | const executor_1 = require("../lib/executor");
|
13 | const open_1 = require("../lib/open");
|
14 | const debug = Debug('ionic:commands:link');
|
15 | const CHOICE_CREATE_NEW_APP = 'createNewApp';
|
16 | const CHOICE_NEVERMIND = 'nevermind';
|
17 | const CHOICE_RELINK = 'relink';
|
18 | const CHOICE_LINK_EXISTING_APP = 'linkExistingApp';
|
19 | const CHOICE_IONIC = 'ionic';
|
20 | const CHOICE_GITHUB = 'github';
|
21 | const CHOICE_MASTER_ONLY = 'master';
|
22 | const CHOICE_SPECIFIC_BRANCHES = 'specific';
|
23 | class 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: `
|
31 | Link apps on Ionic Appflow to local Ionic projects with this command.
|
32 |
|
33 | If the ${color_1.input('id')} argument is excluded, this command will prompt you to select an app from Ionic Appflow.
|
34 |
|
35 | Ionic 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 |
|
37 | Ultimately, this command sets the ${color_1.strong('id')} property in ${color_1.strong(format_1.prettyPath(projectFile))}, which marks this app as linked.
|
38 |
|
39 | If 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" ],
|
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" ],
|
70 | },
|
71 | {
|
72 | name: 'pro-id',
|
73 | summary: 'Specify an app ID from the Ionic Appflow to link',
|
74 | groups: ["deprecated" , "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);
|
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 |
|
228 |
|
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 |
|
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 |
|
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 | }
|
512 | exports.LinkCommand = LinkCommand;
|