Skip to content

Commit 5d0ca91

Browse files
authored
fix(hub-ui-onboard): confirm installs in the terminal (#445)
* fix(hub-ui-onboard): require a terminal code to install or disable * fix(hub-ui-onboard): confirm installs in the terminal through a configurable hook * refactor(hub-ui-onboard): rename confirm option to onConfirm
1 parent 0661c6c commit 5d0ca91

15 files changed

Lines changed: 304 additions & 25 deletions

File tree

‎docs/content/1.guide/23.hub-ui-onboard.md‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A hub UI provider, its Vue runtime, and the devframes it mounts add tens of mega
1515

1616
The button sits at the bottom left, dimmed until hovered. It opens a panel with the product name and logo, one sentence, the exact command the install will run (for example `pnpm add -D @nuxt/devtools`), and three actions:
1717

18-
- **Install** runs the command in the project. The panel shows progress, then either the real dock replaces the button in place, or the panel asks for a restart.
18+
- **Install** runs the command in the project after the developer allows it in the terminal (below). The panel shows progress, then either the real dock replaces the button in place, or the panel asks for a restart.
1919
- **Hide for now** removes the button for the current browser tab.
2020
- **Disable entirely** writes a state file so the host stops injecting the button on every later start.
2121

@@ -39,7 +39,30 @@ const onboarding = createOnboarding({
3939
- `scriptSrc`: `<base>embedded.js`, the URL to inject as `<script type="module">`.
4040
- `disabled` and `installed`: what the host needs to decide whether to inject the script at all (below).
4141

42-
`packages` are package specs as the package manager accepts them. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn`, `bun`, `deno`), `dev: true` adds `-D`, and `cwd` (default `process.cwd()`) is the project that receives the dependency. In a workspace, point `cwd` at the package that runs the dev server. The packages are fixed at creation, and a `POST` from another origin is refused.
42+
`packages` are package specs as the package manager accepts them. The package manager comes from the lockfile (`npm`, `pnpm`, `yarn`, `bun`, `deno`), `dev: true` adds `-D`, and `cwd` (default `process.cwd()`) is the project that receives the dependency. In a workspace, point `cwd` at the package that runs the dev server. The packages are fixed at creation.
43+
44+
## Confirm the install
45+
46+
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:
47+
48+
1. The user clicks Install. The panel shows "Confirm in your terminal..." and its request waits.
49+
2. The terminal prints [DF9005](/errors/DF9005). The developer types `y` and presses Enter to allow the install. Any other answer refuses it.
50+
3. The install runs. After a refusal, the panel says that the dev server did not allow the install.
51+
52+
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.
53+
54+
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`:
55+
56+
```ts
57+
import { consola } from 'consola'
58+
59+
const onboarding = createOnboarding({
60+
packages: ['@nuxt/devtools'],
61+
onConfirm: ({ command }) => consola.prompt(`Run \`${command.join(' ')}\` for the browser that asked?`, { type: 'confirm' }),
62+
})
63+
```
64+
65+
Disable entirely does not ask. It changes only the state file in `node_modules/.devframe`.
4366

4467
## Hand the base to the hub
4568

@@ -184,4 +207,4 @@ Frameworks with a `Request => Response` surface (Hono, Nitro, Deno) mount `onboa
184207

185208
## Errors
186209

187-
The Node side reports through `DF9000` to `DF9004`. 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).
210+
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).

‎docs/content/6.errors/DF9005.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
title: 'DF9005: Confirm the Install'
3+
description: 'A browser asked to run `{command}`. Type y and press Enter to allow it.'
4+
---
5+
6+
## Message
7+
8+
> A browser asked to run `{command}`. Type y and press Enter to allow it.
9+
10+
## Cause
11+
12+
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. The panel's `POST <base>__onboard/install` waits until you answer. Without an `onConfirm` option, `createOnboarding()` asks in the terminal that runs the dev server.
13+
14+
## Fix
15+
16+
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`.
17+
18+
To ask in another way, for example with your own logger, pass an `onConfirm` function to `createOnboarding()`.
19+
20+
## Source
21+
22+
- [`packages/hub-ui-onboard/src/terminal-confirm.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/terminal-confirm.ts): `confirmInTerminal()` prints this, then reads one line from stdin.

