Skip to content

Commit ebcbd34

Browse files
committed
fix(hub-ui-onboard): confirm installs in the terminal through a configurable hook
1 parent fe758d4 commit ebcbd34

17 files changed

Lines changed: 269 additions & 205 deletions

File tree

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@ 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

22-
Install and Disable entirely first ask for a 6-digit code that the dev server prints in the terminal (below).
23-
2422
The panel follows the shared design tokens, the host's `primaryColor`, and the user's hub color scheme, so the swap to the real dock looks like one product.
2523

2624
## Create the onboarding
@@ -43,15 +41,28 @@ const onboarding = createOnboarding({
4341

4442
`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.
4543

46-
## Confirm with the terminal code
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.
4751

48-
Install and Disable entirely change the project, and a dev server can listen on the network. So the onboarding accepts them only from a person who can read the terminal:
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.
4953

50-
1. The first `POST` from the panel has no code. The onboarding prints [DF9005](/errors/DF9005) with a 6-digit code and answers `401`.
51-
2. The panel shows a field for the code. The user types it, and the panel sends it again in the `x-devframes-onboard-code` header.
52-
3. After five wrong codes, both actions answer `429` with [DF9006](/errors/DF9006) until the dev server restarts.
54+
To ask in another way, pass `confirm`. 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+
confirm: ({ command }) => consola.prompt(`Run \`${command.join(' ')}\` for the browser that asked?`, { type: 'confirm' }),
62+
})
63+
```
5364

54-
The code stays the same for the life of the process, so Retry after a failed install does not ask again. A `POST` from another origin is also refused with `403`. The host has nothing to configure.
65+
Disable entirely does not ask. It changes only the state file in `node_modules/.devframe`.
5566

5667
## Hand the base to the hub
5768

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
---
2-
title: 'DF9005: Onboarding Code Requested'
3-
description: 'A browser asked to {action}. To allow it, enter this code in the panel: {code}'
2+
title: 'DF9005: Confirm the Install'
3+
description: 'A browser asked to run `{command}`. Type y and press Enter to allow it.'
44
---
55

66
## Message
77

8-
> A browser asked to `{action}`. To allow it, enter this code in the panel: `{code}`
8+
> A browser asked to run `{command}`. Type y and press Enter to allow it.
99
1010
## Cause
1111

12-
Install and Disable change the project, and a dev server can listen on the network. So the onboarding panel must send a 6-digit code that only the terminal shows. A request to `POST <base>__onboard/install` or `POST <base>__onboard/disable` came without the `x-devframes-onboard-code` header, and `createOnboarding()` printed the code. The code stays the same until the process exits.
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 a `confirm` option, `createOnboarding()` asks in the terminal that runs the dev server.
1313

1414
## Fix
1515

16-
If you clicked Install or Disable entirely, type the code into the panel. If you did not, another device or page can reach this dev server. Do not enter the code anywhere, and bind the dev server to `localhost`.
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 a `confirm` function to `createOnboarding()`.
1719

1820
## Source
1921

20-
- [`packages/hub-ui-onboard/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/index.ts): `authorize()` prints this and answers with status 401 when the request has no code.
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: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
---
2-
title: 'DF9006: Too Many Wrong Onboarding Codes'
3-
description: 'Five wrong onboarding codes were entered. Install and Disable are blocked until the dev server restarts.'
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.'
44
---
55

66
## Message
77

8-
> Five wrong onboarding codes were entered. Install and Disable are blocked until the dev server restarts.
8+
> A browser asked to run `{command}`, but no interactive terminal can confirm it. The install was refused.
99
1010
## Cause
1111

12-
Install and Disable need the code that [DF9005](/errors/DF9005) prints. After five wrong codes, `createOnboarding()` refuses both actions for the rest of the process, so nobody can guess the code.
12+
Each install that the panel asks for needs the developer's permission ([DF9005](/errors/DF9005)). Without a `confirm` 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.
1313

1414
## Fix
1515

16-
Restart the dev server. It prints a new code the next time you click Install or Disable entirely. If you did not enter the wrong codes, another device or page can reach this dev server: bind it to `localhost`. You can also run the install command from the panel in a terminal.
16+
Run the command from the panel in a terminal yourself. Or pass a `confirm` function to `createOnboarding()` that asks in another way.
1717

1818
## Source
1919

