link.js 24 KB

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