From 546745edc9057a6cfef43df4bb0242ad79992744 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 14 Oct 2026 07:35:37 +0000 Subject: [PATCH] fix(hub-ui-onboard): refuse the terminal question when nobody can answer it Under nuxi, the default terminal question could be answered by an unrelated key, or stay open forever. It now refuses at once when another reader holds stdin in raw mode (DF9007), and after 5 minutes without an answer (DF9008). The guide now tells hosts under a CLI that owns the terminal to pass onConfirm. closes #448 --- docs/content/1.guide/23.hub-ui-onboard.md | 14 +++++--- docs/content/6.errors/DF9005.md | 6 ++-- docs/content/6.errors/DF9006.md | 2 +- docs/content/6.errors/DF9007.md | 22 +++++++++++++ docs/content/6.errors/DF9008.md | 22 +++++++++++++ docs/content/8.references/6.hub-api.md | 2 +- packages/hub-ui-onboard/src/diagnostics.ts | 10 +++++- .../hub-ui-onboard/src/terminal-confirm.ts | 33 +++++++++++++++---- packages/hub-ui-onboard/src/types.ts | 5 +-- .../test/terminal-confirm.test.ts | 23 ++++++++++++- 10 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 docs/content/6.errors/DF9007.md create mode 100644 docs/content/6.errors/DF9008.md diff --git a/docs/content/1.guide/23.hub-ui-onboard.md b/docs/content/1.guide/23.hub-ui-onboard.md index c676a318..b27249a6 100644 --- a/docs/content/1.guide/23.hub-ui-onboard.md +++ b/docs/content/1.guide/23.hub-ui-onboard.md @@ -46,12 +46,16 @@ const onboarding = createOnboarding({ Install changes `package.json` and the lockfile, and a dev server can be reachable from other devices. So each install that the panel asks for needs the developer's permission. By default, the onboarding asks in the terminal that runs the dev server: 1. The user clicks Install. The panel shows "Confirm in your terminal..." and its request waits. -2. The terminal prints [DF9005](/errors/DF9005). The developer types `y` and presses Enter to allow the install. Any other answer refuses it. +2. The terminal prints [DF9005](/errors/DF9005). The developer types `y` and presses Enter within 5 minutes to allow the install. Any other answer refuses it. No answer in that time also refuses it ([DF9008](/errors/DF9008)). 3. The install runs. After a refusal, the panel says that the dev server did not allow the install. -When stdin is not a TTY (CI, a detached container, a task runner that pipes stdin), nobody can answer. The onboarding then refuses at once with [DF9006](/errors/DF9006). Only one question is open at a time: more clicks while it is open share its answer. A `POST` from another origin is refused with `403` before any question. +Only one question is open at a time: more clicks while it is open share its answer. A `POST` from another origin is refused with `403` before any question. -To ask in another way, pass `onConfirm`. It gets the `packages`, the exact `command`, and the panel's `request`, and returns `true` to allow the install. For example, a Nuxt module can ask through `consola`: +The default question needs a terminal that the dev server process reads line by line, as under `vite`. When nobody can answer, the onboarding refuses at once. It reports [DF9006](/errors/DF9006) when stdin is not a TTY, for example in CI, in a detached container, or under a task runner that pipes stdin. It reports [DF9007](/errors/DF9007) when another program holds stdin in raw mode to read single-key shortcuts. That program also captures the log, so the question does not show. + +A CLI that owns the terminal takes the answer away from the dev server. `nuxi dev` is one: its interactive interface holds stdin in raw mode, it runs the dev server in a child process without stdin after a restart, and in plain mode it reads each line for its own shortcuts. A host that runs under such a CLI must pass `onConfirm` and ask through that CLI. + +To ask in another way, pass `onConfirm`. It gets the `packages`, the exact `command`, and the panel's `request`, and returns `true` to allow the install. For example, a host that owns its terminal can ask through `consola`: ```ts import { consola } from 'consola' @@ -131,7 +135,7 @@ function hubOnboarding(): Plugin { ### Nuxt -Nuxt runs Vite, so a module reuses the plugin above and adds the tag to `app.head`. Gate it on the dev server, and skip it when the user set your own devtools option. +Nuxt runs Vite, so a module reuses the plugin above and adds the tag to `app.head`. Gate it on the dev server, and skip it when the user set your own devtools option. The developer cannot answer the default terminal question under `nuxi dev`, so the module must pass its own `onConfirm`. ```ts [modules/devtools-onboarding.ts] import { createOnboarding } from '@devframes/hub-ui-onboard' @@ -207,4 +211,4 @@ Frameworks with a `Request => Response` surface (Hono, Nitro, Deno) mount `onboa ## Errors -The Node side reports through `DF9000` to `DF9006`. An install failure or a throwing `onInstalled` also reaches the panel as `{ state: 'error', error: { code, message } }`, with a Retry button. See the [error reference](/errors). +The Node side reports through `DF9000` to `DF9008`. An install failure or a throwing `onInstalled` also reaches the panel as `{ state: 'error', error: { code, message } }`, with a Retry button. See the [error reference](/errors). diff --git a/docs/content/6.errors/DF9005.md b/docs/content/6.errors/DF9005.md index cdab38a8..0d4e9512 100644 --- a/docs/content/6.errors/DF9005.md +++ b/docs/content/6.errors/DF9005.md @@ -1,11 +1,11 @@ --- title: 'DF9005: Confirm the Install' -description: 'A browser asked to run `{command}`. Type y and press Enter to allow it.' +description: 'A browser asked to run `{command}`. Type y and press Enter within {minutes} minutes to allow it.' --- ## Message -> A browser asked to run `{command}`. Type y and press Enter to allow it. +> A browser asked to run `{command}`. Type y and press Enter within `{minutes}` minutes to allow it. ## Cause @@ -13,7 +13,7 @@ Install changes `package.json` and the lockfile, and a dev server can be reachab ## Fix -If you clicked Install, type `y` and press Enter. Any other answer refuses the install, and the panel tells the user. If you did not click Install, press Enter. Another device or page can reach this dev server, so bind it to `localhost`. +If you clicked Install, type `y` and press Enter within 5 minutes. Any other answer refuses the install, and the panel tells the user. Without an answer in that time, the install is refused with [DF9008](/errors/DF9008). If you did not click Install, press Enter. Another device or page can reach this dev server, so bind it to `localhost`. To ask in another way, for example with your own logger, pass an `onConfirm` function to `createOnboarding()`. diff --git a/docs/content/6.errors/DF9006.md b/docs/content/6.errors/DF9006.md index 49b17f93..e64a2ecf 100644 --- a/docs/content/6.errors/DF9006.md +++ b/docs/content/6.errors/DF9006.md @@ -9,7 +9,7 @@ description: 'A browser asked to run `{command}`, but no interactive terminal ca ## Cause -Each install that the panel asks for needs the developer's permission ([DF9005](/errors/DF9005)). Without an `onConfirm` option, `createOnboarding()` asks in the terminal. Here stdin is not a TTY, so nobody can answer. This happens in CI, in a detached container, or when a task runner pipes stdin. The install is refused at once, and the panel tells the user. +Each install that the panel asks for needs the developer's permission ([DF9005](/errors/DF9005)). Without an `onConfirm` option, `createOnboarding()` asks in the terminal. Here stdin is not a TTY, so nobody can answer. This happens in CI, in a detached container, when a task runner pipes stdin, or when a CLI starts the dev server in a child process without stdin. `nuxi dev` in its interactive interface does this after each restart. The install is refused at once, and the panel tells the user. ## Fix diff --git a/docs/content/6.errors/DF9007.md b/docs/content/6.errors/DF9007.md new file mode 100644 index 00000000..41635770 --- /dev/null +++ b/docs/content/6.errors/DF9007.md @@ -0,0 +1,22 @@ +--- +title: 'DF9007: Terminal Held by Another Program' +description: 'A browser asked to run `{command}`, but another program reads this terminal key by key, so it cannot show the question. The install was refused.' +--- + +## Message + +> A browser asked to run `{command}`, but another program reads this terminal key by key, so it cannot show the question. The install was refused. + +## Cause + +Each install that the panel asks for needs the developer's permission ([DF9005](/errors/DF9005)). Without an `onConfirm` option, `createOnboarding()` asks in the terminal. Here stdin is in raw mode, so another program in the same process reads each key as it is typed. A full-screen CLI does this for its single-key shortcuts. The CLI also captures the log, so the question does not show, and the next key would answer it. The install is refused at once, and the panel tells the user. + +The Nuxt CLI (`nuxi dev`) does this in its interactive interface, the default in a terminal of at least 40 by 10 characters. + +## Fix + +Run the command from the panel in a terminal yourself. If you maintain the host, pass an `onConfirm` function to `createOnboarding()` that asks through the CLI that owns the terminal. + +## Source + +- [`packages/hub-ui-onboard/src/terminal-confirm.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/terminal-confirm.ts): `confirmInTerminal()` reports this and returns `false` when stdin is a TTY in raw mode. diff --git a/docs/content/6.errors/DF9008.md b/docs/content/6.errors/DF9008.md new file mode 100644 index 00000000..d6e715de --- /dev/null +++ b/docs/content/6.errors/DF9008.md @@ -0,0 +1,22 @@ +--- +title: 'DF9008: No Answer to the Install Question' +description: 'Nobody allowed `{command}` within {minutes} minutes. The install was refused.' +--- + +## Message + +> Nobody allowed `{command}` within `{minutes}` minutes. The install was refused. + +## Cause + +Each install that the panel asks for needs the developer's permission ([DF9005](/errors/DF9005)). Without an `onConfirm` option, `createOnboarding()` asks in the terminal and waits 5 minutes for an answer. Nobody answered in that time, so the install is refused, and the panel tells the user. + +Sometimes the answer cannot arrive. Another process can read the same terminal before the dev server does. For example, `nuxi dev` in plain mode moves the dev server to a child process after a restart, and the parent process reads each line for its own shortcuts. + +## Fix + +Click Install again, then type `y` and press Enter in the terminal that runs the dev server. If the answer still does not arrive, run the command from the panel in a terminal yourself. If you maintain the host, pass an `onConfirm` function to `createOnboarding()` that asks through the CLI that owns the terminal. + +## Source + +- [`packages/hub-ui-onboard/src/terminal-confirm.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/terminal-confirm.ts): `confirmInTerminal()` reports this and returns `false` when no line arrives in time. diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 06bfa890..348b2a0c 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -98,7 +98,7 @@ What `createOnboarding()` from `@devframes/hub-ui-onboard` accepts and serves: [ | `branding` | `{}` | `productName` (default `Devframes`), `logo` (URL or `{ light, dark }`), `primaryColor` | | `messages` | derived from `productName` | Overrides for `title`, `description`, `install`, `confirming`, `refused`, `hide`, `disable`, `installing`, `restart`, `retry` | | `onInstalled` | none | Runs after a verified install, or on the first request when the packages already exist; a returned handler takes over `base` | -| `onConfirm` | `y/N` in the terminal | Gets `{ packages, command, request }` before each install the panel asks for; return `true` to allow it. Refuses when stdin is not a TTY | +| `onConfirm` | `y/N` in the terminal | Gets `{ packages, command, request }` before each install the panel asks for; return `true` to allow it. Refuses at once when stdin is not a TTY or is in raw mode, and after 5 minutes without an answer | | Route | Serves | | --- | --- | diff --git a/packages/hub-ui-onboard/src/diagnostics.ts b/packages/hub-ui-onboard/src/diagnostics.ts index 158f395c..e85458b4 100644 --- a/packages/hub-ui-onboard/src/diagnostics.ts +++ b/packages/hub-ui-onboard/src/diagnostics.ts @@ -30,12 +30,20 @@ export const diagnostics = defineDiagnostics({ fix: 'Make sure that `stateDir` is writable, or point it at another directory.', }, DF9005: { - why: (p: { command: string }) => `A browser asked to run \`${p.command}\`. Type y and press Enter to allow it.`, + why: (p: { command: string, minutes: number }) => `A browser asked to run \`${p.command}\`. Type y and press Enter within ${p.minutes} minutes to allow it.`, fix: 'If you did not click Install, press Enter to refuse. Another device can reach this dev server, so bind it to localhost.', }, DF9006: { why: (p: { command: string }) => `A browser asked to run \`${p.command}\`, but no interactive terminal can confirm it. The install was refused.`, fix: 'Run the command yourself, or pass an `onConfirm` function to `createOnboarding()`.', }, + DF9007: { + why: (p: { command: string }) => `A browser asked to run \`${p.command}\`, but another program reads this terminal key by key, so it cannot show the question. The install was refused.`, + fix: 'Run the command yourself. If a CLI owns the terminal, pass an `onConfirm` function to `createOnboarding()` that asks through that CLI.', + }, + DF9008: { + why: (p: { command: string, minutes: number }) => `Nobody allowed \`${p.command}\` within ${p.minutes} minutes. The install was refused.`, + fix: 'Click Install again and answer in the terminal that runs the dev server. If the answer does not arrive, another program reads that terminal: pass an `onConfirm` function to `createOnboarding()`.', + }, }, }) diff --git a/packages/hub-ui-onboard/src/terminal-confirm.ts b/packages/hub-ui-onboard/src/terminal-confirm.ts index 1d54a8e5..5ff6cbb1 100644 --- a/packages/hub-ui-onboard/src/terminal-confirm.ts +++ b/packages/hub-ui-onboard/src/terminal-confirm.ts @@ -3,24 +3,43 @@ import type { Readable } from 'node:stream' import process from 'node:process' import { diagnostics } from './diagnostics' +const ANSWER_MINUTES = 5 + /** * The default `onConfirm`: a `y/N` question in the terminal that runs the dev - * server. Without an interactive terminal nobody can answer, so it refuses. + * server. When nobody can answer it, it refuses. */ -export function confirmInTerminal(command: string[], input: Readable & { isTTY?: boolean } = process.stdin): Promise { +export function confirmInTerminal(command: string[], input: Readable & { isTTY?: boolean, isRaw?: boolean } = process.stdin): Promise { + const line = command.join(' ') if (!input.isTTY) { - diagnostics.DF9006({ command: command.join(' ') }) + diagnostics.DF9006({ command: line }) + return Promise.resolve(false) + } + // Raw mode means a TUI (nuxi's) takes each key as a shortcut and hides the + // question in its log view. The next key would answer a question nobody saw. + if (input.isRaw) { + diagnostics.DF9007({ command: line }) return Promise.resolve(false) } - diagnostics.DF9005({ command: command.join(' ') }) + diagnostics.DF9005({ command: line, minutes: ANSWER_MINUTES }) // Vite reads the same stream for its shortcuts. Listen next to it, and // pause again only when nothing read the stream before. const flowing = input.readableFlowing return new Promise((resolve) => { - input.once('data', (chunk: Buffer | string) => { + // Another process can read the same terminal first (nuxi's plain mode + // after a restart), so the answer may never reach this stream. + const timer = setTimeout(() => { + diagnostics.DF9008({ command: line, minutes: ANSWER_MINUTES }) + settle(false) + }, ANSWER_MINUTES * 60_000) + const onData = (chunk: Buffer | string) => settle(/^y(?:es)?$/i.test(String(chunk).trim())) + function settle(allowed: boolean): void { + clearTimeout(timer) + input.off('data', onData) if (!flowing) input.pause() - resolve(/^y(?:es)?$/i.test(String(chunk).trim())) - }) + resolve(allowed) + } + input.on('data', onData) }) } diff --git a/packages/hub-ui-onboard/src/types.ts b/packages/hub-ui-onboard/src/types.ts index d72865dd..503dc8f6 100644 --- a/packages/hub-ui-onboard/src/types.ts +++ b/packages/hub-ui-onboard/src/types.ts @@ -68,8 +68,9 @@ export interface CreateOnboardingOptions { * Ask the developer to allow an install that the panel requested; the * dev server can be reachable from other devices. Return `true` to run * it. Only one call is open at a time: clicks meanwhile share its answer. - * Default: a `y/N` question in the terminal, or a refusal when stdin is - * not a TTY. + * Default: a `y/N` question in the terminal. It refuses at once when stdin + * is not a TTY or another reader holds it in raw mode, and after 5 minutes + * without an answer. */ onConfirm?: (confirmation: OnboardingConfirmation) => boolean | Promise } diff --git a/packages/hub-ui-onboard/test/terminal-confirm.test.ts b/packages/hub-ui-onboard/test/terminal-confirm.test.ts index d6d0645c..9740871c 100644 --- a/packages/hub-ui-onboard/test/terminal-confirm.test.ts +++ b/packages/hub-ui-onboard/test/terminal-confirm.test.ts @@ -1,5 +1,5 @@ import { PassThrough } from 'node:stream' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import { confirmInTerminal } from '../src/terminal-confirm' const COMMAND = ['pnpm', 'add', '-D', '@nuxt/devtools'] @@ -42,3 +42,24 @@ it('refuses at once without a TTY', async () => { expect(await confirmInTerminal(COMMAND, new PassThrough())).toBe(false) expect(printed()).toContain('DF9006') }) + +it('refuses at once when another reader holds the terminal in raw mode', async () => { + const input = Object.assign(terminal(), { isRaw: true }) + expect(await confirmInTerminal(COMMAND, input)).toBe(false) + expect(printed()).toContain('DF9007') + expect(input.readableFlowing).toBe(null) +}) + +it('refuses and releases the terminal when nobody answers within 5 minutes', async () => { + vi.useFakeTimers() + onTestFinished(() => { + vi.useRealTimers() + }) + const input = terminal() + const answer = confirmInTerminal(COMMAND, input) + + vi.advanceTimersByTime(5 * 60_000) + expect(await answer).toBe(false) + expect(printed()).toContain('DF9008') + expect(input.readableFlowing).toBe(false) +})