Skip to content

Commit 0b68aea

Browse files
authored
refactor: use Promise.withResolvers in node-only flows and tests (#2378)
1 parent 4c41b7b commit 0b68aea

File tree

4 files changed

+136
-142
lines changed

4 files changed

+136
-142
lines changed

cli/src/cli.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,21 @@ const DEFAULT_FRONTEND_URL = 'https://npmx.dev/'
1414
const DEV_FRONTEND_URL = 'http://127.0.0.1:3000/'
1515

1616
async function runNpmLogin(): Promise<boolean> {
17-
return new Promise(resolve => {
18-
const child = spawn('npm', ['login', `--registry=${NPM_REGISTRY_URL}`], {
19-
stdio: 'inherit',
20-
shell: true,
21-
})
17+
const { promise, resolve } = Promise.withResolvers<boolean>()
18+
const child = spawn('npm', ['login', `--registry=${NPM_REGISTRY_URL}`], {
19+
stdio: 'inherit',
20+
shell: true,
21+
})
2222

23-
child.on('close', code => {
24-
resolve(code === 0)
25-
})
23+
child.on('close', code => {
24+
resolve(code === 0)
25+
})
2626

27-
child.on('error', () => {
28-
resolve(false)
29-
})
27+
child.on('error', () => {
28+
resolve(false)
3029
})
30+
31+
return promise
3132
}
3233

3334
const main = defineCommand({

cli/src/npm-client.ts

Lines changed: 115 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -179,139 +179,140 @@ async function execNpmInteractive(
179179
options: ExecNpmOptions = {},
180180
): Promise<NpmExecResult> {
181181
const openUrls = options.openUrls === true
182+
const { promise, resolve } = Promise.withResolvers<NpmExecResult>()
182183

183184
// Lazy-load node-pty so the native addon is only required when interactive mode is actually used.
184185
const pty = await import('@lydell/node-pty')
185186

186-
return new Promise(resolve => {
187-
const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
187+
const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
188188

189-
if (!options.silent) {
190-
const displayCmd = options.otp
191-
? ['npm', ...args, '--otp', '******'].join(' ')
192-
: ['npm', ...args].join(' ')
193-
logCommand(`${displayCmd} (interactive/pty)`)
194-
}
189+
if (!options.silent) {
190+
const displayCmd = options.otp
191+
? ['npm', ...args, '--otp', '******'].join(' ')
192+
: ['npm', ...args].join(' ')
193+
logCommand(`${displayCmd} (interactive/pty)`)
194+
}
195195

196-
let output = ''
197-
let resolved = false
198-
let otpPromptSeen = false
199-
let authUrlSeen = false
200-
let enterSent = false
201-
let authUrlTimeout: ReturnType<typeof setTimeout> | null = null
202-
let authUrlTimedOut = false
196+
let output = ''
197+
let resolved = false
198+
let otpPromptSeen = false
199+
let authUrlSeen = false
200+
let enterSent = false
201+
let authUrlTimeout: ReturnType<typeof setTimeout> | null = null
202+
let authUrlTimedOut = false
203203

204-
const env = createNpmEnv()
204+
const env = createNpmEnv()
205205

206-
// When openUrls is false, tell npm not to open the browser.
207-
// npm still prints the auth URL and polls doneUrl
208-
if (!openUrls) {
209-
env.npm_config_browser = 'false'
210-
}
206+
// When openUrls is false, tell npm not to open the browser.
207+
// npm still prints the auth URL and polls doneUrl
208+
if (!openUrls) {
209+
env.npm_config_browser = 'false'
210+
}
211211

212-
const child = pty.spawn('npm', npmArgs, {
213-
name: 'xterm-256color',
214-
cols: 120,
215-
rows: 30,
216-
env,
217-
})
212+
const child = pty.spawn('npm', npmArgs, {
213+
name: 'xterm-256color',
214+
cols: 120,
215+
rows: 30,
216+
env,
217+
})
218218

219-
// General timeout: 5 minutes (covers non-auth interactive commands)
220-
const timeout = setTimeout(() => {
221-
if (resolved) return
222-
logDebug('Interactive command timed out', { output })
223-
child.kill()
224-
}, 300000)
225-
226-
child.onData((data: string) => {
227-
output += data
228-
const clean = stripAnsi(data)
229-
logDebug('pty data:', { text: clean.trim() })
230-
231-
const cleanAll = stripAnsi(output)
232-
233-
// Detect auth URL in output and notify the caller.
234-
if (!authUrlSeen) {
235-
const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE)
236-
237-
if (urlMatch && urlMatch[1]) {
238-
authUrlSeen = true
239-
const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '')
240-
logDebug('Auth URL detected:', { authUrl, openUrls })
241-
options.onAuthUrl?.(authUrl)
242-
243-
authUrlTimeout = setTimeout(() => {
244-
if (resolved) return
245-
authUrlTimedOut = true
246-
logDebug('Auth URL timeout (90s) — killing process')
247-
logError('Authentication timed out after 90 seconds')
248-
child.kill()
249-
}, AUTH_URL_TIMEOUT_MS)
250-
}
219+
// General timeout: 5 minutes (covers non-auth interactive commands)
220+
const timeout = setTimeout(() => {
221+
if (resolved) return
222+
logDebug('Interactive command timed out', { output })
223+
child.kill()
224+
}, 300000)
225+
226+
child.onData((data: string) => {
227+
output += data
228+
const clean = stripAnsi(data)
229+
logDebug('pty data:', { text: clean.trim() })
230+
231+
const cleanAll = stripAnsi(output)
232+
233+
// Detect auth URL in output and notify the caller.
234+
if (!authUrlSeen) {
235+
const urlMatch = cleanAll.match(AUTH_URL_TITLE_RE)
236+
237+
if (urlMatch && urlMatch[1]) {
238+
authUrlSeen = true
239+
const authUrl = urlMatch[1].replace(/[.,;:!?)]+$/, '')
240+
logDebug('Auth URL detected:', { authUrl, openUrls })
241+
options.onAuthUrl?.(authUrl)
242+
243+
authUrlTimeout = setTimeout(() => {
244+
if (resolved) return
245+
authUrlTimedOut = true
246+
logDebug('Auth URL timeout (90s) — killing process')
247+
logError('Authentication timed out after 90 seconds')
248+
child.kill()
249+
}, AUTH_URL_TIMEOUT_MS)
251250
}
251+
}
252252

253-
if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) {
254-
enterSent = true
255-
logDebug('Web auth prompt detected, pressing ENTER')
256-
child.write('\r')
257-
}
253+
if (authUrlSeen && openUrls && !enterSent && AUTH_URL_PROMPT_RE.test(cleanAll)) {
254+
enterSent = true
255+
logDebug('Web auth prompt detected, pressing ENTER')
256+
child.write('\r')
257+
}
258258

259-
if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) {
260-
otpPromptSeen = true
261-
if (options.otp) {
262-
logDebug('OTP prompt detected, writing OTP')
263-
child.write(options.otp + '\r')
264-
} else {
265-
logDebug('OTP prompt detected but no OTP provided, killing process')
266-
child.kill()
267-
}
259+
if (!otpPromptSeen && OTP_PROMPT_RE.test(cleanAll)) {
260+
otpPromptSeen = true
261+
if (options.otp) {
262+
logDebug('OTP prompt detected, writing OTP')
263+
child.write(options.otp + '\r')
264+
} else {
265+
logDebug('OTP prompt detected but no OTP provided, killing process')
266+
child.kill()
268267
}
269-
})
268+
}
269+
})
270+
271+
child.onExit(({ exitCode }) => {
272+
if (resolved) return
273+
resolved = true
274+
clearTimeout(timeout)
275+
if (authUrlTimeout) clearTimeout(authUrlTimeout)
276+
277+
const cleanOutput = stripAnsi(output)
278+
logDebug('Interactive command exited:', { exitCode, output: cleanOutput })
279+
280+
const requiresOtp =
281+
authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput)
282+
const authFailure = detectAuthFailure(cleanOutput)
283+
const urls = extractUrls(cleanOutput)
270284

271-
child.onExit(({ exitCode }) => {
272-
if (resolved) return
273-
resolved = true
274-
clearTimeout(timeout)
275-
if (authUrlTimeout) clearTimeout(authUrlTimeout)
276-
277-
const cleanOutput = stripAnsi(output)
278-
logDebug('Interactive command exited:', { exitCode, output: cleanOutput })
279-
280-
const requiresOtp =
281-
authUrlTimedOut || (otpPromptSeen && !options.otp) || detectOtpRequired(cleanOutput)
282-
const authFailure = detectAuthFailure(cleanOutput)
283-
const urls = extractUrls(cleanOutput)
284-
285-
if (!options.silent) {
286-
if (exitCode === 0) {
287-
logSuccess('Done')
288-
} else if (requiresOtp) {
289-
logError('OTP required')
290-
} else if (authFailure) {
291-
logError('Authentication required - please run "npm login" and restart the connector')
292-
} else {
293-
const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed'
294-
logError(firstLine)
295-
}
285+
if (!options.silent) {
286+
if (exitCode === 0) {
287+
logSuccess('Done')
288+
} else if (requiresOtp) {
289+
logError('OTP required')
290+
} else if (authFailure) {
291+
logError('Authentication required - please run "npm login" and restart the connector')
292+
} else {
293+
const firstLine = filterNpmWarnings(cleanOutput).split('\n')[0] || 'Command failed'
294+
logError(firstLine)
296295
}
296+
}
297+
298+
// If auth URL timed out, force a non-zero exit code so it's marked as failed
299+
const finalExitCode = authUrlTimedOut ? 1 : exitCode
297300

298-
// If auth URL timed out, force a non-zero exit code so it's marked as failed
299-
const finalExitCode = authUrlTimedOut ? 1 : exitCode
300-
301-
resolve({
302-
stdout: cleanOutput.trim(),
303-
stderr: requiresOtp
304-
? 'This operation requires a one-time password (OTP).'
305-
: authFailure
306-
? 'Authentication failed. Please run "npm login" and restart the connector.'
307-
: filterNpmWarnings(cleanOutput),
308-
exitCode: finalExitCode,
309-
requiresOtp,
310-
authFailure,
311-
urls: urls.length > 0 ? urls : undefined,
312-
})
301+
resolve({
302+
stdout: cleanOutput.trim(),
303+
stderr: requiresOtp
304+
? 'This operation requires a one-time password (OTP).'
305+
: authFailure
306+
? 'Authentication failed. Please run "npm login" and restart the connector.'
307+
: filterNpmWarnings(cleanOutput),
308+
exitCode: finalExitCode,
309+
requiresOtp,
310+
authFailure,
311+
urls: urls.length > 0 ? urls : undefined,
313312
})
314313
})
314+
315+
return promise
315316
}
316317

317318
async function execNpm(args: string[], options: ExecNpmOptions = {}): Promise<NpmExecResult> {

test/nuxt/components/Package/Versions.spec.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -815,11 +815,8 @@ describe('PackageVersions', () => {
815815
describe('loading states', () => {
816816
it('shows loading spinner when fetching versions', async () => {
817817
// Create a promise that won't resolve immediately
818-
let resolvePromise: (value: unknown[]) => void
819-
const loadingPromise = new Promise<unknown[]>(resolve => {
820-
resolvePromise = resolve
821-
})
822-
mockFetchAllPackageVersions.mockReturnValue(loadingPromise)
818+
const { promise, resolve } = Promise.withResolvers<unknown[]>()
819+
mockFetchAllPackageVersions.mockReturnValue(promise)
823820

824821
const component = await mountSuspended(PackageVersions, {
825822
props: {
@@ -842,14 +839,12 @@ describe('PackageVersions', () => {
842839
})
843840

844841
// Resolve the promise to clean up
845-
resolvePromise!([])
842+
resolve([])
846843
})
847844

848845
it('shows loading spinner for other versions when fetching', async () => {
849-
let resolvePromise: (value: unknown[]) => void
850-
const loadingPromise = new Promise<unknown[]>(resolve => {
851-
resolvePromise = resolve
852-
})
846+
const { promise: loadingPromise, resolve: resolvePromise } =
847+
Promise.withResolvers<unknown[]>()
853848
mockFetchAllPackageVersions.mockReturnValue(loadingPromise)
854849

855850
const component = await mountSuspended(PackageVersions, {
@@ -876,7 +871,7 @@ describe('PackageVersions', () => {
876871
})
877872

878873
// Resolve the promise to clean up
879-
resolvePromise!([])
874+
resolvePromise([])
880875
})
881876
})
882877

test/nuxt/components/VersionSelector.spec.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -519,11 +519,8 @@ describe('VersionSelector', () => {
519519

520520
describe('loading states', () => {
521521
it('shows loading spinner when fetching versions', async () => {
522-
let resolvePromise: (value: unknown[]) => void
523-
const loadingPromise = new Promise<unknown[]>(resolve => {
524-
resolvePromise = resolve
525-
})
526-
mockFetchAllPackageVersions.mockReturnValue(loadingPromise)
522+
const { promise, resolve } = Promise.withResolvers<unknown[]>()
523+
mockFetchAllPackageVersions.mockReturnValue(promise)
527524

528525
const component = await mountSuspended(VersionSelector, {
529526
props: {
@@ -549,7 +546,7 @@ describe('VersionSelector', () => {
549546
})
550547

551548
// Resolve the promise to clean up
552-
resolvePromise!([])
549+
resolve([])
553550
})
554551
})
555552

0 commit comments

Comments
 (0)