Skip to content

Commit 1d6a0f6

Browse files
antfubotopencode
andcommitted
feat(assets): port Nuxt DevTools' Assets tab as a framework-neutral plugin
Adds @devframes/plugin-assets, a Preact SPA that browses, previews, uploads, renames, and deletes the files in a managed directory (default <cwd>/public). Full RPC surface (list/capabilities/read-text/ read-image-meta/upload/rename/delete/mkdir/write-text/open-in-editor/ reveal-in-folder), a live chokidar-backed change broadcast, real byte serving via ctx.views.hostStatic(), and binary uploads over devframe's streaming-channel primitive. Preact ports the same @antfu/design class vocabulary every other plugin uses. Core changes in support of this: - DevframeDefinition.capabilities.build now actually does something: createCac skips the build subcommand when it's false, and createBuild throws DF0042 for the same case unless {force: true} is passed. The assets plugin uses this by default since a static export can never carry real bytes or write actions. - Wired into alias.ts/tsconfig.base.json/turbo.json/vitest.config.ts and dogfooded in examples/minimal-vite-devframe-hub. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 2565679 commit 1d6a0f6

104 files changed

Lines changed: 5343 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎alias.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ export const alias = {
105105
'@devframes/plugin-messages/cli': p('messages/src/cli.ts'),
106106
'@devframes/plugin-messages/vite': p('messages/src/vite.ts'),
107107
'@devframes/plugin-messages': p('messages/src/index.ts'),
108+
'@devframes/plugin-assets/client': p('assets/src/client/index.ts'),
109+
'@devframes/plugin-assets/node': p('assets/src/node/index.ts'),
110+
'@devframes/plugin-assets/rpc': p('assets/src/rpc/index.ts'),
111+
'@devframes/plugin-assets/cli': p('assets/src/cli.ts'),
112+
'@devframes/plugin-assets/vite': p('assets/src/vite.ts'),
113+
'@devframes/plugin-assets': p('assets/src/index.ts'),
108114
}
109115

110116
// update tsconfig.base.json — CSS aliases exist for Vite resolution only;

‎docs/.vitepress/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function pluginsItems(prefix: string) {
7070
{ text: 'Git', link: `${prefix}/plugins/git` },
7171
{ text: 'Terminals', link: `${prefix}/plugins/terminals` },
7272
{ text: 'Code Server', link: `${prefix}/plugins/code-server` },
73+
{ text: 'Assets', link: `${prefix}/plugins/assets` },
7374
] satisfies DefaultTheme.NavItemWithLink[]
7475
}
7576

