Skip to content
Open
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
6 changes: 6 additions & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ export const alias = {
'@devframes/plugin-messages/cli': p('messages/src/cli.ts'),
'@devframes/plugin-messages/vite': p('messages/src/vite.ts'),
'@devframes/plugin-messages': p('messages/src/index.ts'),
'@devframes/plugin-assets/client': p('assets/src/client/index.ts'),
'@devframes/plugin-assets/node': p('assets/src/node/index.ts'),
'@devframes/plugin-assets/rpc': p('assets/src/rpc/index.ts'),
'@devframes/plugin-assets/cli': p('assets/src/cli.ts'),
'@devframes/plugin-assets/vite': p('assets/src/vite.ts'),
'@devframes/plugin-assets': p('assets/src/index.ts'),
}

// update tsconfig.base.json — CSS aliases exist for Vite resolution only;
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function pluginsItems(prefix: string) {
{ text: 'Git', link: `${prefix}/plugins/git` },
{ text: 'Terminals', link: `${prefix}/plugins/terminals` },
{ text: 'Code Server', link: `${prefix}/plugins/code-server` },
{ text: 'Assets', link: `${prefix}/plugins/assets` },
] satisfies DefaultTheme.NavItemWithLink[]
}

Expand Down
32 changes: 32 additions & 0 deletions docs/errors/DF0042.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
outline: deep
---

# DF0042: Static Build Disabled By The Definition

## Message

