@mui/internal-code-infra 0.0.3-canary.6 → 0.0.3-canary.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +55 -0
  2. package/build/babel-config.d.mts +40 -0
  3. package/build/brokenLinksChecker/index.d.mts +138 -0
  4. package/build/cli/cmdArgosPush.d.mts +13 -0
  5. package/build/cli/cmdBuild.d.mts +56 -0
  6. package/build/cli/cmdCopyFiles.d.mts +20 -0
  7. package/build/cli/cmdExtractErrorCodes.d.mts +3 -0
  8. package/build/cli/cmdGithubAuth.d.mts +6 -0
  9. package/build/cli/cmdListWorkspaces.d.mts +18 -0
  10. package/build/cli/cmdPublish.d.mts +27 -0
  11. package/build/cli/cmdPublishCanary.d.mts +30 -0
  12. package/build/cli/cmdPublishNewPackage.d.mts +8 -0
  13. package/build/cli/cmdSetVersionOverrides.d.mts +9 -0
  14. package/build/cli/cmdValidateBuiltTypes.d.mts +2 -0
  15. package/build/cli/index.d.mts +1 -0
  16. package/build/eslint/baseConfig.d.mts +10 -0
  17. package/build/eslint/docsConfig.d.mts +4 -0
  18. package/build/eslint/extensions.d.mts +8 -0
  19. package/build/eslint/index.d.mts +4 -0
  20. package/build/eslint/jsonConfig.d.mts +4 -0
  21. package/build/eslint/material-ui/config.d.mts +8 -0
  22. package/build/eslint/material-ui/index.d.mts +2 -0
  23. package/build/eslint/material-ui/rules/disallow-active-element-as-key-event-target.d.mts +5 -0
  24. package/build/eslint/material-ui/rules/disallow-react-api-in-server-components.d.mts +2 -0
  25. package/build/eslint/material-ui/rules/docgen-ignore-before-comment.d.mts +2 -0
  26. package/build/eslint/material-ui/rules/mui-name-matches-component-name.d.mts +5 -0
  27. package/build/eslint/material-ui/rules/no-empty-box.d.mts +5 -0
  28. package/build/eslint/material-ui/rules/no-restricted-resolved-imports.d.mts +12 -0
  29. package/build/eslint/material-ui/rules/no-styled-box.d.mts +5 -0
  30. package/build/eslint/material-ui/rules/rules-of-use-theme-variants.d.mts +9 -0
  31. package/build/eslint/material-ui/rules/straight-quotes.d.mts +5 -0
  32. package/build/eslint/testConfig.d.mts +14 -0
  33. package/build/markdownlint/duplicate-h1.d.mts +27 -0
  34. package/build/markdownlint/git-diff.d.mts +8 -0
  35. package/build/markdownlint/index.d.mts +56 -0
  36. package/build/markdownlint/straight-quotes.d.mts +8 -0
  37. package/build/markdownlint/table-alignment.d.mts +8 -0
  38. package/build/markdownlint/terminal-language.d.mts +8 -0
  39. package/build/prettier.d.mts +20 -0
  40. package/build/stylelint/index.d.mts +32 -0
  41. package/build/utils/babel.d.mts +71 -0
  42. package/build/utils/build.d.mts +50 -0
  43. package/build/utils/changelog.d.mts +64 -0
  44. package/build/utils/credentials.d.mts +17 -0
  45. package/build/utils/extractErrorCodes.d.mts +19 -0
  46. package/build/utils/git.d.mts +26 -0
  47. package/build/utils/github.d.mts +41 -0
  48. package/build/utils/pnpm.d.mts +238 -0
  49. package/build/utils/typescript.d.mts +35 -0
  50. package/package.json +92 -42
  51. package/src/babel-config.mjs +52 -8
  52. package/src/brokenLinksChecker/__fixtures__/static-site/broken-links.html +20 -0
  53. package/src/brokenLinksChecker/__fixtures__/static-site/broken-targets.html +22 -0
  54. package/src/brokenLinksChecker/__fixtures__/static-site/example.md +9 -0
  55. package/src/brokenLinksChecker/__fixtures__/static-site/external-links.html +21 -0
  56. package/src/brokenLinksChecker/__fixtures__/static-site/ignored-page.html +17 -0
  57. package/src/brokenLinksChecker/__fixtures__/static-site/index.html +26 -0
  58. package/src/brokenLinksChecker/__fixtures__/static-site/known-targets.json +5 -0
  59. package/src/brokenLinksChecker/__fixtures__/static-site/nested/page.html +19 -0
  60. package/src/brokenLinksChecker/__fixtures__/static-site/orphaned-page.html +20 -0
  61. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-api-links.html +20 -0
  62. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-custom-targets.html +24 -0
  63. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-ignored-content.html +28 -0
  64. package/src/brokenLinksChecker/__fixtures__/static-site/page-with-known-target-links.html +19 -0
  65. package/src/brokenLinksChecker/__fixtures__/static-site/valid.html +20 -0
  66. package/src/brokenLinksChecker/__fixtures__/static-site/with-anchors.html +31 -0
  67. package/src/brokenLinksChecker/index.mjs +641 -0
  68. package/src/brokenLinksChecker/index.test.ts +178 -0
  69. package/src/cli/cmdArgosPush.mjs +13 -2
  70. package/src/cli/cmdBuild.mjs +228 -31
  71. package/src/cli/cmdGithubAuth.mjs +36 -0
  72. package/src/cli/cmdListWorkspaces.mjs +2 -2
  73. package/src/cli/cmdPublish.mjs +203 -49
  74. package/src/cli/cmdPublishCanary.mjs +404 -46
  75. package/src/cli/cmdPublishNewPackage.mjs +86 -0
  76. package/src/cli/cmdSetVersionOverrides.mjs +17 -1
  77. package/src/cli/cmdValidateBuiltTypes.mjs +49 -0
  78. package/src/cli/index.mjs +6 -2
  79. package/src/cli/packageJson.d.ts +729 -0
  80. package/src/eslint/baseConfig.mjs +96 -78
  81. package/src/eslint/docsConfig.mjs +26 -13
  82. package/src/eslint/extensions.mjs +8 -8
  83. package/src/eslint/jsonConfig.mjs +40 -0
  84. package/src/eslint/material-ui/config.mjs +8 -9
  85. package/src/eslint/material-ui/rules/mui-name-matches-component-name.mjs +4 -2
  86. package/src/eslint/material-ui/rules/rules-of-use-theme-variants.mjs +2 -1
  87. package/src/eslint/testConfig.mjs +72 -66
  88. package/src/stylelint/index.mjs +46 -0
  89. package/src/untyped-plugins.d.ts +13 -0
  90. package/src/{cli → utils}/babel.mjs +10 -3
  91. package/src/utils/build.mjs +27 -1
  92. package/src/utils/changelog.mjs +157 -0
  93. package/src/utils/credentials.mjs +71 -0
  94. package/src/utils/extractErrorCodes.mjs +2 -2
  95. package/src/utils/git.mjs +67 -0
  96. package/src/utils/github.mjs +263 -0
  97. package/src/{cli → utils}/pnpm.mjs +23 -13
  98. package/src/{cli → utils}/typescript.mjs +13 -7
  99. package/src/cli/cmdJsonLint.mjs +0 -69
