Skip to content

Commit dac5dc3

Browse files
committed
Simplify Cloudflare login output
1 parent 18e9d5a commit dac5dc3

9 files changed

Lines changed: 161 additions & 43 deletions

‎packages/void/src/cli/cf-compat.ts‎

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* orchestrators still use their established argument/result contracts; this
55
* process translates them to the pinned cf CLI. It never invokes Wrangler.
66
*/
7-
import { spawnSync } from 'node:child_process';
7+
import { spawn, spawnSync } from 'node:child_process';
88
import {
99
copyFileSync,
1010
mkdtempSync,
@@ -20,6 +20,7 @@ import { parse } from 'jsonc-parser';
2020
import { dirname, join, resolve } from 'pathslash';
2121
import { EnvHttpProxyAgent, fetch as httpFetch } from 'undici';
2222
import { prepareCfBuildOutput } from './cf-build-output.ts';
23+
import { cloudflareDeviceCode, printCloudflareLoginPrompt } from './cloudflare-login-output.ts';
2324

2425
type Result = { status: number; stdout: string; stderr: string };
2526
type Json = Record<string, unknown>;
@@ -82,17 +83,54 @@ function runCf(command: Array<string>, cwd = process.cwd()): Result {
8283
return result(child.status ?? 1, child.stdout ?? '', stderr);
8384
}
8485

85-
function runInteractiveCf(command: Array<string>): Result {
86-
const child = spawnSync(process.execPath, [cf, ...command, '--quiet'], {
87-
cwd: process.cwd(),
88-
// cf's global quiet flag hides its banner; OAuth's browser URL and device
89-
// code are still printed by the login handler for the user to verify.
90-
env: { ...cfEnvironment(), CF_QUIET: '1' },
91-
shell: false,
92-
stdio: 'inherit',
93-
windowsHide: true,
86+
function runInteractiveCf(command: Array<string>): Promise<Result> {
87+
return new Promise((resolve) => {
88+
const child = spawn(process.execPath, [cf, ...command, '--quiet'], {
89+
cwd: process.cwd(),
90+
env: { ...cfEnvironment(), CF_QUIET: '1' },
91+
shell: false,
92+
stdio: ['inherit', 'pipe', 'pipe'],
93+
windowsHide: true,
94+
});
95+
let output = '';
96+
let announced = false;
97+
let settled = false;
98+
const finish = (value: Result) => {
99+
if (!settled) {
100+
settled = true;
101+
resolve(value);
102+
}
103+
};
104+
const collect = (chunk: Buffer) => {
105+
output = (output + chunk.toString()).slice(-32_768);
106+
if (!announced) {
107+
const code = cloudflareDeviceCode(output);
108+
if (code) {
109+
announced = true;
110+
printCloudflareLoginPrompt(code);
111+
}
112+
}
113+
};
114+
child.stdout.on('data', collect);
115+
child.stderr.on('data', collect);
116+
child.once('error', (error) => finish(result(1, '', error.message)));
117+
child.once('close', (status, signal) => {
118+
if (status === 0) {
119+
if (!announced) {
120+
printCloudflareLoginPrompt(null);
121+
}
122+
finish(result(0));
123+
} else {
124+
finish(
125+
result(
126+
status ?? 1,
127+
'',
128+
output.trim() || `Cloudflare login ended${signal ? ` (${signal})` : ''}.`,
129+
),
130+
);
131+
}
132+
});
94133
});
95-
return result(child.status ?? 1, '', child.error?.message ?? '');
96134
}
97135

98136
async function withBuildOutput(
@@ -463,7 +501,7 @@ async function sessionCredential(): Promise<{ type: 'oauth' | 'api_token'; token
463501

464502
async function auth(): Promise<Result> {
465503
if (args[0] === 'login') {
466-
return runInteractiveCf(['auth', 'login', '--force']);
504+
return await runInteractiveCf(['auth', 'login', '--force']);
467505
}
468506
if (args[0] === 'logout') {
469507
return runCf(['auth', 'logout']);

‎packages/void/src/cli/cloudflare-auth.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { execFile, spawn, spawnSync } from 'node:child_process';
2-
import { log } from '@clack/prompts';
32
import { resolveWranglerArgv } from './preset.ts';
43

54
export type CloudflareAccount = Readonly<{
@@ -115,7 +114,6 @@ export async function ensureCloudflareAuthentication(
115114
);
116115
}
117116

118-
announceCloudflareLogin();
119117
const login = await run(['login'], false);
120118
if (!isCleanExit(login)) {
121119
throw new CloudflareAuthenticationError(
@@ -313,9 +311,3 @@ function isCleanExit(result: CloudflareAuthRunResult): boolean {
313311
result.status === 0 && result.signal == null && (result.error == null || result.error === '')
314312
);
315313
}
316-
317-
export function announceCloudflareLogin(): void {
318-
log.info(
319-
'Opening Cloudflare login in your browser. Compare the code below with the browser prompt. Press Ctrl+C to cancel.',
320-
);
321-
}

‎packages/void/src/cli/cloudflare-cmd.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export async function runCloudflareCommand(root: string, args: CloudflareArgs):
1414
const runWrangler = createBundledCloudflareAuthRunner(root);
1515
switch (args.subcommand) {
1616
case 'login': {
17-
const session = await ensureCloudflareAuthentication({ runWrangler, forceLogin: true });
18-
log.success(formatSession(root, session));
17+
await ensureCloudflareAuthentication({ runWrangler, forceLogin: true });
18+
console.log('Successfully logged in.');
1919
return;
2020
}
2121
case 'status': {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { stripVTControlCharacters } from 'node:util';
2+
import pc from 'picocolors';
3+
4+
/** The bundled cf login prints the device code before opening the browser. */
5+
export function cloudflareDeviceCode(output: string): string | null {
6+
const plain = stripVTControlCharacters(output);
7+
return (
8+
/and enter the code:\s*([A-Za-z0-9-]+)\s/i.exec(plain)?.[1] ??
9+
/[?&]user_code=([A-Za-z0-9-]+)\s/i.exec(plain)?.[1] ??
10+
null
11+
);
12+
}
13+
14+
export function printCloudflareLoginPrompt(code: string | null): void {
15+
console.log(
16+
code
17+
? `Opening Cloudflare login in your browser. Confirm the code ${pc.blue(code)} in your browser. Press Ctrl+C to cancel.`
18+
: 'Opening Cloudflare login in your browser. Confirm the code shown in your browser. Press Ctrl+C to cancel.',
19+
);
20+
}

‎packages/void/src/cli/platform-auth.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
announceCloudflareLogin,
3-
runCloudflareAuthCommand,
4-
type CloudflareAuthRunner,
5-
} from './cloudflare-auth.ts';
1+
import { runCloudflareAuthCommand, type CloudflareAuthRunner } from './cloudflare-auth.ts';
62
import type { PlatformInstallPlan } from './platform-plan.ts';
73

84
export type PlatformCloudflareCredential = Readonly<{
@@ -89,7 +85,6 @@ export async function resolvePlatformCloudflareCredentialWithInput<T>(
8985
return { input, credential: result.credential };
9086
}
9187

92-
announceCloudflareLogin();
9388
const login = await runWrangler(['login'], false);
9489
assertNotCancelled();
9590
if (login.status !== 0) {

‎packages/void/test/unit/cloudflare-auth.test.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { log } from '@clack/prompts';
21
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
32
import {
43
cloudflareAuthCommandEnvironment,
@@ -27,7 +26,6 @@ describe('Cloudflare authentication', () => {
2726
});
2827

2928
it('signs in with cf and resumes', async () => {
30-
const loginNotice = vi.spyOn(log, 'info').mockImplementation(() => {});
3129
const calls: Array<string> = [];
3230
let whoamiCalls = 0;
3331
const session = await ensureCloudflareAuthentication({
@@ -47,9 +45,6 @@ describe('Cloudflare authentication', () => {
4745

4846
expect(session.accounts).toEqual([{ id: 'account-1', name: 'Example' }]);
4947
expect(calls).toEqual(['whoami --json', 'login', 'whoami --json']);
50-
expect(loginNotice).toHaveBeenCalledWith(
51-
'Opening Cloudflare login in your browser. Compare the code below with the browser prompt. Press Ctrl+C to cancel.',
52-
);
5348
});
5449

5550
it('requires a token or a prior browser session outside a terminal', async () => {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { expect, it, vi } from 'vite-plus/test';
2+
import {
3+
cloudflareDeviceCode,
4+
printCloudflareLoginPrompt,
5+
} from '../../src/cli/cloudflare-login-output.ts';
6+
7+
it('extracts the device code from cf output, including streamed and colored output', () => {
8+
expect(cloudflareDeviceCode('and enter the code:\n\n a1B2c3D4\n')).toBe('a1B2c3D4');
9+
expect(cloudflareDeviceCode('and enter the code:\n')).toBeNull();
10+
expect(cloudflareDeviceCode('and enter the code:\n \u001b[34ma1B2c3D4\u001b[0m\n')).toBe(
11+
'a1B2c3D4',
12+
);
13+
expect(
14+
cloudflareDeviceCode(
15+
'Opening a link in your default browser: https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2c3D4\n',
16+
),
17+
).toBe('a1B2c3D4');
18+
});
19+
20+
it('prints only the short Void prompt with the actual code', () => {
21+
const printed = vi.spyOn(console, 'log').mockImplementation(() => {});
22+
try {
23+
printCloudflareLoginPrompt('a1B2c3D4');
24+
expect(printed).toHaveBeenCalledTimes(1);
25+
expect(printed.mock.calls[0]?.[0]).toContain(
26+
'Opening Cloudflare login in your browser. Confirm the code a1B2c3D4 in your browser. Press Ctrl+C to cancel.',
27+
);
28+
} finally {
29+
printed.mockRestore();
30+
}
31+
});
32+
33+
it('hides cf login chatter and prints the code before the child exits', async () => {
34+
const directory = mkdtempSync(join(tmpdir(), 'void-login-output-'));
35+
try {
36+
const preload = join(directory, 'mock-login.mjs');
37+
writeFileSync(
38+
preload,
39+
`import child from 'node:child_process';
40+
import { EventEmitter } from 'node:events';
41+
import { syncBuiltinESMExports } from 'node:module';
42+
child.spawn = () => {
43+
const process = new EventEmitter();
44+
process.stdout = new EventEmitter();
45+
process.stderr = new EventEmitter();
46+
setImmediate(() => {
47+
process.stderr.emit('data', Buffer.from('Attempting to login via OAuth Device Authorization Grant...\\nand enter the code:\\n a1B2'));
48+
process.stderr.emit('data', Buffer.from('c3D4\\nOpening a link in your default browser: https://dash.cloudflare.com/oauth2/device/verify?user_code=a1B2c3D4\\nSuccessfully logged in.\\nConfig file: sample\\n'));
49+
process.emit('close', 0, null);
50+
});
51+
return process;
52+
};
53+
syncBuiltinESMExports();
54+
`,
55+
);
56+
const entry = join(import.meta.dirname, '..', '..', 'src', 'cli', 'cf-compat.ts');
57+
const output = await new Promise<{ status: number; stdout: string; stderr: string }>(
58+
(resolve) => {
59+
execFile(
60+
process.execPath,
61+
['--import', pathToFileURL(preload).href, entry, 'login'],
62+
{ cwd: directory, encoding: 'utf8', timeout: 10_000 },
63+
(error, stdout, stderr) => resolve({ status: error ? 1 : 0, stdout, stderr }),
64+
);
65+
},
66+
);
67+
expect(output.status, output.stderr).toBe(0);
68+
expect(output.stdout.trim()).toBe(
69+
'Opening Cloudflare login in your browser. Confirm the code a1B2c3D4 in your browser. Press Ctrl+C to cancel.',
70+
);
71+
expect(output.stdout + output.stderr).not.toContain('Attempting to login');
72+
expect(output.stdout + output.stderr).not.toContain('Config file:');
73+
expect(output.stdout + output.stderr).not.toContain('Successfully logged in.');
74+
} finally {
75+
rmSync(directory, { recursive: true, force: true });
76+
}
77+
});
78+
import { execFile } from 'node:child_process';
79+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
80+
import { tmpdir } from 'node:os';
81+
import { join } from 'node:path';
82+
import { pathToFileURL } from 'node:url';

‎packages/void/test/unit/cloudflare-session.test.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ beforeEach(() => {
6565
Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true });
6666
Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true });
6767
output = [];
68+
vi.spyOn(console, 'log').mockImplementation((message) => {
69+
output.push(String(message));
70+
});
6871
for (const level of ['info', 'success', 'warn'] as const) {
6972
vi.spyOn(log, level).mockImplementation((message) => {
7073
output.push(String(message));
@@ -121,12 +124,13 @@ describe('Cloudflare session switching through the bundled CLI runner', () => {
121124
it('explicit login replaces the old session without retargeting the project', async () => {
122125
await runCloudflareCommand(root, { subcommand: 'login' });
123126
expect(read().session?.email).toBe(newSession.email);
127+
expect(output).toEqual(['Successfully logged in.']);
128+
expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(pin);
129+
await runCloudflareCommand(root, { subcommand: 'status' });
124130
expect(output.join('\n')).toContain(newSession.email);
125131
expect(output.join('\n')).toContain('OAuth Token');
126132
expect(output.join('\n')).toContain('work-id');
127133
expect(output.join('\n')).toContain('individual-id');
128-
expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')).toBe(pin);
129-
await runCloudflareCommand(root, { subcommand: 'status' });
130134
expect(read().credentialStores).toEqual(['true', 'true', 'true']);
131135
});
132136

‎packages/void/test/unit/platform-auth.test.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { log } from '@clack/prompts';
21
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
32
import { cloudflareAuthEnvironment } from '../../src/cli/cloudflare-auth.ts';
43
import {
@@ -74,7 +73,6 @@ describe('platform Cloudflare authentication', () => {
7473
});
7574

7675
it('uses cf browser login when no session exists', async () => {
77-
const loginNotice = vi.spyOn(log, 'info').mockImplementation(() => {});
7876
const calls: Array<string> = [];
7977
let tokenAttempts = 0;
8078
const credential = await resolvePlatformCloudflareCredential({
@@ -97,9 +95,6 @@ describe('platform Cloudflare authentication', () => {
9795

9896
expect(credential).toEqual({ source: 'oauth', token: 'browser-token' });
9997
expect(calls).toEqual(['auth token --json', 'login', 'auth token --json']);
100-
expect(loginNotice).toHaveBeenCalledWith(
101-
'Opening Cloudflare login in your browser. Compare the code below with the browser prompt. Press Ctrl+C to cancel.',
102-
);
10398
});
10499

105100
it('requires an explicit environment token in non-interactive environments', async () => {
@@ -163,7 +158,6 @@ describe('platform authentication during input', () => {
163158

164159
it('waits for the name before opening browser login', async () => {
165160
const input = deferred<string>();
166-
const loginNotice = vi.spyOn(log, 'info').mockImplementation(() => {});
167161
const runWrangler = vi
168162
.fn()
169163
.mockResolvedValueOnce({ ...success, status: 1 })
@@ -174,7 +168,6 @@ describe('platform authentication during input', () => {
174168
runWrangler,
175169
});
176170
await vi.waitFor(() => expect(runWrangler).toHaveBeenCalledTimes(1));
177-
expect(loginNotice).not.toHaveBeenCalled();
178171
input.resolve('team');
179172
await expect(result).resolves.toEqual({
180173
input: 'team',
@@ -185,7 +178,6 @@ describe('platform authentication during input', () => {
185178
'login',
186179
'auth token --json',
187180
]);
188-
expect(loginNotice).toHaveBeenCalledOnce();
189181
});
190182

191183
it('does not start another auth command after input is cancelled', async () => {

0 commit comments

Comments
 (0)