> "`{id}`" declares `capabilities.build: false` — its static export is not meaningful (writes are excluded and any live-served data won't be there).

## Cause

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

## Example

```ts
// ✗ Bad — builds a devframe that opted out of static export
await createBuild(assetsDevframe) // throws DF0042

// ✓ Good — the degraded export is still useful to you
await createBuild(assetsDevframe, { force: true })
```

## Fix

- Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you.
- Otherwise, drop `capabilities.build: false` on the definition if a static export should be supported after all.

## Source

- [`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.
90 changes: 90 additions & 0 deletions docs/plugins/assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
outline: deep
---

# Assets

Browse, preview, upload, rename, and delete the files in a directory, built as a **Vue** SPA on `@antfu/design` — a framework-neutral port of Nuxt DevTools' Assets tab.

Package: `@devframes/plugin-assets` · framework: **Vue + @antfu/design**

## What it does

Search by name and filter by type from an inline chip row, switch between a thumbnail grid (grouped by folder) and a file tree, and open a resizable right-hand 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). Upload files with the toolbar button (native file picker) or by dropping them anywhere on the frame, and 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.

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.

## Standalone

```sh
pnpx @devframes/plugin-assets # manages <cwd>/public
pnpx @devframes/plugin-assets --read-only # disable upload / rename / delete / mkdir
```

## Mount into a Vite host

```ts
// vite.config.ts
import { assetsVitePlugin } from '@devframes/plugin-assets/vite'
import { defineConfig } from 'vite'

export default defineConfig({
plugins: [
assetsVitePlugin(),
],
})
```

## Programmatic

`createAssetsDevframe(options)` returns a definition you can deploy through any adapter:

```ts
import { createAssetsDevframe } from '@devframes/plugin-assets'

export default createAssetsDevframe({
dir: 'static', // defaults to `<cwd>/public`
baseURL: '/', // the URL the host serves `dir` at
write: true,
uploadExtensions: ['png', 'jpg', 'svg', 'webp'], // defaults to Nuxt DevTools' own allow-list, or '*' for any
})
```

| Option | Default | Description |
|--------|---------|-------------|
| `dir` | `<cwd>/public` | Directory this devframe manages. |
| `baseURL` | `/` | URL base the host serves `dir` at — each asset's `publicPath` is `baseURL` + its path. Match a non-root deployment base (e.g. Nuxt's `app.baseURL`). |
| `write` | `true` | Enable upload, rename, delete, and folder creation from the UI. |
| `uploadExtensions` | Nuxt DevTools' allow-list | Extensions `upload` accepts, or `'*'` for any. |
| `serveStatic` | `false` | Serve the directory's bytes from this devframe itself. Left off when mounted into a host that already serves `public/`; the standalone CLI turns it on. |
| `build` | `false` | Register the `build` CLI subcommand. See [why it's off by default](#static-export) below. |

## How previews are served

Asset previews (`<img>`, `<video>`, download links) load the files by their **public URL**, and the host the plugin is mounted into serves those files — Vite, Nuxt, and most frameworks already serve their `public/` folder at `/`. The plugin never stands up its own byte-serving route; it just resolves each asset's `publicPath` as `baseURL` + the file's path. Point `baseURL` at wherever the host serves `dir` (the default `/` matches the usual `public/` convention). The standalone CLI (`pnpx @devframes/plugin-assets`) is its own host, so it flips `serveStatic` on and serves the directory under a dedicated base.

## RPC surface

All functions are namespaced `devframes:plugin:assets:*`:

| Function | Type | Notes |
|----------|------|-------|
| `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. |
| `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. |
| `read-image-meta` | `query` | Width, height, and orientation for an image asset. |
| `read-text` | `query` | Truncated text content, for preview. |
| `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
| `rename` | `action` | Renames an asset within its folder, preserving its extension. |
| `delete` | `action` | Deletes one or more assets in a single call. |
| `mkdir` | `action` | Creates a folder, including missing parents. |
| `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`. |

`upload` / `rename` / `delete` / `mkdir` are registered only when `write` is enabled.

## Static export

Every devframe's `build` CLI subcommand is disabled here by default (`capabilities: { build: false }`). A static export has no live host serving the files, 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.

## Source

[`plugins/assets`](https://github.com/devframes/devframe/tree/main/plugins/assets)
2 changes: 2 additions & 0 deletions docs/plugins/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Each plugin is built with a **different UI framework**. That is deliberate: devf
| [Git](./git) | React (Next.js) | A repository dashboard — status, a commit graph, branches, and diffs, with optional staging and committing. |
| [Terminals](./terminals) | Svelte | Stream read-only command output and run fully interactive PTY shells in the browser. |
| [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. |
| [Assets](./assets) | Vue | Browse, preview, upload, rename, and delete the files in a managed directory. |

## One client, any framework

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

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.
1 change: 1 addition & 0 deletions examples/vite-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@devframes/json-render": "workspace:*",
"@devframes/json-render-ui": "workspace:*",
"@devframes/plugin-a11y": "workspace:*",
"@devframes/plugin-assets": "workspace:*",
"@devframes/plugin-code-server": "workspace:*",
"@devframes/plugin-data-inspector": "workspace:*",
"@devframes/plugin-git": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions examples/vite-devframe-hub/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mountDevframe } from '@devframes/hub/node'
import { toJsonRenderDockEntry } from '@devframes/json-render/hub'
import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y'
import assetsDevframe from '@devframes/plugin-assets'
import codeServerDevframe from '@devframes/plugin-code-server'
import dataInspectorDevframe from '@devframes/plugin-data-inspector'
import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
Expand Down Expand Up @@ -72,6 +73,7 @@ export default defineConfig({
a11yDevframe,
messagesDevframe,
ogDevframe,
assetsDevframe,
],
// Attach the a11y inspector's in-page agent as its dock's client script.
// The hub client runtime (booted in src/client/main.ts) imports it into
Expand Down
63 changes: 63 additions & 0 deletions packages/devframe/src/adapters/__tests__/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { defineDevframe } from '../../types/devframe'
import { createBuild } from '../build'

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

function baseDevframe(overrides: Partial<ReturnType<typeof defineDevframe>> = {}) {
const distDir = makeTmpDist()
return defineDevframe({
id: 'devframe-build-test',
name: 'Devframe Build Test',
version: '0.0.0',
packageName: 'devframe-build-test',
homepage: 'https://example.test',
description: 'Test devframe.',
cli: { distDir },
setup: () => {},
...overrides,
})
}

describe('adapters/build', () => {
it('rejects a definition with capabilities.build: false by default', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
try {
await expect(
createBuild(baseDevframe({ capabilities: { build: false } }), { outDir }),
).rejects.toThrow(/capabilities\.build: false/)
}
finally {
rmSync(outDir, { recursive: true, force: true })
}
})

it('proceeds past capabilities.build: false when force is set', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
try {
await expect(
createBuild(baseDevframe({ capabilities: { build: false } }), { outDir, force: true }),
).resolves.toBeUndefined()
}
finally {
rmSync(outDir, { recursive: true, force: true })
}
})

it('proceeds normally when capabilities.build is unset', async () => {
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
try {
await expect(createBuild(baseDevframe(), { outDir })).resolves.toBeUndefined()
}
finally {
rmSync(outDir, { recursive: true, force: true })
}
})
})
45 changes: 45 additions & 0 deletions packages/devframe/src/adapters/__tests__/cac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { defineDevframe } from '../../types/devframe'
import { createCac } from '../cac'

function baseDevframe(overrides: Partial<ReturnType<typeof defineDevframe>> = {}) {
return defineDevframe({
id: 'devframe-test',
name: 'Devframe Test',
version: '0.0.0',
packageName: 'devframe-test',
homepage: 'https://example.test',
description: 'Test devframe.',
setup: () => {},
...overrides,
})
}

describe('adapters/cac', () => {
it('registers the build subcommand by default', () => {
const { cli } = createCac(baseDevframe())
expect(cli.commands.map(c => c.name)).toContain('build')
})

it('skips the build subcommand when capabilities.build is false', () => {
const { cli } = createCac(baseDevframe({ capabilities: { build: false } }))
expect(cli.commands.map(c => c.name)).not.toContain('build')
})

it('still registers build when capabilities.build is true or a record', () => {
const truthy = createCac(baseDevframe({ capabilities: { build: true } }))
expect(truthy.cli.commands.map(c => c.name)).toContain('build')

const record = createCac(baseDevframe({ capabilities: { build: { dump: true } } }))
expect(record.cli.commands.map(c => c.name)).toContain('build')
})

it('always registers the dev and mcp commands regardless of capabilities.build', () => {
const { cli } = createCac(baseDevframe({ capabilities: { build: false } }))
const names = cli.commands.map(c => c.name)
expect(names).toContain('mcp')
// The dev command is registered as the catch-all `[...args]` command,
// which cac surfaces with an empty `name`.
expect(cli.commands.some(c => c.rawName === '[...args]')).toBe(true)
})
})
11 changes: 11 additions & 0 deletions packages/devframe/src/adapters/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DEVFRAME_RPC_DUMP_MANIFEST_FILENAME,
} from '../constants'
import { createHostContext } from '../node/context'
import { diagnostics } from '../node/diagnostics'
import { createH3DevframeHost } from '../node/host-h3'
import { collectStaticRpcDump } from '../rpc/dump/static'
import { strictJsonStringify } from '../rpc/serialization'
Expand All @@ -34,6 +35,13 @@ export interface CreateBuildOptions {
* minified. Set `true` when you need to diff / read the dumps by hand.
*/
pretty?: boolean
/**
* Proceed even when the definition declares `capabilities.build: false`.
* `createCac` already skips registering the `build` subcommand for such
* a definition — this only matters for a caller invoking `createBuild`
* directly, bypassing the CLI.
*/
force?: boolean
}

/**
Expand All @@ -50,6 +58,9 @@ export interface CreateBuildOptions {
* works at `/`, `/devframe/`, or any base, no rewriting required.
*/
export async function createBuild(d: DevframeDefinition, options: CreateBuildOptions = {}): Promise<void> {
if (d.capabilities?.build === false && !options.force)
throw diagnostics.DF0042({ id: d.id })

const outDir = resolve(options.outDir ?? 'dist-static')
const distDir = options.distDir ?? d.cli?.distDir
if (!distDir)
Expand Down
21 changes: 13 additions & 8 deletions packages/devframe/src/adapters/cac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,19 @@ export function createCac(d: DevframeDefinition, options: CreateCacOptions = {})
})
})

