Skip to content

Commit c12b721

Browse files
authored
fix(onboard): confirm installs in nuxi's terminal, on devframe v1.2.6 (#1126)
1 parent 7d70d42 commit c12b721

8 files changed

Lines changed: 344 additions & 209 deletions

File tree

‎packages/devtools-onboard/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ The entry point of [Nuxt DevTools](https://devtools.nuxt.com). It is a tiny Nuxt
77

88
Nothing is injected outside `nuxt dev`.
99

10+
The dev server can be reachable from other devices, so each install from the button waits for you in the terminal that runs `nuxt dev`. In nuxi's interactive view, press `y`; in plain mode, type `yes` and press Enter. Anything else refuses, and so does no answer within 5 minutes. When the terminal cannot answer, for example after the dev server restarted under the interactive view, the install is refused and the panel shows the command to run yourself.
11+
1012
## Turning it off
1113

1214
- `devtools: false` (or `devtools: { enabled: false }`) in `nuxt.config` disables both the button and DevTools.

‎packages/devtools-onboard/src/module.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { NuxtOptions } from '@nuxt/schema'
22
import { fileURLToPath } from 'node:url'
33
import { createOnboarding } from '@devframes/hub-ui-onboard'
4-
import { defineNuxtModule, installModule, tryResolveModule } from '@nuxt/kit'
4+
import { defineNuxtModule, installModule, logger, tryResolveModule } from '@nuxt/kit'
55
import { readPackageJSON } from 'pkg-types'
6+
import { confirmInTerminal } from './terminal-confirm'
67

78
const DEVTOOLS = '@nuxt/devtools'
89
const DEVTOOLS_NIGHTLY = `${DEVTOOLS}-nightly`
@@ -70,6 +71,11 @@ export default defineNuxtModule({
7071
messages: {
7172
restart: 'Installed. Restarting the dev server…',
7273
},
74+
// Upstream's own question refuses at once under nuxi's TUI (DF9007).
75+
onConfirm: ({ command }) => confirmInTerminal(command, {
76+
ask: message => logger.box(message),
77+
warn: message => logger.warn(message),
78+
}),
7379
onInstalled() {
7480
// A Nuxt module only mounts during setup, so the dev server has to
7581
// start over. Delay past the install response so the panel can show
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Buffer } from 'node:buffer'
2+
import type { Readable } from 'node:stream'
3+
import process from 'node:process'
4+
5+
/** As long as `@devframes/hub-ui-onboard`'s own terminal question waits (DF9008). */
6+
const ANSWER_TIMEOUT = 5 * 60_000
7+
8+
export interface Terminal {
9+
input?: Readable & { isTTY?: boolean, isRaw?: boolean }
10+
/** Shows the question; nuxi's TUI pins a boxed log as an ACTION line. */
11+
ask: (message: string) => void
12+
warn: (message: string) => void
13+
}
14+
15+
/**
16+
* The `y/N` question of `@devframes/hub-ui-onboard`, adapted to `nuxi dev`.
17+
* Its TUI holds stdin in raw mode, so a single key answers and the question
18+
* is boxed to stay visible. In plain mode nuxi's own `y` shortcut copies the
19+
* URL, so the answer is `yes`. Like upstream it refuses without a terminal
20+
* (nuxi's TUI gives the dev server's fork none) and after 5 minutes.
21+
*/
22+
export function confirmInTerminal(command: string[], { input = process.stdin, ask, warn }: Terminal): Promise<boolean> {
23+
const run = `\`${command.join(' ')}\``
24+
if (!input.isTTY) {
25+
warn(`A browser asked to install Nuxt DevTools, but no terminal can confirm it, so it was refused. Run ${run} yourself.`)
26+
return Promise.resolve(false)
27+
}
28+
const answerWith = input.isRaw ? 'Press y' : 'Type yes and press Enter'
29+
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.`)
30+
31+
const flowing = input.readableFlowing
32+
return new Promise((resolve) => {
33+
const timer = setTimeout(() => {
34+
warn('Nobody allowed installing Nuxt DevTools within 5 minutes, so it was refused.')
35+
answer(false)
36+
}, ANSWER_TIMEOUT)
37+
const onData = (chunk: Buffer | string) => answer(/^y(?:es)?$/i.test(String(chunk).trim()))
38+
function answer(allowed: boolean) {
39+
clearTimeout(timer)
40+
input.off('data', onData)
41+
// Leave the stream to the TUI or readline that read it before.
42+
if (!flowing)
43+
input.pause()
44+
resolve(allowed)
45+
}
46+
input.on('data', onData)
47+
})
48+
}

‎packages/devtools-onboard/test/module.test.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ function onboardScripts(nuxt: Nuxt) {
5858

5959
type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void
6060

61-
async function viteMiddlewareFetch(nuxt: Nuxt, path: string): Promise<Response> {
61+
/** Serves what the module mounts on the client Vite server; resolves to its origin. */
62+
async function serveViteMiddleware(nuxt: Nuxt): Promise<string> {
6263
const mounted: Middleware[] = []
6364
// Only `middlewares.use` is read; the rest of a real ViteDevServer is irrelevant here.
6465
const viteServer = { middlewares: { use: (fn: Middleware) => mounted.push(fn) } } as never
@@ -74,9 +75,11 @@ async function viteMiddlewareFetch(nuxt: Nuxt, path: string): Promise<Response>
7475
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
7576
cleanups.push(() => new Promise(resolve => server.close(() => resolve())))
7677
const { port } = server.address() as { port: number } // listening on a TCP port, never a pipe
77-
return fetch(`http://127.0.0.1:${port}${path}`)
78+
return `http://127.0.0.1:${port}`
7879
}
7980

81+
const onboardStatus = (origin: string) => fetch(`${origin}/__devtools/__onboard/status`).then(r => r.json())
82+
8083
describe('@nuxt/devtools-onboard', () => {
8184
it('does nothing when devtools are disabled', async () => {
8285
const nuxt = await bootNuxt({ devtools: false, modules: [onboardModule] })
@@ -101,14 +104,24 @@ describe('@nuxt/devtools-onboard', () => {
101104
expect(nuxt.options._requiredModules['@nuxt/devtools']).toBeUndefined()
102105
expect(onboardScripts(nuxt)).toHaveLength(1)
103106

104-
const status = await viteMiddlewareFetch(nuxt, '/__devtools/__onboard/status').then(r => r.json())
107+
const status = await onboardStatus(await serveViteMiddleware(nuxt))
105108
expect(status.state).toBe('idle')
106109
// No lockfile in the fixture, so the package manager falls back to npm.
107110
expect(status.command.slice(0, 3)).toEqual(['npm', 'i', '-D'])
108111
expect(status.command[3]).toMatch(/^@nuxt\/devtools@\^4\./)
109112
expect(status.branding.productName).toBe('Nuxt DevTools')
110113
}, 60_000)
111114

115+
it('refuses to install when nobody can confirm it in the terminal', async () => {
116+
const nuxt = await bootNuxt({ dev: true }, { withoutDevtools: true })
117+
const origin = await serveViteMiddleware(nuxt)
118+
119+
// Vitest workers get a piped stdin, so no terminal can answer the question.
120+
const install = await fetch(`${origin}/__devtools/__onboard/install`, { method: 'POST' })
121+
expect(install.status).toBe(403)
122+
expect((await onboardStatus(origin)).state).toBe('idle')
123+
}, 60_000)
124+
112125
it('stays out of the way outside nuxt dev when @nuxt/devtools is missing', async () => {
113126
const nuxt = await bootNuxt({ dev: false }, { withoutDevtools: true })
114127
expect(onboardScripts(nuxt)).toHaveLength(0)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { PassThrough } from 'node:stream'
2+
import { afterEach, expect, it, vi } from 'vitest'
3+
import { confirmInTerminal } from '../src/terminal-confirm'
4+
5+
const COMMAND = ['npm', 'i', '-D', '@nuxt/devtools@^4.0.0']
6+
7+
function terminal({ isTTY = true, isRaw = false } = {}) {
8+
const asked: string[] = []
9+
const warned: string[] = []
10+
return {
11+
input: Object.assign(new PassThrough(), { isTTY, isRaw }),
12+
asked,
13+
warned,
14+
ask: (message: string) => asked.push(message),
15+
warn: (message: string) => warned.push(message),
16+
}
17+
}
18+
19+
afterEach(() => {
20+
vi.useRealTimers()
21+
})
22+
23+
it.each([
24+
// nuxi's TUI holds stdin in raw mode, so a single key answers
25+
[true, 'y', true],
26+
[true, 'Y', true],
27+
[true, 'w', false],
28+
[true, '\r', false],
29+
// nuxi's plain mode reads lines
30+
[false, 'yes\n', true],
31+
[false, 'y\n', true],
32+
[false, '\n', false],
33+
[false, 'no\n', false],
34+
])('raw mode %s: answers %j with %s', async (isRaw, key, allowed) => {
35+
const t = terminal({ isRaw })
36+
const answer = confirmInTerminal(COMMAND, t)
37+
expect(t.asked[0]).toMatch(isRaw ? /^Press y / : /^Type yes and press Enter /)
38+
expect(t.asked[0]).toContain('npm i -D @nuxt/devtools@^4.0.0')
39+
40+
t.input.write(key)
41+
expect(await answer).toBe(allowed)
42+
expect(t.input.readableFlowing).toBe(false)
43+
})
44+
45+
it('keeps the terminal flowing for the TUI that read it first', async () => {
46+
const t = terminal({ isRaw: true })
47+
const keys: string[] = []
48+
t.input.on('data', chunk => keys.push(String(chunk)))
49+
50+
const answer = confirmInTerminal(COMMAND, t)
51+
t.input.write('y')
52+
expect(await answer).toBe(true)
53+
expect(t.input.readableFlowing).toBe(true)
54+
expect(keys).toEqual(['y'])
55+
})
56+
57+
it('refuses at once without a terminal', async () => {
58+
const t = terminal({ isTTY: false })
59+
expect(await confirmInTerminal(COMMAND, t)).toBe(false)
60+
expect(t.asked).toEqual([])
61+
expect(t.warned[0]).toContain('npm i -D @nuxt/devtools@^4.0.0')
62+
})
63+
64+
it('refuses when nobody answers within 5 minutes', async () => {
65+
vi.useFakeTimers()
66+
const t = terminal({ isRaw: true })
67+
const answer = confirmInTerminal(COMMAND, t)
68+
69+
vi.advanceTimersByTime(5 * 60_000)
70+
expect(await answer).toBe(false)
71+
expect(t.warned).toHaveLength(1)
72+
t.input.write('y')
73+
expect(t.input.readableFlowing).toBe(false)
74+
})

0 commit comments

Comments
 (0)