@@ -2,11 +2,11 @@
2
2
  /// <reference types="../untyped-plugins" />
3
3
 
4
4
  import { findWorkspaceDir } from '@pnpm/find-workspace-dir';
5
+ import { $ } from 'execa';
5
6
  import { globby } from 'globby';
6
7
  import * as fs from 'node:fs/promises';
7
8
  import * as path from 'node:path';
8
- import { $ } from 'execa';
9
- import { BASE_IGNORES } from '../utils/build.mjs';
9
+ import { BASE_IGNORES } from './build.mjs';
10
10
 
11
11
  const TO_TRANSFORM_EXTENSIONS = ['.js', '.ts', '.tsx'];
12
12
 
@@ -70,6 +70,8 @@ export async function cjsCopy({ from, to }) {
70
70
  * @param {boolean} [options.verbose=false] - Whether to enable verbose logging.
71
71
  * @param {boolean} [options.optimizeClsx=false] - Whether to enable clsx call optimization transform.
72
72
  * @param {boolean} [options.removePropTypes=true] - Whether to enable removal of React prop types.
73
+ * @param {Object} [options.reactCompiler] - Whether to use the React compiler.
74
+ * @param {string} [options.reactCompiler.reactVersion] - The React version to use with the React compiler.
73
75
  * @param {string[]} [options.ignores] - The globs to be ignored by Babel.
74
76
  * @param {string} options.cwd - The package root directory.
75
77
  * @param {string} options.pkgVersion - The package version.
@@ -77,7 +79,7 @@ export async function cjsCopy({ from, to }) {
77
79
  * @param {string} options.outDir - The output directory for the build.
78
80
  * @param {string} options.outExtension - The output file extension for the build.
79
81
  * @param {boolean} options.hasLargeFiles - Whether the build includes large files.
80
- * @param {import('../utils/build.mjs').BundleType} options.bundle - The bundles to build.
82
+ * @param {import('./build.mjs').BundleType} options.bundle - The bundles to build.
81
83
  * @param {string} options.babelRuntimeVersion - The version of @babel/runtime to use.
82
84
  * @returns {Promise<void>}
83
85
  */
@@ -94,6 +96,7 @@ export async function babelBuild({
94
96
  removePropTypes = false,
95
97
  verbose = false,
96
98
  ignores = [],
99
+ reactCompiler,
97
100
  }) {
98
101
  console.log(
99
102
  `Transpiling files to "${path.relative(path.dirname(sourceDir), outDir)}" for "${bundle}" bundle.`,
@@ -111,6 +114,8 @@ export async function babelBuild({
111
114
  ) {
112
115
  configFile = path.join(workspaceDir, 'babel.config.mjs');
113
116
  }
117
+
118
+ const reactVersion = reactCompiler?.reactVersion;
114
119
  const env = {
115
120
  NODE_ENV: 'production',
116
121
  BABEL_ENV: bundle === 'esm' ? 'stable' : 'node',
@@ -120,6 +125,8 @@ export async function babelBuild({
120
125
  MUI_BABEL_RUNTIME_VERSION: babelRuntimeVersion,
121
126
  MUI_OUT_FILE_EXTENSION: outExtension ?? '.js',
122
127
  ...getVersionEnvVariables(pkgVersion),
128
+ MUI_REACT_COMPILER: reactVersion ? '1' : '0',
129
+ MUI_REACT_COMPILER_REACT_VERSION: reactVersion,
123
130
  };
124
131
  const res = await $({
125
132
  stdio: 'inherit',
@@ -1,3 +1,5 @@
1
+ import * as semver from 'semver';
2
+
1
3
  /**
2
4
  * @typedef {'esm' | 'cjs'} BundleType
3
5
  */
@@ -24,9 +26,10 @@ export function getOutExtension(bundle, isType = false) {
24
26
  * @param {Record<string, any>} packageJson
25
27
  * @param {Object} [options]
26
28
  * @param {boolean} [options.skipMainCheck=false] - Whether to skip checking for main field in package.json.
29
+ * @param {boolean} [options.enableReactCompiler=false] - Whether to enable React compiler checks.
27
30
  */
28
31
  export function validatePkgJson(packageJson, options = {}) {
29
- const { skipMainCheck = false } = options;
32
+ const { skipMainCheck = false, enableReactCompiler = false } = options;
30
33
  /**
31
34
  * @type {string[]}
32
35
  */
@@ -63,6 +66,29 @@ export function validatePkgJson(packageJson, options = {}) {
63
66
  }
64
67
  }
65
68
 
69
+ const reactVersion = packageJson.peerDependencies?.react;
70
+ if (enableReactCompiler) {
71
+ if (!reactVersion) {
72
+ errors.push(
73
+ 'When building with React compiler, "react" must be specified as a peerDependency in package.json.',
74
+ );
75
+ }
76
+ const minSupportedReactVersion = semver.minVersion(reactVersion);
77
+ if (!minSupportedReactVersion) {
78
+ errors.push(
79
+ `Unable to determine the minimum supported React version from the peerDependency range: "${reactVersion}".`,
80
+ );
81
+ } else if (
82
+ semver.lt(minSupportedReactVersion, '19.0.0') &&
83
+ !packageJson.peerDependencies?.['react-compiler-runtime'] &&
84
+ !packageJson.dependencies?.['react-compiler-runtime']
85
+ ) {
86
+ errors.push(
87
+ 'When building with React compiler for React versions below 19, "react-compiler-runtime" must be specified as a dependency or peerDependency in package.json.',
88
+ );
89
+ }
90
+ }
91
+
66
92
  if (errors.length > 0) {
67
93
  const error = new Error(errors.join('\n'));
68
94
  throw error;
@@ -0,0 +1,157 @@
1
+ import { Octokit } from '@octokit/rest';
2
+ import { $ } from 'execa';
3
+
4
+ import { persistentAuthStrategy } from './github.mjs';
5
+
6
+ /**
7
+ * @typedef {import('@octokit/rest').Octokit} OctokitType
8
+ */
9
+
10
+ /**
11
+ * @typedef {'team' | 'first_timer' | 'contributor'} AuthorAssociation
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} FetchedCommitDetails
16
+ * @property {string} sha
17
+ * @property {string} message
18
+ * @property {string[]} labels
19
+ * @property {number} prNumber
20
+ * @property {string} html_url
21
+ * @property {{login: string; association: AuthorAssociation} | null} author
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} FetchCommitsOptions
26
+ * @property {string} repo
27
+ * @property {string} lastRelease
28
+ * @property {string} release
29
+ * @property {string} [org='mui'] - GitHub organization name, defaults to 'mui'
30
+ */
31
+
32
+ /**
33
+ * @param {Object} opts
34
+ * @param {string} opts.cwd
35
+ * @param {boolean} [opts.fetchAll=true] Whether to fetch all tags from all remotes before finding the latest tag.
36
+ * @returns {Promise<string>}
37
+ */
38
+ export async function findLatestTaggedVersion(opts) {
39
+ const $$ = $({ cwd: opts.cwd });
40
+ const fetchAll = opts.fetchAll ?? true;
41
+ if (fetchAll) {
42
+ // Fetch all tags from all remotes to ensure we have the latest tags.
43
+ await $$`git fetch --tags --all`;
44
+ }
45
+ const { stdout } = await $$`git describe --tags --abbrev=0 --match ${'v*'}`; // only include "version-tags"
46
+ return stdout.trim();
47
+ }
48
+
49
+ /**
50
+ * Fetches commits between two refs (lastRelease..release) including PR details.
51
+ * Automatically handles GitHub OAuth authentication if none provided.
52
+ *
53
+ * @param {FetchCommitsOptions & {octokit?: OctokitType}} opts
54
+ * @returns {Promise<FetchedCommitDetails[]>}
55
+ */
56
+ export async function fetchCommitsBetweenRefs(opts) {
57
+ const octokit =
58
+ 'octokit' in opts && opts.octokit
59
+ ? opts.octokit
60
+ : new Octokit({ authStrategy: persistentAuthStrategy });
61
+
62
+ return fetchCommitsRest({
63
+ octokit,
64
+ repo: opts.repo,
65
+ lastRelease: opts.lastRelease,
66
+ release: opts.release,
67
+ org: opts.org ?? 'mui',
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Fetches commits between two refs using GitHub's REST API.
73
+ * It is more reliable than the GraphQL API but requires multiple network calls (1 + n).
74
+ * One to list all commits between the two refs and then one for each commit to get the PR details.
75
+ *
76
+ * @param {FetchCommitsOptions & { octokit: OctokitType}} param0
77
+ *
78
+ * @returns {Promise<FetchedCommitDetails[]>}
79
+ */
80
+ async function fetchCommitsRest({ octokit, repo, lastRelease, release, org = 'mui' }) {
81
+ /**
82
+ * @typedef {Awaited<ReturnType<Octokit['repos']['compareCommits']>>['data']['commits']} Commits
83
+ */
84
+ /**
85
+ * @type {Commits}
86
+ */
87
+ const results = [];
88
+ /**
89
+ * @type {any}
90
+ */
91
+ const timeline = octokit.paginate.iterator(
92
+ octokit.repos.compareCommitsWithBasehead.endpoint.merge({
93
+ owner: org,
94
+ repo,
95
+ basehead: `${lastRelease}...${release}`,
96
+ }),
97
+ );
98
+ for await (const response of timeline) {
99
+ results.push(...response.data.commits);
100
+ }
101
+
102
+ const promises = results.map(async (commit) => {
103
+ const prMatch = commit.commit.message.match(/#(\d+)/);
104
+ if (prMatch === null) {
105
+ return null;
106
+ }
107
+
108
+ const prNumber = parseInt(prMatch[1], 10);
109
+
110
+ const pr = await octokit.pulls.get({
111
+ owner: org,
112
+ repo,
113
+ pull_number: prNumber,
114
+ headers: {
115
+ Accept: 'application/vnd.github.text+json',
116
+ },
117
+ });
118
+
119
+ const labels = pr.data.labels.map((label) => label.name);
120
+
121
+ return /** @type {FetchedCommitDetails} */ ({
122
+ sha: commit.sha,
123
+ message: commit.commit.message,
124
+ labels,
125
+ prNumber,
126
+ html_url: pr.data.html_url,
127
+ author: pr.data.user?.login
128
+ ? {
129
+ login: pr.data.user.login,
130
+ association: getAuthorAssociation(pr.data.author_association),
131
+ }
132
+ : null,
133
+ });
134
+ });
135
+
136
+ return (await Promise.all(promises)).filter((entry) => entry !== null);
137
+ }
138
+
139
+ /**
140
+ *
141
+ * @param {import('@octokit/rest').RestEndpointMethodTypes["pulls"]["get"]["response"]["data"]["author_association"]} input
142
+ * @returns {AuthorAssociation}
143
+ */
144
+ function getAuthorAssociation(input) {
145
+ switch (input) {
146
+ case 'OWNER':
147
+ case 'MEMBER':
148
+ return 'team';
149
+ case 'MANNEQUIN':
150
+ case 'NONE':
151
+ case 'FIRST_TIMER':
152
+ case 'FIRST_TIME_CONTRIBUTOR':
153
+ return 'first_timer';
154
+ default:
155
+ return 'contributor';
156
+ }
157
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Secure credential storage for CLI tools
3
+ *
4
+ * Provides secure credential storage for the MUI code infrastructure tools.
5
+ * It uses OS keychain/credential manager to store credentials.
6
+ *
7
+ */
8
+
9
+ import { AsyncEntry } from '@napi-rs/keyring';
10
+
11
+ export const KEYRING_SERVICE = 'mui-code-infra';
12
+
13
+ /**
14
+ * @type {Map<string, AsyncEntry>}
15
+ */
16
+ const credentials = new Map();
17
+
18
+ /**
19
+ * @type {Map<string, string>} In-memory cache for credentials that have been accessed.
20
+ * This is used to avoid multiple prompts for the same credential during a single run.
21
+ */
22
+ const accessedCredentials = new Map();
23
+
24
+ /**
25
+ * @param {string} key
26
+ * @returns {Promise<string | undefined>}
27
+ */
28
+ export async function getPassword(key) {
29
+ if (accessedCredentials.has(key)) {
30
+ return accessedCredentials.get(key);
31
+ }
32
+ let credential = credentials.get(key);
33
+ if (!credential) {
34
+ credential = new AsyncEntry(KEYRING_SERVICE, key);
35
+ credentials.set(key, credential);
36
+ }
37
+ const res = await credential.getPassword();
38
+ if (res) {
39
+ accessedCredentials.set(key, res);
40
+ }
41
+ return res;
42
+ }
43
+
44
+ /**
45
+ * @param {string} key
46
+ * @param {string} password
47
+ * @returns {Promise<void>}
48
+ */
49
+ export async function setPassword(key, password) {
50
+ accessedCredentials.set(key, password);
51
+ let credential = credentials.get(key);
52
+ if (!credential) {
53
+ credential = new AsyncEntry(KEYRING_SERVICE, key);
54
+ credentials.set(key, credential);
55
+ }
56
+ await credential.setPassword(password);
57
+ }
58
+
59
+ /**
60
+ * @param {string} key
61
+ * @returns {Promise<void>}
62
+ */
63
+ export async function deleteKey(key) {
64
+ accessedCredentials.delete(key);
65
+ let credential = credentials.get(key);
66
+ credentials.delete(key);
67
+ if (!credential) {
68
+ credential = new AsyncEntry(KEYRING_SERVICE, key);
69
+ }
70
+ await credential.deletePassword();
71
+ }
@@ -7,7 +7,7 @@ import { globby } from 'globby';
7
7
  import * as fs from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
9
 
10
- import { getWorkspacePackages } from '../cli/pnpm.mjs';
10
+ import { getWorkspacePackages } from './pnpm.mjs';
11
11
  import { BASE_IGNORES, mapConcurrently } from './build.mjs';
12
12
 
13
13
  /**
@@ -20,7 +20,7 @@ import { BASE_IGNORES, mapConcurrently } from './build.mjs';
20
20
  /**
21
21
  * Gets all relevant files for a package to parse.
22
22
  *
23
- * @param {import('../cli/pnpm.mjs').PublicPackage} pkg
23
+ * @param {import('./pnpm.mjs').PublicPackage} pkg
24
24
  * @returns {Promise<string[]>} An array of file paths.
25
25
  */
26
26
  async function getFilesForPackage(pkg) {
@@ -0,0 +1,67 @@
1
+ import { $ } from 'execa';
2
+ import gitUrlParse from 'git-url-parse';
3
+
4
+ /**
5
+ * @typedef {Object} RepoInfo
6
+ * @property {string} owner - Repository owner
7
+ * @property {string} repo - Repository name
8
+ */
9
+
10
+ /**
11
+ * Get current repository info from git remote
12
+ * @param {string[]} [remotes=['upstream', 'origin']] - Remote name(s) to check (default: ['upstream', 'origin'])
13
+ * @returns {Promise<RepoInfo>} Repository owner and name
14
+ */
15
+ export async function getRepositoryInfo(remotes = ['upstream', 'origin']) {
16
+ /**
17
+ * @type {Record<string, string>}
18
+ */
19
+ const cause = {};
20
+ const cliResult = $`git remote -v`;
21
+ /**
22
+ * @type {Map<string, string>}
23
+ */
24
+ const repoRemotes = new Map();
25
+
26
+ for await (const line of cliResult) {
27
+ // Match pattern: "remoteName url (fetch|push)"
28
+ const [remoteName, url, type] = line.trim().split(/\s+/, 3);
29
+ if (type === '(fetch)') {
30
+ repoRemotes.set(remoteName, url);
31
+ } else if (type !== '(push)') {
32
+ throw new Error(`Unexpected line format for "git remote -v": "${line}"`);
33
+ }
34
+ }
35
+
36
+ for (const remote of remotes) {
37
+ if (!repoRemotes.has(remote)) {
38
+ cause[remote] = 'Remote not found';
39
+ continue;
40
+ }
41
+ const url = /** @type {string} */ (repoRemotes.get(remote));
42
+ try {
43
+ const parsed = gitUrlParse(url);
44
+ if (parsed.source !== 'github.com' || parsed.owner !== 'mui') {
45
+ cause[remote] = `Remote is not a GitHub repository under 'mui' organization: ${url}`;
46
+ continue;
47
+ }
48
+ return {
49
+ owner: parsed.owner,
50
+ repo: parsed.name,
51
+ };
52
+ } catch (error) {
53
+ cause[remote] = `Failed to parse URL for remote ${remote}: ${url}`;
54
+ }
55
+ }
56
+
57
+ throw new Error(`Failed to find remote(s): ${remotes.join(', ')}`, { cause });
58
+ }
59
+
60
+ /**
61
+ * Get current git SHA
62
+ * @returns {Promise<string>} Current git commit SHA
63
+ */
64
+ export async function getCurrentGitSha() {
65
+ const result = await $`git rev-parse HEAD`;
66
+ return result.stdout.trim();
67
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Core GitHub OAuth authentication utilities
3
+ *
4
+ * This utility provides core GitHub OAuth functionality using openid-client.
5
+ * It handles token storage, refresh, and device flow initiation.
6
+ * CLI-specific user interaction is handled in cli/cmdGithubAuth.mjs.
7
+ *
8
+ */
9
+
10
+ import {
11
+ createDeviceCode,
12
+ exchangeDeviceCode,
13
+ refreshToken as ghRefreshToken,
14
+ } from '@octokit/oauth-methods';
15
+ import clipboardy from 'clipboardy';
16
+ import open from 'open';
17
+ import chalk from 'chalk';
18
+
19
+ import * as credentials from './credentials.mjs';
20
+
21
+ const GITHUB_APP_CLIENT_ID = 'Iv23lilHsGU3i1tIARsT'; // MUI Code Infra Oauth App
22
+ // Use the client id as the key so that if it changes, we don't conflict with old tokens
23
+ const GITHUB_APP_CREDENTIAL_KEY = GITHUB_APP_CLIENT_ID;
24
+
25
+ /**
26
+ * @typedef {Object} GitHubAppAuthenticationWithRefreshToken
27
+ * @property {string} token - The access token
28
+ * @property {string} [expiresAt] - ISO string when the access token expires
29
+ * @property {string} [refreshToken] - The refresh token
30
+ * @property {string} [refreshTokenExpiresAt] - ISO string when the refresh token expires
31
+ */
32
+
33
+ export function persistentAuthStrategy() {
34
+ /**
35
+ * Request hook to add authentication token to requests.
36
+ * Automatically handles token refresh on 401 errors.
37
+ *
38
+ * @param {import('@octokit/types').RequestInterface} request
39
+ * @param {import('@octokit/types').Route} route
40
+ * @param {import('@octokit/types').RequestParameters} parameters
41
+ * @returns
42
+ */
43
+ async function hook(request, route, parameters) {
44
+ const token = await endToEndGhAuthGetToken({ log: true });
45
+ const endpoint = request.endpoint.merge(route, parameters);
46
+ endpoint.headers.authorization = `token ${token}`;
47
+ try {
48
+ // @ts-expect-error - request.endpoint.merge doesn't return correct type
49
+ return await request(endpoint);
50
+ } catch (error) {
51
+ const err =
52
+ /** @type {import('@octokit/types').RequestError & {response: {data: {message: string}}}} */ (
53
+ error
54
+ );
55
+ if (err.status === 401 && err.response.data.message.toLowerCase() === 'bad credentials') {
56
+ // refresh token and retry again
57
+ await clearGitHubAuth();
58
+ const newToken = await endToEndGhAuthGetToken();
59
+ endpoint.headers.authorization = `token ${newToken}`;
60
+ // @ts-expect-error - request.endpoint.merge doesn't return correct type
61
+ return await request(endpoint);
62
+ }
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ return { hook };
68
+ }
69
+
70
+ /**
71
+ * @param {Object} data
72
+ * @param {string} data.url
73
+ * @param {string} data.code
74
+ * @param {Object} options
75
+ * @param {boolean} [options.openInBrowser=true] - Whether to open the URL in the default browser
76
+ * @param {boolean} [options.copyToClipboard=true] - Whether to copy the code to clipboard
77
+ * @returns {Promise<void>}
78
+ */
79
+ async function logAuthInformation(data, { openInBrowser = true, copyToClipboard = true } = {}) {
80
+ if (copyToClipboard) {
81
+ await clipboardy.write(data.code);
82
+ console.warn(`Pasted authentication code ${chalk.bold(data.code)} to system clipboard...`);
83
+ } else {
84
+ console.warn(`To authenticate, paste "${chalk.bold(data.code)}" when prompted.`);
85
+ }
86
+ if (openInBrowser) {
87
+ console.warn(`Opening ${chalk.bold(data.url)} in default browser, or goto the link manually.`);
88
+ await open(data.url);
89
+ } else {
90
+ console.warn(`Open ${data.url} in your browser to authenticate.`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Checks if the stored access token is expired
96
+ * @returns {Promise<boolean>}
97
+ */
98
+ async function isTokenExpired() {
99
+ try {
100
+ const tokens = await getCredentialData();
101
+
102
+ if (tokens.expiresAt) {
103
+ return Date.now() > new Date(tokens.expiresAt).getTime();
104
+ }
105
+
106
+ if (tokens.refreshTokenExpiresAt) {
107
+ return Date.now() > new Date(tokens.refreshTokenExpiresAt).getTime();
108
+ }
109
+ return false;
110
+ } catch (error) {
111
+ return true; // If we can't get expiry, assume expired
112
+ }
113
+ }
114
+
115
+ /**
116
+ *
117
+ * @returns {Promise<GitHubAppAuthenticationWithRefreshToken>} Stored GitHub authentication tokens
118
+ */
119
+ async function getCredentialData() {
120
+ const data = await credentials.getPassword(GITHUB_APP_CREDENTIAL_KEY);
121
+ if (!data) {
122
+ return {
123
+ token: '',
124
+ };
125
+ }
126
+ return /** @type {GitHubAppAuthenticationWithRefreshToken} */ (JSON.parse(data));
127
+ }
128
+
129
+ /**
130
+ * Stores GitHub authentication tokens securely
131
+ * @param {{token: string, refreshToken?: string, expiresAt?: string; refreshTokenExpiresAt?: string}} tokens - Token response from openid-client
132
+ * @returns {Promise<void>}
133
+ */
134
+ async function storeGitHubTokens(tokens) {
135
+ /**
136
+ * @type {GitHubAppAuthenticationWithRefreshToken}
137
+ */
138
+ const newTokens = {
139
+ token: tokens.token,
140
+ expiresAt: tokens.expiresAt,
141
+ refreshToken: tokens.refreshToken,
142
+ refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
143
+ };
144
+
145
+ await credentials.setPassword(GITHUB_APP_CREDENTIAL_KEY, JSON.stringify(newTokens));
146
+ }
147
+
148
+ /**
149
+ * @returns {Promise<string>} Refreshed GitHub access token
150
+ */
151
+ async function refreshAccessToken() {
152
+ const tokenData = await getCredentialData();
153
+ if (!tokenData.refreshToken) {
154
+ return await doGithubAuth();
155
+ }
156
+ if (
157
+ tokenData.refreshTokenExpiresAt &&
158
+ Date.now() > new Date(tokenData.refreshTokenExpiresAt).getTime()
159
+ ) {
160
+ // Refresh token has also expired. Need to re-authenticate
161
+ await clearGitHubAuth();
162
+ return await doGithubAuth();
163
+ }
164
+ const { authentication } = await ghRefreshToken({
165
+ clientId: GITHUB_APP_CLIENT_ID,
166
+ refreshToken: tokenData.refreshToken,
167
+ clientType: 'github-app',
168
+ clientSecret: '',
169
+ });
170
+ storeGitHubTokens(authentication);
171
+ return authentication.token;
172
+ }
173
+
174
+ /**
175
+ * @returns {Promise<{url: string, code: string; deviceCode: string}>} Device flow response with verification URI and user code
176
+ */
177
+ async function getAuthInformation() {
178
+ const { data } = await createDeviceCode({
179
+ clientId: GITHUB_APP_CLIENT_ID,
180
+ clientType: 'github-app',
181
+ });
182
+ return {
183
+ url: data.verification_uri,
184
+ code: data.user_code,
185
+ deviceCode: data.device_code,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Retries exchanging device code for tokens until success or timeout.
191
+ * Defaults to 12 retries with 5s delay (1 minute total).
192
+ * Has initial delay to allow user to enter code in browser.
193
+ *
194
+ * @param {string} deviceCode
195
+ * @param {{delay?: number, retries?: number}} [options]
196
+ * @returns {Promise<import('@octokit/oauth-methods').GitHubAppAuthenticationWithRefreshToken>}
197
+ */
198
+ async function exchangeDeviceCodeWithRetry(deviceCode, { delay = 5000, retries = 12 } = {}) {
199
+ if (delay) {
200
+ await new Promise((resolve) => {
201
+ setTimeout(resolve, delay);
202
+ });
203
+ }
204
+ try {
205
+ const { authentication } = await exchangeDeviceCode({
206
+ clientId: GITHUB_APP_CLIENT_ID,
207
+ clientType: 'github-app',
208
+ code: deviceCode,
209
+ });
210
+ return /** @type {import('@octokit/oauth-methods').GitHubAppAuthenticationWithRefreshToken} */ (
211
+ authentication
212
+ );
213
+ } catch (/** @type {any} */ ex) {
214
+ if (ex.response.data.error !== 'authorization_pending') {
215
+ throw ex; // Some other error
216
+ }
217
+ if (retries > 0) {
218
+ console.warn('Retrying device code exchange...');
219
+ return exchangeDeviceCodeWithRetry(deviceCode, { delay, retries: retries - 1 });
220
+ }
221
+ throw new Error(`[github-auth]: Timed out waiting for user authentication`);
222
+ }
223
+ }
224
+
225
+ async function doGithubAuth() {
226
+ const data = await getAuthInformation();
227
+ await logAuthInformation(data);
228
+ const tokens = await exchangeDeviceCodeWithRetry(data.deviceCode);
229
+ await storeGitHubTokens(tokens);
230
+ return tokens.token;
231
+ }
232
+
233
+ /**
234
+ * @param {Object} [options]
235
+ * @param {boolean} [options.log=false] - Whether to log progress to console
236
+ * @returns {Promise<string>} Valid GitHub access token
237
+ */
238
+ export async function endToEndGhAuthGetToken({ log = false } = {}) {
239
+ const tokenData = await getCredentialData();
240
+ if (!tokenData.token) {
241
+ if (log) {
242
+ console.warn("🔍 GitHub token doesn't exist. Starting authentication flow...");
243
+ }
244
+ return await doGithubAuth();
245
+ }
246
+
247
+ if (await isTokenExpired()) {
248
+ if (log) {
249
+ console.warn('GitHub token expired. Attempting to refresh...');
250
+ }
251
+ return refreshAccessToken();
252
+ }
253
+
254
+ return tokenData.token;
255
+ }
256
+
257
+ /**
258
+ * Clears stored GitHub authentication tokens
259
+ * @returns {Promise<void>}
260
+ */
261
+ export async function clearGitHubAuth() {
262
+ await credentials.deleteKey(GITHUB_APP_CREDENTIAL_KEY);
263
+ }