‎docs/content/6.errors/DF9006.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
title: 'DF9006: No Terminal to Confirm the Install'
3+
description: 'A browser asked to run `{command}`, but no interactive terminal can confirm it. The install was refused.'
4+
---
5+
6+
## Message
7+
8+
> A browser asked to run `{command}`, but no interactive terminal can confirm it. The install was refused.
9+
10+
## Cause
11+
12+
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.
13+
14+
## Fix
15+
16+
Run the command from the panel in a terminal yourself. Or pass an `onConfirm` function to `createOnboarding()` that asks in another way.
17+
18+
## Source
19+
20+
- [`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 not a TTY.

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,15 @@ What `createOnboarding()` from `@devframes/hub-ui-onboard` accepts and serves: [
9696
| `stateDir` | `<cwd>/node_modules/.devframe` | Directory of the `hub-ui-onboard.json` state file |
9797
| `dev` | `true` | Install as a devDependency |
9898
| `branding` | `{}` | `productName` (default `Devframes`), `logo` (URL or `{ light, dark }`), `primaryColor` |
99-
| `messages` | derived from `productName` | Overrides for `title`, `description`, `install`, `hide`, `disable`, `installing`, `restart`, `retry` |
99+
| `messages` | derived from `productName` | Overrides for `title`, `description`, `install`, `confirming`, `refused`, `hide`, `disable`, `installing`, `restart`, `retry` |
100100
| `onInstalled` | none | Runs after a verified install, or on the first request when the packages already exist; a returned handler takes over `base` |
101+
| `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 |
101102

102103
| Route | Serves |
103104
| --- | --- |
104105
| `GET <base>embedded.js` | the floating button, a self-contained ES module |
105106
| `GET <base>__onboard/status` | `{ state, command, branding, messages, error? }` |
106-
| `POST <base>__onboard/install` | starts the install (`202`), same-origin only |
107+
| `POST <base>__onboard/install` | waits for `onConfirm`, then starts the install (`202`) or refuses it (`403`); same-origin only |
107108
| `POST <base>__onboard/disable` | writes the state file (`204`), same-origin only |
108109

109110
| `state` | Meaning |

‎examples/hub-onboard-vite/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pnpm --filter hub-onboard-vite dev
88

99
Open the printed URL. The button at the bottom left opens a panel with two actions:
1010

11-
- Install runs `pnpm add -D @devframes/hub @devframes/hub-ui @devframes/plugin-git` in this directory. When it finishes, the host starts the real hub on `/__devframes/` in the same process and the button swaps itself for the floating dock.
11+
- Install asks you to type `y` in the terminal that runs Vite, then runs `pnpm add -D @devframes/hub @devframes/hub-ui @devframes/plugin-git` in this directory. When it finishes, the host starts the real hub on `/__devframes/` in the same process and the button swaps itself for the floating dock.
1212
- Hide for now hides the button for this browser tab.
1313
- Disable entirely writes `node_modules/.devframe/hub-ui-onboard.json`. The host reads `onboarding.disabled` on the next start and injects no script.
1414

‎packages/hub-ui-onboard/playground/index.html‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ <h1>hub-ui-onboard</h1>
2222
<button data-state="installed">installed</button>
2323
<button data-state="error">error</button>
2424
<button data-state="idle" data-outcome="error">idle (install fails)</button>
25+
<button data-state="idle" data-outcome="refused">idle (install refused)</button>
2526
</fieldset>
2627
<fieldset>
2728
<legend>Theme</legend>

‎packages/hub-ui-onboard/playground/vite.config.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ const productName = 'Devframes'
1111
/**
1212
* A fake of the `__onboard/*` routes `createOnboarding` serves. The client
1313
* resolves them next to its own module (`/src/client/`). The playground page
14-
* picks the state through `/__mock/state`; Install runs a timer instead of a
14+
* picks the state through `/__mock/state`; Install waits as if the developer
15+
* answered in the terminal, then is refused, or runs a timer instead of a
1516
* package manager and ends in `installed` or `error`.
1617
*/
1718
function mockOnboarding(): Plugin {
1819
let state: OnboardingState = 'idle'
19-
let outcome: 'installed' | 'error' = 'installed'
20+
let outcome: 'installed' | 'error' | 'refused' = 'installed'
2021
let timer: ReturnType<typeof setTimeout> | undefined
2122

2223
const status = (): OnboardingStatus => ({
@@ -40,19 +41,28 @@ function mockOnboarding(): Plugin {
4041
if (url.pathname === '/__mock/state') {
4142
clearTimeout(timer)
4243
state = (url.searchParams.get('state') ?? 'idle') as OnboardingState
43-
outcome = url.searchParams.get('outcome') === 'error' ? 'error' : 'installed'
44+
const requested = url.searchParams.get('outcome')
45+
outcome = requested === 'error' || requested === 'refused' ? requested : 'installed'
4446
return send({ state, outcome })
4547
}
4648
if (!url.pathname.startsWith('/src/client/__onboard/'))
4749
return next()
4850
const route = url.pathname.split('/').pop()
4951
if (route === 'install') {
50-
state = 'installing'
51-
timer = setTimeout(() => (state = outcome), 2500)
52+
setTimeout(() => {
53+
if (outcome === 'refused') {
54+
res.statusCode = 403
55+
return res.end()
56+
}
57+
const end = outcome
58+
state = 'installing'
59+
timer = setTimeout(() => (state = end), 2500)
60+
send({ ok: true })
61+
}, 1500)
62+
return
5263
}
53-
else if (route === 'disable') {
64+
if (route === 'disable')
5465
state = 'disabled'
55-
}
5666
send(route === 'status' ? status() : { ok: true })
5767
})
5868
},

‎packages/hub-ui-onboard/src/client/index.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,17 @@ function mount(initial: OnboardingStatus): void {
266266
}
267267

268268
async function startInstall(): Promise<void> {
269+
// The request stays open until the developer answers in the terminal.
270+
install.disabled = true
271+
installLabel.textContent = messages.confirming
272+
const response = await api('install', { method: 'POST' })
273+
if (!response.ok) {
274+
await poll()
275+
note.hidden = false
276+
note.textContent = messages.refused
277+
return
278+
}
269279
render({ ...initial, state: 'installing' })
270-
await api('install', { method: 'POST' })
271280
await poll()
272281
}
273282

‎packages/hub-ui-onboard/src/diagnostics.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,13 @@ export const diagnostics = defineDiagnostics({
2929
why: (p: { file: string }) => `The onboarding state file ${p.file} could not be read or written.`,
3030
fix: 'Make sure that `stateDir` is writable, or point it at another directory.',
3131
},
32+
DF9005: {
33+
why: (p: { command: string }) => `A browser asked to run \`${p.command}\`. Type y and press Enter to allow it.`,
34+
fix: 'If you did not click Install, press Enter to refuse. Another device can reach this dev server, so bind it to localhost.',
35+
},
36+
DF9006: {
37+
why: (p: { command: string }) => `A browser asked to run \`${p.command}\`, but no interactive terminal can confirm it. The install was refused.`,
38+
fix: 'Run the command yourself, or pass an `onConfirm` function to `createOnboarding()`.',
39+
},
3240
},
3341
})

