Skip to content

Commit 10bb624

Browse files
committed
Use OS credential manager to store tokens
Also added device token generation
1 parent b0f7cbc commit 10bb624

File tree

7 files changed

+489
-261
lines changed

7 files changed

+489
-261
lines changed

packages/code-infra/package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@
2121
"types": "./build/utils/changelog.d.mts",
2222
"default": "./src/utils/changelog.mjs"
2323
},
24-
"./credentials": {
25-
"types": "./build/utils/credentials.d.mts",
26-
"default": "./src/utils/credentials.mjs"
27-
},
2824
"./eslint": {
2925
"types": "./build/eslint/index.d.mts",
3026
"default": "./src/eslint/index.mjs"
@@ -62,6 +58,7 @@
6258
"@mui/internal-babel-plugin-display-name": "workspace:*",
6359
"@mui/internal-babel-plugin-minify-errors": "workspace:*",
6460
"@mui/internal-babel-plugin-resolve-imports": "workspace:*",
61+
"@napi-rs/keyring": "^1.2.0",
6562
"@next/eslint-plugin-next": "^15.5.3",
6663
"@octokit/auth-action": "^6.0.1",
6764
"@octokit/rest": "^22.0.0",
@@ -86,6 +83,7 @@
8683
"globals": "^16.4.0",
8784
"globby": "^14.1.0",
8885
"minimatch": "^10.0.3",
86+
"openid-client": "^6.8.0",
8987
"postcss-styled-syntax": "^0.7.1",
9088
"regexp.escape": "^2.0.1",
9189
"resolve-pkg-maps": "^1.0.0",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* CLI command for GitHub OAuth authentication
4+
*
5+
* This file contains the user interaction and CLI-specific logic for GitHub authentication.
6+
* It uses the core GitHub OAuth utilities from utils/github.mjs.
7+
*/
8+
9+
import chalk from 'chalk';
10+
import * as client from 'openid-client';
11+
12+
/**
13+
* Interactive GitHub authentication with user prompts and device flow
14+
*
15+
* @param {import('../utils/github.mjs')} gh - GitHub utility module
16+
* @returns {Promise<void>}
17+
*/
18+
async function authenticateWithGitHub(gh) {
19+
try {
20+
// Try to get existing valid token first
21+
const existingToken = await gh.getStoredGitHubToken();
22+
if (existingToken) {
23+
console.log('✅ Using existing GitHub token');
24+
return;
25+
}
26+
} catch (error) {
27+
// No existing token or expired, continue with authentication
28+
}
29+
30+
try {
31+
// Try token refresh first
32+
const refreshedToken = await gh.refreshGitHubToken();
33+
if (refreshedToken) {
34+
console.log('✅ GitHub token refreshed successfully');
35+
return;
36+
}
37+
} catch (error) {
38+
console.log('Token refresh failed, initiating new authentication...');
39+
}
40+
41+
// Start new device flow authentication
42+
console.log('Starting GitHub authentication...');
43+
44+
/** @type {{verification_uri: string, user_code: string, config: any, interval: number, device_code: string, expires_in: number}} */
45+
const deviceFlow = await gh.initiateGitHubDeviceFlow();
46+
47+
console.log(`\nPlease visit: ${chalk.cyan(deviceFlow.verification_uri)}`);
48+
console.log(`Enter code: ${chalk.blueBright(chalk.underline(deviceFlow.user_code))}`);
49+
console.log('Waiting for authentication...');
50+
51+
// Poll for completion with user-friendly output
52+
let accessToken = '';
53+
let pollCount = 0;
54+
const startPoll = async () => {
55+
if (pollCount > 12) {
56+
throw new Error('Authentication timed out. Please try again.');
57+
}
58+
pollCount += 1;
59+
try {
60+
// The built-in polling method is not reliable since it throws for unexpected response.
61+
// So we handle the polling loop manually here.
62+
const tokens = await client.pollDeviceAuthorizationGrant(deviceFlow.config, deviceFlow);
63+
accessToken = tokens.access_token;
64+
65+
// Store the tokens using core utility
66+
await gh.storeGitHubTokens(tokens);
67+
} catch (err) {
68+
if (err instanceof client.ClientError && err.code === 'OAUTH_INVALID_RESPONSE') {
69+
// Still waiting for user authorization, continue polling
70+
await startPoll();
71+
return;
72+
}
73+
throw err;
74+
}
75+
};
76+
77+
await startPoll();
78+
79+
if (accessToken) {
80+
console.log('✅ GitHub authentication successful');
81+
return;
82+
}
83+
84+
throw new Error('Authentication failed');
85+
}
86+
87+
/**
88+
* @typedef {Object} Args
89+
* @property {boolean} authorize
90+
* @property {boolean} clear
91+
*/
92+
93+
export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({
94+
command: 'github',
95+
describe: 'Authenticates the user with GitHub and stores the access token securely.',
96+
builder: (yargs) =>
97+
yargs
98+
.option('authorize', {
99+
type: 'boolean',
100+
describe: 'Trigger the authentication flow to get a new token.',
101+
default: false,
102+
})
103+
.option('clear', {
104+
type: 'boolean',
105+
describe: 'Clear stored GitHub authentication tokens.',
106+
default: false,
107+
}),
108+
async handler(args) {
109+
const gh = await import('../utils/github.mjs');
110+
if (args.clear) {
111+
try {
112+
await gh.clearGitHubAuth();
113+
console.log('✅ GitHub authentication cleared');
114+
} catch (/** @type {any} */ error) {
115+
console.error('❌ Failed to clear GitHub authentication:', error.message);
116+
process.exit(1);
117+
}
118+
} else if (args.authorize) {
119+
try {
120+
await authenticateWithGitHub(gh);
121+
} catch (/** @type {any} */ error) {
122+
console.error('❌ GitHub authentication failed:', error.message);
123+
process.exit(1);
124+
}
125+
}
126+
},
127+
});

packages/code-infra/src/cli/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import cmdArgosPush from './cmdArgosPush.mjs';
66
import cmdBuild from './cmdBuild.mjs';
77
import cmdCopyFiles from './cmdCopyFiles.mjs';
88
import cmdExtractErrorCodes from './cmdExtractErrorCodes.mjs';
9+
import cmdGithubAuth from './cmdGithubAuth.mjs';
910
import cmdJsonLint from './cmdJsonLint.mjs';
1011
import cmdListWorkspaces from './cmdListWorkspaces.mjs';
1112
import cmdPublish from './cmdPublish.mjs';
@@ -21,6 +22,7 @@ yargs()
2122
.command(cmdBuild)
2223
.command(cmdCopyFiles)
2324
.command(cmdExtractErrorCodes)
25+
.command(cmdGithubAuth)
2426
.command(cmdJsonLint)
2527
.command(cmdListWorkspaces)
2628
.command(cmdPublish)

packages/code-infra/src/utils/changelog.mjs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Octokit } from '@octokit/rest';
22
import { $ } from 'execa';
3+
import { getGitHubToken } from './github.mjs';
34

