Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions docs/content/1.guide/23.hub-ui-onboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A hub UI provider, its Vue runtime, and the devframes it mounts add tens of mega

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:

- **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.
- **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.
- **Hide for now** removes the button for the current browser tab.
- **Disable entirely** writes a state file so the host stops injecting the button on every later start.

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

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

## Confirm the install

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.
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.

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`:

```ts
import { consola } from 'consola'

const onboarding = createOnboarding({
packages: ['@nuxt/devtools'],
onConfirm: ({ command }) => consola.prompt(`Run \`${command.join(' ')}\` for the browser that asked?`, { type: 'confirm' }),
})
```

Disable entirely does not ask. It changes only the state file in `node_modules/.devframe`.

## Hand the base to the hub

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

## Errors

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).
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).
22 changes: 22 additions & 0 deletions docs/content/6.errors/DF9005.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
title: 'DF9005: Confirm the Install'
description: 'A browser asked to run `{command}`. Type y and press Enter to allow it.'
---

## Message

> A browser asked to run `{command}`. Type y and press Enter to allow it.

## Cause

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.

## 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`.

To ask in another way, for example with your own logger, pass an `onConfirm` function to `createOnboarding()`.

## 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()` prints this, then reads one line from stdin.
20 changes: 20 additions & 0 deletions docs/content/6.errors/DF9006.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
title: 'DF9006: No Terminal to Confirm the Install'
description: 'A browser asked to run `{command}`, but no interactive terminal can confirm it. The install was refused.'
---

## Message

> A browser asked to run `{command}`, but no interactive terminal can confirm it. 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 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.

## Fix

Run the command from the panel in a terminal yourself. Or pass an `onConfirm` function to `createOnboarding()` that asks in another way.

## 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 not a TTY.
5 changes: 3 additions & 2 deletions docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,15 @@ What `createOnboarding()` from `@devframes/hub-ui-onboard` accepts and serves: [
| `stateDir` | `<cwd>/node_modules/.devframe` | Directory of the `hub-ui-onboard.json` state file |
| `dev` | `true` | Install as a devDependency |
| `branding` | `{}` | `productName` (default `Devframes`), `logo` (URL or `{ light, dark }`), `primaryColor` |
| `messages` | derived from `productName` | Overrides for `title`, `description`, `install`, `hide`, `disable`, `installing`, `restart`, `retry` |
| `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 |

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

| `state` | Meaning |
Expand Down
2 changes: 1 addition & 1 deletion examples/hub-onboard-vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pnpm --filter hub-onboard-vite dev

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

- 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.
- 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.
- Hide for now hides the button for this browser tab.
- Disable entirely writes `node_modules/.devframe/hub-ui-onboard.json`. The host reads `onboarding.disabled` on the next start and injects no script.

Expand Down
1 change: 1 addition & 0 deletions packages/hub-ui-onboard/playground/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ <h1>hub-ui-onboard</h1>
<button data-state="installed">installed</button>
<button data-state="error">error</button>
<button data-state="idle" data-outcome="error">idle (install fails)</button>
<button data-state="idle" data-outcome="refused">idle (install refused)</button>
</fieldset>
<fieldset>
<legend>Theme</legend>
Expand Down
24 changes: 17 additions & 7 deletions packages/hub-ui-onboard/playground/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ const productName = 'Devframes'
/**
* A fake of the `__onboard/*` routes `createOnboarding` serves. The client
* resolves them next to its own module (`/src/client/`). The playground page
* picks the state through `/__mock/state`; Install runs a timer instead of a
* picks the state through `/__mock/state`; Install waits as if the developer
* answered in the terminal, then is refused, or runs a timer instead of a
* package manager and ends in `installed` or `error`.
*/
function mockOnboarding(): Plugin {
let state: OnboardingState = 'idle'
let outcome: 'installed' | 'error' = 'installed'
let outcome: 'installed' | 'error' | 'refused' = 'installed'
let timer: ReturnType<typeof setTimeout> | undefined

const status = (): OnboardingStatus => ({
Expand All @@ -40,19 +41,28 @@ function mockOnboarding(): Plugin {
if (url.pathname === '/__mock/state') {
clearTimeout(timer)
state = (url.searchParams.get('state') ?? 'idle') as OnboardingState
outcome = url.searchParams.get('outcome') === 'error' ? 'error' : 'installed'
const requested = url.searchParams.get('outcome')
outcome = requested === 'error' || requested === 'refused' ? requested : 'installed'
return send({ state, outcome })
}
if (!url.pathname.startsWith('/src/client/__onboard/'))
return next()
const route = url.pathname.split('/').pop()
if (route === 'install') {
state = 'installing'
timer = setTimeout(() => (state = outcome), 2500)
setTimeout(() => {
if (outcome === 'refused') {
res.statusCode = 403
return res.end()
}
const end = outcome
state = 'installing'
timer = setTimeout(() => (state = end), 2500)
send({ ok: true })
}, 1500)
return
}
else if (route === 'disable') {
if (route === 'disable')
state = 'disabled'
}
send(route === 'status' ? status() : { ok: true })
})
},
Expand Down
11 changes: 10 additions & 1 deletion packages/hub-ui-onboard/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,17 @@ function mount(initial: OnboardingStatus): void {
}

async function startInstall(): Promise<void> {
// The request stays open until the developer answers in the terminal.
install.disabled = true
installLabel.textContent = messages.confirming
const response = await api('install', { method: 'POST' })
if (!response.ok) {
await poll()
note.hidden = false
note.textContent = messages.refused
return
}
render({ ...initial, state: 'installing' })
await api('install', { method: 'POST' })
await poll()
}

Expand Down
8 changes: 8 additions & 0 deletions packages/hub-ui-onboard/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,13 @@ export const diagnostics = defineDiagnostics({
why: (p: { file: string }) => `The onboarding state file ${p.file} could not be read or written.`,
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.`,
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()`.',
},
},
})
38 changes: 31 additions & 7 deletions packages/hub-ui-onboard/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'
import { Diagnostic } from 'nostics'
import { diagnostics } from './diagnostics'
import { packagesInstalled, resolveInstallCommand, runInstall } from './install'
import { confirmInTerminal } from './terminal-confirm'

