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