cli
.command('build', 'Build a self-contained static deploy of the devframe')
.option('--out-dir <outDir>', 'Output directory', { default: 'dist-static' })
.option('--base <base>', 'URL base', { default: '/' })
.option('--pretty', 'Pretty-print dump JSON (larger on disk)')
.action(async (flags: { outDir: string, base?: string, pretty?: boolean }) => {
await createBuild(d, { outDir: flags.outDir, base: flags.base, pretty: flags.pretty })
})
// `capabilities.build === false` opts a devframe out of the `build`
// subcommand entirely, rather than registering it only to have it
// produce a broken or misleading static export.
if (d.capabilities?.build !== false) {
cli
.command('build', 'Build a self-contained static deploy of the devframe')
.option('--out-dir <outDir>', 'Output directory', { default: 'dist-static' })
.option('--base <base>', 'URL base', { default: '/' })
.option('--pretty', 'Pretty-print dump JSON (larger on disk)')
.action(async (flags: { outDir: string, base?: string, pretty?: boolean }) => {
await createBuild(d, { outDir: flags.outDir, base: flags.base, pretty: flags.pretty })
})
}

cli
.command('mcp', 'Start an MCP server exposing agent-facing tools (stdio) [experimental]')
Expand Down
4 changes: 4 additions & 0 deletions packages/devframe/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,9 @@ export const diagnostics = defineDiagnostics({
why: (p: { id: string }) => `A service is already provided under "${p.id}".`,
fix: 'Service ids are unique per context. Revoke the existing provider first (the `provide()` call returns a revoke function), or namespace the id with your plugin id to avoid collisions.',
},
DF0042: {
why: (p: { id: string }) => `"${p.id}" declares \`capabilities.build: false\` — its static export is not meaningful (writes are excluded and any live-served data won't be there).`,
fix: 'Pass `{ force: true }` to `createBuild()` if the degraded export is still useful to you, or drop `capabilities.build: false` on the definition.',
},
},
})
10 changes: 10 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,16 @@ export interface DevframeDefinition {
* @default 'warn'
*/
duplicationStrategy?: DevframeDuplicationStrategy
/**
* Declares which runtimes meaningfully support this devframe. Consulted
* by adapters that can act on a plain `false` before doing any work:
* `createCac` skips registering the `build` subcommand entirely when
* `capabilities.build` is `false` — useful for a devframe whose value is
* inherently live (e.g. it manages real files on disk), so a static
* export would only ever produce a broken, write-less shell of the tool.
* The `Record<string, boolean>` shape is reserved for finer-grained
* sub-capabilities and currently unconsumed by any adapter.
*/
capabilities?: {
dev?: boolean | Record<string, boolean>
build?: boolean | Record<string, boolean>
Expand Down
Loading
Loading