Repository navigation
fix(onboard): confirm installs in nuxi's terminal, on devframe v1.2.6 #1126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
80ce3e6
206c148
6090e58
e264e86
5343ef3
1ab9bfa
450f0f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import type { Buffer } from 'node:buffer' | ||
| import type { Readable } from 'node:stream' | ||
| import process from 'node:process' | ||
|
|
||
| /** As long as `@devframes/hub-ui-onboard`'s own terminal question waits (DF9008). */ | ||
| const ANSWER_TIMEOUT = 5 * 60_000 | ||
|
|
||
| export interface Terminal { | ||
| input?: Readable & { isTTY?: boolean, isRaw?: boolean } | ||
| /** Shows the question; nuxi's TUI pins a boxed log as an ACTION line. */ | ||
| ask: (message: string) => void | ||
| warn: (message: string) => void | ||
| } | ||
|
|
||
| /** | ||
| * The `y/N` question of `@devframes/hub-ui-onboard`, adapted to `nuxi dev`. | ||
| * Its TUI holds stdin in raw mode, so a single key answers and the question | ||
| * is boxed to stay visible. In plain mode nuxi's own `y` shortcut copies the | ||
| * URL, so the answer is `yes`. Like upstream it refuses without a terminal | ||
| * (nuxi's TUI gives the dev server's fork none) and after 5 minutes. | ||
| */ | ||
| export function confirmInTerminal(command: string[], { input = process.stdin, ask, warn }: Terminal): Promise<boolean> { | ||
| const run = `\`${command.join(' ')}\`` | ||
| if (!input.isTTY) { | ||
| warn(`A browser asked to install Nuxt DevTools, but no terminal can confirm it, so it was refused. Run ${run} yourself.`) | ||
| return Promise.resolve(false) | ||
| } | ||
| const answerWith = input.isRaw ? 'Press y' : 'Type yes and press Enter' | ||
| ask(`${answerWith} to allow installing Nuxt DevTools; anything else refuses.\nThe browser asked to run ${run}. If you did not click Install, refuse it: another device can reach this dev server.`) | ||
|
|
||
| const flowing = input.readableFlowing | ||
| return new Promise((resolve) => { | ||
| const timer = setTimeout(() => { | ||
| warn('Nobody allowed installing Nuxt DevTools within 5 minutes, so it was refused.') | ||
| answer(false) | ||
| }, ANSWER_TIMEOUT) | ||
| const onData = (chunk: Buffer | string) => answer(/^y(?:es)?$/i.test(String(chunk).trim())) | ||
| function answer(allowed: boolean) { | ||
| clearTimeout(timer) | ||
| input.off('data', onData) | ||
| // Leave the stream to the TUI or readline that read it before. | ||
| if (!flowing) | ||
| input.pause() | ||
| resolve(allowed) | ||
| } | ||
| input.on('data', onData) | ||
|
Comment on lines
+32
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Refuse when terminal input ends. If a TTY input stream ends before an answer, this promise has no EOF handler. The install request remains open until the five-minute timeout. Resolve 🤖 Prompt for AI AgentsSource: Learnings |
||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { PassThrough } from 'node:stream' | ||
| import { afterEach, expect, it, vi } from 'vitest' | ||
| import { confirmInTerminal } from '../src/terminal-confirm' | ||
|
|
||
| const COMMAND = ['npm', 'i', '-D', '@nuxt/devtools@^4.0.0'] | ||
|
|
||
| function terminal({ isTTY = true, isRaw = false } = {}) { | ||
| const asked: string[] = [] | ||
| const warned: string[] = [] | ||
| return { | ||
| input: Object.assign(new PassThrough(), { isTTY, isRaw }), | ||
| asked, | ||
| warned, | ||
| ask: (message: string) => asked.push(message), | ||
| warn: (message: string) => warned.push(message), | ||
| } | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| it.each([ | ||
| // nuxi's TUI holds stdin in raw mode, so a single key answers | ||
| [true, 'y', true], | ||
| [true, 'Y', true], | ||
| [true, 'w', false], | ||
| [true, '\r', false], | ||
| // nuxi's plain mode reads lines | ||
| [false, 'yes\n', true], | ||
| [false, 'y\n', true], | ||
| [false, '\n', false], | ||
| [false, 'no\n', false], | ||
| ])('raw mode %s: answers %j with %s', async (isRaw, key, allowed) => { | ||
| const t = terminal({ isRaw }) | ||
| const answer = confirmInTerminal(COMMAND, t) | ||
| expect(t.asked[0]).toMatch(isRaw ? /^Press y / : /^Type yes and press Enter /) | ||
| expect(t.asked[0]).toContain('npm i -D @nuxt/devtools@^4.0.0') | ||
|
|
||
| t.input.write(key) | ||
| expect(await answer).toBe(allowed) | ||
| expect(t.input.readableFlowing).toBe(false) | ||
| }) | ||
|
|
||
| it('keeps the terminal flowing for the TUI that read it first', async () => { | ||
| const t = terminal({ isRaw: true }) | ||
| const keys: string[] = [] | ||
| t.input.on('data', chunk => keys.push(String(chunk))) | ||
|
|
||
| const answer = confirmInTerminal(COMMAND, t) | ||
| t.input.write('y') | ||
| expect(await answer).toBe(true) | ||
| expect(t.input.readableFlowing).toBe(true) | ||
| expect(keys).toEqual(['y']) | ||
| }) | ||
|
|
||
| it('refuses at once without a terminal', async () => { | ||
| const t = terminal({ isTTY: false }) | ||
| expect(await confirmInTerminal(COMMAND, t)).toBe(false) | ||
| expect(t.asked).toEqual([]) | ||
| expect(t.warned[0]).toContain('npm i -D @nuxt/devtools@^4.0.0') | ||
| }) | ||
|
|
||
| it('refuses when nobody answers within 5 minutes', async () => { | ||
| vi.useFakeTimers() | ||
| const t = terminal({ isRaw: true }) | ||
| const answer = confirmInTerminal(COMMAND, t) | ||
|
|
||
| vi.advanceTimersByTime(5 * 60_000) | ||
| expect(await answer).toBe(false) | ||
| expect(t.warned).toHaveLength(1) | ||
| t.input.write('y') | ||
| expect(t.input.readableFlowing).toBe(false) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: nuxt/devtools
Length of output: 4519
🏁 Script executed:
Repository: nuxt/devtools
Length of output: 4409
🏁 Script executed:
Repository: nuxt/devtools
Length of output: 7489
Resume input for a later confirmation.
After the first confirmation pauses a non-flowing stream, a later
datalistener does not resume it. A second confirmation on the same terminal can therefore wait five minutes without receiving the user's answer. Resume the stream while the confirmation owns it, then pause it again when the answer completes. Add a regression test that declines once and answers on the next call.🐛 Suggested fix
input.on('data', onData) + if (!flowing) + input.resume()🤖 Prompt for AI Agents