export type * from './types'

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

const commandLine = async () => {
const resolved = await getCommand()
return [resolved.command, ...resolved.args]
}

// The dev server can be reachable from other devices, and a non-browser
// request can send any `Origin`. So the developer allows each install.
const onConfirm = options.onConfirm ?? (({ command }) => confirmInTerminal(command))
let asking: Promise<boolean> | undefined

async function startInstall(request: Request): Promise<Response> {
if (state !== 'idle' && state !== 'error')
return new Response(null, { status: 202 })
asking ??= commandLine()
.then(command => onConfirm({ packages: plan.packages, command, request }))
.finally(() => {
asking = undefined
})
if (!await asking)
return new Response(null, { status: 403 })
// Clicks that shared the answer start one install.
if (state === 'idle' || state === 'error')
void install()
return new Response(null, { status: 202 })
}

async function status(): Promise<OnboardingStatus> {
const command = await getCommand()
return { state, command: [command.command, ...command.args], branding, messages, error }
return { state, command: await commandLine(), branding, messages, error }
}

const handler: OnboardingHandler = async (request) => {
Expand All @@ -210,11 +238,7 @@ export function createOnboarding(options: CreateOnboardingOptions): Onboarding {
if (route === 'POST __onboard/install' || route === 'POST __onboard/disable') {
if (!isSameOrigin(request))
return new Response(null, { status: 403 })
if (route.endsWith('disable'))
return disable()
if (state === 'idle' || state === 'error')
void install()
return new Response(null, { status: 202 })
return route.endsWith('disable') ? disable() : startInstall(request)
}
return new Response(null, { status: 404 })
}
Expand Down
26 changes: 26 additions & 0 deletions packages/hub-ui-onboard/src/terminal-confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Buffer } from 'node:buffer'
import type { Readable } from 'node:stream'
import process from 'node:process'
import { diagnostics } from './diagnostics'

/**
* 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.
*/
export function confirmInTerminal(command: string[], input: Readable & { isTTY?: boolean } = process.stdin): Promise<boolean> {
if (!input.isTTY) {
diagnostics.DF9006({ command: command.join(' ') })
return Promise.resolve(false)
}
diagnostics.DF9005({ command: command.join(' ') })
// 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) => {
if (!flowing)
input.pause()
resolve(/^y(?:es)?$/i.test(String(chunk).trim()))
})
})
}
22 changes: 22 additions & 0 deletions packages/hub-ui-onboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface OnboardingMessages {
/** Short sentence under the heading. */
description: string
install: string
/** Install button label while `onConfirm` waits for the developer. */
confirming: string
/** Shown when `onConfirm` refused the install. */
refused: string
/** Hides the button for this browser tab (`sessionStorage`). */
hide: string
/** Writes the state file; the host stops injecting the button. */
Expand All @@ -30,6 +34,16 @@ export interface OnboardingMessages {
/** A web-standard request handler the hub exposes once installed. */
export type OnboardingHandler = (request: Request) => Response | Promise<Response>

/** What `onConfirm` receives when the panel asks to install. */
export interface OnboardingConfirmation {
/** The packages Install adds, as given in `packages`. */
packages: string[]
/** The exact command Install runs. */
command: string[]
/** The panel's request, for example to read its `Origin` or `User-Agent`. */
request: Request
}

export interface CreateOnboardingOptions {
/** Packages to install when the user clicks Install. Package specs as the package manager accepts them. */
packages: string[]
Expand All @@ -50,6 +64,14 @@ export interface CreateOnboardingOptions {
* user to restart the dev server.
*/
onInstalled?: () => void | OnboardingHandler | Promise<void | OnboardingHandler>
/**
* 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.
*/
onConfirm?: (confirmation: OnboardingConfirmation) => boolean | Promise<boolean>
}

export type OnboardingState = 'idle' | 'installing' | 'installed' | 'ready' | 'error' | 'disabled'
Expand Down
Loading
Loading