45
/**
56
* @typedef {'team' | 'first_timer' | 'contributor'} AuthorAssociation
@@ -19,35 +20,31 @@ import { $ } from 'execa';
1920
* @returns {Promise<string>}
2021
*/
2122
export async function findLatestTaggedVersion(opts) {
22-
const { stdout } = await $({
23-
cwd: opts.cwd,
24-
// First fetch all tags from all remotes to ensure we have the latest tags. Uses -q flag to suppress output.
25-
// And then find the latest tag matching "v*".
26-
})`git fetch --tags --all -q && git describe --tags --abbrev=0 --match ${'v*'}`; // only include "version-tags"
23+
const $$ = $({ cwd: opts.cwd });
24+
await $$`git fetch --tags --all`; // Fetch all tags from all remotes to ensure we have the latest tags.
25+
const { stdout } = await $$`git describe --tags --abbrev=0 --match ${'v*'}`; // only include "version-tags"
2726
return stdout.trim();
2827
}
2928

3029
/**
3130
* @typedef {Object} FetchCommitsOptions
32-
* @property {string} token
3331
* @property {string} repo
3432
* @property {string} lastRelease
3533
* @property {string} release
34+
* @property {string} [token]
3635
* @property {string} [org="mui"]
3736
*/
3837

3938
/**
4039
* Fetches commits between two refs (lastRelease..release) including PR details.
41-
* Throws if the `token` option is not provided.
40+
* Automatically handles GitHub OAuth authentication.
4241
*
4342
* @param {FetchCommitsOptions} param0
4443
* @returns {Promise<FetchedCommitDetails[]>}
4544
*/
4645
export async function fetchCommitsBetweenRefs({ org = 'mui', ...options }) {
47-
if (!options.token) {
48-
throw new Error('Missing "token" option. The token needs `public_repo` permissions.');
49-
}
50-
const opts = { ...options, org };
46+
const token = options.token ?? (await getGitHubToken());
47+
const opts = { ...options, token, org };
5148

5249
return await fetchCommitsRest(opts);
5350
}
@@ -57,7 +54,7 @@ export async function fetchCommitsBetweenRefs({ org = 'mui', ...options }) {
5754
* It is more reliable than the GraphQL API but requires multiple network calls (1 + n).
5855
* One to list all commits between the two refs and then one for each commit to get the PR details.
5956
*
60-
* @param {FetchCommitsOptions & { org: string }} param0
57+
* @param {FetchCommitsOptions & { org: string, token: string }} param0
6158
*
6259
* @returns {Promise<FetchedCommitDetails[]>}
6360
*/

0 commit comments

Comments
 (0)