‎packages/hub-ui-onboard/src/index.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
1212
import { Diagnostic } from 'nostics'
1313
import { diagnostics } from './diagnostics'
1414
import { packagesInstalled, resolveInstallCommand, runInstall } from './install'
15+
import { confirmInTerminal } from './terminal-confirm'
1516

1617
export type * from './types'
1718

@@ -38,6 +39,8 @@ export function defaultMessages(productName: string): OnboardingMessages {
3839
title: productName,
3940
description: `${productName} enables development features in your project. Install it with:`,
4041
install: `Install ${productName}`,
42+
confirming: 'Confirm in your terminal...',
43+
refused: 'The dev server did not allow the install. Check its terminal, or run the command above yourself.',
4144
hide: 'Hide in this session',
4245
disable: 'Disable entirely',
4346
installing: 'Installing...',
@@ -185,9 +188,34 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
185188
return new Response(null, { status: 204 })
186189
}
187190

191+
const commandLine = async () => {
192+
const resolved = await getCommand()
193+
return [resolved.command, ...resolved.args]
194+
}
195+
196+
// The dev server can be reachable from other devices, and a non-browser
197+
// request can send any `Origin`. So the developer allows each install.
198+
const onConfirm = options.onConfirm ?? (({ command }) => confirmInTerminal(command))
199+
let asking: Promise<boolean> | undefined
200+
201+
async function startInstall(request: Request): Promise<Response> {
202+
if (state !== 'idle' && state !== 'error')
203+
return new Response(null, { status: 202 })
204+
asking ??= commandLine()
205+
.then(command => onConfirm({ packages: plan.packages, command, request }))
206+
.finally(() => {
207+
asking = undefined
208+
})
209+
if (!await asking)
210+
return new Response(null, { status: 403 })
211+
// Clicks that shared the answer start one install.
212+
if (state === 'idle' || state === 'error')
213+
void install()
214+
return new Response(null, { status: 202 })
215+
}
216+
188217
async function status(): Promise<OnboardingStatus> {
189-
const command = await getCommand()
190-
return { state, command: [command.command, ...command.args], branding, messages, error }
218+
return { state, command: await commandLine(), branding, messages, error }
191219
}
192220

193221
const handler: OnboardingHandler = async (request) => {
@@ -210,11 +238,7 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
210238
if (route === 'POST __onboard/install' || route === 'POST __onboard/disable') {
211239
if (!isSameOrigin(request))
212240
return new Response(null, { status: 403 })
213-
if (route.endsWith('disable'))
214-
return disable()
215-
if (state === 'idle' || state === 'error')
216-
void install()
217-
return new Response(null, { status: 202 })
241+
return route.endsWith('disable') ? disable() : startInstall(request)
218242
}
219243
return new Response(null, { status: 404 })
220244
}

0 commit comments

Comments
 (0)