20-
- [`packages/hub-ui-onboard/src/index.ts`](https://github.com/devframes/devframe/blob/main/packages/hub-ui-onboard/src/index.ts): `authorize()` reports this on the fifth wrong code, then answers each later Install or Disable with status 429 and this code.
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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,17 +96,16 @@ 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`, `code`, `wrongCode`, `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+
| `confirm` | `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`), with the terminal code only |
107-
| `POST <base>__onboard/disable` | writes the state file (`204`), with the terminal code only |
108-
109-
Both `POST` routes answer `403` to another origin. Without the `x-devframes-onboard-code` header, they print the code ([DF9005](/errors/DF9005)) and answer `401`. A wrong code also gets `401`. After five wrong codes, they answer `429` with [DF9006](/errors/DF9006) until the process restarts.
107+
| `POST <base>__onboard/install` | waits for `confirm`, then starts the install (`202`) or refuses it (`403`); same-origin only |
108+
| `POST <base>__onboard/disable` | writes the state file (`204`), same-origin only |
110109

111110
| `state` | Meaning |
112111
| --- | --- |

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ A Vite host that ships only `@devframes/hub-ui-onboard` (about 20 kB in the brow
66
pnpm --filter hub-onboard-vite dev
77
```
88

9-
Open the printed URL. The button at the bottom left opens a panel with three actions:
9+
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

15-
Install and Disable entirely ask for the 6-digit code that the dev server prints in the terminal (`DF9005`).
16-
1715
When the three packages are already installed, the button never shows: the host's `onInstalled` runs on the first request and the real dock appears directly.
1816

1917
Install changes this example's `package.json` and the lockfile. Run `git checkout -- examples/hub-onboard-vite pnpm-lock.yaml` and delete the state file to reset the demo.

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
</head>
1515
<body>
1616
<h1>hub-ui-onboard</h1>
17-
<p>The floating button loads from <code>src/client</code> and talks to a mock <code>__onboard</code> API. When Install or Disable asks for the terminal code, enter <code>123456</code>.</p>
17+
<p>The floating button loads from <code>src/client</code> and talks to a mock <code>__onboard</code> API.</p>
1818
<fieldset>
1919
<legend>State</legend>
2020
<button data-state="idle">idle</button>
2121
<button data-state="installing">installing</button>
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: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,20 @@ import { spawn } from 'node:child_process'
44
import { fileURLToPath } from 'node:url'
55
import { defineConfig } from 'vite'
66
import { defaultMessages } from '../src/index'
7-
import { CODE_HEADER } from '../src/protocol'
87

98
const root = fileURLToPath(new URL('..', import.meta.url))
109
const productName = 'Devframes'
11-
const MOCK_CODE = '123456'
1210

1311
/**
1412
* A fake of the `__onboard/*` routes `createOnboarding` serves. The client
1513
* resolves them next to its own module (`/src/client/`). The playground page
16-
* picks the state through `/__mock/state`; Install runs a timer instead of a
17-
* package manager and ends in `installed` or `error`. Install and Disable
18-
* accept only the code `123456`.
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
16+
* package manager and ends in `installed` or `error`.
1917
*/
2018
function mockOnboarding(): Plugin {
2119
let state: OnboardingState = 'idle'
22-
let outcome: 'installed' | 'error' = 'installed'
20+
let outcome: 'installed' | 'error' | 'refused' = 'installed'
2321
let timer: ReturnType<typeof setTimeout> | undefined
2422

2523
const status = (): OnboardingStatus => ({
@@ -43,23 +41,28 @@ function mockOnboarding(): Plugin {
4341
if (url.pathname === '/__mock/state') {
4442
clearTimeout(timer)
4543
state = (url.searchParams.get('state') ?? 'idle') as OnboardingState
46-
outcome = url.searchParams.get('outcome') === 'error' ? 'error' : 'installed'
44+
const requested = url.searchParams.get('outcome')
45+
outcome = requested === 'error' || requested === 'refused' ? requested : 'installed'
4746
return send({ state, outcome })
4847
}
4948
if (!url.pathname.startsWith('/src/client/__onboard/'))
5049
return next()
5150
const route = url.pathname.split('/').pop()
52-
if (route !== 'status' && req.headers[CODE_HEADER] !== MOCK_CODE) {
53-
res.statusCode = 401
54-
return res.end()
55-
}
5651
if (route === 'install') {
57-
state = 'installing'
58-
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
5963
}
60-
else if (route === 'disable') {
64+
if (route === 'disable')
6165
state = 'disabled'
62-
}
6366
send(route === 'status' ? status() : { ok: true })
6467
})
6568
},

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

Lines changed: 18 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { OnboardingBranding, OnboardingStatus } from '../types'
2-
import { CODE_HEADER } from '../protocol'
32
import css from './.generated/css'
43
import { DEVFRAME_LOGO } from './logo'
54

@@ -190,32 +189,9 @@ function mount(initial: OnboardingStatus): void {
190189
commandRow.className = 'devframes-onboard-command-row'
191190
commandRow.append(command, copy)
192191

193-
// Shown when the server asks for the code it printed in the terminal.
194-
// A form, so Enter repeats the action that asked for it.
195-
let pending: 'install' | 'disable' = 'install'
196-
const codeForm = document.createElement('form')
197-
codeForm.hidden = true
198-
const codeField = document.createElement('label')
199-
codeField.className = 'flex flex-col gap-1 text-xs color-muted'
200-
const codeInput = document.createElement('input')
201-
codeInput.className = 'devframes-onboard-code'
202-
codeInput.inputMode = 'numeric'
203-
codeInput.autocomplete = 'one-time-code'
204-
codeInput.maxLength = 6
205-
codeField.append(messages.code, codeInput)
206-
codeForm.append(codeField)
207-
codeForm.addEventListener('submit', (event) => {
208-
event.preventDefault()
209-
void (pending === 'install' ? startInstall() : disableDevtools())
210-
})
211-
212192
const note = document.createElement('p')
213-
note.className = 'm-0 text-xs text-red-600 dark:text-red-400'
193+
note.className = 'm-0 text-xs'
214194
note.hidden = true
215-
const showNote = (text: string) => {
216-
note.hidden = !text
217-
note.textContent = text
218-
}
219195

220196
const install = button('btn-primary text-sm justify-center gap-1', messages.install, () => void startInstall())
221197
// The spinner takes its space from the line height, so the button keeps its size.
@@ -237,7 +213,7 @@ function mount(initial: OnboardingStatus): void {
237213
actions.className = 'flex flex-col gap-2'
238214
actions.append(secondary, install)
239215

240-
panel.append(heading, description, commandRow, codeForm, note, actions)
216+
panel.append(heading, description, commandRow, note, actions)
241217
scheme.append(glow, pill, panel)
242218

243219
let open = false
@@ -270,7 +246,11 @@ function mount(initial: OnboardingStatus): void {
270246
: state === 'installed'
271247
? messages.restart
272248
: state === 'error' ? messages.retry : messages.install
273-
showNote(state === 'error' ? `${status.error?.code}: ${status.error?.message}` : '')
249+
note.hidden = state !== 'error'
250+
note.className = 'm-0 text-xs text-red-600 dark:text-red-400'
251+
note.textContent = state === 'error'
252+
? `${status.error?.code}: ${status.error?.message}`
253+
: ''
274254
}
275255

276256
async function poll(): Promise<void> {
@@ -285,38 +265,24 @@ function mount(initial: OnboardingStatus): void {
285265
setTimeout(() => void poll(), POLL_MS)
286266
}
287267

288-
/** POST `action` with the entered code; `true` when the server accepted it. */
289-
async function send(action: typeof pending): Promise<boolean> {
290-
pending = action
291-
// Kept after success, so Retry does not ask again.
292-
const code = codeInput.value.trim()
293-
const response = await api(action, { method: 'POST', headers: code ? { [CODE_HEADER]: code } : {} })
294-
if (response.status === 401) {
295-
showNote(code ? messages.wrongCode : '')
296-
codeInput.value = ''
297-
codeForm.hidden = false
298-
codeInput.focus()
299-
return false
300-
}
301-
if (!response.ok) {
302-
// A `DF90xx` body: too many wrong codes, or the state file failed.
303-
const error: OnboardingStatus['error'] = await response.json().catch(() => undefined)
304-
showNote(error ? `${error.code}: ${error.message}` : '')
305-
return false
306-
}
307-
codeForm.hidden = true
308-
return true
309-
}
310-
311268
async function startInstall(): Promise<void> {
312-
if (!await send('install'))
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
313277
return
278+
}
314279
render({ ...initial, state: 'installing' })
315280
await poll()
316281
}
317282

318283
async function disableDevtools(): Promise<void> {
319-
if (await send('disable'))
284+
const response = await api('disable', { method: 'POST' })
285+
if (response.ok)
320286
host.remove()
321287
}
322288

‎packages/hub-ui-onboard/src/client/style.css‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,6 @@
5757
--uno: transition-opacity duration-200;
5858
}
5959

60-
.devframes-onboard-code {
61-
--uno: w-full min-w-0 rounded border border-base bg-base color-base px-2.5 py-1 font-mono text-sm tabular-nums tracking-widest;
62-
--uno: outline-none transition focus-visible:border-active focus-visible:ring-2 ring-primary-400;
63-
}
64-
6560
.devframes-onboard-spinner[hidden] {
6661
--uno: hidden;
6762
}

0 commit comments

Comments
 (0)