‎docs/errors/DF0042.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0042: Static Build Disabled By The Definition
6+
7+
## Message
8+
9+
> "`{id}`" declares `capabilities.build: false` — its static export is not meaningful (writes are excluded and any live-served data won't be there).
10+
11+
## Cause
12+
13+
`createBuild` runs unconditionally when called directly, but a definition can opt out of static export via `capabilities.build: false` — typically because the devframe is inherently live (it manages real files on disk, spawns a process, etc.) and a `mode: 'build'` export would only ever produce a broken, write-less shell of the tool. `createCac` already skips registering the `build` subcommand for such a definition; this diagnostic covers the remaining path — a caller invoking `createBuild()` directly, bypassing the CLI.
14+
15+
## Example
16+
17+
```ts
18+
// ✗ Bad — builds a devframe that opted out of static export
19+
await createBuild(assetsDevframe) // throws DF0042
20+
21+
// ✓ Good — the degraded export is still useful to you
22+
await createBuild(assetsDevframe, { force: true })
23+
```
24+
25+
## Fix
26+
27+
- Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you.
28+
- Otherwise, drop `capabilities.build: false` on the definition if a static export should be supported after all.
29+
30+
## Source
31+
32+
- [`packages/devframe/src/adapters/build.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/build.ts) — `createBuild()` throws this when `capabilities.build` is `false` and `force` isn't set.

‎docs/plugins/assets.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# Assets
6+
7+
Browse, preview, upload, rename, and delete the files in a directory, built as a **Preact** SPA — a framework-neutral port of Nuxt DevTools' Assets tab.
8+
9+
Package: `@devframes/plugin-assets` · framework: **Preact**
10+
11+
## What it does
12+
13+
Search and filter by extension, switch between a thumbnail grid (grouped by folder) and a file tree, and open a details panel with a live preview (image, video, audio, font, or text), file metadata, and ready-to-copy usage snippets (`<img>`, CSS `background-image`, `@font-face`, a download link). Drag-and-drop files to upload them, or select multiple assets to delete them together. A live file watcher keeps every connected client's listing in sync with changes made outside the UI.
14+
15+
The standalone server requires devframe's trust handshake by default because it can read, write, and delete real files. Uploads, renames, deletes, and folder creation are enabled by default — pass `{ write: false }` (or `--read-only` on the standalone CLI) for a browse-only deployment.
16+
17+
## Standalone
18+
19+
```sh
20+
pnpx @devframes/plugin-assets # manages <cwd>/public
21+
pnpx @devframes/plugin-assets --read-only # disable upload / rename / delete / mkdir
22+
```
23+
24+
## Mount into a Vite host
25+
26+
```ts
27+
// vite.config.ts
28+
import { assetsVitePlugin } from '@devframes/plugin-assets/vite'
29+
import { defineConfig } from 'vite'
30+
31+
export default defineConfig({
32+
plugins: [
33+
assetsVitePlugin(),
34+
],
35+
})
36+
```
37+
38+
## Programmatic
39+
40+
`createAssetsDevframe(options)` returns a definition you can deploy through any adapter:
41+
42+
```ts
43+
import { createAssetsDevframe } from '@devframes/plugin-assets'
44+
45+
export default createAssetsDevframe({
46+
dir: 'static', // defaults to `<cwd>/public`
47+
write: true,
48+
uploadExtensions: ['png', 'jpg', 'svg', 'webp'], // defaults to Nuxt DevTools' own allow-list, or '*' for any
49+
})
50+
```
51+
52+
| Option | Default | Description |
53+
|--------|---------|-------------|
54+
| `dir` | `<cwd>/public` | Directory this devframe manages. |
55+
| `write` | `true` | Enable upload, rename, delete, and folder creation from the UI. |
56+
| `uploadExtensions` | Nuxt DevTools' allow-list | Extensions `upload` accepts, or `'*'` for any. |
57+
| `build` | `false` | Register the `build` CLI subcommand. See [why it's off by default](#static-export) below. |
58+
59+
## RPC surface
60+
61+
All functions are namespaced `devframes:plugin:assets:*`:
62+
63+
| Function | Type | Notes |
64+
|----------|------|-------|
65+
| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. |
66+
| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. |
67+
| `read-image-meta` | `query` | Width, height, and orientation for an image asset. |
68+
| `read-text` | `query` | Truncated text content, for preview or editing. |
69+
| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
70+
| `rename` | `action` | Renames an asset within its folder, preserving its extension. |
71+
| `delete` | `action` | Deletes one or more assets in a single call. |
72+
| `mkdir` | `action` | Creates a folder, including missing parents. |
73+
| `write-text` | `action` | Overwrites a text asset's content in place (the details panel's inline editor). |
74+
| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. |
75+
76+
`upload` / `rename` / `delete` / `mkdir` / `write-text` are registered only when `write` is enabled.
77+
78+
## Static export
79+
80+
Every devframe's `build` CLI subcommand is disabled here by default (`capabilities: { build: false }`). Real byte serving for previews goes through `ctx.views.hostStatic()`, which only mounts real files under a live adapter (`cli` / `vite` / `embedded`) — a static export can never copy those bytes, and every write action is inherently excluded from a static dump. Rather than ship a broken, preview-less, write-less shell of the tool, the `build` command is simply not registered. Pass `{ build: true }` to `createAssetsDevframe()` (and `{ force: true }` if calling `createBuild()` directly) if that degraded export is still useful to you — the file listing itself still bakes into the static RPC dump.
81+
82+
## Source
83+
84+
[`plugins/assets`](https://github.com/devframes/devframe/tree/main/plugins/assets)

‎docs/plugins/index.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ Each plugin is built with a **different UI framework**. That is deliberate: devf
1717
| [Git](./git) | React (Next.js) | A repository dashboard — status, a commit graph, branches, and diffs, with optional staging and committing. |
1818
| [Terminals](./terminals) | Svelte | Stream read-only command output and run fully interactive PTY shells in the browser. |
1919
| [Code Server](./code-server) | Vue | Launch VS Code in the browser (code-server, `code serve-web`, or a tunnel) on demand and embed it in an auto-authenticated iframe. |
20+
| [Assets](./assets) | Preact | Browse, preview, upload, rename, and delete the files in a managed directory. |
2021

2122
## One client, any framework
2223

23-
The collection spans Vue, Solid, React, and Svelte, yet every plugin shares the same node-side surface: register RPC functions, publish shared state, and connect from the browser with `connectDevframe`. Whatever renders the UI, it talks to the backend through the same protocol — the framework-free Vite hub example drives it with a handful of DOM calls.
24+
The collection spans Vue, Solid, React, Svelte, and Preact, yet every plugin shares the same node-side surface: register RPC functions, publish shared state, and connect from the browser with `connectDevframe`. Whatever renders the UI, it talks to the backend through the same protocol — the framework-free Vite hub example drives it with a handful of DOM calls.
2425

2526
This is the framework-agnostic promise in practice. The browser bundle is the author's to choose; devframe handles the transport, the data model, the adapters, and the agent surface underneath.
2627

@@ -32,6 +33,7 @@ Most plugins publish a `bin`, so the quickest path is `pnpx`:
3233
pnpx @devframes/plugin-inspect # the Devframe Inspector, standalone
3334
pnpx @devframes/plugin-og # inspect Open Graph metadata and social cards
3435
pnpx @devframes/plugin-git # the Git dashboard against the current repo
36+
pnpx @devframes/plugin-assets # manage the files under <cwd>/public
3537
```
3638

3739
Each also exports a `create…Devframe` factory (or, for the Accessibility Inspector, a ready-made definition) you can drive through any adapter — see the individual pages for the factory name, options, and host-mount snippets.

‎examples/minimal-vite-devframe-hub/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@devframes/json-render": "workspace:*",
1717
"@devframes/json-render-ui": "workspace:*",
1818
"@devframes/plugin-a11y": "workspace:*",
19+
"@devframes/plugin-assets": "workspace:*",
1920
"@devframes/plugin-code-server": "workspace:*",
2021
"@devframes/plugin-data-inspector": "workspace:*",
2122
"@devframes/plugin-git": "workspace:*",

‎examples/minimal-vite-devframe-hub/vite.config.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mountDevframe } from '@devframes/hub/node'
22
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
33
import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
4+
import assetsDevframe from '@devframes/plugin-assets'
45
import codeServerDevframe from '@devframes/plugin-code-server'
56
import dataInspectorDevframe from '@devframes/plugin-data-inspector'
67
import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
@@ -72,6 +73,7 @@ export default defineConfig({
7273
a11yDevframe,
7374
messagesDevframe,
7475
ogDevframe,
76+
assetsDevframe,
7577
],
7678
// Attach the a11y inspector's in-page agent as its dock's client script.
7779
// The hub client runtime (booted in src/client/main.ts) imports it into
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { describe, expect, it } from 'vitest'
5+
import { defineDevframe } from '../../types/devframe'
6+
import { createBuild } from '../build'
7+
8+
function makeTmpDist(): string {
9+
const dir = mkdtempSync(join(tmpdir(), 'devframe-build-test-'))
10+
writeFileSync(join(dir, 'index.html'), '<!doctype html><title>test</title>', 'utf-8')
11+
return dir
12+
}
13+
14+
function baseDevframe(overrides: Partial<ReturnType<typeof defineDevframe>> = {}) {
15+
const distDir = makeTmpDist()
16+
return defineDevframe({
17+
id: 'devframe-build-test',
18+
name: 'Devframe Build Test',
19+
version: '0.0.0',
20+
packageName: 'devframe-build-test',
21+
homepage: 'https://example.test',
22+
description: 'Test devframe.',
23+
cli: { distDir },
24+
setup: () => {},
25+
...overrides,
26+
})
27+
}
28+
29+
describe('adapters/build', () => {
30+
it('rejects a definition with capabilities.build: false by default', async () => {
31+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
32+
try {
33+
await expect(
34+
createBuild(baseDevframe({ capabilities: { build: false } }), { outDir }),
35+
).rejects.toThrow(/capabilities\.build: false/)
36+
}
37+
finally {
38+
rmSync(outDir, { recursive: true, force: true })
39+
}
40+
})
41+
42+
it('proceeds past capabilities.build: false when force is set', async () => {
43+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
44+
try {
45+
await expect(
46+
createBuild(baseDevframe({ capabilities: { build: false } }), { outDir, force: true }),
47+
).resolves.toBeUndefined()
48+
}
49+
finally {
50+
rmSync(outDir, { recursive: true, force: true })
51+
}
52+
})
53+
54+
it('proceeds normally when capabilities.build is unset', async () => {
55+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
56+
try {
57+
await expect(createBuild(baseDevframe(), { outDir })).resolves.toBeUndefined()
58+
}
59+
finally {
60+
rmSync(outDir, { recursive: true, force: true })
61+
}
62+
})
63+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { defineDevframe } from '../../types/devframe'
3+
import { createCac } from '../cac'
4+
5+
function baseDevframe(overrides: Partial<ReturnType<typeof defineDevframe>> = {}) {
6+
return defineDevframe({
7+
id: 'devframe-test',
8+
name: 'Devframe Test',
9+
version: '0.0.0',
10+
packageName: 'devframe-test',
11+
homepage: 'https://example.test',
12+
description: 'Test devframe.',
13+
setup: () => {},
14+
...overrides,
15+
})
16+
}
17+
18+
describe('adapters/cac', () => {
19+
it('registers the build subcommand by default', () => {
20+
const { cli } = createCac(baseDevframe())
21+
expect(cli.commands.map(c => c.name)).toContain('build')
22+
})
23+
24+
it('skips the build subcommand when capabilities.build is false', () => {
25+
const { cli } = createCac(baseDevframe({ capabilities: { build: false } }))
26+
expect(cli.commands.map(c => c.name)).not.toContain('build')
27+
})
28+
29+
it('still registers build when capabilities.build is true or a record', () => {
30+
const truthy = createCac(baseDevframe({ capabilities: { build: true } }))
31+
expect(truthy.cli.commands.map(c => c.name)).toContain('build')
32+
33+
const record = createCac(baseDevframe({ capabilities: { build: { dump: true } } }))
34+
expect(record.cli.commands.map(c => c.name)).toContain('build')
35+
})
36+
37+
it('always registers the dev and mcp commands regardless of capabilities.build', () => {
38+
const { cli } = createCac(baseDevframe({ capabilities: { build: false } }))
39+
const names = cli.commands.map(c => c.name)
40+
expect(names).toContain('mcp')
41+
// The dev command is registered as the catch-all `[...args]` command,
42+
// which cac surfaces with an empty `name`.
43+
expect(cli.commands.some(c => c.rawName === '[...args]')).toBe(true)
44+
})
45+
})

‎packages/devframe/src/adapters/build.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
DEVFRAME_RPC_DUMP_MANIFEST_FILENAME,
1313
} from '../constants'
1414
import { createHostContext } from '../node/context'
15+
import { diagnostics } from '../node/diagnostics'
1516
import { createH3DevframeHost } from '../node/host-h3'
1617
import { collectStaticRpcDump } from '../rpc/dump/static'
1718
import { strictJsonStringify } from '../rpc/serialization'
@@ -34,6 +35,13 @@ export interface CreateBuildOptions {
3435
* minified. Set `true` when you need to diff / read the dumps by hand.
3536
*/
3637
pretty?: boolean
38+
/**
39+
* Proceed even when the definition declares `capabilities.build: false`.
40+
* `createCac` already skips registering the `build` subcommand for such
41+
* a definition — this only matters for a caller invoking `createBuild`
42+
* directly, bypassing the CLI.
43+
*/
44+
force?: boolean
3745
}
3846

3947
/**
@@ -50,6 +58,9 @@ export interface CreateBuildOptions {
5058
* works at `/`, `/devframe/`, or any base, no rewriting required.
5159
*/
5260
export async function createBuild(d: DevframeDefinition, options: CreateBuildOptions = {}): Promise<void> {
61+
if (d.capabilities?.build === false && !options.force)
62+
throw diagnostics.DF0042({ id: d.id })
63+
5364
const outDir = resolve(options.outDir ?? 'dist-static')
5465
const distDir = options.distDir ?? d.cli?.distDir
5566
if (!distDir)

0 commit comments

Comments
 (0)