diff --git a/.yarn/versions/e692e68e.yml b/.yarn/versions/e692e68e.yml new file mode 100644 index 00000000000..9304d669585 --- /dev/null +++ b/.yarn/versions/e692e68e.yml @@ -0,0 +1,23 @@ +releases: + "@yarnpkg/cli": minor + "@yarnpkg/plugin-npm-cli": minor + +declined: + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-essentials" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - "@yarnpkg/plugin-workspace-tools" + - "@yarnpkg/builder" + - "@yarnpkg/core" + - "@yarnpkg/doctor" diff --git a/packages/plugin-npm-cli/sources/commands/npm/login.ts b/packages/plugin-npm-cli/sources/commands/npm/login.ts index b999b23bea7..449586991f5 100644 --- a/packages/plugin-npm-cli/sources/commands/npm/login.ts +++ b/packages/plugin-npm-cli/sources/commands/npm/login.ts @@ -1,10 +1,10 @@ -import {BaseCommand, openWorkspace} from '@yarnpkg/cli'; -import {Configuration, MessageName, Report, miscUtils, formatUtils} from '@yarnpkg/core'; -import {StreamReport} from '@yarnpkg/core'; -import {PortablePath} from '@yarnpkg/fslib'; -import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm'; -import {Command, Option, Usage} from 'clipanion'; -import {prompt} from 'enquirer'; +import {BaseCommand, openWorkspace} from '@yarnpkg/cli'; +import {Configuration, MessageName, Report, miscUtils, formatUtils, nodeUtils, httpUtils} from '@yarnpkg/core'; +import {StreamReport} from '@yarnpkg/core'; +import {PortablePath} from '@yarnpkg/fslib'; +import {npmConfigUtils, npmHttpUtils} from '@yarnpkg/plugin-npm'; +import {Command, Option, Usage} from 'clipanion'; +import {prompt} from 'enquirer'; // eslint-disable-next-line arca/no-default-export export default class NpmLoginCommand extends BaseCommand { @@ -46,6 +46,10 @@ export default class NpmLoginCommand extends BaseCommand { description: `Set the npmAlwaysAuth configuration`, }); + webLogin = Option.Boolean(`--web-login`, { + description: `Enable web login`, + }); + async execute() { const configuration = await Configuration.find(this.context.cwd, this.context.plugins); @@ -61,16 +65,15 @@ export default class NpmLoginCommand extends BaseCommand { stdout: this.context.stdout, includeFooter: false, }, async report => { - const credentials = await getCredentials({ - configuration, + const token = await performAuthentication({ registry, + configuration, report, + webLogin: this.webLogin, stdin: this.context.stdin as NodeJS.ReadStream, stdout: this.context.stdout as NodeJS.WriteStream, }); - const token = await registerOrLogin(registry, credentials, configuration); - await setAuthToken(registry, token, {alwaysAuth: this.alwaysAuth, scope: this.scope}); return report.reportInfo(MessageName.UNNAMED, `Successfully logged in`); }); @@ -92,10 +95,119 @@ export async function getRegistry({scope, publish, configuration, cwd}: {scope?: return npmConfigUtils.getDefaultRegistry({configuration}); } +type NpmWebLoginInitResponse = { + loginUrl: string; + doneUrl: string; +}; + +async function webLoginInit(registry: string, configuration: Configuration): Promise { + let response: any; + try { + response = await npmHttpUtils.post(`/-/v1/login`, null, { + configuration, + registry, + authType: npmHttpUtils.AuthType.NO_AUTH, + jsonResponse: true, + headers: { + [`npm-auth-type`]: `web`, + }, + }); + } catch { + return null; + } + + return response; +} + +type NpmWebLoginCheckResponse = + | {type: `success`, token: string} + | {type: `waiting`, sleep: number}; + +async function webLoginCheck(doneUrl: string, configuration: Configuration): Promise { + const response = await httpUtils.request(doneUrl, null, { + configuration, + jsonResponse: true, + }); + + if (response.statusCode === 202) { + const retryAfter = response.headers[`retry-after`] ?? `1`; + return {type: `waiting`, sleep: parseInt(retryAfter, 10)}; + } + + if (response.statusCode === 200) + return {type: `success`, token: response.body.token}; + + return null; +} + +async function loginViaWeb({registry, configuration, report}: CredentialOptions): Promise { + const loginResponse = await webLoginInit(registry, configuration); + if (!loginResponse) + return null; + + if (nodeUtils.openUrl) { + report.reportInfo(MessageName.UNNAMED, `Starting the web login process...`); + report.reportSeparator(); + + const {openNow} = await prompt<{openNow: boolean}>({ + type: `confirm`, + name: `openNow`, + message: `Do you want to try to open your browser now?`, + required: true, + initial: true, + onCancel: () => process.exit(130), + }); + + report.reportSeparator(); + + if (!openNow || !await nodeUtils.openUrl(loginResponse.loginUrl)) { + report.reportWarning(MessageName.UNNAMED, `We failed to automatically open the url; you'll have to open it yourself in your browser of choice:`); + report.reportWarning(MessageName.UNNAMED, formatUtils.pretty(configuration, loginResponse.loginUrl, formatUtils.Type.URL)); + report.reportSeparator(); + } + } + + while (true) { + const sleepDuration = await webLoginCheck(loginResponse.doneUrl, configuration); + if (sleepDuration === null) + return null; + + if (sleepDuration.type === `waiting`) { + await new Promise(resolve => setTimeout(resolve, sleepDuration.sleep * 1000)); + } else { + return sleepDuration.token; + } + } +} + +const WEB_LOGIN_REGISTRIES = [ + `https://registry.yarnpkg.com`, + `https://registry.npmjs.org`, +]; + +async function performAuthentication(opts: CredentialOptions & {webLogin?: boolean}): Promise { + if (opts.webLogin ?? WEB_LOGIN_REGISTRIES.includes(opts.registry)) { + const webToken = await loginViaWeb(opts); + if (webToken !== null) { + return webToken; + } + } + + return await loginOrRegisterViaPassword(opts); +} + /** * Register a new user, or login if the user already exists */ -async function registerOrLogin(registry: string, credentials: Credentials, configuration: Configuration): Promise { +async function loginOrRegisterViaPassword({registry, configuration, report, stdin, stdout}: CredentialOptions): Promise { + const credentials = await getCredentials({ + configuration, + registry, + report, + stdin, + stdout, + }); + // Registration and login are both handled as a `put` by npm. Npm uses a lax // endpoint as of 2023-11 where there are no conflicts if the user already // exists, but some registries such as Verdaccio are stricter and return a @@ -193,7 +305,15 @@ interface Credentials { password: string; } -async function getCredentials({configuration, registry, report, stdin, stdout}: {configuration: Configuration, registry: string, report: Report, stdin: NodeJS.ReadStream, stdout: NodeJS.WriteStream}): Promise { +interface CredentialOptions { + configuration: Configuration; + registry: string; + report: Report; + stdin: NodeJS.ReadStream; + stdout: NodeJS.WriteStream; +} + +async function getCredentials({configuration, registry, report, stdin, stdout}: CredentialOptions): Promise { report.reportInfo(MessageName.UNNAMED, `Logging in to ${formatUtils.pretty(configuration, registry, formatUtils.Type.URL)}`); let isToken = false;