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
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const alias = {
'devframe/utils/state': df('devframe/src/utils/state.ts'),
'devframe/utils/when': df('devframe/src/utils/when.ts'),
'devframe/adapters/cli': df('devframe/src/adapters/cli.ts'),
'devframe/adapters/dev': df('devframe/src/adapters/dev.ts'),
'devframe/adapters/build': df('devframe/src/adapters/build.ts'),
'devframe/adapters/vite': df('devframe/src/adapters/vite.ts'),
'devframe/adapters/kit': df('devframe/src/adapters/kit.ts'),
Expand Down
60 changes: 60 additions & 0 deletions devframe/docs/guide/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All adapter factories share the same shape: `createXxx(devtoolDef, options?)`.
| Adapter | Entry | Factory | Best for |
|---------|-------|---------|----------|
| [`cli`](#cli) | `devframe/adapters/cli` | `createCli(def, options?)` | Standalone tools run via `node ./my-tool.js` |
| [`dev`](#dev) | `devframe/adapters/dev` | `createDevServer(def, options?)` | Run the dev server programmatically — drive it from any CLI framework |
| [`vite`](#vite) | `devframe/adapters/vite` | `createVitePlugin(def, options?)` | Mount a tool's UI inside an existing Vite dev server |
| [`build`](#build) | `devframe/adapters/build` | `createBuild(def, options?)` | Offline reports, CI artifacts, deployable SPA snapshots |
| [`kit`](#kit) | `devframe/adapters/kit` | `createKitPlugin(def, options?)` | Integrating into Vite DevTools Kit |
Expand Down Expand Up @@ -113,6 +114,65 @@ await createCli(devtool, {

Structured diagnostics (via `logs-sdk`) continue to surface through their normal reporters.

### Use your own CLI framework

When `createCli`'s baked-in `dev` / `build` / `mcp` triplet doesn't fit — e.g. integrating devframe into an existing commander/yargs program, or exposing a different command structure — drop down to the peer factories. Same `DevtoolDefinition`, different shell:

| Building block | Entry | Purpose |
|----------------|-------|---------|
| [`createDevServer(def, opts?)`](#dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount |
| [`createBuild(def, opts?)`](#build) | `devframe/adapters/build` | Static deploy |
| [`createMcpServer(def, opts?)`](#mcp) | `devframe/adapters/mcp` | stdio MCP server |
| `parseCliFlags(schema, raw)` | `devframe/adapters/cli` | Validate a flag bag against a `CliFlagsSchema` |

See the [Standalone CLI guide](./standalone-cli#use-your-own-cli-framework) for a worked commander example.

## Dev

The `dev` adapter is the building block `createCli` uses internally — h3 + WebSocket RPC + the author's SPA mounted at the resolved base path. Reach for it directly when you want to mount the dev server inside an existing CLI program (commander, yargs, hand-rolled CAC) or attach custom middleware to the underlying h3 app.

```ts
import { createDevServer } from 'devframe/adapters/dev'
import devtool from './devtool'

const handle = await createDevServer(devtool, {
port: 7777,
onReady: ({ origin }) => console.log(`Ready at ${origin}`),
})

// graceful shutdown — SIGINT, hot reload, test teardown
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
```

`createDevServer` returns the underlying `StartedServer` (origin, port, h3 app, WS server, RPC group, `close()`), so callers integrate cleanly into their own process lifecycle.

| Option | Default | Description |
|--------|---------|-------------|
| `host` | `def.cli?.host ?? 'localhost'` | Bind host. |
| `port` | resolved via `resolveDevServerPort` | Port to listen on. |
| `flags` | `{}` | Parsed flag bag forwarded to `setup(ctx, { flags })`. |
| `distDir` | `def.cli?.distDir` | Required — throws when neither is set. |
| `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. |
| `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). |
| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. |
| `onReady` | — | Callback when the WS server is bound. |

### Port resolution

`resolveDevServerPort(def, opts?)` is exposed separately so authors can resolve a port up-front (to print it, log it, etc.) before starting the server:

```ts
import { resolveDevServerPort } from 'devframe/adapters/dev'

const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' })
// honors def.cli?.port / portRange / random
```

| Option | Default | Description |
|--------|---------|-------------|
| `host` | `def.cli?.host ?? 'localhost'` | Bind host (passed to `get-port-please` for in-use detection). |
| `defaultPort` | `def.cli?.port ?? 9999` | Override the preferred port. |

## Mount paths

The basePath where a devtool's SPA is mounted depends on the adapter it's running under:
Expand Down
59 changes: 57 additions & 2 deletions devframe/docs/guide/standalone-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ const payload = await rpc.call('my-tool:get-payload')
For flags that are specific to your tool, declare them as valibot schemas so they're validated at parse time and typed at the call site:

```ts
import type { InferCliFlags } from 'devframe'
import { defineCliFlags, defineDevtool } from 'devframe'
import type { InferCliFlags } from 'devframe/adapters/cli'
import { defineDevtool } from 'devframe'
import { defineCliFlags } from 'devframe/adapters/cli'
import * as v from 'valibot'

const appFlags = defineCliFlags({
Expand Down Expand Up @@ -255,6 +256,59 @@ const state = await rpc.sharedState.get('my-tool:version')
state.on('updated', () => fetchPayload().then(setData))
```

## Use your own CLI framework

`createCli` is a convenience wrapper around three lower-level factories — reach for them directly when you already own a CLI framework (commander, yargs, oclif, hand-rolled cac) or want a different command structure:

| Building block | Entry |
|----------------|-------|
| `createDevServer(def, opts?)` | `devframe/adapters/dev` |
| `createBuild(def, opts?)` | `devframe/adapters/build` |
| `createMcpServer(def, opts?)` | `devframe/adapters/mcp` |

Each one runs against the same `DevtoolDefinition` you'd pass to `createCli`. A commander example:

```ts [src/cli.ts]
import process from 'node:process'
import { Command } from 'commander'
import { defineDevtool } from 'devframe'
import { createBuild } from 'devframe/adapters/build'
import { createDevServer } from 'devframe/adapters/dev'

const devtool = defineDevtool({
id: 'my-tool',
name: 'My Tool',
cli: { distDir: './dist/public', port: 7777 },
setup(ctx, { flags }) { /* ... */ },
})

const program = new Command('my-tool')

program
.command('dev', { isDefault: true })
.option('-p, --port <port>', 'Port', '7777')
.option('--config <file>', 'Config file path')
.action(async (opts) => {
const handle = await createDevServer(devtool, {
port: Number(opts.port),
flags: { config: opts.config },
onReady: ({ origin }) => console.log(`Ready at ${origin}`),
})
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
})

program
.command('build')
.option('--out-dir <dir>', 'Output directory', 'dist-static')
.action(opts => createBuild(devtool, { outDir: opts.outDir }))

await program.parseAsync()
```

`createDevServer` returns the underlying `StartedServer` handle (`origin`, `port`, `app`, `wss`, `rpcGroup`, `close()`) so the surrounding program can drive graceful shutdown — SIGINT, hot reload, integration tests.

For typed flag schemas, `parseCliFlags(schema, rawBag)` (from `devframe/adapters/cli`) validates a commander/yargs flag bag against a `CliFlagsSchema` (the same `defineCliFlags(...)` value you'd put on `cli.flags`). Typed-schema validation isn't tied to cac.

## Why this shape

- **One command, one binary.** `createCli` is a complete CLI — dev, build, spa, mcp all from a single `defineDevtool` value.
Expand All @@ -267,5 +321,6 @@ state.on('updated', () => fetchPayload().then(setData))

- [Devtool Definition](./devtool-definition) — field reference
- [Adapters → CLI](./adapters#cli) — full CLI adapter reference including `configureCli` and mount-path rules
- [Adapters → Dev](./adapters#dev) — `createDevServer` reference for bring-your-own-CLI integration
- [Client](./client) — `connectDevtool`, shared state, caching
- [Agent-Native](./agent-native) — exposing your tool to Claude Desktop, Cursor, etc.
1 change: 1 addition & 0 deletions devframe/packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
".": "./dist/index.mjs",
"./adapters/build": "./dist/adapters/build.mjs",
"./adapters/cli": "./dist/adapters/cli.mjs",
"./adapters/dev": "./dist/adapters/dev.mjs",
"./adapters/embedded": "./dist/adapters/embedded.mjs",
"./adapters/kit": "./dist/adapters/kit.mjs",
"./adapters/mcp": "./dist/adapters/mcp.mjs",
Expand Down
84 changes: 84 additions & 0 deletions devframe/packages/devframe/src/adapters/__tests__/dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getPort } from 'get-port-please'
import { describe, expect, it } from 'vitest'
import { defineDevtool } from '../../types/devtool'
import { createDevServer, resolveDevServerPort } from '../dev'

function makeTmpDist(): string {
const dir = mkdtempSync(join(tmpdir(), 'devframe-dev-'))
writeFileSync(join(dir, 'index.html'), '<!doctype html><title>test</title>', 'utf-8')
return dir
}

describe('adapters/dev', () => {
it('createDevServer starts, exposes .connection.json, and closes', async () => {
const distDir = makeTmpDist()
const devtool = defineDevtool({
id: 'devframe-test',
name: 'Devframe Test',
setup: () => {},
})

const host = '127.0.0.1'
const port = await getPort({ port: 19999, host })
const handle = await createDevServer(devtool, {
host,
port,
distDir,
openBrowser: false,
})

try {
expect(handle.port).toBe(port)
expect(handle.origin).toBe(`http://${host}:${port}`)

const res = await fetch(`http://${host}:${port}/.connection.json`)
expect(res.ok).toBe(true)
const meta = await res.json()
expect(meta).toEqual({ backend: 'websocket', websocket: port })
}
finally {
await handle.close()
}
})

it('createDevServer throws when no distDir is configured', async () => {
const devtool = defineDevtool({
id: 'devframe-test-nodist',
name: 'No Dist',
setup: () => {},
})
await expect(createDevServer(devtool, { openBrowser: false }))
.rejects
.toThrow(/no distDir/)
})

it('resolveDevServerPort honors def.cli.port as the preferred default', async () => {
const preferred = await getPort({ port: 19500, host: '127.0.0.1' })
const devtool = defineDevtool({
id: 'devframe-test-port',
name: 'Port Test',
setup: () => {},
cli: { port: preferred },
})
const port = await resolveDevServerPort(devtool, { host: '127.0.0.1' })
expect(port).toBe(preferred)
})

it('resolveDevServerPort: defaultPort overrides def.cli.port', async () => {
const override = await getPort({ port: 19600, host: '127.0.0.1' })
const devtool = defineDevtool({
id: 'devframe-test-port-override',
name: 'Port Override',
setup: () => {},
cli: { port: 9999 },
})
const port = await resolveDevServerPort(devtool, {
host: '127.0.0.1',
defaultPort: override,
})
expect(port).toBe(override)
})
})
2 changes: 1 addition & 1 deletion devframe/packages/devframe/src/adapters/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function resolveBasePath(def: DevtoolDefinition, kind: DevtoolDeploymentK
return kind === 'standalone' ? '/' : `/.${def.id}/`
}

function normalizeBasePath(base: string): string {
export function normalizeBasePath(base: string): string {
let out = base.startsWith('/') ? base : `/${base}`
if (!out.endsWith('/'))
out = `${out}/`
Expand Down
Loading
Loading