diff --git a/alias.ts b/alias.ts index 77aecb6..22d4e91 100644 --- a/alias.ts +++ b/alias.ts @@ -62,9 +62,18 @@ export const alias = { '@devframes/plugin-terminals/vite': p('terminals/src/vite.ts'), '@devframes/plugin-terminals': p('terminals/src/index.ts'), '@devframes/plugin-git': p('git/src/index.ts'), + 'devframe/recipes/interactive-auth': r('devframe/src/recipes/interactive-auth.ts'), 'devframe/recipes/open-helpers': r('devframe/src/recipes/open-helpers.ts'), 'devframe/client': r('devframe/src/client/index.ts'), 'devframe': r('devframe/src'), + '@devframes/plugin-data-inspector/client': p('data-inspector/src/client/index.ts'), + '@devframes/plugin-data-inspector/node': p('data-inspector/src/node/index.ts'), + '@devframes/plugin-data-inspector/registry': p('data-inspector/src/registry/index.ts'), + '@devframes/plugin-data-inspector/engine': p('data-inspector/src/engine/index.ts'), + '@devframes/plugin-data-inspector/agent': p('data-inspector/src/agent/index.ts'), + '@devframes/plugin-data-inspector/cli': p('data-inspector/src/cli.ts'), + '@devframes/plugin-data-inspector/vite': p('data-inspector/src/vite.ts'), + '@devframes/plugin-data-inspector': p('data-inspector/src/index.ts'), '@devframes/plugin-inspect/client': p('inspect/src/client/index.ts'), '@devframes/plugin-inspect/node': p('inspect/src/node/index.ts'), '@devframes/plugin-inspect/cli': p('inspect/src/cli.ts'), diff --git a/docs/errors/DF0037.md b/docs/errors/DF0037.md new file mode 100644 index 0000000..27930ce --- /dev/null +++ b/docs/errors/DF0037.md @@ -0,0 +1,36 @@ +--- +outline: deep +--- + +# DF0037: Duplicate Service Provider + +## Message + +> A service is already provided under "`{id}`". + +## Cause + +`ctx.services` holds exactly one provider per service id. A second `provide()` under the same id throws instead of silently replacing a service another integration may already hold a reference to. + +## Example + +```ts +// ✗ Bad — provided twice +ctx.services.provide('my-plugin:sources', hostA) +ctx.services.provide('my-plugin:sources', hostB) + +// ✓ Good — revoke the previous provider first +const revoke = ctx.services.provide('my-plugin:sources', hostA) +revoke() +ctx.services.provide('my-plugin:sources', hostB) +``` + +## Fix + +- Revoke the existing provider first — `provide()` returns a revoke function. +- If the collision is between two unrelated integrations, namespace the id with your plugin id (`:`), the same rule RPC function names follow. +- Guard idempotent setup paths with `ctx.services.has(id)`. + +## Source + +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `provide()` throws this when the id is already taken. diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 3280419..dee9d9b 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -112,11 +112,37 @@ interface DevframeNodeContext { views: DevframeViewHost // static file hosting (`hostStatic`) diagnostics: DevframeDiagnosticsHost agent: DevframeAgentHost // experimental + services: DevframeServicesHost // typed cross-plugin service registry scope: (id) => DevframeScopedNodeContext // namespaced view (preferred) } ``` +### Cross-plugin services + +`ctx.services` is a typed, namespaced registry through which one integration exposes a capability and others consume it without a hard package dependency. The provider augments the `DevframeServicesRegistry` interface (so consumers get full typing from a types-only import) and provides the implementation at setup time; consumers use `whenAvailable`, which absorbs setup-order differences: + +```ts +// provider +declare module 'devframe' { + interface DevframeServicesRegistry { + 'my-plugin:sources': SourcesService + } +} +ctx.services.provide('my-plugin:sources', sources) + +// consumer — types come from `import type`, no runtime dependency +ctx.services.whenAvailable('my-plugin:sources', (sources) => { + sources.register(/* ... */) +}) +``` + +Service ids follow the RPC naming rule: prefix with the providing plugin's id. Duplicate ids throw [`DF0037`](https://devfra.me/errors/DF0037). + +### Storage scopes + +`ctx.host.getStorageDir(scope)` places persisted state in one of three classes: `'workspace'` (committable, shared with the team — conventionally `/.devframe/`), `'project'` (per-checkout private, under `node_modules`), and `'global'` (per-user, under the home directory). + `ctx.scope(id)` returns a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel and adds a persisted top-level `settings` store. It's the recommended entry point from a single tool's setup code — see [Scoped Context](./scoped-context). Host adapters can augment `ctx` with additional surfaces. For example, the [`vite` adapter](/adapters/vite) exposes Vite DevTools' dock, command, message, and terminal hosts via an optional `setup` hook on `createPluginFromDevframe` — consult the host's docs for those extras. diff --git a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts index 21285b3..55e1902 100644 --- a/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts +++ b/examples/minimal-next-devframe-hub/src/client/devframe/minimal-next-devframe-hub.ts @@ -177,9 +177,11 @@ export async function minimalNextDevframeHub( return `http://${hostName}:3000` }, getStorageDir(scope) { - return scope === 'workspace' - ? join(cwd, 'node_modules/.minimal-next-devframe-hub') - : join(homedir(), '.minimal-next-devframe-hub') + if (scope === 'workspace') + return join(cwd, '.devframe') + if (scope === 'project') + return join(cwd, 'node_modules/.minimal-next-devframe-hub') + return join(homedir(), '.minimal-next-devframe-hub') }, } diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index 52fe54b..55ed088 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -15,6 +15,7 @@ "@devframes/hub": "workspace:*", "@devframes/plugin-a11y": "workspace:*", "@devframes/plugin-code-server": "workspace:*", + "@devframes/plugin-data-inspector": "workspace:*", "@devframes/plugin-git": "workspace:*", "@devframes/plugin-inspect": "workspace:*", "@devframes/plugin-messages": "workspace:*", diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts index 015b172..9d53c85 100644 --- a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -115,9 +115,11 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = return resolved ? new URL(resolved).origin : 'http://localhost:5173' }, getStorageDir(scope) { - return scope === 'workspace' - ? join(cwd, 'node_modules/.minimal-vite-devframe-hub') - : join(homedir(), '.minimal-vite-devframe-hub') + if (scope === 'workspace') + return join(cwd, '.devframe') + if (scope === 'project') + return join(cwd, 'node_modules/.minimal-vite-devframe-hub') + return join(homedir(), '.minimal-vite-devframe-hub') }, } diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index 3200992..c8e1a73 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,5 +1,7 @@ import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' import codeServerDevframe from '@devframes/plugin-code-server' +import dataInspectorDevframe from '@devframes/plugin-data-inspector' +import { registerDataSource } from '@devframes/plugin-data-inspector/registry' import gitDevframe from '@devframes/plugin-git' import inspectDevframe from '@devframes/plugin-inspect' import messagesDevframe from '@devframes/plugin-messages' @@ -18,6 +20,34 @@ export default defineConfig({ server: { allowedHosts: true, strictPort: false }, plugins: [ UnoCSS(), + { + // The host registers its own live objects as data-inspector sources — + // the registry is process-global, so this works from any plugin hook. + name: 'minimal-vite-devframe-hub:data-sources', + configureServer(server) { + registerDataSource({ + id: 'vite:server', + title: 'Vite Dev Server', + description: 'The live ViteDevServer instance serving this hub.', + icon: 'i-ph:lightning-duotone', + data: () => server, + queries: [ + { title: 'Plugin names', query: 'config.plugins.name' }, + { + title: 'Module graph', + description: 'Client-environment modules with their importers', + query: 'environments.client.moduleGraph.idToModuleMap.mapEntries().value.({ url, type, importers: importers.fromSet().url })', + }, + { + title: 'Resolved config (clean)', + query: 'config', + excludeFunctions: true, + excludeUnderscoreProps: true, + }, + ], + }) + }, + }, minimalViteDevframeHub({ devframes: [ demoDevframe, @@ -28,6 +58,7 @@ export default defineConfig({ terminalsDevframe, codeServerDevframe, inspectDevframe, + dataInspectorDevframe, a11yDevframe, messagesDevframe, ], diff --git a/examples/storybook-hub/src/storybook-hub.ts b/examples/storybook-hub/src/storybook-hub.ts index 100bc9e..18ba29f 100644 --- a/examples/storybook-hub/src/storybook-hub.ts +++ b/examples/storybook-hub/src/storybook-hub.ts @@ -177,9 +177,11 @@ export function storybookHub(options: StorybookHubOptions = {}): Plugin { return resolved ? new URL(resolved).origin : 'http://localhost:5173' }, getStorageDir(scope) { - return scope === 'workspace' - ? join(cwd, 'node_modules/.storybook-hub') - : join(homedir(), '.storybook-hub') + if (scope === 'workspace') + return join(cwd, '.devframe') + if (scope === 'project') + return join(cwd, 'node_modules/.storybook-hub') + return join(homedir(), '.storybook-hub') }, } diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts index 1888571..12de0a3 100644 --- a/packages/devframe/src/adapters/mcp/build-server.ts +++ b/packages/devframe/src/adapters/mcp/build-server.ts @@ -107,9 +107,13 @@ export async function createMcpServer( const host: DevframeHost = { mountStatic: () => { /* MCP has no static surface */ }, resolveOrigin: () => 'mcp://devframe', - getStorageDir: scope => scope === 'workspace' - ? join(process.cwd(), `node_modules/.${definition.id}/devframe`) - : join(homedir(), `.${definition.id}/devframe`), + getStorageDir: (scope) => { + if (scope === 'workspace') + return join(process.cwd(), '.devframe') + if (scope === 'project') + return join(process.cwd(), `node_modules/.${definition.id}/devframe`) + return join(homedir(), `.${definition.id}/devframe`) + }, } const ctx = await createHostContext({ diff --git a/packages/devframe/src/node/__tests__/scope.test.ts b/packages/devframe/src/node/__tests__/scope.test.ts index 9fef72e..47c2c73 100644 --- a/packages/devframe/src/node/__tests__/scope.test.ts +++ b/packages/devframe/src/node/__tests__/scope.test.ts @@ -161,7 +161,7 @@ describe('ctx.scope()', () => { expect(ctx.rpc.sharedState.keys()).toContain('devframe:settings:project:my-plugin') }) - it('persists to the workspace and global storage dirs', async () => { + it('persists to the project and global storage dirs', async () => { const { ctx, dir } = await createCtx() const { settings } = ctx.scope('my-plugin') await settings.project.set('theme', 'dark') @@ -169,7 +169,10 @@ describe('ctx.scope()', () => { await sleep(250) - const projectFile = join(dir, 'workspace', 'settings', 'my-plugin.json') + // Project settings are per-checkout private state -> the host's + // ignored 'project' dir (the committable 'workspace' dir is for + // team-shared files). + const projectFile = join(dir, 'project', 'settings', 'my-plugin.json') const globalFile = join(dir, 'global', 'settings', 'my-plugin.json') expect(existsSync(projectFile)).toBe(true) expect(existsSync(globalFile)).toBe(true) diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts new file mode 100644 index 0000000..8089f6d --- /dev/null +++ b/packages/devframe/src/node/__tests__/services.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { DevframeServicesHostImpl } from '../host-services' + +describe('devframeServicesHost', () => { + it('provides and gets a service', () => { + const services = new DevframeServicesHostImpl() + const impl = { register: () => {} } + services.provide('my-plugin:thing', impl) + expect(services.get('my-plugin:thing')).toBe(impl) + expect(services.has('my-plugin:thing')).toBe(true) + expect(services.keys()).toEqual(['my-plugin:thing']) + }) + + it('throws DF0037 on duplicate provide', () => { + const services = new DevframeServicesHostImpl() + services.provide('a:s', 1) + expect(() => services.provide('a:s', 2)).toThrowError(/already provided under "a:s"/) + }) + + it('revoke removes only the matching provider', () => { + const services = new DevframeServicesHostImpl() + const revoke = services.provide('a:s', 1) + revoke() + expect(services.has('a:s')).toBe(false) + // A stale revoke from a previous provider must not remove a newer one. + services.provide('a:s', 2) + revoke() + expect(services.get('a:s')).toBe(2) + }) + + it('whenAvailable fires immediately when already provided', () => { + const services = new DevframeServicesHostImpl() + services.provide('a:s', 41) + const spy = vi.fn() + services.whenAvailable('a:s', spy) + expect(spy).toHaveBeenCalledWith(41) + }) + + it('whenAvailable fires on later provide (order independence)', () => { + const services = new DevframeServicesHostImpl() + const spy = vi.fn() + services.whenAvailable('a:s', spy) + expect(spy).not.toHaveBeenCalled() + services.provide('a:s', 'late') + expect(spy).toHaveBeenCalledWith('late') + }) + + it('whenAvailable re-fires after revoke + re-provide, and unsubscribes', () => { + const services = new DevframeServicesHostImpl() + const spy = vi.fn() + const unsubscribe = services.whenAvailable('a:s', spy) + const revoke = services.provide('a:s', 1) + revoke() + services.provide('a:s', 2) + expect(spy).toHaveBeenCalledTimes(2) + unsubscribe() + services.get('a:s') // no-op + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index e59e833..4aa2026 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -5,6 +5,7 @@ import { diagnostics as devframeDiagnostics } from './diagnostics' import { DevframeAgentHost } from './host-agent' import { DevframeDiagnosticsHost } from './host-diagnostics' import { RpcFunctionsHost } from './host-functions' +import { DevframeServicesHostImpl } from './host-services' import { DevframeViewHost } from './host-views' import { BUILTIN_AGENT_RPC } from './rpc' import { createScopedNodeContext } from './scope' @@ -42,6 +43,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom views: undefined!, diagnostics: undefined!, agent: undefined!, + services: undefined!, scope: undefined!, } as unknown as DevframeNodeContext @@ -51,6 +53,7 @@ export async function createHostContext(options: CreateHostContextOptions): Prom context.rpc = rpcHost context.views = viewsHost context.diagnostics = diagnosticsHost + context.services = new DevframeServicesHostImpl() // Agent host must be constructed after `rpcHost` so it can subscribe // to `onChanged` — it auto-discovers RPC functions flagged with diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 5c04700..58d4be2 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -72,5 +72,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { name: string }) => `RPC call to "${p.name}" was rejected: the caller is not authorized.`, fix: 'Complete the auth handshake (or connect with a static/pre-shared token) before calling a trusted method. Untrusted callers may only call `anonymous:`-prefixed methods — see `isAnonymousRpcMethod`.', }, + DF0037: { + 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.', + }, }, }) diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts index 6b48b56..dcd39d4 100644 --- a/packages/devframe/src/node/host-h3.ts +++ b/packages/devframe/src/node/host-h3.ts @@ -19,8 +19,9 @@ export interface CreateH3DevframeHostOptions { mount?: (base: string, distDir: string) => void | Promise /** * Namespace for storage paths returned by `getStorageDir`. Workspace - * state lives under `${workspaceRoot}/node_modules/./devframe/` - * and global state under `${homedir()}/./devframe/`. Pick the + * state (committable) lives under `${workspaceRoot}/.devframe/`, project + * state under `${workspaceRoot}/node_modules/./devframe/`, and + * global state under `${homedir()}/./devframe/`. Pick the * devtool's id (or another stable, filesystem-safe identifier) so the * standalone host doesn't collide with other tools' storage. */ @@ -46,9 +47,11 @@ export function createH3DevframeHost(options: CreateH3DevframeHostOptions): Devf }, getStorageDir(scope) { const namespace = `.${options.appName}/devframe` - return scope === 'workspace' - ? join(workspaceRoot, 'node_modules', namespace) - : join(homedir(), namespace) + if (scope === 'workspace') + return join(workspaceRoot, '.devframe') + if (scope === 'project') + return join(workspaceRoot, 'node_modules', namespace) + return join(homedir(), namespace) }, } } diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts new file mode 100644 index 0000000..f20fe62 --- /dev/null +++ b/packages/devframe/src/node/host-services.ts @@ -0,0 +1,56 @@ +import type { DevframeServiceId, DevframeServiceOf, DevframeServicesHost } from 'devframe/types' +import { diagnostics } from './diagnostics' + +/** + * Cross-plugin service registry (see `types/services.ts` for the contract). + * Values are held per context instance; `whenAvailable` subscriptions make + * the mechanism robust against setup ordering between provider and consumer. + */ +export class DevframeServicesHostImpl implements DevframeServicesHost { + private services = new Map() + private listeners = new Map void>>() + + provide(id: ID, service: DevframeServiceOf): () => void { + const key = id as string + if (this.services.has(key)) + throw diagnostics.DF0037({ id: key }) + this.services.set(key, service) + for (const listener of this.listeners.get(key) ?? []) + listener(service) + return () => { + if (this.services.get(key) === service) + this.services.delete(key) + } + } + + get(id: ID): DevframeServiceOf | undefined { + return this.services.get(id as string) as DevframeServiceOf | undefined + } + + has(id: DevframeServiceId): boolean { + return this.services.has(id as string) + } + + whenAvailable( + id: ID, + callback: (service: DevframeServiceOf) => void, + ): () => void { + const key = id as string + if (this.services.has(key)) + callback(this.services.get(key) as DevframeServiceOf) + let set = this.listeners.get(key) + if (!set) { + set = new Set() + this.listeners.set(key, set) + } + const listener = callback as (service: unknown) => void + set.add(listener) + return () => { + set.delete(listener) + } + } + + keys(): string[] { + return Array.from(this.services.keys()) + } +} diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index 974bb82..c904d4f 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -4,6 +4,7 @@ export * from './host-agent' export * from './host-diagnostics' export * from './host-functions' export * from './host-h3' +export * from './host-services' export * from './host-views' export * from './rpc-shared-state' export * from './rpc-streaming' diff --git a/packages/devframe/src/node/settings.ts b/packages/devframe/src/node/settings.ts index 07ee9b8..2037978 100644 --- a/packages/devframe/src/node/settings.ts +++ b/packages/devframe/src/node/settings.ts @@ -4,7 +4,9 @@ import { join } from 'pathe' import { createStorage } from './storage' // Map a settings scope to the host storage scope it persists under. -const STORAGE_SCOPE = { global: 'global', project: 'workspace' } as const +// Project settings are per-checkout private state, so they live in the +// host's ignored `project` dir (not the committable `workspace` one). +const STORAGE_SCOPE = { global: 'global', project: 'project' } as const function createNodeSettingsStore>( context: DevframeNodeContext, diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index a7a2fd0..aa55c3c 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -2,6 +2,7 @@ import type { DevframeAgentHost } from './agent' import type { DevframeDiagnosticsHost } from './diagnostics' import type { DevframeHost } from './host' import type { DevframeScopedNodeContext, SettingsForNamespace } from './scope' +import type { DevframeServicesHost } from './services' import type { DevframeViewHost } from './views' export interface DevframeCapabilities { @@ -52,6 +53,15 @@ export interface DevframeNodeContext { * @experimental */ agent: DevframeAgentHost + /** + * Cross-plugin services — a typed, namespaced registry through which one + * integration exposes a capability (e.g. a data-source registry) and + * others consume it without a hard package dependency. Ids follow the RPC + * namespacing rule (`:`); types come from augmenting + * the `DevframeServicesRegistry` interface. `whenAvailable` subscriptions + * absorb setup-order differences between provider and consumer. + */ + services: DevframeServicesHost /** * Create a namespace-scoped view of this context. The returned * `ctx.scope('my-plugin')` auto-namespaces every RPC id, shared-state diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 0c2798e..c5406a1 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -51,8 +51,14 @@ export interface DevframeHost { * collide between, say, the Vite host (`.vite/devframe`) and a * standalone CLI host (`./devframe`). * - * - `workspace` — per-project state (settings, caches). Typically - * under `${workspaceRoot}/node_modules/./devframe/`. + * - `workspace` — state shared with the whole team through version + * control (saved queries, shared presets). Conventionally + * `${workspaceRoot}/.devframe/`; hosts must place it somewhere + * committable. + * - `project` — per-checkout private state (caches, personal + * settings). Typically under + * `${cwd}/node_modules/./devframe/`, which version + * control ignores. * - `global` — per-user state (auth tokens, machine-wide * preferences). Typically under * `${homedir()}/./devframe/`. @@ -61,5 +67,12 @@ export interface DevframeHost { * pass to a downstream `createStorage(...)` call that creates it * lazily. */ - getStorageDir: (scope: 'workspace' | 'global') => string + getStorageDir: (scope: DevframeStorageScope) => string } + +/** + * Storage placement classes for {@link DevframeHost.getStorageDir}: + * `workspace` is committable and team-shared, `project` is per-checkout + * private, `global` is per-user. + */ +export type DevframeStorageScope = 'workspace' | 'project' | 'global' diff --git a/packages/devframe/src/types/index.ts b/packages/devframe/src/types/index.ts index 56abde2..85028ab 100644 --- a/packages/devframe/src/types/index.ts +++ b/packages/devframe/src/types/index.ts @@ -7,5 +7,6 @@ export * from './host' export * from './rpc' export * from './rpc-augments' export * from './scope' +export * from './services' export * from './utils' export * from './views' diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts new file mode 100644 index 0000000..615a3d7 --- /dev/null +++ b/packages/devframe/src/types/services.ts @@ -0,0 +1,67 @@ +/** + * Cross-plugin services — a typed, namespaced registry on the shared node + * context through which one integration exposes a capability and others + * consume it without a hard package dependency. + * + * A provider ships a *types-only* augmentation of {@link DevframeServicesRegistry} + * (so consumers get full typing from `import type`), then provides the + * implementation at setup time: + * + * ```ts + * // provider (e.g. the data-inspector plugin) + * declare module 'devframe' { + * interface DevframeServicesRegistry { + * 'devframes:plugin:data-inspector:sources': DataSourcesService + * } + * } + * ctx.services.provide('devframes:plugin:data-inspector:sources', host) + * + * // consumer (another plugin) — no runtime dependency on the provider + * ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) => { + * sources.register({ id: 'my-plugin:state', title: 'My state', data: () => state }) + * }) + * ``` + * + * Service ids follow the same namespacing rule as RPC functions: prefix with + * the providing plugin's id. + */ + +/** + * Augmentation point mapping service ids to their implementation types. + * Providers extend this via `declare module 'devframe'`. + */ +export interface DevframeServicesRegistry {} + +/** + * A known (augmented) service id, or any namespaced string for services + * without a published type augmentation. + */ +export type DevframeServiceId = keyof DevframeServicesRegistry | (string & {}) + +/** Resolved service type for an id: augmented type, or `unknown`. */ +export type DevframeServiceOf = ID extends keyof DevframeServicesRegistry + ? DevframeServicesRegistry[ID] + : unknown + +export interface DevframeServicesHost { + /** + * Publish a service under a namespaced id. Throws `DF0037` when the id is + * already provided (revoke first to replace). Returns a revoke function. + */ + provide: (id: ID, service: DevframeServiceOf) => () => void + /** The service currently provided under `id`, or `undefined`. */ + get: (id: ID) => DevframeServiceOf | undefined + has: (id: DevframeServiceId) => boolean + /** + * Run `callback` with the service as soon as it is available — immediately + * when already provided, otherwise on `provide`. Survives provider/consumer + * setup-order differences. The callback also re-fires if the service is + * revoked and provided again. Returns an unsubscribe function. + */ + whenAvailable: ( + id: ID, + callback: (service: DevframeServiceOf) => void, + ) => () => void + /** Ids of every currently-provided service. */ + keys: () => string[] +} diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index 5a5d246..489f3ef 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -96,7 +96,8 @@ export class DevframeDocksHost implements DevframeDocksHostType { async init() { this.userSettings = await this.context.rpc.sharedState.get('devframe:user-settings', { sharedState: createStorage({ - filepath: join(this.context.host.getStorageDir('workspace'), 'settings.json'), + // Personal dock layout/preferences: per-checkout private state. + filepath: join(this.context.host.getStorageDir('project'), 'settings.json'), initialValue: DEFAULT_STATE_USER_SETTINGS(), }), }) diff --git a/plugins/data-inspector/README.md b/plugins/data-inspector/README.md new file mode 100644 index 0000000..1e9cd93 --- /dev/null +++ b/plugins/data-inspector/README.md @@ -0,0 +1,69 @@ +# @devframes/plugin-data-inspector + +Inspect live server-side objects interactively. Other plugins and hosts register **data sources**; the workbench composes [jora](https://github.com/discoveryjs/jora) queries against them — executed in the process that owns the objects — and renders normalized results in a [discovery.js](https://github.com/discoveryjs/discovery) struct view with type badges, a shape panel, saved queries, and shareable URL state. + +## Register a data source + +The registry is **process-global** — register from anywhere, before or after the plugin mounts: + +```ts +import { registerDataSource } from '@devframes/plugin-data-inspector/registry' + +registerDataSource({ + id: 'my-plugin:state', + title: 'My plugin state', + description: 'The live state store', + data: () => store, // value or (async) factory; `static: true` memoizes + queries: [{ title: 'Active entries', query: 'entries.mapEntries()' }], +}) +``` + +Integrations that prefer zero package dependency consume the same store through the typed context service: + +```ts +ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) => { + sources.register({ id: 'my-plugin:state', title: 'My state', data: () => store }) +}) +``` + +## Mount + +```ts +// hub hosts mount the default export like any devframe: +import dataInspectorDevframe from '@devframes/plugin-data-inspector' +// Vite +import { dataInspectorVitePlugin } from '@devframes/plugin-data-inspector/vite' +``` + +## Standalone CLI + +```sh +devframe-data-inspector stats.json trace.jsonl # inspect local data files +devframe-data-inspector build stats.json # self-contained static export +devframe-data-inspector attach # attach to a process running the agent +``` + +Static exports embed the dataset and run the same query engine client-side, so saved recipes stay portable. + +## Attach to another Node process + +```ts +import { exposeDataInspector } from '@devframes/plugin-data-inspector/agent' + +await exposeDataInspector() +``` + +or with zero code changes: + +```sh +DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/agent server.js +``` + +The agent binds `127.0.0.1`, requires devframe's trust handshake with a per-run token by default, and advertises its endpoint in `node_modules/.data-inspector/agent.json`, which `devframe-data-inspector attach` picks up automatically. + +> [!WARNING] +> A connected inspector runs eval-grade jora queries against live objects: queries can invoke functions reachable as own properties and fire getters. Treat the agent endpoint like a debugger port — keep it on loopback and keep auth on. + +## Saved queries + +Recipes (`{ query, title?, description?, ...filters }`) persist id-keyed in two scopes: **workspace** (committable, `getStorageDir('workspace')/data-inspector/queries.json` — shared with the team) and **project** (per-checkout, under `node_modules`). diff --git a/plugins/data-inspector/bin.mjs b/plugins/data-inspector/bin.mjs new file mode 100755 index 0000000..44d1609 --- /dev/null +++ b/plugins/data-inspector/bin.mjs @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createDataInspectorCli } from './dist/cli.mjs' + +async function main() { + const cli = createDataInspectorCli() + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/data-inspector/package.json b/plugins/data-inspector/package.json new file mode 100644 index 0000000..bf67a7c --- /dev/null +++ b/plugins/data-inspector/package.json @@ -0,0 +1,81 @@ +{ + "name": "@devframes/plugin-data-inspector", + "type": "module", + "version": "0.7.2", + "description": "Devframe plugin — inspect live server-side objects interactively with jora queries: registry-contributed data sources, saved queries, file and process-attach inspection.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "plugins/data-inspector", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devframe", + "devframe-plugin", + "devtools", + "jora", + "data-inspector" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./agent": "./dist/agent/index.mjs", + "./client": "./dist/client/index.mjs", + "./cli": "./dist/cli.mjs", + "./engine": "./dist/engine/index.mjs", + "./node": "./dist/node/index.mjs", + "./registry": "./dist/registry/index.mjs", + "./vite": "./dist/vite.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "devframe-data-inspector": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "dist" + ], + "scripts": { + "build": "tsdown && vite build --config src/spa/vite.config.ts", + "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", + "watch": "tsdown --watch", + "prepack": "pnpm build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "devframe": "workspace:*", + "vite": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "dependencies": { + "cac": "catalog:deps", + "get-port-please": "catalog:deps", + "jora": "catalog:", + "nostics": "catalog:deps" + }, + "devDependencies": { + "@antfu/design": "catalog:frontend", + "@discoveryjs/discovery": "catalog:", + "@iconify-json/ph": "catalog:frontend", + "@types/codemirror": "catalog:", + "@vitejs/plugin-vue": "catalog:build", + "devframe": "workspace:*", + "floating-vue": "catalog:frontend", + "reka-ui": "catalog:frontend", + "splitpanes": "catalog:", + "tsdown": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build", + "vitest": "catalog:testing", + "vue": "catalog:frontend" + } +} diff --git a/plugins/data-inspector/src/agent/index.ts b/plugins/data-inspector/src/agent/index.ts new file mode 100644 index 0000000..99205f1 --- /dev/null +++ b/plugins/data-inspector/src/agent/index.ts @@ -0,0 +1,152 @@ +/** + * In-process agent — expose this process's registered data sources to an + * external data-inspector UI. + * + * Two ways in: + * + * ```ts + * // 1. explicit, from the target's code + * import { exposeDataInspector } from '@devframes/plugin-data-inspector/agent' + * import { registerDataSource } from '@devframes/plugin-data-inspector/registry' + * + * registerDataSource({ id: 'app:store', title: 'App store', data: () => store }) + * await exposeDataInspector() + * ``` + * + * ```sh + * # 2. zero code change — preload the agent into any Node process + * DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/agent server.js + * ``` + * + * The agent binds `127.0.0.1` and requires devframe's trust handshake by + * default: a random pre-shared token is minted per run, printed to stderr, + * and written (with the endpoint) to the discovery file + * `/node_modules/.data-inspector/agent.json`, which + * `devframe-data-inspector attach` consumes automatically. Connected + * inspectors run eval-grade queries against live objects in this process — + * treat the endpoint like a debugger port. + */ +import type { DevframeHost, DevframeNodeContext } from 'devframe/types' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { createHostContext, startHttpAndWs } from 'devframe/node' +import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { randomToken } from 'devframe/utils/crypto-token' +import { getPort } from 'get-port-please' + +/** Discovery file path, relative to the target process's cwd. */ +export const AGENT_DISCOVERY_FILE = 'node_modules/.data-inspector/agent.json' + +export interface AgentDiscovery { + /** WS endpoint of the agent, e.g. `ws://127.0.0.1:9878`. */ + websocket: string + /** Pre-shared token the inspector must present. Absent when `auth: false`. */ + token?: string + pid: number + startedAt: number +} + +export interface ExposeDataInspectorOptions { + /** Preferred port (falls back to a free one nearby). Default 9878. */ + port?: number + /** + * Require the trust handshake with a pre-shared token (default `true`). + * `false` opens the endpoint to any local connection. + */ + auth?: boolean + /** Override the minted token (e.g. from an env secret). */ + token?: string + /** Skip writing the discovery file. */ + discoveryFile?: boolean + /** Suppress the stderr banner. */ + silent?: boolean +} + +export interface DataInspectorAgent { + websocket: string + token?: string + close: () => Promise +} + +/** Start the agent endpoint in the current process. */ +export async function exposeDataInspector(options: ExposeDataInspectorOptions = {}): Promise { + // Deferred so `--import`ing the agent never pulls the whole node surface + // into processes that don't enable it. + const { setupDataInspector } = await import('../node/index') + + const cwd = process.cwd() + const port = await getPort({ port: options.port ?? 9878, portRange: [9878, 9978] }) + const websocket = `ws://127.0.0.1:${port}` + + const host: DevframeHost = { + mountStatic() {}, + mountConnectionMeta() {}, + resolveOrigin: () => `http://127.0.0.1:${port}`, + getStorageDir(scope) { + if (scope === 'workspace') + return join(cwd, '.devframe') + if (scope === 'project') + return join(cwd, 'node_modules/.data-inspector/devframe') + return join(homedir(), '.data-inspector/devframe') + }, + } + + const context: DevframeNodeContext = await createHostContext({ cwd, mode: 'dev', host }) + setupDataInspector(context) + + const useAuth = options.auth ?? true + const token = useAuth ? (options.token ?? randomToken()) : undefined + const auth = useAuth + ? createInteractiveAuth(context, { clientAuthTokens: [token!], banner: () => {} }) + : false + + const handle = await startHttpAndWs({ context, port, auth }) + + const discoveryPath = join(cwd, AGENT_DISCOVERY_FILE) + if (options.discoveryFile !== false) { + const discovery: AgentDiscovery = { websocket, token, pid: process.pid, startedAt: Date.now() } + mkdirSync(dirname(discoveryPath), { recursive: true }) + writeFileSync(discoveryPath, `${JSON.stringify(discovery, null, 2)}\n`) + process.once('exit', () => { + try { + rmSync(discoveryPath, { force: true }) + } + catch {} + }) + } + + if (!options.silent) { + // The agent runs inside arbitrary user processes: stderr, not stdout, + // so it never corrupts piped program output. + console.error(`[data-inspector] agent listening on ${websocket} (pid ${process.pid})`) + console.error(`[data-inspector] attach with: devframe-data-inspector attach${options.discoveryFile === false && token ? ` ${websocket} --token ${token}` : ''}`) + } + + return { + websocket, + token, + close: async () => { + if (options.discoveryFile !== false) { + try { + rmSync(discoveryPath, { force: true }) + } + catch {} + } + await handle.close() + }, + } +} + +// `node --import @devframes/plugin-data-inspector/agent` path: opt in via env +// so merely importing the module never opens a port. +if (process.env.DEVFRAME_DATA_INSPECTOR === '1' || process.env.DEVFRAME_DATA_INSPECTOR === 'true') { + void exposeDataInspector({ + port: process.env.DEVFRAME_DATA_INSPECTOR_PORT ? Number(process.env.DEVFRAME_DATA_INSPECTOR_PORT) : undefined, + auth: process.env.DEVFRAME_DATA_INSPECTOR_AUTH !== '0', + token: process.env.DEVFRAME_DATA_INSPECTOR_TOKEN, + }).catch((error) => { + console.error('[data-inspector] agent failed to start:', error) + }) +} diff --git a/plugins/data-inspector/src/cli.ts b/plugins/data-inspector/src/cli.ts new file mode 100644 index 0000000..5f6c3ac --- /dev/null +++ b/plugins/data-inspector/src/cli.ts @@ -0,0 +1,144 @@ +/** + * Standalone CLI — `devframe-data-inspector`: + * + * ```sh + * devframe-data-inspector stats.json trace.jsonl # inspect local data files + * devframe-data-inspector attach # attach via ./node_modules/.data-inspector/agent.json + * devframe-data-inspector attach ws://127.0.0.1:9878 --token + * devframe-data-inspector build stats.json # self-contained static export + * ``` + */ +import type { AgentDiscovery } from './agent/index' +import { existsSync, readFileSync } from 'node:fs' +import { cp, mkdir, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { cac } from 'cac' +import { createDevServer } from 'devframe/adapters/dev' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' +import { getPort } from 'get-port-please' +import pkg from '../package.json' with { type: 'json' } +import { AGENT_DISCOVERY_FILE } from './agent/index' +import { createDataInspectorDevframe } from './index' +import { createFileDataSource, loadDataFile } from './node/files' +import { listDataSources, registerDataSource } from './registry/index' + +const distDir = fileURLToPath(new URL('../dist/spa', import.meta.url)) + +/** The static-export dataset consumed by the SPA in static mode. */ +export interface StaticDataset { + sources: Array<{ + id: string + title: string + description?: string + icon?: string + static: boolean + queries?: { query: string, title?: string, description?: string }[] + data: unknown + }> +} + +export function createDataInspectorCli() { + const cli = cac('data-inspector') + cli.version(pkg.version) + + cli + .command('[...files]', 'Inspect local data files (.json / .jsonl / .ndjson)') + .option('--port ', 'Port to listen on') + .option('--host ', 'Host to bind to', { default: 'localhost' }) + .option('--open', 'Open the browser on start') + .option('--no-open', 'Do not open the browser') + .action(async (files: string[], flags: { port?: number, host: string, open?: boolean }) => { + for (const file of files) + registerDataSource(createFileDataSource(file)) + const def = createDataInspectorDevframe() + await createDevServer(def, { + host: flags.host, + port: flags.port ? Number(flags.port) : undefined, + flags: flags as Record, + }) + }) + + cli + .command('attach [endpoint]', 'Attach to a process running the data-inspector agent') + .option('--token ', 'Pre-shared token (defaults to the discovery file\'s)') + .option('--port ', 'Local port for the inspector UI') + .option('--host ', 'Host to bind the UI to', { default: '127.0.0.1' }) + .action(async (endpoint: string | undefined, flags: { token?: string, port?: number, host: string }) => { + let websocket = endpoint + let token = flags.token + if (!websocket) { + // No endpoint given: discover the agent advertised by a process + // started from this directory. + const discoveryPath = resolve(process.cwd(), AGENT_DISCOVERY_FILE) + if (!existsSync(discoveryPath)) { + console.error(`No agent endpoint given and no discovery file at ${discoveryPath}.`) + console.error('Start the target with the agent (see @devframes/plugin-data-inspector/agent) or pass a ws:// endpoint.') + process.exitCode = 1 + return + } + const discovery = JSON.parse(readFileSync(discoveryPath, 'utf-8')) as AgentDiscovery + websocket = discovery.websocket + token ??= discovery.token + } + + const port = flags.port ? Number(flags.port) : await getPort({ port: 9014, portRange: [9014, 9114] }) + const serveSpa = serveStaticNodeMiddleware(distDir) + const server = createServer((req, res) => { + if (req.url?.startsWith(`/${DEVFRAME_CONNECTION_META_FILENAME}`)) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ backend: 'websocket', websocket })) + return + } + serveSpa(req, res, () => { + res.statusCode = 404 + res.end('not found') + }) + }) + await new Promise(done => server.listen(port, flags.host, done)) + const url = `http://${flags.host}:${port}/${token ? `?di_token=${token}` : ''}` + console.log(`data-inspector attached to ${websocket}`) + console.log(`open ${url}`) + }) + + cli + .command('build [...files]', 'Build a self-contained static inspector for data files') + .option('--out-dir ', 'Output directory', { default: 'dist-data-inspector' }) + .action(async (files: string[], flags: { outDir: string }) => { + for (const file of files) + registerDataSource(createFileDataSource(file)) + + const outDir = resolve(process.cwd(), flags.outDir) + await mkdir(outDir, { recursive: true }) + await cp(distDir, outDir, { recursive: true }) + + // Embed the raw parsed data per source: the SPA's static backend runs + // the same engine client-side, so filters and normalization still + // apply per query. + const metas = listDataSources() + const dataset: StaticDataset = { sources: [] } + for (const meta of metas) { + const file = meta.description // absolute path recorded by createFileDataSource + dataset.sources.push({ ...meta, data: file ? await loadDataFile(file) : null }) + } + await writeFile(join(outDir, 'data-inspector-static.json'), JSON.stringify(dataset)) + await writeFile( + join(outDir, DEVFRAME_CONNECTION_META_FILENAME), + `${JSON.stringify({ backend: 'static' })}\n`, + ) + console.log(`static inspector written to ${outDir} (${dataset.sources.length} source${dataset.sources.length === 1 ? '' : 's'})`) + }) + + cli.help() + + return { + cli, + parse: async (argv?: string[]) => { + cli.parse(argv, { run: false }) + await cli.runMatchedCommand() + }, + } +} diff --git a/plugins/data-inspector/src/client/index.ts b/plugins/data-inspector/src/client/index.ts new file mode 100644 index 0000000..03ab6aa --- /dev/null +++ b/plugins/data-inspector/src/client/index.ts @@ -0,0 +1,26 @@ +import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOptions } from 'devframe/client' +import { connectDevframe } from 'devframe/client' + +export type { DevframeConnectionStatus, DevframeRpcClient } +export type { + DataSourceMeta, + FilterOptions, + Query, + QueryOutcome, + QueryStats, + SavedQuery, + SavedQueryScope, + SaveQueryInput, + SkeletonOutcome, + SuggestItem, + SuggestOutcome, +} from '../engine/contract' + +/** + * Connect to the data inspector's devframe backend. A thin, typed wrapper + * around devframe's {@link connectDevframe}; the SPA derives its base from + * `document.baseURI`, so no options are required in the common case. + */ +export function connectDataInspector(options?: DevframeRpcClientOptions): Promise { + return connectDevframe(options) +} diff --git a/plugins/data-inspector/src/engine/contract.ts b/plugins/data-inspector/src/engine/contract.ts new file mode 100644 index 0000000..d04a194 --- /dev/null +++ b/plugins/data-inspector/src/engine/contract.ts @@ -0,0 +1,91 @@ +/** + * Wire types shared by the server RPC functions and the SPA. Types only — + * safe to import from browser code without dragging jora into the bundle. + */ + +/** Client-controlled filtering, applied by the normalizer and the skeleton. */ +export interface FilterOptions { + excludeFunctions?: boolean + excludeUnderscoreProps?: boolean + excludeDollarProps?: boolean +} + +/** A query recipe: the text plus the filter options it was authored with. */ +export interface Query extends FilterOptions { + query: string + title?: string + description?: string +} + +/** What the client sees of a registered data source. */ +export interface DataSourceMeta { + id: string + title: string + description?: string + /** Phosphor icon class shown in the source picker (e.g. `i-ph:database-duotone`). */ + icon?: string + /** Data never changes; the server memoizes the resolved value. */ + static: boolean + /** Suggested queries provided by the source (shown read-only). */ + queries?: Query[] +} + +/** One completion candidate: replace [from, to) with `value`. */ +export interface SuggestItem { + type: string + from: number + to: number + /** The fragment currently typed in that range. */ + current: string + /** The completion to insert. */ + value: string +} + +export interface SuggestOutcome { + ok: boolean + suggestions: SuggestItem[] + statMs: number + error?: string +} + +export interface NormalizeStatsWire { + nodes: number + refs: number + truncatedDepth: number + truncatedEntries: number + truncatedProps: number + ms: number +} + +export interface QueryStats { + queryMs: number + normalize: NormalizeStatsWire + payloadBytes: number +} + +export type QueryOutcome + = | { ok: true, result: unknown, stats: QueryStats } + | { ok: false, error: { name: string, message: string } } + +export type SkeletonOutcome + = | { ok: true, skeleton: unknown, nodes: number, ms: number } + | { ok: false, error: { name: string, message: string } } + +/** + * Where a saved query persists — mirrors the host storage scopes: + * `workspace` is committable and shared with the team, `project` is + * per-checkout private (node_modules). + */ +export type SavedQueryScope = 'workspace' | 'project' + +export interface SavedQuery extends Query { + /** Storage key. Derived from the title (or query) when not supplied. */ + id: string + scope: SavedQueryScope + updatedAt: number +} + +export interface SaveQueryInput extends Query { + id?: string + scope: SavedQueryScope +} diff --git a/plugins/data-inspector/src/engine/index.ts b/plugins/data-inspector/src/engine/index.ts new file mode 100644 index 0000000..ed36782 --- /dev/null +++ b/plugins/data-inspector/src/engine/index.ts @@ -0,0 +1,4 @@ +export * from './contract' +export * from './normalize' +export * from './query-engine' +export * from './skeleton' diff --git a/plugins/data-inspector/src/engine/jora.d.ts b/plugins/data-inspector/src/engine/jora.d.ts new file mode 100644 index 0000000..eea347b --- /dev/null +++ b/plugins/data-inspector/src/engine/jora.d.ts @@ -0,0 +1,31 @@ +/** + * Minimal jora typings — the package ships none. Referenced via a + * triple-slash directive from `query-engine.ts` so every TS program that + * pulls the engine in (this package, the SPA, source-aliased consumers) + * sees the declaration. + */ +declare module 'jora' { + export interface JoraQueryOptions { + tolerant?: boolean + stat?: boolean + } + export interface JoraSetupOptions { + methods?: Record unknown)> + assertions?: Record boolean)> + } + export type JoraQueryFn = (data: unknown, context?: unknown) => unknown + export interface JoraSyntax { + parse: (source: string, tolerantMode?: boolean) => unknown + tokenize: (source: string) => unknown + stringify: (ast: unknown) => string + walk: (ast: unknown, visitor: unknown) => void + } + export interface Jora { + (query: string, options?: JoraQueryOptions): JoraQueryFn + setup: (options?: JoraSetupOptions) => (query: string, options?: JoraQueryOptions) => JoraQueryFn + syntax: JoraSyntax + version: string + } + const jora: Jora + export default jora +} diff --git a/plugins/data-inspector/src/engine/normalize.ts b/plugins/data-inspector/src/engine/normalize.ts new file mode 100644 index 0000000..0208d8c --- /dev/null +++ b/plugins/data-inspector/src/engine/normalize.ts @@ -0,0 +1,235 @@ +/** + * The result normalizer: walks an arbitrary live JS graph (jora query output) + * into a plain-JSON graph safe to send over RPC and feed to discovery's + * `struct` view. Handles what neither wire codec nor discovery can: + * + * - circular refs -> { $ref: '' } + * - Map -> { $type: 'Map', size, entries | value } + * - Set -> { $type: 'Set', size, values } + * - functions -> { $type: 'function', name } + * - class instances -> own enumerable props + $class tag + * - Date / RegExp / URL -> tagged string forms + * - BigInt / Symbol -> tagged string forms + * - Error -> { $type: 'Error', name, message } + * - Promise / WeakMap/.. -> opaque tags + * - depth / entry caps -> { $truncated: ... } markers + stats + */ + +export interface NormalizeOptions { + /** Max object/array nesting depth before truncation. */ + maxDepth?: number + /** Max array items / Map+Set entries emitted per collection. */ + maxEntries?: number + /** Max own properties emitted per object. */ + maxProps?: number + /** Max string length before truncation. */ + maxString?: number + /** Drop function values (object props and array items). */ + excludeFunctions?: boolean + /** Drop object properties whose key starts with `_`. */ + excludeUnderscoreProps?: boolean + /** Drop object properties whose key starts with `$`. */ + excludeDollarProps?: boolean +} + +/** True when a property key is excluded by the filter options. */ +export function isExcludedKey(key: string, opts: Pick): boolean { + if (opts.excludeUnderscoreProps && key.startsWith('_')) + return true + if (opts.excludeDollarProps && key.startsWith('$')) + return true + return false +} + +export interface NormalizeStats { + nodes: number + refs: number + truncatedDepth: number + truncatedEntries: number + truncatedProps: number + ms: number +} + +interface Walker { + seen: Map + stats: NormalizeStats + opts: Required +} + +const OPAQUE_TAGS: [abstract new (...args: never[]) => unknown, string][] = [] +// Guard: some of these globals may not exist in every runtime. +for (const name of ['WeakMap', 'WeakSet', 'WeakRef', 'ArrayBuffer', 'SharedArrayBuffer'] as const) { + const ctor = (globalThis as Record)[name] + if (typeof ctor === 'function') + OPAQUE_TAGS.push([ctor as never, name]) +} + +export function normalize(value: unknown, options: NormalizeOptions = {}): { data: unknown, stats: NormalizeStats } { + const start = performance.now() + const walker: Walker = { + seen: new Map(), + stats: { nodes: 0, refs: 0, truncatedDepth: 0, truncatedEntries: 0, truncatedProps: 0, ms: 0 }, + opts: { + maxDepth: options.maxDepth ?? 8, + maxEntries: options.maxEntries ?? 200, + maxProps: options.maxProps ?? 150, + maxString: options.maxString ?? 4000, + excludeFunctions: options.excludeFunctions ?? false, + excludeUnderscoreProps: options.excludeUnderscoreProps ?? false, + excludeDollarProps: options.excludeDollarProps ?? false, + }, + } + const data = walk(value, walker, 0, '#') + walker.stats.ms = Math.round((performance.now() - start) * 100) / 100 + return { data, stats: walker.stats } +} + +function walk(value: unknown, w: Walker, depth: number, path: string): unknown { + w.stats.nodes++ + + // ── primitives ────────────────────────────────────────────────────── + if (value === null || value === undefined) + return value ?? null + const t = typeof value + if (t === 'string') { + const s = value as string + if (s.length > w.opts.maxString) + return `${s.slice(0, w.opts.maxString)}… [$truncated string, ${s.length} chars]` + return s + } + if (t === 'number') + return Number.isFinite(value as number) ? value : String(value) + if (t === 'boolean') + return value + if (t === 'bigint') + return { $type: 'bigint', value: String(value) } + if (t === 'symbol') + return { $type: 'symbol', value: String(value) } + if (t === 'function') { + const fn = value as { name?: string } + return { $type: 'function', name: fn.name || '(anonymous)' } + } + + // ── objects ───────────────────────────────────────────────────────── + const obj = value as object + + const seenPath = w.seen.get(obj) + if (seenPath !== undefined) { + w.stats.refs++ + return { $ref: seenPath } + } + + // Cheap non-recursive exotic types first. + if (obj instanceof Date) + return { $type: 'Date', value: Number.isNaN(obj.getTime()) ? 'Invalid Date' : obj.toISOString() } + if (obj instanceof RegExp) + return { $type: 'RegExp', value: String(obj) } + if (obj instanceof URL) + return { $type: 'URL', value: obj.href } + if (obj instanceof Error) { + return { $type: 'Error', name: obj.name, message: obj.message } + } + if (obj instanceof Promise) + return { $type: 'Promise' } + for (const [ctor, tag] of OPAQUE_TAGS) { + if (obj instanceof ctor) + return { $type: tag } + } + + if (depth >= w.opts.maxDepth) { + w.stats.truncatedDepth++ + return { $truncated: 'depth', $preview: preview(obj) } + } + + w.seen.set(obj, path) + + if (Array.isArray(obj)) { + const source = w.opts.excludeFunctions ? obj.filter(item => typeof item !== 'function') : obj + const cap = Math.min(source.length, w.opts.maxEntries) + const out: unknown[] = Array.from({ length: cap }) + for (let i = 0; i < cap; i++) + out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`) + if (source.length > cap) { + w.stats.truncatedEntries++ + out.push({ $truncated: 'entries', $total: source.length, $shown: cap }) + } + return out + } + + if (ArrayBuffer.isView(obj)) { + const view = obj as unknown as { length?: number, byteLength: number } + return { $type: obj.constructor?.name ?? 'TypedArray', length: view.length ?? view.byteLength } + } + + if (obj instanceof Map) { + const entries = [...obj.entries()].slice(0, w.opts.maxEntries) + if (obj.size > entries.length) + w.stats.truncatedEntries++ + const allStringKeys = entries.every(([k]) => typeof k === 'string') + if (allStringKeys) { + const value: Record = {} + for (const [k, v] of entries) + value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`) + return { $type: 'Map', size: obj.size, value } + } + return { + $type: 'Map', + size: obj.size, + entries: entries.map(([k, v], i) => ({ + key: walk(k, w, depth + 1, `${path}~keys[${i}]`), + value: walk(v, w, depth + 1, `${path}~values[${i}]`), + })), + } + } + + if (obj instanceof Set) { + const values = [...obj].slice(0, w.opts.maxEntries) + if (obj.size > values.length) + w.stats.truncatedEntries++ + return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`)) } + } + + // Plain object or class instance: own enumerable string-keyed props. + const proto = Object.getPrototypeOf(obj) + const className = proto && proto !== Object.prototype && proto !== null + ? (proto.constructor?.name as string | undefined) + : undefined + + const out: Record = {} + if (className && className !== 'Object') + out.$class = className + + const keys = Object.keys(obj).filter(key => !isExcludedKey(key, w.opts)) + const cap = Math.min(keys.length, w.opts.maxProps) + for (let i = 0; i < cap; i++) { + const key = keys[i] + let v: unknown + try { + v = (obj as Record)[key] // own getters may fire or throw + } + catch (error) { + out[key] = { $type: 'getter-error', message: error instanceof Error ? error.message : String(error) } + continue + } + if (w.opts.excludeFunctions && typeof v === 'function') + continue + out[key] = walk(v, w, depth + 1, `${path}.${key}`) + } + if (keys.length > cap) { + w.stats.truncatedProps++ + out.$truncated = `props: showing ${cap} of ${keys.length}` + } + return out +} + +function preview(obj: object): string { + if (Array.isArray(obj)) + return `Array(${obj.length})` + if (obj instanceof Map) + return `Map(${obj.size})` + if (obj instanceof Set) + return `Set(${obj.size})` + const name = obj.constructor?.name ?? 'Object' + const keys = Object.keys(obj) + return `${name} { ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ', …' : ''} }` +} diff --git a/plugins/data-inspector/src/engine/query-engine.ts b/plugins/data-inspector/src/engine/query-engine.ts new file mode 100644 index 0000000..4a42275 --- /dev/null +++ b/plugins/data-inspector/src/engine/query-engine.ts @@ -0,0 +1,149 @@ +/// +/** + * Isomorphic jora execution. Runs server-side against LIVE objects (dev, + * agent-attach) and client-side against NORMALIZED datasets (static + * exports), so the same saved query works in both worlds: + * + * - the Map/Set bridge methods duck-type live collections, Map-shaped + * facades (Vite 8's compat module graph), AND the normalizer's tagged + * forms (`{ $type: 'Map', value }`), keeping queries portable; + * - suggestions come from jora's stat mode, flattened into plain + * RPC-safe completion items. + */ +import type { QueryOutcome, SuggestItem, SuggestOutcome } from './contract' +import type { NormalizeOptions } from './normalize' +import jora from 'jora' +import { normalize } from './normalize' + +export type { SuggestItem, SuggestOutcome } from './contract' + +interface MapTag { $type: 'Map', value?: Record, entries?: { key: unknown, value: unknown }[] } +interface SetTag { $type: 'Set', values?: unknown[] } + +function isMapTag(v: unknown): v is MapTag { + return !!v && typeof v === 'object' && (v as MapTag).$type === 'Map' +} + +function isSetTag(v: unknown): v is SetTag { + return !!v && typeof v === 'object' && (v as SetTag).$type === 'Set' +} + +/** + * Duck-typed Map detection: Vite 8's backward-compat `moduleGraph.idToModuleMap` + * is a Map-like facade (plain object with get/entries/size), not a real Map. + */ +function isMapLike(v: unknown): v is Map { + return !!v && typeof v === 'object' + && typeof (v as Map).entries === 'function' + && typeof (v as Map).get === 'function' +} + +function isSetLike(v: unknown): v is Set { + return !!v && typeof v === 'object' + && typeof (v as Set).has === 'function' + && typeof (v as Set)[Symbol.iterator] === 'function' + && typeof (v as Map).get !== 'function' +} + +const createQuery = jora.setup({ + methods: { + /** Map(-like or normalized tag) -> plain object (string-coerced keys). */ + fromMap: (v) => { + if (isMapLike(v)) + return Object.fromEntries(v.entries()) + if (isMapTag(v)) + return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value])) + return v + }, + /** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */ + mapEntries: (v) => { + if (isMapLike(v)) + return [...v.entries()].map(([key, value]) => ({ key, value })) + if (isMapTag(v)) { + if (v.entries) + return v.entries + return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value })) + } + return [] + }, + /** Set(-like or normalized tag) -> array. */ + fromSet: (v) => { + if (isSetLike(v)) + return [...v] + if (isSetTag(v)) + return v.values ?? [] + return v + }, + /** Constructor name of any value. */ + typeOf: (v) => { + if (v === null) + return 'null' + if (typeof v !== 'object') + return typeof v + return (v as object).constructor?.name ?? 'Object' + }, + /** All own keys (incl. non-enumerable), as strings. */ + ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], + }, +}) + +export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome { + try { + const started = performance.now() + const raw = createQuery(query)(target) + const queryMs = Math.round((performance.now() - started) * 100) / 100 + const { data, stats } = normalize(raw, options) + // The normalizer guarantees plain JSON, so this measures the actual wire payload. + const payloadBytes = new TextEncoder().encode(JSON.stringify(data) ?? '').length + return { ok: true, result: data, stats: { queryMs, normalize: stats, payloadBytes } } + } + catch (error) { + const e = error instanceof Error ? error : new Error(String(error)) + return { ok: false, error: { message: e.message, name: e.name } } + } +} + +interface JoraStatEntry { + type: string + from: number + to: number + text: string + suggestions: unknown[] | null +} + +/** + * jora stat mode: evaluates the (tolerant) query against the target and + * reports completions for the given cursor position. Each stat entry carries + * its candidates in a nested `suggestions` array — flattened here into plain, + * RPC-safe completion items. + */ +export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome { + try { + const started = performance.now() + const statApi = createQuery(query, { tolerant: true, stat: true })(target) as { + suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null + } + const raw = statApi.suggestion(pos, { limit }) ?? [] + const statMs = Math.round((performance.now() - started) * 100) / 100 + const suggestions: SuggestItem[] = [] + for (const entry of raw) { + for (const candidate of entry.suggestions ?? []) { + suggestions.push({ + type: String(entry.type), + from: entry.from, + to: entry.to, + current: String(entry.text ?? ''), + value: String(candidate).slice(0, 200), + }) + if (suggestions.length >= limit) + break + } + if (suggestions.length >= limit) + break + } + return { ok: true, suggestions, statMs } + } + catch (error) { + return { ok: false, suggestions: [], statMs: 0, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/plugins/data-inspector/src/engine/skeleton.ts b/plugins/data-inspector/src/engine/skeleton.ts new file mode 100644 index 0000000..4077182 --- /dev/null +++ b/plugins/data-inspector/src/engine/skeleton.ts @@ -0,0 +1,150 @@ +/** + * "What data are available": walks a live object into a compact type + * SKELETON (keys and type names, no values) so users can see the shape of a + * source while composing queries — independent of any query. + * + * - primitives -> their type name ('string', 'number', ...) + * - functions -> 'function' + * - arrays -> [skeleton of first item, '+N more'] + * - Map/Set (incl. -like)-> 'Map(size) { => }' expansions + * - class instances -> own props + $class tag + * - circular -> '[circular]' + * - depth/prop caps -> '...' + */ +import type { NormalizeOptions } from './normalize' +import { isExcludedKey } from './normalize' + +export type SkeletonOptions = Pick< + NormalizeOptions, + 'maxDepth' | 'maxProps' | 'excludeFunctions' | 'excludeUnderscoreProps' | 'excludeDollarProps' +> + +interface SkeletonWalker { + seen: WeakSet + nodes: number + opts: Required +} + +export function skeletonOf(value: unknown, options: SkeletonOptions = {}): { skeleton: unknown, nodes: number, ms: number } { + const started = performance.now() + const walker: SkeletonWalker = { + seen: new WeakSet(), + nodes: 0, + opts: { + maxDepth: options.maxDepth ?? 5, + maxProps: options.maxProps ?? 80, + excludeFunctions: options.excludeFunctions ?? false, + excludeUnderscoreProps: options.excludeUnderscoreProps ?? false, + excludeDollarProps: options.excludeDollarProps ?? false, + }, + } + const skeleton = walk(value, walker, 0) + return { skeleton, nodes: walker.nodes, ms: Math.round((performance.now() - started) * 100) / 100 } +} + +function isMapLike(v: object): v is Map { + return typeof (v as Map).entries === 'function' + && typeof (v as Map).get === 'function' + && typeof (v as Map).size === 'number' +} + +function isSetLike(v: object): v is Set { + return typeof (v as Set).has === 'function' + && typeof (v as Set)[Symbol.iterator] === 'function' + && typeof (v as Map).get !== 'function' + && typeof (v as Set).size === 'number' +} + +function walk(value: unknown, w: SkeletonWalker, depth: number): unknown { + w.nodes++ + if (value === null || value === undefined) + return String(value) + const t = typeof value + if (t !== 'object') + return t // 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function' + + const obj = value as object + if (obj instanceof Date) + return 'Date' + if (obj instanceof RegExp) + return 'RegExp' + if (obj instanceof URL) + return 'URL' + if (obj instanceof Error) + return 'Error' + if (obj instanceof Promise) + return 'Promise' + + if (w.seen.has(obj)) + return '[circular]' + if (depth >= w.opts.maxDepth) + return '...' + // Ancestor-path tracking (add/delete) so SHARED refs still expand and only + // true cycles collapse to '[circular]'. + w.seen.add(obj) + try { + return walkObject(obj, w, depth) + } + finally { + w.seen.delete(obj) + } +} + +function walkObject(obj: object, w: SkeletonWalker, depth: number): unknown { + if (Array.isArray(obj)) { + const items = w.opts.excludeFunctions ? obj.filter(item => typeof item !== 'function') : obj + if (items.length === 0) + return [] + const first = walk(items[0], w, depth + 1) + return items.length > 1 ? [first, `+${items.length - 1} more`] : [first] + } + + if (ArrayBuffer.isView(obj)) + return obj.constructor?.name ?? 'TypedArray' + + if (isMapLike(obj)) { + const first = obj.entries().next().value as [unknown, unknown] | undefined + if (!first) + return `Map(0)` + return { + [`Map(${obj.size})`]: { + key: walk(first[0], w, depth + 1), + value: walk(first[1], w, depth + 1), + }, + } + } + + if (isSetLike(obj)) { + const first = obj[Symbol.iterator]().next().value + return first === undefined + ? `Set(0)` + : { [`Set(${obj.size})`]: walk(first, w, depth + 1) } + } + + const proto = Object.getPrototypeOf(obj) + const className = proto && proto !== Object.prototype ? (proto.constructor?.name as string | undefined) : undefined + + const out: Record = {} + if (className && className !== 'Object') + out.$class = className + + const keys = Object.keys(obj).filter(key => !isExcludedKey(key, w.opts)) + const cap = Math.min(keys.length, w.opts.maxProps) + for (let i = 0; i < cap; i++) { + const key = keys[i] + let v: unknown + try { + v = (obj as Record)[key] + } + catch { + out[key] = 'getter-error' + continue + } + if (w.opts.excludeFunctions && typeof v === 'function') + continue + out[key] = walk(v, w, depth + 1) + } + if (keys.length > cap) + out['...'] = `+${keys.length - cap} more props` + return out +} diff --git a/plugins/data-inspector/src/index.ts b/plugins/data-inspector/src/index.ts new file mode 100644 index 0000000..1b258a4 --- /dev/null +++ b/plugins/data-inspector/src/index.ts @@ -0,0 +1,81 @@ +import type { DevframeDefinition } from 'devframe/types' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } +import { setupDataInspector } from './node/index' + +/** Default devframe id — also the RPC namespace. */ +const DEFAULT_ID = 'devframes:plugin:data-inspector' + +/** Preferred standalone CLI port. */ +const DEFAULT_PORT = 9014 + +// The Vue SPA is built (by Vite) into `dist/spa`. From both the source +// entry (`src/index.ts`, via the workspace alias) and the published +// entry (`dist/index.mjs`), `../dist/spa` resolves to `/dist/spa`. +const distDir = fileURLToPath(new URL('../dist/spa', import.meta.url)) + +export interface DataInspectorDevframeOptions { + /** Override the devframe id (and default mount path). */ + id?: string + /** Override the display name shown in a host dock. */ + name?: string + /** Override the dock icon. */ + icon?: string + /** + * Override the mount path. Left unset, the SPA mounts at `/` standalone + * and `/__/` when hosted (Vite/embedded). + */ + basePath?: string + /** Preferred standalone CLI port. */ + port?: number + /** + * Require the trust handshake on the standalone server. Defaults to + * `false` (auto-trust) for the single-user localhost CLI. The in-process + * agent (`@devframes/plugin-data-inspector/agent`) defaults to `true`. + */ + auth?: boolean +} + +/** + * Build a {@link DevframeDefinition} for the Data Inspector: an interactive + * jora query workbench over data sources registered by other plugins, hosts, + * files, or attached processes. + * + * The plugin is fully headless about sources — register them via + * `@devframes/plugin-data-inspector/registry` (process-global, no context + * needed) or through the `devframes:plugin:data-inspector:sources` context + * service. + * + * @experimental This plugin is experimental and may change without a major + * version bump until it stabilizes. + */ +export function createDataInspectorDevframe(options: DataInspectorDevframeOptions = {}): DevframeDefinition { + const id = options.id ?? DEFAULT_ID + return defineDevframe({ + id, + name: options.name ?? 'Data Inspector', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: pkg.description, + icon: options.icon ?? 'ph:crosshair-duotone', + basePath: options.basePath, + cli: { + command: 'data-inspector', + port: options.port ?? DEFAULT_PORT, + distDir: existsSync(distDir) ? distDir : undefined, + auth: options.auth ?? false, + }, + spa: { loader: 'none' }, + dock: { category: '~builtin' }, + setup(ctx) { + setupDataInspector(ctx) + }, + }) +} + +export default createDataInspectorDevframe() +export type { DataSourceEntry, DataSourcesService } from './registry/index' +export { DATA_SOURCES_SERVICE_ID, registerDataSource } from './registry/index' diff --git a/plugins/data-inspector/src/node/diagnostics.ts b/plugins/data-inspector/src/node/diagnostics.ts new file mode 100644 index 0000000..fd6d480 --- /dev/null +++ b/plugins/data-inspector/src/node/diagnostics.ts @@ -0,0 +1,24 @@ +import { defineDiagnostics } from 'nostics' + +/** + * Structured diagnostics for `@devframes/plugin-data-inspector`. Node-side + * only. Codes use the plugin-private `DP_DATA_INSPECTOR_` band so they never + * collide with devframe core (`DF00xx`) or `@devframes/hub` (`DF80xx`). + */ +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + codes: { + DP_DATA_INSPECTOR_0001: { + why: (p: { id: string }) => `No data source is registered under "${p.id}".`, + fix: 'List the available sources via the `sources` RPC (or `listDataSources()`), and make sure the contributor registered the source before querying it.', + }, + DP_DATA_INSPECTOR_0002: { + why: (p: { filepath: string }) => `Unsupported data file "${p.filepath}".`, + fix: 'The standalone CLI loads .json and .jsonl/.ndjson files. Convert the data, or register a custom source via the registry.', + }, + DP_DATA_INSPECTOR_0003: { + why: (p: { filepath: string, message: string }) => `Failed to load data file "${p.filepath}": ${p.message}`, + fix: 'Check that the file exists and contains valid JSON (or one JSON value per line for .jsonl/.ndjson).', + }, + }, +}) diff --git a/plugins/data-inspector/src/node/files.ts b/plugins/data-inspector/src/node/files.ts new file mode 100644 index 0000000..0f99bb3 --- /dev/null +++ b/plugins/data-inspector/src/node/files.ts @@ -0,0 +1,51 @@ +/** + * File data sources for the standalone CLI: `.json` parses whole, `.jsonl` / + * `.ndjson` parse as an array of records. Each file becomes one static + * source in the registry. + */ +import type { DataSourceEntry } from '../registry/index' +import { readFile } from 'node:fs/promises' +import { basename, extname, resolve } from 'node:path' +import process from 'node:process' +import { diagnostics } from './diagnostics' + +export const SUPPORTED_DATA_EXTENSIONS = ['.json', '.jsonl', '.ndjson'] as const + +/** Parse a data file into the value a source exposes. */ +export async function loadDataFile(filepath: string): Promise { + const ext = extname(filepath).toLowerCase() + if (!SUPPORTED_DATA_EXTENSIONS.includes(ext as typeof SUPPORTED_DATA_EXTENSIONS[number])) + throw diagnostics.DP_DATA_INSPECTOR_0002({ filepath }) + let text: string + try { + text = await readFile(filepath, 'utf-8') + } + catch (error) { + throw diagnostics.DP_DATA_INSPECTOR_0003({ filepath, message: error instanceof Error ? error.message : String(error) }) + } + try { + if (ext === '.json') + return JSON.parse(text) + return text + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map(line => JSON.parse(line)) + } + catch (error) { + throw diagnostics.DP_DATA_INSPECTOR_0003({ filepath, message: error instanceof Error ? error.message : String(error) }) + } +} + +/** Build a static registry entry for a data file (lazy: reads on first query). */ +export function createFileDataSource(filepath: string, cwd = process.cwd()): DataSourceEntry { + const absolute = resolve(cwd, filepath) + return { + id: `file:${filepath}`, + title: basename(filepath), + description: absolute, + icon: 'i-ph:file-duotone', + static: true, + data: () => loadDataFile(absolute), + } +} diff --git a/plugins/data-inspector/src/node/index.ts b/plugins/data-inspector/src/node/index.ts new file mode 100644 index 0000000..75406d9 --- /dev/null +++ b/plugins/data-inspector/src/node/index.ts @@ -0,0 +1,30 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { createDataSourcesService, DATA_SOURCES_SERVICE_ID, onDataSourcesChanged } from '../registry/index' +import { serverFunctions } from '../rpc/index' + +/** Broadcast whenever the source registry changes (register/unregister). */ +export const SOURCES_CHANGED_EVENT = 'devframes:plugin:data-inspector:sources:changed' + +/** + * Register the data-inspector's RPC functions on a devframe node context, + * provide the source registry as a typed context service, and broadcast + * registry changes so connected UIs refresh their source list live. + * + * Called from the definition's `setup(ctx)` and reusable by host adapters + * (the CLI and the in-process agent wire their own contexts through this). + */ +export function setupDataInspector(ctx: DevframeNodeContext): void { + for (const fn of serverFunctions) + ctx.rpc.register(fn) + + // The registry itself is process-global; the service is the typed, + // zero-dependency access path for other integrations on this context. + if (!ctx.services.has(DATA_SOURCES_SERVICE_ID)) + ctx.services.provide(DATA_SOURCES_SERVICE_ID, createDataSourcesService()) + + onDataSourcesChanged(() => { + ctx.rpc.broadcast(SOURCES_CHANGED_EVENT as never) + }) +} + +export { serverFunctions } diff --git a/plugins/data-inspector/src/node/saved-queries.ts b/plugins/data-inspector/src/node/saved-queries.ts new file mode 100644 index 0000000..6431018 --- /dev/null +++ b/plugins/data-inspector/src/node/saved-queries.ts @@ -0,0 +1,110 @@ +/** + * Saved-query persistence, id-keyed, in two scopes mapped onto the host + * storage classes: + * + * - `workspace` -> `getStorageDir('workspace')/data-inspector/queries.json` + * (committable, shared with the team) + * - `project` -> `getStorageDir('project')/data-inspector/queries.json` + * (per-checkout private, under node_modules) + * + * A saved query is a source-agnostic recipe: the query text, optional + * title/description, and the FilterOptions it was authored with. Backed by + * devframe's `createStorage` (debounced atomic JSON writes). + */ +import type { DevframeNodeContext } from 'devframe/types' +import type { SavedQuery, SavedQueryScope, SaveQueryInput } from '../engine/contract' +import { join } from 'node:path' +import { createStorage } from 'devframe/node' + +interface QueriesFile { + queries: Record> +} + +type Store = ReturnType> + +const stores = new WeakMap>() + +function storesFor(ctx: DevframeNodeContext): Record { + let byScope = stores.get(ctx) + if (!byScope) { + const open = (scope: SavedQueryScope): Store => createStorage({ + filepath: join(ctx.host.getStorageDir(scope), 'data-inspector/queries.json'), + initialValue: { queries: {} }, + }) + byScope = { workspace: open('workspace'), project: open('project') } + stores.set(ctx, byScope) + } + return byScope +} + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) +} + +/** djb2 — stable short id for untitled queries (same query, same id). */ +function hashOf(text: string): string { + let hash = 5381 + for (let i = 0; i < text.length; i++) + hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0 + return hash.toString(36) +} + +function deriveId(input: SaveQueryInput): string { + if (input.id) + return input.id + if (input.title?.trim()) + return slugify(input.title) + return `q-${hashOf(input.query)}` +} + +export function listSavedQueries(ctx: DevframeNodeContext): SavedQuery[] { + const byScope = storesFor(ctx) + const out: SavedQuery[] = [] + for (const scope of ['workspace', 'project'] as const) { + for (const entry of Object.values(byScope[scope].value().queries)) + out.push({ ...entry, scope }) + } + return out.sort((a, b) => b.updatedAt - a.updatedAt) +} + +export function saveQuery(ctx: DevframeNodeContext, input: SaveQueryInput): SavedQuery { + const byScope = storesFor(ctx) + const id = deriveId(input) + const record: Omit = { + id, + query: input.query, + title: input.title?.trim() || undefined, + description: input.description?.trim() || undefined, + excludeFunctions: input.excludeFunctions || undefined, + excludeUnderscoreProps: input.excludeUnderscoreProps || undefined, + excludeDollarProps: input.excludeDollarProps || undefined, + updatedAt: Date.now(), + } + byScope[input.scope].mutate((draft) => { + draft.queries[id] = record + }) + // The id is the storage key across both scopes — saving into one scope + // moves the query there rather than leaving a stale twin behind. + const other: SavedQueryScope = input.scope === 'project' ? 'workspace' : 'project' + if (byScope[other].value().queries[id]) { + byScope[other].mutate((draft) => { + delete draft.queries[id] + }) + } + return { ...record, scope: input.scope } +} + +export function deleteSavedQuery(ctx: DevframeNodeContext, id: string, scope: SavedQueryScope): boolean { + const byScope = storesFor(ctx) + const exists = !!byScope[scope].value().queries[id] + if (exists) { + byScope[scope].mutate((draft) => { + delete draft.queries[id] + }) + } + return exists +} diff --git a/plugins/data-inspector/src/registry/index.ts b/plugins/data-inspector/src/registry/index.ts new file mode 100644 index 0000000..b054f97 --- /dev/null +++ b/plugins/data-inspector/src/registry/index.ts @@ -0,0 +1,170 @@ +/** + * The data-source registry — how anything in the process hands the + * data-inspector an object to query. + * + * The store is **process-global**, held under a `Symbol.for` key on + * `globalThis`: registrations need no devframe context (register before any + * context exists — CLI, agent, early plugin code), duplicate copies of this + * module converge on one store, and setup ordering can never drop a source. + * + * ```ts + * import { registerDataSource } from '@devframes/plugin-data-inspector/registry' + * + * registerDataSource({ + * id: 'my-plugin:state', + * title: 'My plugin state', + * data: () => state, // value or (async) factory + * }) + * ``` + * + * Integrations that prefer zero package dependency consume the same store + * through the typed context service instead (see `DATA_SOURCES_SERVICE_ID`): + * + * ```ts + * ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) => { + * sources.register({ id: 'my-plugin:state', title: 'My state', data: () => state }) + * }) + * ``` + */ +import type { DataSourceMeta, Query } from '../engine/contract' + +export interface DataSourceEntry { + /** Unique id — namespace it with your plugin id (`my-plugin:thing`). */ + id: string + title: string + description?: string + /** Phosphor icon class shown in the source picker. */ + icon?: string + /** + * The data to inspect: a plain value, or a factory returning it (sync or + * async). Live objects passed directly stay live — queries read their + * current state. Wrap functions you want to inspect in a factory. + */ + data: unknown | (() => unknown | Promise) + /** + * The resolved data never changes: the factory runs once and the settled + * value is memoized (default `false`). + */ + static?: boolean + /** Suggested queries, surfaced read-only next to saved ones. */ + queries?: Query[] +} + +/** The service provided on `ctx.services` (same store as the module API). */ +export interface DataSourcesService { + register: (entry: DataSourceEntry) => () => void + unregister: (id: string) => void + list: () => DataSourceMeta[] + get: (id: string) => DataSourceEntry | undefined + onChanged: (listener: () => void) => () => void +} + +/** Id under which the registry is provided on `ctx.services`. */ +export const DATA_SOURCES_SERVICE_ID = 'devframes:plugin:data-inspector:sources' + +declare module 'devframe' { + interface DevframeServicesRegistry { + 'devframes:plugin:data-inspector:sources': DataSourcesService + } +} + +interface RegistryStore { + entries: Map + staticCache: Map> + listeners: Set<() => void> +} + +const GLOBAL_KEY = Symbol.for('devframes:plugin:data-inspector:registry@1') + +function store(): RegistryStore { + const holder = globalThis as Record + let value = holder[GLOBAL_KEY] as RegistryStore | undefined + if (!value) { + value = { entries: new Map(), staticCache: new Map(), listeners: new Set() } + holder[GLOBAL_KEY] = value + } + return value +} + +function notify(registry: RegistryStore): void { + for (const listener of registry.listeners) + listener() +} + +/** Register (or replace) a data source. Returns an unregister function. */ +export function registerDataSource(entry: DataSourceEntry): () => void { + const registry = store() + registry.entries.set(entry.id, entry) + registry.staticCache.delete(entry.id) + notify(registry) + return () => unregisterDataSource(entry.id) +} + +export function unregisterDataSource(id: string): void { + const registry = store() + if (registry.entries.delete(id)) { + registry.staticCache.delete(id) + notify(registry) + } +} + +export function listDataSources(): DataSourceMeta[] { + return Array.from(store().entries.values()).map(entry => ({ + id: entry.id, + title: entry.title, + description: entry.description, + icon: entry.icon, + static: entry.static ?? false, + queries: entry.queries, + })) +} + +export function getDataSource(id: string): DataSourceEntry | undefined { + return store().entries.get(id) +} + +/** Resolve a source's data, honoring value-vs-factory and `static` memoization. */ +export async function resolveSourceData(entry: DataSourceEntry): Promise { + if (typeof entry.data !== 'function') + return entry.data + const factory = entry.data as () => unknown | Promise + if (!entry.static) + return factory() + const registry = store() + let cached = registry.staticCache.get(entry.id) + if (!cached) { + cached = Promise.resolve(factory()) + registry.staticCache.set(entry.id, cached) + // A rejected factory must not poison the cache permanently. + cached.catch(() => registry.staticCache.delete(entry.id)) + } + return cached +} + +/** Subscribe to registry changes (register/unregister). Returns unsubscribe. */ +export function onDataSourcesChanged(listener: () => void): () => void { + const registry = store() + registry.listeners.add(listener) + return () => { + registry.listeners.delete(listener) + } +} + +/** Drop every registration and cache — test isolation helper. */ +export function resetDataSources(): void { + const registry = store() + registry.entries.clear() + registry.staticCache.clear() + notify(registry) +} + +/** The service implementation provided on `ctx.services` by `setup()`. */ +export function createDataSourcesService(): DataSourcesService { + return { + register: registerDataSource, + unregister: unregisterDataSource, + list: listDataSources, + get: getDataSource, + onChanged: onDataSourcesChanged, + } +} diff --git a/plugins/data-inspector/src/rpc/functions/_define.ts b/plugins/data-inspector/src/rpc/functions/_define.ts new file mode 100644 index 0000000..a27164e --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/_define.ts @@ -0,0 +1,7 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { createDefineWrapperWithContext } from 'devframe/rpc' + +export const defineDataInspectorRpc = createDefineWrapperWithContext() + +/** RPC namespace — the plugin id. */ +export const NS = 'devframes:plugin:data-inspector' diff --git a/plugins/data-inspector/src/rpc/functions/query.ts b/plugins/data-inspector/src/rpc/functions/query.ts new file mode 100644 index 0000000..795db0a --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/query.ts @@ -0,0 +1,31 @@ +import type { FilterOptions, QueryOutcome } from '../../engine/contract' +import { runQuery } from '../../engine/query-engine' +import { getDataSource, resolveSourceData } from '../../registry/index' +import { defineDataInspectorRpc, NS } from './_define' + +/** + * Execute a jora query against a registered source. Runs in-process against + * the live object; the result is normalized to strict JSON (circulars -> + * `$ref`, exotic types tagged, depth/entry caps) before it rides the wire. + */ +export const query = defineDataInspectorRpc({ + name: `${NS}:query`, + type: 'query', + jsonSerializable: true, + agent: { + title: 'Run a jora query', + description: 'Execute a jora query against a registered data source and return the normalized result with stats.', + }, + setup: () => ({ + handler: async ( + sourceId: string, + joraQuery: string, + options?: { maxDepth?: number, maxEntries?: number } & FilterOptions, + ): Promise => { + const source = getDataSource(sourceId) + if (!source) + return { ok: false, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } } + return runQuery(await resolveSourceData(source), joraQuery, options) + }, + }), +}) diff --git a/plugins/data-inspector/src/rpc/functions/saved.ts b/plugins/data-inspector/src/rpc/functions/saved.ts new file mode 100644 index 0000000..d462364 --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/saved.ts @@ -0,0 +1,30 @@ +import type { SavedQueryScope, SaveQueryInput } from '../../engine/contract' +import { deleteSavedQuery, listSavedQueries, saveQuery } from '../../node/saved-queries' +import { defineDataInspectorRpc, NS } from './_define' + +export const savedList = defineDataInspectorRpc({ + name: `${NS}:saved:list`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async () => listSavedQueries(ctx), + }), +}) + +export const savedSave = defineDataInspectorRpc({ + name: `${NS}:saved:save`, + type: 'action', + jsonSerializable: true, + setup: ctx => ({ + handler: async (input: SaveQueryInput) => saveQuery(ctx, input), + }), +}) + +export const savedDelete = defineDataInspectorRpc({ + name: `${NS}:saved:delete`, + type: 'action', + jsonSerializable: true, + setup: ctx => ({ + handler: async (id: string, scope: SavedQueryScope) => deleteSavedQuery(ctx, id, scope), + }), +}) diff --git a/plugins/data-inspector/src/rpc/functions/skeleton.ts b/plugins/data-inspector/src/rpc/functions/skeleton.ts new file mode 100644 index 0000000..467185e --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/skeleton.ts @@ -0,0 +1,25 @@ +import type { FilterOptions, SkeletonOutcome } from '../../engine/contract' +import { skeletonOf } from '../../engine/skeleton' +import { getDataSource, resolveSourceData } from '../../registry/index' +import { defineDataInspectorRpc, NS } from './_define' + +/** The type skeleton of a source ("what data are available"), query-independent. */ +export const skeleton = defineDataInspectorRpc({ + name: `${NS}:skeleton`, + type: 'query', + jsonSerializable: true, + setup: () => ({ + handler: async (sourceId: string, options?: FilterOptions): Promise => { + const source = getDataSource(sourceId) + if (!source) + return { ok: false, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } } + try { + return { ok: true, ...skeletonOf(await resolveSourceData(source), options) } + } + catch (error) { + const e = error instanceof Error ? error : new Error(String(error)) + return { ok: false, error: { name: e.name, message: e.message } } + } + }, + }), +}) diff --git a/plugins/data-inspector/src/rpc/functions/sources.ts b/plugins/data-inspector/src/rpc/functions/sources.ts new file mode 100644 index 0000000..73fa7fb --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/sources.ts @@ -0,0 +1,16 @@ +import { listDataSources } from '../../registry/index' +import { defineDataInspectorRpc, NS } from './_define' + +/** Every registered data source (meta only — no data). */ +export const sources = defineDataInspectorRpc({ + name: `${NS}:sources`, + type: 'query', + jsonSerializable: true, + agent: { + title: 'List data sources', + description: 'List the data sources registered with the data inspector (id, title, description, suggested queries).', + }, + setup: () => ({ + handler: async () => listDataSources(), + }), +}) diff --git a/plugins/data-inspector/src/rpc/functions/suggest.ts b/plugins/data-inspector/src/rpc/functions/suggest.ts new file mode 100644 index 0000000..eb1a38d --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/suggest.ts @@ -0,0 +1,19 @@ +import type { SuggestOutcome } from '../../engine/contract' +import { suggest as suggestQuery } from '../../engine/query-engine' +import { getDataSource, resolveSourceData } from '../../registry/index' +import { defineDataInspectorRpc, NS } from './_define' + +/** Autocomplete: jora stat-mode suggestions at a cursor position. */ +export const suggest = defineDataInspectorRpc({ + name: `${NS}:suggest`, + type: 'query', + jsonSerializable: true, + setup: () => ({ + handler: async (sourceId: string, joraQuery: string, pos: number): Promise => { + const source = getDataSource(sourceId) + if (!source) + return { ok: false, suggestions: [], statMs: 0, error: `No data source "${sourceId}"` } + return suggestQuery(await resolveSourceData(source), joraQuery, pos) + }, + }), +}) diff --git a/plugins/data-inspector/src/rpc/index.ts b/plugins/data-inspector/src/rpc/index.ts new file mode 100644 index 0000000..4828256 --- /dev/null +++ b/plugins/data-inspector/src/rpc/index.ts @@ -0,0 +1,24 @@ +import type { RpcDefinitionsToFunctions } from 'devframe/rpc' +import { query } from './functions/query' +import { savedDelete, savedList, savedSave } from './functions/saved' +import { skeleton } from './functions/skeleton' +import { sources } from './functions/sources' +import { suggest } from './functions/suggest' + +/** + * The RPC functions registered by the data-inspector plugin. + * Namespaced `devframes:plugin:data-inspector:*` (the plugin id). + */ +export const serverFunctions = [ + sources, + query, + skeleton, + suggest, + savedList, + savedSave, + savedDelete, +] as const + +declare module 'devframe' { + interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {} +} diff --git a/plugins/data-inspector/src/spa/App.vue b/plugins/data-inspector/src/spa/App.vue new file mode 100644 index 0000000..e1aa38b --- /dev/null +++ b/plugins/data-inspector/src/spa/App.vue @@ -0,0 +1,217 @@ + + + diff --git a/plugins/data-inspector/src/spa/components/DataShapePanel.vue b/plugins/data-inspector/src/spa/components/DataShapePanel.vue new file mode 100644 index 0000000..680daf1 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/DataShapePanel.vue @@ -0,0 +1,113 @@ + + + diff --git a/plugins/data-inspector/src/spa/components/QueryEditor.vue b/plugins/data-inspector/src/spa/components/QueryEditor.vue new file mode 100644 index 0000000..6c364a9 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/QueryEditor.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/plugins/data-inspector/src/spa/components/QuerySettings.vue b/plugins/data-inspector/src/spa/components/QuerySettings.vue new file mode 100644 index 0000000..ee15407 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/QuerySettings.vue @@ -0,0 +1,15 @@ + + + diff --git a/plugins/data-inspector/src/spa/components/ResultViewer.vue b/plugins/data-inspector/src/spa/components/ResultViewer.vue new file mode 100644 index 0000000..1315ce1 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/ResultViewer.vue @@ -0,0 +1,91 @@ + + + diff --git a/plugins/data-inspector/src/spa/components/SavedQueriesPanel.vue b/plugins/data-inspector/src/spa/components/SavedQueriesPanel.vue new file mode 100644 index 0000000..65665b4 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/SavedQueriesPanel.vue @@ -0,0 +1,181 @@ + + + diff --git a/plugins/data-inspector/src/spa/composables/discovery.ts b/plugins/data-inspector/src/spa/composables/discovery.ts new file mode 100644 index 0000000..e6ae01b --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/discovery.ts @@ -0,0 +1,152 @@ +/** + * Discovery `ViewModel` lifecycle bound to a Vue container ref. + * The default page is redefined as a struct view so every result renders + * through discovery's own page cycle (no DOM races), the color scheme follows + * the app's, and value ANNOTATIONS badge the normalizer's type tags + * (`{ $type: 'Map' }`, `$class`, `$ref`, ...) so exotic values read at a glance. + */ +import type { Ref, ShallowRef } from 'vue' +import type { ColorScheme } from './scheme' +import { ViewModel } from '@discoveryjs/discovery' +import discoveryCss from '@discoveryjs/discovery/dist/discovery.css?inline' +import { onMounted, onUnmounted, shallowRef, watch } from 'vue' +import { keyBadges, objectBadges } from './display-transform' + +// Bridge discovery's theme custom props to the design tokens (see style.css +// for the `.di-result-host` values that flip with `.dark`), zero out the +// stock page padding, and style the type badges rendered by the annotation. +const themeBridge = ` + :host, .discovery-root { + --discovery-background-color: var(--di-result-bg); + --discovery-color: var(--di-result-fg); + --discovery-page-padding-top: 0; + --discovery-page-padding-right: 0; + --discovery-page-padding-bottom: 0; + --discovery-page-padding-left: 0; + font-family: var(--font-mono, ui-monospace, monospace); + } + .view-struct .di-type-badge { + font-size: 10px; + line-height: 1.4; + padding: 0 5px; + border-radius: 999px !important; + border: 1px solid transparent; + background: color-mix(in srgb, var(--di-badge-color, #888) 14%, transparent) !important; + border-color: color-mix(in srgb, var(--di-badge-color, #888) 32%, transparent) !important; + color: var(--di-badge-color, #888) !important; + } + .di-type-function { --di-badge-color: #8a63d2; } + .di-type-class { --di-badge-color: #c98a1f; } + .di-type-map { --di-badge-color: #2f7fd0; } + .di-type-set { --di-badge-color: #0f9b8e; } + .di-type-date { --di-badge-color: #3f9c50; } + .di-type-ref { --di-badge-color: #c25577; } + .di-type-other { --di-badge-color: #7a8699; } +` + +interface AnnotationBadge { + place: 'before' | 'after' + style: 'badge' + text: string + className: string + tooltip?: unknown +} + +interface AnnotationContext { + host?: unknown + key?: string | number +} + +/** + * Badge values from the display-transform side-tables (`$class`/`$type` meta + * is stripped from the rendered data; badges carry the type info instead). + */ +function typeAnnotation(value: unknown, context?: AnnotationContext): AnnotationBadge | undefined { + // Object-valued entries: identity lookup. + if (value && typeof value === 'object') { + const v = value as Record + if (typeof v.$ref === 'string') + return { place: 'after', style: 'badge', text: '#Circular', className: 'di-type-badge di-type-ref' } + const badge = objectBadges.get(value as object) + if (badge) + return { place: 'after', style: 'badge', ...badge } + return undefined + } + // Primitive-valued entries (Date strings, BigInt, ...): parent+key lookup. + const parent = context?.host + if (parent && typeof parent === 'object' && context?.key !== undefined) { + const badge = keyBadges.get(parent as object)?.[context.key] + if (badge) + return { place: 'after', style: 'badge', ...badge } + } + return undefined +} + +export interface DiscoveryQueryActions { + /** "Create a subquery from the path" in the struct value-actions popup. */ + onQuerySubquery?: (path: string) => void + /** "Append path to current query" in the struct value-actions popup. */ + onQueryAppend?: (path: string) => void +} + +export function useDiscoveryViewer( + container: Readonly>, + scheme: Ref, + viewConfig: Record = { view: 'struct', expanded: 2 }, + actions: DiscoveryQueryActions = {}, +) { + const host = shallowRef(null) + let pendingData: { data: unknown } | null = null + + onMounted(async () => { + if (!container.value) + return + const vm = new ViewModel({ + container: container.value, + styles: [discoveryCss, themeBridge], + colorScheme: scheme.value, + colorSchemePersistent: false, + }) + vm.page.define( + 'default', + { + annotations: [typeAnnotation], + ...viewConfig, + } as never, + ) + // Opting into discovery's built-in query actions makes the struct view's + // per-value actions popup offer "query this key" entries; the callbacks + // receive a ready-made jora path (host.pathToQuery). + if (actions.onQuerySubquery || actions.onQueryAppend) { + vm.action.define('queryAcceptChanges', () => true) + if (actions.onQuerySubquery) + vm.action.define('querySubquery', path => actions.onQuerySubquery?.(String(path))) + if (actions.onQueryAppend) + vm.action.define('queryAppend', path => actions.onQueryAppend?.(String(path))) + } + await vm.dom.ready + host.value = vm + if (pendingData) { + await vm.setData(pendingData.data, { render: true }) + pendingData = null + } + }) + + onUnmounted(() => { + host.value = null + }) + + watch(scheme, (value) => { + host.value?.colorScheme.set(value) + }) + + async function setData(data: unknown): Promise { + if (!host.value) { + pendingData = { data } // render as soon as the host is ready + return + } + await host.value.setData(data, { render: true }) + } + + return { setData } +} diff --git a/plugins/data-inspector/src/spa/composables/display-transform.ts b/plugins/data-inspector/src/spa/composables/display-transform.ts new file mode 100644 index 0000000..7271727 --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/display-transform.ts @@ -0,0 +1,151 @@ +/** + * Display transform for the discovery struct view. + * + * The normalizer's meta tags (`$class`, `$type`, function stubs, Map/Set + * wrappers) carry type info, but rendering them as plain props is noise once + * badges exist. This transform rewrites a normalized result into its clean + * display shape and parks the badge info in WeakMap side-tables the + * annotation reads back: + * + * - `{ $class: 'X', ...props }` -> `{ ...props }` + `class X` badge + * - `{ $type: 'function', name }` -> `{}` + `fn name` badge + * - `{ $type: 'Map', size, value|entries }` -> inner object/array + `Map(n)` badge + * - `{ $type: 'Set', size, values }` -> values array + `Set(n)` badge + * - `{ $type: 'Date'|'RegExp'|..., value }` -> the value string + type badge (keyed by parent+key) + * - `{ $ref }` / `{ $truncated }` -> untouched (informative as data) + */ + +export interface DisplayBadge { + text: string + className: string +} + +/** Badges for transformed values that are objects/arrays (identity lookup). */ +export const objectBadges = new WeakMap() +/** Badges for primitive-valued entries, keyed by (parent object, key). */ +export const keyBadges = new WeakMap>() + +const KIND_BY_TYPE: Record = { + 'function': 'di-type-function', + 'Map': 'di-type-map', + 'Set': 'di-type-set', + 'Date': 'di-type-date', + 'RegExp': 'di-type-date', + 'URL': 'di-type-date', + 'bigint': 'di-type-other', + 'symbol': 'di-type-other', + 'Error': 'di-type-ref', + 'getter-error': 'di-type-ref', + 'Promise': 'di-type-other', +} + +interface Walked { + value: unknown + badge?: DisplayBadge +} + +function badgeFor(type: string, extra?: string): DisplayBadge { + return { text: extra ?? type, className: `di-type-badge ${KIND_BY_TYPE[type] ?? 'di-type-other'}` } +} + +function walk(value: unknown): Walked { + if (!value || typeof value !== 'object') + return { value } + + if (Array.isArray(value)) { + const out: unknown[] = Array.from({ length: value.length }) + const childKeyBadges: Record = {} + let hasKeyBadges = false + value.forEach((item, i) => { + const walked = walk(item) + out[i] = walked.value + if (walked.badge) { + if (walked.value && typeof walked.value === 'object') { + objectBadges.set(walked.value as object, walked.badge) + } + else { + childKeyBadges[i] = walked.badge + hasKeyBadges = true + } + } + }) + if (hasKeyBadges) + keyBadges.set(out, childKeyBadges) + return { value: out } + } + + const obj = value as Record + + // ── normalizer stubs ──────────────────────────────────────────────── + if (typeof obj.$type === 'string') { + const type = obj.$type + switch (type) { + case 'function': { + const name = typeof obj.name === 'string' && obj.name !== '(anonymous)' ? obj.name : '' + return { value: name ? `` : '', badge: badgeFor('function', 'Function') } + } + case 'Map': { + const inner = walk(obj.value ?? obj.entries ?? {}) + return { value: inner.value, badge: badgeFor('Map', `Map(${obj.size ?? '?'})`) } + } + case 'Set': { + const inner = walk(obj.values ?? []) + return { value: inner.value, badge: badgeFor('Set', `Set(${obj.size ?? '?'})`) } + } + case 'Date': + case 'RegExp': + case 'URL': + case 'bigint': + case 'symbol': + return { value: obj.value, badge: badgeFor(type, type === 'bigint' ? 'BigInt' : type === 'symbol' ? 'Symbol' : type) } + case 'Error':{ + const clone = { ...obj } + delete clone.$type + return { value: clone, badge: badgeFor('Error') } + } + case 'getter-error': + return { value: String(obj.message ?? ''), badge: badgeFor('getter-error', 'getter threw') } + default: + // Promise, WeakMap, TypedArray tags, ... - opaque stubs + return { + value: `<${type}>`, + badge: badgeFor(type, typeof obj.length === 'number' ? `${type}(${obj.length})` : type), + } + } + } + + // ── plain object / class instance ─────────────────────────────────── + const out: Record = {} + const childKeyBadges: Record = {} + let hasKeyBadges = false + let classBadge: DisplayBadge | undefined + + for (const [key, child] of Object.entries(obj)) { + if (key === '$class' && typeof child === 'string') { + classBadge = { text: `class ${child}`, className: 'di-type-badge di-type-class' } + continue + } + const walked = walk(child) + out[key] = walked.value + if (walked.badge) { + if (walked.value && typeof walked.value === 'object') { + objectBadges.set(walked.value as object, walked.badge) + } + else { + childKeyBadges[key] = walked.badge + hasKeyBadges = true + } + } + } + if (hasKeyBadges) + keyBadges.set(out, childKeyBadges) + return { value: out, badge: classBadge } +} + +/** Rewrite a normalized result into its display shape; badges land in the tables. */ +export function prepareForDisplay(result: unknown): unknown { + const walked = walk(result) + if (walked.badge && walked.value && typeof walked.value === 'object') + objectBadges.set(walked.value as object, walked.badge) + return walked.value +} diff --git a/plugins/data-inspector/src/spa/composables/rpc.ts b/plugins/data-inspector/src/spa/composables/rpc.ts new file mode 100644 index 0000000..655301c --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/rpc.ts @@ -0,0 +1,224 @@ +/** + * Devframe connection state + the data BACKEND the workbench talks through. + * + * The backend is chosen once at boot (`connect()`): + * - **rpc** — a live devframe server; every method is a namespaced RPC call. + * - **static** — a pre-exported dataset (`./data-inspector-static.json`, + * advertised by a `backend: 'static'` `./__connection.json`); queries, + * suggestions and skeletons run entirely client-side via the isomorphic + * engine, and saved-query persistence is unavailable. + */ +// The type-only package-root import pulls `devframe` into this TS program so +// the package's `declare module 'devframe'` augmentations (src/registry) +// resolve until the plugin's node entries land and import it for real. +// Erased at build time. +import type {} from 'devframe' +import type { DevframeConnectionStatus, DevframeRpcClient } from 'devframe/client' +import type { + DataSourceMeta, + FilterOptions, + QueryOutcome, + SavedQuery, + SavedQueryScope, + SaveQueryInput, + SkeletonOutcome, + SuggestOutcome, +} from '../../engine' +import { connectDevframe } from 'devframe/client' +import { reactive, shallowRef } from 'vue' +import { runQuery, skeletonOf, suggest as suggestQuery } from '../../engine' + +export const connection = reactive<{ + connected: boolean + status: DevframeConnectionStatus + error: string | null + /** Which backend serves the workbench; decided at boot. */ + mode: 'rpc' | 'static' +}>({ + connected: false, + status: 'connecting', + error: null, + mode: 'rpc', +}) + +/** Everything the workbench needs from a data backend, transport-agnostic. */ +export interface DataBackend { + /** True when running against a pre-exported dataset (no live server). */ + readonly static: boolean + sources: () => Promise + query: (sourceId: string, query: string, options: FilterOptions) => Promise + suggest: (sourceId: string, query: string, pos: number) => Promise + skeleton: (sourceId: string, options: FilterOptions) => Promise + savedList: () => Promise + savedSave: (input: SaveQueryInput) => Promise + savedDelete: (id: string, scope: SavedQueryScope) => Promise + /** Fires when the server's source registry changes (rpc mode only). */ + onSourcesChanged: (listener: () => void) => void +} + +const backendRef = shallowRef(null) + +/** The active backend — `connect()` must have completed. */ +export function backend(): DataBackend { + if (!backendRef.value) + throw new Error('not connected') + return backendRef.value +} + +// ── rpc backend ────────────────────────────────────────────────────── + +function createRpcBackend(client: DevframeRpcClient): DataBackend { + /** Untyped call escape hatch — the functions aren't module-augmented here. */ + const call = (name: string, ...args: unknown[]): Promise => + (client.call as unknown as (name: string, ...args: unknown[]) => Promise)(name, ...args) + + return { + static: false, + sources: () => call('devframes:plugin:data-inspector:sources'), + query: (sourceId, query, options) => + call('devframes:plugin:data-inspector:query', sourceId, query, options), + suggest: (sourceId, query, pos) => + call('devframes:plugin:data-inspector:suggest', sourceId, query, pos), + skeleton: (sourceId, options) => + call('devframes:plugin:data-inspector:skeleton', sourceId, options), + savedList: () => call('devframes:plugin:data-inspector:saved:list'), + savedSave: input => call('devframes:plugin:data-inspector:saved:save', input), + savedDelete: async (id, scope) => { + await call('devframes:plugin:data-inspector:saved:delete', id, scope) + }, + onSourcesChanged: (listener) => { + // The node side broadcasts this client event on register/unregister. + client.client.register({ + name: 'devframes:plugin:data-inspector:sources:changed' as never, + type: 'event', + handler: listener, + } as never) + }, + } +} + +// ── static backend ─────────────────────────────────────────────────── + +/** One exported source: its meta plus the pre-normalized dataset. */ +interface StaticSourceEntry extends DataSourceMeta { + data: unknown +} + +interface StaticDataset { + sources: StaticSourceEntry[] +} + +function createStaticBackend(dataset: StaticDataset): DataBackend { + const entries = dataset.sources + + function dataOf(sourceId: string): unknown { + const source = entries.find(s => s.id === sourceId) + if (!source) + throw new Error(`unknown data source "${sourceId}"`) + return source.data + } + + return { + static: true, + async sources() { + return entries.map(({ data: _data, ...meta }) => meta) + }, + async query(sourceId, query, options) { + return runQuery(dataOf(sourceId), query, options) + }, + async suggest(sourceId, query, pos) { + return suggestQuery(dataOf(sourceId), query, pos) + }, + async skeleton(sourceId, options) { + try { + return { ok: true, ...skeletonOf(dataOf(sourceId), options) } + } + catch (error) { + const e = error instanceof Error ? error : new Error(String(error)) + return { ok: false, error: { name: e.name, message: e.message } } + } + }, + async savedList() { + return [] + }, + async savedSave() { + throw new Error('saved queries are unavailable in static mode') + }, + async savedDelete() { + throw new Error('saved queries are unavailable in static mode') + }, + onSourcesChanged: () => {}, // a static dataset never changes + } +} + +// ── boot ───────────────────────────────────────────────────────────── + +/** + * Read and strip the pre-shared auth token the attach CLI appends to the SPA + * URL (`?di_token=…`), so it never lingers in the address bar or history. + */ +function consumeAuthToken(): string | undefined { + const params = new URLSearchParams(location.search) + const token = params.get('di_token') + if (!token) + return undefined + params.delete('di_token') + const search = params.toString() + history.replaceState(null, '', search ? `?${search}` : location.pathname) + return token +} + +/** + * A `backend: 'static'` connection meta means there is no server to talk to: + * load the exported dataset instead. Any probe failure (no meta, non-static + * backend) falls through to the live RPC connection. + */ +async function probeStaticDataset(): Promise { + try { + const res = await fetch('./__connection.json') + if (!res.ok) + return null + const meta = await res.json() as { backend?: string } | null + if (meta?.backend !== 'static') + return null + } + catch { + return null + } + const res = await fetch('./data-inspector-static.json') + if (!res.ok) + throw new Error(`failed to load static dataset (${res.status})`) + return await res.json() as StaticDataset +} + +function applyStatus(client: DevframeRpcClient): void { + connection.status = client.status + connection.connected = client.status === 'connected' + connection.error = client.connectionError?.message ?? null +} + +export async function connect(): Promise { + const authToken = consumeAuthToken() + try { + const dataset = await probeStaticDataset() + if (dataset) { + backendRef.value = createStaticBackend(dataset) + connection.mode = 'static' + // A static export has no live socket; it is "connected" for its whole life. + connection.status = 'connected' + connection.connected = true + connection.error = null + return + } + const client = await connectDevframe({ baseURL: './', authToken }) + backendRef.value = createRpcBackend(client) + applyStatus(client) + client.events.on('connection:status', () => applyStatus(client)) + await client.ensureTrusted(10_000).catch(() => {}) + applyStatus(client) + } + catch (error) { + connection.status = 'error' + connection.error = error instanceof Error ? error.message : String(error) + } +} diff --git a/plugins/data-inspector/src/spa/composables/saved.ts b/plugins/data-inspector/src/spa/composables/saved.ts new file mode 100644 index 0000000..60d9068 --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/saved.ts @@ -0,0 +1,27 @@ +/** Saved-query CRUD state over the data backend. */ +import type { SavedQuery, SaveQueryInput } from '../../engine' +import { ref } from 'vue' +import { backend } from './rpc' + +export function useSavedQueries() { + const saved = ref([]) + + async function refresh(): Promise { + saved.value = await backend().savedList() + } + + async function save(input: SaveQueryInput): Promise { + const record = await backend().savedSave(input) + await refresh() + return record + } + + async function remove(entry: SavedQuery): Promise { + await backend().savedDelete(entry.id, entry.scope) + await refresh() + } + + return { saved, refresh, save, remove } +} + +export type SavedQueriesApi = ReturnType diff --git a/plugins/data-inspector/src/spa/composables/scheme.ts b/plugins/data-inspector/src/spa/composables/scheme.ts new file mode 100644 index 0000000..24a1e59 --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/scheme.ts @@ -0,0 +1,17 @@ +/** Color scheme state: OS-initialized, user-togglable. */ +import { ref, watchEffect } from 'vue' + +export type ColorScheme = 'light' | 'dark' + +const mq = window.matchMedia('(prefers-color-scheme: dark)') +export const colorScheme = ref(mq.matches ? 'dark' : 'light') +mq.addEventListener('change', (e) => { + colorScheme.value = e.matches ? 'dark' : 'light' +}) + +// The shared design tokens flip on the `.dark` class (same approach as the +// other devframe plugins). +watchEffect(() => { + document.documentElement.classList.toggle('dark', colorScheme.value === 'dark') + document.documentElement.classList.toggle('light', colorScheme.value === 'light') +}) diff --git a/plugins/data-inspector/src/spa/composables/workbench.ts b/plugins/data-inspector/src/spa/composables/workbench.ts new file mode 100644 index 0000000..01b42f4 --- /dev/null +++ b/plugins/data-inspector/src/spa/composables/workbench.ts @@ -0,0 +1,327 @@ +/** + * The query workbench pipeline: + * debounced auto-run, client-side jora syntax gate (malformed queries never + * hit the wire), stale-response sequencing, non-destructive errors (the last + * good result stays), remote stat-mode suggestions, client query settings, + * and the source SKELETON ("what data are available", query-independent). + * An empty query runs `$` (the root), so every source lands on a full view. + */ +import type { DataSourceMeta, FilterOptions, QueryOutcome, QueryStats, SkeletonOutcome, SuggestItem, SuggestOutcome } from '../../engine' +import jora from 'jora' +import { computed, reactive, ref, shallowRef, watch } from 'vue' +import { backend } from './rpc' + +export type SyntaxState + = | { kind: 'ok' } + | { kind: 'pending' } // incomplete at the cursor: soft state, keep typing + | { kind: 'error', message: string } + +const AUTO_RUN_DEBOUNCE = 400 +const SUGGEST_DEBOUNCE = 150 +const URL_SYNC_DEBOUNCE = 300 +const DRAFTS_KEY = 'data-inspector:drafts' + +const FILTER_KEYS = ['excludeFunctions', 'excludeUnderscoreProps', 'excludeDollarProps'] as const + +/** Per-source query drafts, persisted in localStorage. */ +function loadDrafts(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(DRAFTS_KEY) ?? '{}') + return parsed && typeof parsed === 'object' ? parsed : {} + } + catch { + return {} + } +} + +function checkSyntax(query: string): SyntaxState { + try { + jora.syntax.parse(query) + return { kind: 'ok' } + } + catch (error) { + const e = error as Error & { details?: { loc?: { range?: [number, number] } } } + const range = e.details?.loc?.range + // An error at (or beyond) the end of input means the query is merely + // incomplete while typing. + if (!range || range[1] >= query.trimEnd().length) + return { kind: 'pending' } + return { kind: 'error', message: e.message } + } +} + +/** Read the shareable workbench state from the page URL. */ +function readUrlState(): { sourceId: string, query: string, filters: FilterOptions } { + const params = new URLSearchParams(location.search) + const filters: FilterOptions = {} + for (const key of FILTER_KEYS) { + if (params.get(key) === '1') + filters[key] = true + } + return { + sourceId: params.get('source') ?? '', + query: params.get('query') ?? '', + filters, + } +} + +export function useWorkbench() { + const initial = readUrlState() + + const sources = ref([]) + const sourceId = ref(initial.sourceId) + const query = ref(initial.query) + + // ── per-source query drafts (restored/reset on source switch) ─────── + const drafts = loadDrafts() + let restoringDraft = false + + function saveDraft(): void { + if (!sourceId.value) + return + if (query.value) + drafts[sourceId.value] = query.value + else + delete drafts[sourceId.value] + localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)) + } + + function restoreDraft(): void { + restoringDraft = true + query.value = drafts[sourceId.value] ?? '' + } + + const settings = reactive>({ + excludeFunctions: false, + excludeUnderscoreProps: false, + excludeDollarProps: false, + ...initial.filters, + }) + + // ── URL persistence: source, query, and filters stay shareable ────── + let urlTimer: ReturnType | undefined + function syncUrl(): void { + clearTimeout(urlTimer) + urlTimer = setTimeout(() => { + const params = new URLSearchParams() + if (sourceId.value) + params.set('source', sourceId.value) + if (query.value) + params.set('query', query.value) + for (const key of FILTER_KEYS) { + if (settings[key]) + params.set(key, '1') + } + const search = params.toString() + history.replaceState(null, '', search ? `?${search}` : location.pathname) + }, URL_SYNC_DEBOUNCE) + } + + const syntax = ref({ kind: 'ok' }) + const running = ref(false) + const serverError = ref(null) + const stats = ref<(QueryStats & { rpcMs: number }) | null>(null) + const statsStale = ref(false) + const result = shallowRef() + const hasResult = ref(false) + + const suggestions = ref([]) + + const skeleton = shallowRef() + const skeletonError = ref(null) + const skeletonLoading = ref(false) + + const activeSource = computed(() => sources.value.find(s => s.id === sourceId.value)) + + async function loadSources(): Promise { + sources.value = await backend().sources() + if (!sourceId.value || !sources.value.some(s => s.id === sourceId.value)) + sourceId.value = sources.value[0]?.id ?? '' + // A query arriving via the URL becomes the draft for its source, so the + // source-switch restore below can never clobber a shared link. + if (initial.query) + saveDraft() + } + + // ── auto-run with syntax gate + stale-drop ───────────────────────── + let runSeq = 0 + let runTimer: ReturnType | undefined + + async function runNow(): Promise { + clearTimeout(runTimer) + // Deliberately leaves `suggestions` alone: executions (auto-run included) + // must not close the autocomplete while the user is still composing. + if (!sourceId.value) + return + // An empty query still fires: `$` displays the entire source object. + const text = query.value.trim() || '$' + + if (text !== '$') { + const check = checkSyntax(text) + syntax.value = check + if (check.kind !== 'ok') + return // never send malformed queries over the wire + } + else { + syntax.value = { kind: 'ok' } + } + + const seq = ++runSeq + running.value = true + const started = performance.now() + let outcome: QueryOutcome + try { + outcome = await backend().query(sourceId.value, text, { ...settings }) + } + catch (error) { + if (seq === runSeq) { + running.value = false + serverError.value = `rpc: ${error instanceof Error ? error.message : String(error)}` + statsStale.value = true + } + return + } + if (seq !== runSeq) + return // superseded by a newer keystroke + running.value = false + + if (!outcome.ok) { + serverError.value = `${outcome.error.name}: ${outcome.error.message}` + statsStale.value = true + return + } + serverError.value = null + statsStale.value = false + stats.value = { ...outcome.stats, rpcMs: Math.round(performance.now() - started) } + result.value = outcome.result + hasResult.value = true + } + + function scheduleRun(): void { + clearTimeout(runTimer) + runTimer = setTimeout(() => void runNow(), AUTO_RUN_DEBOUNCE) + } + + // ── skeleton: what data are available (query-independent) ────────── + let skeletonSeq = 0 + + async function loadSkeleton(): Promise { + if (!sourceId.value) + return + const seq = ++skeletonSeq + skeletonLoading.value = true + let out: SkeletonOutcome + try { + out = await backend().skeleton(sourceId.value, { ...settings }) + } + catch (error) { + if (seq === skeletonSeq) { + skeletonLoading.value = false + skeletonError.value = `rpc: ${error instanceof Error ? error.message : String(error)}` + } + return + } + if (seq !== skeletonSeq) + return + skeletonLoading.value = false + if (!out.ok) { + skeletonError.value = `${out.error.name}: ${out.error.message}` + return + } + skeletonError.value = null + skeleton.value = out.skeleton + } + + // ── remote suggestions ───────────────────────────────────────────── + let suggestSeq = 0 + let suggestTimer: ReturnType | undefined + + async function requestSuggestions(pos: number): Promise { + const seq = ++suggestSeq + let out: SuggestOutcome + try { + out = await backend().suggest(sourceId.value, query.value, pos) + } + catch { + return // best-effort; transport errors never surface here + } + if (seq !== suggestSeq) + return + // jora returns the full candidate set per range; prefix-filter client-side. + suggestions.value = (out.ok ? out.suggestions : []).filter(s => + !s.current || s.value.toLowerCase().startsWith(s.current.toLowerCase()), + ) + } + + function scheduleSuggestions(pos: number): void { + clearTimeout(suggestTimer) + suggestTimer = setTimeout(() => void requestSuggestions(pos), SUGGEST_DEBOUNCE) + } + + function acceptSuggestion(item: SuggestItem): string { + const text = query.value + query.value = text.slice(0, item.from) + item.value + text.slice(item.to) + suggestions.value = [] + void runNow() + return query.value + } + + /** Load a query recipe: text + the filter options it was authored with. */ + function applyRecipe(recipe: { query: string } & FilterOptions): void { + for (const key of FILTER_KEYS) + settings[key] = recipe[key] ?? false + query.value = recipe.query + void runNow() + } + + watch(query, () => { + saveDraft() + syncUrl() + if (restoringDraft) { + // Draft restores ride the source-switch runNow; skip the debounce run. + restoringDraft = false + return + } + scheduleRun() + }) + watch(sourceId, () => { + suggestions.value = [] + restoreDraft() + syncUrl() + void runNow() + void loadSkeleton() + }) + watch(settings, () => { + syncUrl() + void runNow() + void loadSkeleton() + }) + + return { + sources, + sourceId, + activeSource, + query, + settings, + syntax, + running, + serverError, + stats, + statsStale, + result, + hasResult, + suggestions, + skeleton, + skeletonError, + skeletonLoading, + loadSources, + loadSkeleton, + runNow, + requestSuggestions, + scheduleSuggestions, + acceptSuggestion, + applyRecipe, + } +} + +export type Workbench = ReturnType diff --git a/plugins/data-inspector/src/spa/index.html b/plugins/data-inspector/src/spa/index.html new file mode 100644 index 0000000..bf5cf3c --- /dev/null +++ b/plugins/data-inspector/src/spa/index.html @@ -0,0 +1,13 @@ + + + + + + + Data Inspector + + +
+ + + diff --git a/plugins/data-inspector/src/spa/main.ts b/plugins/data-inspector/src/spa/main.ts new file mode 100644 index 0000000..ed0239b --- /dev/null +++ b/plugins/data-inspector/src/spa/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import App from './App.vue' +import 'virtual:uno.css' +import 'floating-vue/dist/style.css' +import '@antfu/design/styles.css' +import './style.css' + +createApp(App).mount('#app') diff --git a/plugins/data-inspector/src/spa/shims.d.ts b/plugins/data-inspector/src/spa/shims.d.ts new file mode 100644 index 0000000..52cb1be --- /dev/null +++ b/plugins/data-inspector/src/spa/shims.d.ts @@ -0,0 +1,13 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent, Record, unknown> + export default component +} + +declare module 'virtual:uno.css' {} +declare module '*.css' {} +declare module '*.css?inline' { + const css: string + export default css +} diff --git a/plugins/data-inspector/src/spa/style.css b/plugins/data-inspector/src/spa/style.css new file mode 100644 index 0000000..f05d3e0 --- /dev/null +++ b/plugins/data-inspector/src/spa/style.css @@ -0,0 +1,21 @@ +/* App-level styles beyond the design system. */ + +html, +body, +#app { + margin: 0; + height: 100%; + overflow: hidden; +} + +/* Bridge for the discovery panel's shadow root: the --discovery-* custom + props (set in the injected :host rule) read these values, which flip with + the design system's `.dark` class. Values mirror `bg-base` / `color-base`. */ +.di-result-host { + --di-result-bg: #ffffff; + --di-result-fg: #262626; +} +.dark .di-result-host { + --di-result-bg: #111111; + --di-result-fg: #e5e5e5; +} diff --git a/plugins/data-inspector/src/spa/vite.config.ts b/plugins/data-inspector/src/spa/vite.config.ts new file mode 100644 index 0000000..eb6c660 --- /dev/null +++ b/plugins/data-inspector/src/spa/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' + +// The data-inspector SPA. `base: './'` keeps every asset URL relative so the +// bundle is mount-path portable — it discovers its runtime base from +// `document.baseURI` and connects via `connectDevframe()`. The build is +// copied verbatim by `createBuild`/`createSpa`; no HTML rewriting. There is +// deliberately no host plugin here: the plugin's own `vite.ts` handles +// hosting, and for `pnpm dev` a plain SPA is fine. +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + // discovery (or a dep) references the Node-style `global`; webpack shims it + // by default, Vite needs the classic define. + define: { global: 'globalThis' }, + plugins: [ + vue(), + UnoCSS(), + ], + // `@antfu/design` ships raw `.ts`/`.vue`; let `@vitejs/plugin-vue` compile its + // SFCs instead of esbuild pre-bundling them. + optimizeDeps: { exclude: ['@antfu/design'] }, + build: { + outDir: fileURLToPath(new URL('../../dist/spa', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/plugins/data-inspector/src/vite.ts b/plugins/data-inspector/src/vite.ts new file mode 100644 index 0000000..ad0d13a --- /dev/null +++ b/plugins/data-inspector/src/vite.ts @@ -0,0 +1,25 @@ +import type { DevframeVitePlugin, ViteDevBridgeOptions } from 'devframe/helpers/vite' +import { viteDevBridge } from 'devframe/helpers/vite' +import dataInspectorDevframe from './index' + +export type { ViteDevBridgeOptions } + +/** + * Mount the data inspector into an existing Vite dev server. In the default + * static-mount mode it serves the built SPA at `/__devframes:plugin:data-inspector/`; + * pass `{ devMiddleware: true }` for the bridge mode where the host owns + * the SPA and devframe runs a side-car RPC + WS server. + * + * Register the host's own objects as sources next to it: + * + * ```ts + * import { registerDataSource } from '@devframes/plugin-data-inspector/registry' + * + * configureServer(server) { + * registerDataSource({ id: 'vite:server', title: 'Vite Dev Server', data: () => server }) + * } + * ``` + */ +export function dataInspectorVitePlugin(options?: ViteDevBridgeOptions): DevframeVitePlugin { + return viteDevBridge(dataInspectorDevframe, options) +} diff --git a/plugins/data-inspector/test/engine.test.ts b/plugins/data-inspector/test/engine.test.ts new file mode 100644 index 0000000..02d0994 --- /dev/null +++ b/plugins/data-inspector/test/engine.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { normalize } from '../src/engine/normalize' +import { runQuery, suggest } from '../src/engine/query-engine' +import { skeletonOf } from '../src/engine/skeleton' + +class Store { + name = 'sessions' + entries = new Map([['a', { hits: 3 }], ['b', { hits: 7 }]]) + get upper(): string { + return this.name.toUpperCase() + } +} + +function liveGraph() { + const parent: Record = { name: 'parent' } + parent.self = parent + return { + store: new Store(), + tags: new Set(['alpha', 'beta']), + when: new Date('2026-01-01T00:00:00Z'), + big: 10n, + fn: () => 'x', + circular: parent, + list: Array.from({ length: 500 }, (_, i) => i), + } +} + +describe('normalize', () => { + it('produces strict JSON from a hostile live graph', () => { + const { data, stats } = normalize(liveGraph(), { maxEntries: 50 }) + const text = JSON.stringify(data) + expect(text).toBeTypeOf('string') + expect(JSON.parse(text)).toBeTruthy() + expect(stats.refs).toBe(1) + expect(stats.truncatedEntries).toBeGreaterThan(0) + }) + + it('tags exotic values and marks circulars', () => { + const { data } = normalize(liveGraph()) as { data: any } + expect(data.store.$class).toBe('Store') + expect(data.store.entries.$type).toBe('Map') + expect(data.tags).toMatchObject({ $type: 'Set', size: 2 }) + expect(data.when.$type).toBe('Date') + expect(data.big).toMatchObject({ $type: 'bigint', value: '10' }) + expect(data.fn.$type).toBe('function') + expect(JSON.stringify(data.circular)).toContain('$ref') + }) + + it('honors filter options', () => { + const input = { keep: 1, _private: 2, $meta: 3, fn: () => {} } + const { data } = normalize(input, { + excludeFunctions: true, + excludeUnderscoreProps: true, + excludeDollarProps: true, + }) as { data: Record } + expect(Object.keys(data)).toEqual(['keep']) + }) +}) + +describe('runQuery (live)', () => { + it('queries live Maps and Sets through the bridge methods', () => { + const out = runQuery(liveGraph(), 'store.entries.mapEntries().key') + expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) + const set = runQuery(liveGraph(), 'tags.fromSet()') + expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) + }) + + it('reports payload size and timings', () => { + const out = runQuery(liveGraph(), 'store.name') + expect(out.ok && out.stats.payloadBytes).toBeGreaterThan(0) + }) + + it('fails soft with an error envelope', () => { + const out = runQuery(liveGraph(), 'nope.method()') + expect(out.ok).toBe(false) + }) +}) + +describe('runQuery (static portability)', () => { + it('the same query works against the NORMALIZED form of the data', () => { + const { data } = normalize(liveGraph()) + // `store.entries` is now a `{ $type: 'Map', value }` tag; the bridge + // methods duck-type it so live-authored queries stay portable. + const out = runQuery(data, 'store.entries.mapEntries().key') + expect(out).toMatchObject({ ok: true, result: ['a', 'b'] }) + const set = runQuery(data, 'tags.fromSet()') + expect(set).toMatchObject({ ok: true, result: ['alpha', 'beta'] }) + }) +}) + +describe('suggest', () => { + it('returns flattened, prefix-ranged completion items', () => { + const out = suggest({ foo: { bar: 1, baz: 2 } }, 'foo.', 4) + expect(out.ok).toBe(true) + expect(out.suggestions.map(s => s.value)).toEqual(['bar', 'baz']) + expect(out.suggestions[0]).toMatchObject({ from: 4, to: 4, current: '' }) + }) +}) + +describe('skeletonOf', () => { + it('captures shape without values, expanding shared refs but not cycles', () => { + const shared = { deep: 1 } + const parent: Record = { shared1: shared, shared2: shared } + parent.self = parent + const { skeleton } = skeletonOf(parent) as { skeleton: any } + expect(skeleton.shared1).toEqual({ deep: 'number' }) + expect(skeleton.shared2).toEqual({ deep: 'number' }) + expect(skeleton.self).toBe('[circular]') + }) + + it('labels collections with sizes', () => { + const { skeleton } = skeletonOf(liveGraph()) as { skeleton: any } + expect(Object.keys(skeleton.store).includes('$class')).toBe(true) + expect(JSON.stringify(skeleton.store)).toContain('Map(2)') + expect(JSON.stringify(skeleton.tags)).toContain('Set(2)') + }) +}) diff --git a/plugins/data-inspector/test/files.test.ts b/plugins/data-inspector/test/files.test.ts new file mode 100644 index 0000000..ea4f43e --- /dev/null +++ b/plugins/data-inspector/test/files.test.ts @@ -0,0 +1,41 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createFileDataSource, loadDataFile } from '../src/node/files' + +function tempFile(name: string, content: string): string { + const dir = mkdtempSync(join(tmpdir(), 'di-files-')) + const filepath = join(dir, name) + writeFileSync(filepath, content) + return filepath +} + +describe('file data sources', () => { + it('parses .json whole', async () => { + const file = tempFile('stats.json', '{ "a": [1, 2] }') + expect(await loadDataFile(file)).toEqual({ a: [1, 2] }) + }) + + it('parses .jsonl as an array of records', async () => { + const file = tempFile('trace.jsonl', '{"n":1}\n\n{"n":2}\n') + expect(await loadDataFile(file)).toEqual([{ n: 1 }, { n: 2 }]) + }) + + it('rejects unsupported extensions with a coded diagnostic', async () => { + const file = tempFile('data.yaml', 'a: 1') + await expect(loadDataFile(file)).rejects.toThrow(/Unsupported data file/) + }) + + it('reports parse failures with the filepath', async () => { + const file = tempFile('broken.json', '{ nope') + await expect(loadDataFile(file)).rejects.toThrow(/Failed to load data file/) + }) + + it('builds a static, lazily-read registry entry', async () => { + const file = tempFile('stats.json', '{ "ok": true }') + const entry = createFileDataSource(file) + expect(entry).toMatchObject({ id: `file:${file}`, static: true }) + expect(await (entry.data as () => Promise)()).toEqual({ ok: true }) + }) +}) diff --git a/plugins/data-inspector/test/registry.test.ts b/plugins/data-inspector/test/registry.test.ts new file mode 100644 index 0000000..bf37d4e --- /dev/null +++ b/plugins/data-inspector/test/registry.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createDataSourcesService, + getDataSource, + listDataSources, + onDataSourcesChanged, + registerDataSource, + resetDataSources, + resolveSourceData, + unregisterDataSource, +} from '../src/registry/index' + +afterEach(() => resetDataSources()) + +describe('data source registry (process-global)', () => { + it('registers, lists, gets and unregisters', () => { + const dispose = registerDataSource({ id: 'a:x', title: 'X', data: 1 }) + expect(listDataSources()).toMatchObject([{ id: 'a:x', title: 'X', static: false }]) + expect(getDataSource('a:x')?.title).toBe('X') + dispose() + expect(listDataSources()).toEqual([]) + }) + + it('converges across duplicate module copies via globalThis', () => { + registerDataSource({ id: 'a:x', title: 'X', data: 1 }) + const store = (globalThis as Record)[ + Symbol.for('devframes:plugin:data-inspector:registry@1') + ] as { entries: Map } + expect(store.entries.has('a:x')).toBe(true) + }) + + it('resolves plain values, sync factories, and async factories', async () => { + registerDataSource({ id: 'v', title: 'v', data: { n: 1 } }) + registerDataSource({ id: 's', title: 's', data: () => ({ n: 2 }) }) + registerDataSource({ id: 'a', title: 'a', data: async () => ({ n: 3 }) }) + expect(await resolveSourceData(getDataSource('v')!)).toEqual({ n: 1 }) + expect(await resolveSourceData(getDataSource('s')!)).toEqual({ n: 2 }) + expect(await resolveSourceData(getDataSource('a')!)).toEqual({ n: 3 }) + }) + + it('memoizes static factories, once', async () => { + const factory = vi.fn(() => ({ at: Math.random() })) + registerDataSource({ id: 'st', title: 'st', static: true, data: factory }) + const first = await resolveSourceData(getDataSource('st')!) + const second = await resolveSourceData(getDataSource('st')!) + expect(first).toBe(second) + expect(factory).toHaveBeenCalledTimes(1) + }) + + it('re-registering clears the static memo', async () => { + let value = 1 + registerDataSource({ id: 'st', title: 'st', static: true, data: () => value }) + expect(await resolveSourceData(getDataSource('st')!)).toBe(1) + value = 2 + registerDataSource({ id: 'st', title: 'st', static: true, data: () => value }) + expect(await resolveSourceData(getDataSource('st')!)).toBe(2) + }) + + it('a rejected static factory does not poison the cache', async () => { + let fail = true + registerDataSource({ + id: 'st', + title: 'st', + static: true, + data: () => { + if (fail) + throw new Error('boom') + return 'ok' + }, + }) + await expect(resolveSourceData(getDataSource('st')!)).rejects.toThrow('boom') + fail = false + // The rejection eviction is scheduled on the promise; give it a tick. + await new Promise(resolve => setTimeout(resolve, 0)) + expect(await resolveSourceData(getDataSource('st')!)).toBe('ok') + }) + + it('notifies change listeners on register/unregister and unsubscribes', () => { + const spy = vi.fn() + const unsubscribe = onDataSourcesChanged(spy) + registerDataSource({ id: 'a:x', title: 'X', data: 1 }) + unregisterDataSource('a:x') + expect(spy).toHaveBeenCalledTimes(2) + unsubscribe() + registerDataSource({ id: 'a:y', title: 'Y', data: 1 }) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('the ctx service facade operates on the same store', () => { + const service = createDataSourcesService() + service.register({ id: 'svc:x', title: 'X', data: 1 }) + expect(listDataSources().map(s => s.id)).toContain('svc:x') + service.unregister('svc:x') + expect(listDataSources()).toEqual([]) + }) +}) diff --git a/plugins/data-inspector/test/saved-queries.test.ts b/plugins/data-inspector/test/saved-queries.test.ts new file mode 100644 index 0000000..efc6b72 --- /dev/null +++ b/plugins/data-inspector/test/saved-queries.test.ts @@ -0,0 +1,75 @@ +import type { DevframeNodeContext } from 'devframe/types' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { deleteSavedQuery, listSavedQueries, saveQuery } from '../src/node/saved-queries' + +function contextStub(): DevframeNodeContext { + const root = mkdtempSync(join(tmpdir(), 'di-saved-')) + return { + host: { + getStorageDir: (scope: string) => join(root, scope), + }, + } as unknown as DevframeNodeContext +} + +describe('saved queries', () => { + it('saves per scope, id-keyed, and lists across scopes', () => { + const ctx = contextStub() + saveQuery(ctx, { title: 'Plugin names', query: 'config.plugins.name', scope: 'workspace' }) + saveQuery(ctx, { query: 'keys()', scope: 'project' }) + const all = listSavedQueries(ctx) + expect(all).toHaveLength(2) + expect(all.find(q => q.scope === 'workspace')?.id).toBe('plugin-names') + expect(all.find(q => q.scope === 'project')?.id).toMatch(/^q-/) + }) + + it('derives a stable hash id for untitled queries', () => { + const ctx = contextStub() + const first = saveQuery(ctx, { query: 'a.b.c', scope: 'project' }) + const second = saveQuery(ctx, { query: 'a.b.c', scope: 'project' }) + expect(first.id).toBe(second.id) + expect(listSavedQueries(ctx)).toHaveLength(1) + }) + + it('persists filter options and strips falsy ones', () => { + const ctx = contextStub() + const saved = saveQuery(ctx, { + title: 'clean', + query: 'config', + scope: 'workspace', + excludeFunctions: true, + excludeUnderscoreProps: false, + }) + expect(saved.excludeFunctions).toBe(true) + expect(saved).not.toHaveProperty('excludeUnderscoreProps', false) + }) + + it('re-saving the same id into the other scope moves it', () => { + const ctx = contextStub() + saveQuery(ctx, { title: 'One', query: 'a', scope: 'project' }) + saveQuery(ctx, { title: 'One', query: 'a', scope: 'workspace' }) + const all = listSavedQueries(ctx) + expect(all).toHaveLength(1) + expect(all[0].scope).toBe('workspace') + }) + + it('deletes by id + scope', () => { + const ctx = contextStub() + const saved = saveQuery(ctx, { title: 'One', query: 'a', scope: 'project' }) + expect(deleteSavedQuery(ctx, saved.id, 'project')).toBe(true) + expect(deleteSavedQuery(ctx, saved.id, 'project')).toBe(false) + expect(listSavedQueries(ctx)).toHaveLength(0) + }) + + it('writes into /data-inspector/queries.json per scope', async () => { + const ctx = contextStub() + saveQuery(ctx, { title: 'One', query: 'a', scope: 'workspace' }) + // createStorage debounces writes (100ms default). + await new Promise(resolve => setTimeout(resolve, 250)) + const file = join(ctx.host.getStorageDir('workspace'), 'data-inspector/queries.json') + const parsed = JSON.parse(readFileSync(file, 'utf-8')) + expect(parsed.queries.one.query).toBe('a') + }) +}) diff --git a/plugins/data-inspector/tsconfig.json b/plugins/data-inspector/tsconfig.json new file mode 100644 index 0000000..9c7effe --- /dev/null +++ b/plugins/data-inspector/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "Bundler", + "types": ["node"], + "noEmit": true, + "isolatedDeclarations": false + }, + "include": ["src", "test", "bin.mjs"] +} diff --git a/plugins/data-inspector/tsdown.config.ts b/plugins/data-inspector/tsdown.config.ts new file mode 100644 index 0000000..74a48b4 --- /dev/null +++ b/plugins/data-inspector/tsdown.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from 'tsdown' + +const tsconfig = '../../tsconfig.base.json' + +// Browser-loaded entries. Kept in their own rolldown graph so node-only +// imports can never leak into the client bundle. The engine is isomorphic: +// it runs server-side for live queries and client-side for static exports. +const clientEntries = { + 'client/index': 'src/client/index.ts', + 'engine/index': 'src/engine/index.ts', +} + +// Node-side entries — the devframe definition, the CLI/Vite host adapters, +// the setup module, the source registry, and the in-process agent. +const serverEntries = { + 'index': 'src/index.ts', + 'cli': 'src/cli.ts', + 'vite': 'src/vite.ts', + 'node/index': 'src/node/index.ts', + 'registry/index': 'src/registry/index.ts', + 'agent/index': 'src/agent/index.ts', +} + +export default defineConfig([ + { + clean: true, + platform: 'browser', + tsconfig, + dts: false, + outExtensions: () => ({ js: '.mjs' }), + entry: clientEntries, + }, + { + clean: false, + platform: 'node', + tsconfig, + dts: false, + entry: serverEntries, + }, + { + clean: false, + platform: 'neutral', + tsconfig, + dts: { emitDtsOnly: true }, + outExtensions: () => ({ dts: '.d.mts' }), + entry: { ...clientEntries, ...serverEntries }, + }, +]) diff --git a/plugins/data-inspector/uno.config.ts b/plugins/data-inspector/uno.config.ts new file mode 100644 index 0000000..713eb4b --- /dev/null +++ b/plugins/data-inspector/uno.config.ts @@ -0,0 +1,42 @@ +import { presetAnthonyDesign } from '@antfu/design/unocss' +import { + defineConfig, + presetIcons, + presetWebFonts, + presetWind4, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +// The inspector uses `@antfu/design` directly: its preset (tuned to devframe's +// sage green) over a Wind4 base, with Phosphor icons, DM Sans/Mono and the +// directive/variant-group transformers. Vue templates are scanned by default; +// `.ts` is opted in for class strings authored in composables/helpers. The +// named `z-*` layers are the app's to own (the preset blocks plain `z-`). +export default defineConfig({ + presets: [ + presetAnthonyDesign({ primary: '#3a6a45' }), + presetWind4(), + presetIcons({ scale: 1.1 }), + presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + // Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle + // shared border color (matching `border-base`) for unqualified borders. + preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + shortcuts: { + 'z-nav': 'z-[30]', + 'z-dropdown': 'z-[40]', + 'z-tooltip': 'z-[45]', + 'z-toast': 'z-[50]', + 'z-modal-backdrop': 'z-[60]', + 'z-modal-content': 'z-[70]', + 'z-drawer-backdrop': 'z-[80]', + 'z-drawer-content': 'z-[90]', + }, + content: { + pipeline: { + include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91d9e9..26025e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,19 @@ catalogs: vite-plugin-solid: specifier: ^2.11.12 version: 2.11.12 + default: + '@discoveryjs/discovery': + specifier: 1.0.0-beta.99 + version: 1.0.0-beta.99 + '@types/codemirror': + specifier: ^5.60.16 + version: 5.60.17 + jora: + specifier: 1.0.0-beta.16 + version: 1.0.0-beta.16 + splitpanes: + specifier: ^4.0.4 + version: 4.1.2 deps: '@modelcontextprotocol/sdk': specifier: ^1.29.0 @@ -390,7 +403,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) cac: specifier: catalog:deps version: 7.0.0 @@ -433,7 +446,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@devframes/hub': specifier: workspace:* version: link:../../packages/hub @@ -503,7 +516,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@devframes/hub': specifier: workspace:* version: link:../../packages/hub @@ -513,6 +526,9 @@ importers: '@devframes/plugin-code-server': specifier: workspace:* version: link:../../plugins/code-server + '@devframes/plugin-data-inspector': + specifier: workspace:* + version: link:../../plugins/data-inspector '@devframes/plugin-git': specifier: workspace:* version: link:../../plugins/git @@ -552,7 +568,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) cac: specifier: catalog:deps version: 7.0.0 @@ -601,7 +617,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@devframes/hub': specifier: workspace:* version: link:../../packages/hub @@ -641,7 +657,7 @@ importers: dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) cac: specifier: catalog:deps version: 7.0.0 @@ -831,7 +847,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) colorjs.io: specifier: catalog:frontend version: 0.7.0 @@ -886,7 +902,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 @@ -924,6 +940,64 @@ importers: specifier: catalog:deps version: 8.21.1 + plugins/data-inspector: + dependencies: + cac: + specifier: catalog:deps + version: 7.0.0 + get-port-please: + specifier: catalog:deps + version: 3.2.0 + jora: + specifier: 'catalog:' + version: 1.0.0-beta.16 + nostics: + specifier: catalog:deps + version: 1.1.4 + devDependencies: + '@antfu/design': + specifier: catalog:frontend + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + '@discoveryjs/discovery': + specifier: 'catalog:' + version: 1.0.0-beta.99 + '@iconify-json/ph': + specifier: catalog:frontend + version: 1.2.2 + '@types/codemirror': + specifier: 'catalog:' + version: 5.60.17 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.8(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + devframe: + specifier: workspace:* + version: link:../../packages/devframe + floating-vue: + specifier: catalog:frontend + version: 5.2.2(vue@3.5.39(typescript@6.0.3)) + reka-ui: + specifier: catalog:frontend + version: 2.10.1(vue@3.5.39(typescript@6.0.3)) + splitpanes: + specifier: 'catalog:' + version: 4.1.2(vue@3.5.39(typescript@6.0.3)) + tsdown: + specifier: catalog:build + version: 0.22.7(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + unocss: + specifier: catalog:frontend + version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vite: + specifier: catalog:build + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.1.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vue: + specifier: catalog:frontend + version: 3.5.39(typescript@6.0.3) + plugins/git: dependencies: cac: @@ -938,7 +1012,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@floating-ui/react': specifier: catalog:frontend version: 0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1038,7 +1112,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@5.9.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@5.9.3)) '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 @@ -1105,7 +1179,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@devframes/hub': specifier: workspace:* version: link:../../packages/hub @@ -1190,7 +1264,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 @@ -1247,7 +1321,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) + version: 0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3)) '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 @@ -1575,6 +1649,18 @@ packages: '@colordx/core@5.4.3': resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} + '@discoveryjs/discovery@1.0.0-beta.99': + resolution: {integrity: sha512-Dy374TnEbEJpgNDogwHHeY9bMFwnNOvs5V8qGZmMyfn0irGsoKldb+wi6d5xY+9DZ9G3D6WJ9M/jKaB7bjvJqg==} + engines: {node: '>=8.0.0'} + + '@discoveryjs/json-ext@0.6.3': + resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} + engines: {node: '>=14.17.0'} + + '@discoveryjs/natural-compare@1.1.0': + resolution: {integrity: sha512-yuctPJs5lRXoI8LkpVZGAV6n+DKOuEsfpfcIDQ8ZjWHwazqk1QjBc4jMlof0UlZHyUqv4dwsOTooMiAmtzvwXA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + '@docsearch/css@4.6.3': resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} @@ -4428,6 +4514,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/codemirror@5.60.17': + resolution: {integrity: sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -4586,6 +4675,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/tern@0.23.9': + resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -5214,6 +5306,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5548,10 +5644,19 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + codemirror@5.65.21: + resolution: {integrity: sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -6677,6 +6782,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hitext@1.0.0-beta.1: + resolution: {integrity: sha512-H7ph5MF3lrc8BE47hhyHi+ZgoRiGi5kqxnA5Ou6rg9dVHtPnmUU8/Man19X/H3Cj1C/DF+jsN/ch9eV7nfpzMQ==} + engines: {node: '>=8.0.0'} + hono@4.12.18: resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} @@ -6899,6 +7008,14 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jora@1.0.0-beta.15: + resolution: {integrity: sha512-P3Ztojc+qvHbnqdS7vrIbt5TUoiYBD+o/ICvw9Paf+ozr2+dCnCBetmteojv6ISPBrBnE/5ZlCjBMe96YqYnoA==} + engines: {node: ^10.12.0 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + jora@1.0.0-beta.16: + resolution: {integrity: sha512-gkR5tmjfhAC7GwuAbXSDOG691Ei3VLTvkKc5hGG5NoPWPwqSTCy0Q3I39dH1Aae0ExC3OXHfHjZSZtFDwqfRwg==} + engines: {node: '>=18.0.0'} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -7166,6 +7283,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.1.4: + resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -8401,6 +8523,11 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + splitpanes@4.1.2: + resolution: {integrity: sha512-frNchoCv7w0nMQEuaDGgMGMU6jv3NhN6bBGQVfCNgwJdVcYaRmi1dwYDjp7SifAoUFdiQjBRB254LJNidzo15Q==} + peerDependencies: + vue: ^3.2.0 + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -9495,7 +9622,7 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@5.9.3))': + '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@5.9.3))': dependencies: '@vueuse/core': 14.3.0(vue@3.5.39(typescript@5.9.3)) unocss: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -9509,8 +9636,9 @@ snapshots: floating-vue: 5.2.2(vue@3.5.39(typescript@5.9.3)) playwright: 1.61.1 reka-ui: 2.10.1(vue@3.5.39(typescript@5.9.3)) + splitpanes: 4.1.2(vue@3.5.39(typescript@5.9.3)) - '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3))': + '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3))': dependencies: '@vueuse/core': 14.3.0(vue@3.5.39(typescript@6.0.3)) unocss: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -9524,8 +9652,9 @@ snapshots: floating-vue: 5.2.2(vue@3.5.39(typescript@6.0.3)) playwright: 1.61.1 reka-ui: 2.10.1(vue@3.5.39(typescript@6.0.3)) + splitpanes: 4.1.2(vue@3.5.39(typescript@6.0.3)) - '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3))': + '@antfu/design@0.2.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.39(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(floating-vue@5.2.2(vue@3.5.39(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.39(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.39(typescript@6.0.3))': dependencies: '@vueuse/core': 14.3.0(vue@3.5.39(typescript@6.0.3)) unocss: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.16))(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -9539,6 +9668,7 @@ snapshots: floating-vue: 5.2.2(vue@3.5.39(typescript@6.0.3)) playwright: 1.61.1 reka-ui: 2.10.1(vue@3.5.39(typescript@6.0.3)) + splitpanes: 4.1.2(vue@3.5.39(typescript@6.0.3)) '@antfu/eslint-config@9.1.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.63.0(typescript@6.0.3))(@typescript-eslint/utils@8.63.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))': dependencies: @@ -9846,6 +9976,20 @@ snapshots: '@colordx/core@5.4.3': {} + '@discoveryjs/discovery@1.0.0-beta.99': + dependencies: + '@discoveryjs/json-ext': 0.6.3 + codemirror: 5.65.21 + diff: 8.0.4 + github-slugger: 2.0.0 + hitext: 1.0.0-beta.1 + jora: 1.0.0-beta.15 + marked: 14.1.4 + + '@discoveryjs/json-ext@0.6.3': {} + + '@discoveryjs/natural-compare@1.1.0': {} + '@docsearch/css@4.6.3': {} '@docsearch/js@4.6.3': {} @@ -10210,7 +10354,6 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' - vue - optional: true '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: @@ -10750,7 +10893,7 @@ snapshots: dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) - '@vitejs/plugin-vue': 6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) + '@vitejs/plugin-vue': 6.0.8(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) '@vitejs/plugin-vue-jsx': 5.1.5(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) autoprefixer: 10.5.0(postcss@8.5.16) consola: 3.4.2 @@ -11311,7 +11454,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -12131,7 +12274,6 @@ snapshots: dependencies: '@tanstack/virtual-core': 3.17.2 vue: 3.5.39(typescript@6.0.3) - optional: true '@testing-library/dom@10.4.1': dependencies: @@ -12208,6 +12350,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/codemirror@5.60.17': + dependencies: + '@types/tern': 0.23.9 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -12382,6 +12528,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/tern@0.23.9': + dependencies: + '@types/estree': 1.0.9 + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -12755,16 +12905,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.1.3(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.3(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) vue: 3.5.39(typescript@6.0.3) - '@vitejs/plugin-vue@6.0.7(vite@8.1.3(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.8(vite@7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.3(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 7.3.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) vue: 3.5.39(typescript@6.0.3) '@vitejs/plugin-vue@6.0.8(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@5.9.3))': @@ -13151,6 +13301,10 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -13481,10 +13635,18 @@ snapshots: cluster-key-slot@1.1.2: {} + codemirror@5.65.21: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} colorette@2.0.20: {} @@ -14513,7 +14675,6 @@ snapshots: '@floating-ui/dom': 1.1.1 vue: 3.5.39(typescript@6.0.3) vue-resize: 2.0.0-alpha.1(vue@3.5.39(typescript@6.0.3)) - optional: true focus-trap@8.2.2: dependencies: @@ -14700,6 +14861,10 @@ snapshots: he@1.2.0: {} + hitext@1.0.0-beta.1: + dependencies: + ansi-styles: 3.2.1 + hono@4.12.18: {} hookable@5.5.3: {} @@ -14884,6 +15049,14 @@ snapshots: jiti@2.7.0: {} + jora@1.0.0-beta.15: + dependencies: + '@discoveryjs/natural-compare': 1.1.0 + + jora@1.0.0-beta.16: + dependencies: + '@discoveryjs/natural-compare': 1.1.0 + jose@6.2.3: {} js-stringify@1.0.2: {} @@ -15119,6 +15292,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.1.4: {} + marked@16.4.2: {} math-intrinsics@1.1.0: {} @@ -16663,7 +16838,6 @@ snapshots: vue: 3.5.39(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' - optional: true require-from-string@2.0.2: {} @@ -16759,7 +16933,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.1.4)(rollup@4.60.3): dependencies: open: 11.0.0 - picomatch: 4.0.4 + picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: @@ -16769,7 +16943,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.60.3): dependencies: open: 11.0.0 - picomatch: 4.0.4 + picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: @@ -17060,6 +17234,15 @@ snapshots: spdx-license-ids@3.0.23: {} + splitpanes@4.1.2(vue@3.5.39(typescript@5.9.3)): + dependencies: + vue: 3.5.39(typescript@5.9.3) + optional: true + + splitpanes@4.1.2(vue@3.5.39(typescript@6.0.3)): + dependencies: + vue: 3.5.39(typescript@6.0.3) + sprintf-js@1.0.3: {} srvx@0.11.15: {} @@ -17928,7 +18111,6 @@ snapshots: vue-demi@0.14.10(vue@3.5.39(typescript@6.0.3)): dependencies: vue: 3.5.39(typescript@6.0.3) - optional: true vue-devtools-stub@0.1.0: {} @@ -17991,7 +18173,6 @@ snapshots: vue-resize@2.0.0-alpha.1(vue@3.5.39(typescript@6.0.3)): dependencies: vue: 3.5.39(typescript@6.0.3) - optional: true vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(vite@8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cbfa4f2..4c3f11d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,6 +31,11 @@ overrides: crossws: ^0.4.10 semver: ^7.8.5 shell-quote: ^1.10.0 +catalog: + '@discoveryjs/discovery': 1.0.0-beta.99 + '@types/codemirror': ^5.60.16 + jora: 1.0.0-beta.16 + splitpanes: ^4.0.4 catalogs: build: '@antfu/ni': ^30.2.0 diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.d.ts new file mode 100644 index 0000000..303e05e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.d.ts @@ -0,0 +1,31 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/agent` + */ +// #region Interfaces +export interface AgentDiscovery { + websocket: string; + token?: string; + pid: number; + startedAt: number; +} +export interface DataInspectorAgent { + websocket: string; + token?: string; + close: () => Promise; +} +export interface ExposeDataInspectorOptions { + port?: number; + auth?: boolean; + token?: string; + discoveryFile?: boolean; + silent?: boolean; +} +// #endregion + +// #region Functions +export declare function exposeDataInspector(_?: ExposeDataInspectorOptions): Promise; +// #endregion + +// #region Variables +export declare const AGENT_DISCOVERY_FILE: string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.js new file mode 100644 index 0000000..9f86611 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/agent.snapshot.js @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/agent` + */ +// #region Functions +export async function exposeDataInspector(_) {} +// #endregion + +// #region Variables +export var AGENT_DISCOVERY_FILE /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.d.ts new file mode 100644 index 0000000..a94b5a2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.d.ts @@ -0,0 +1,27 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/cli` + */ +// #region Interfaces +export interface StaticDataset { + sources: Array<{ + id: string; + title: string; + description?: string; + icon?: string; + static: boolean; + queries?: { + query: string; + title?: string; + description?: string; + }[]; + data: unknown; + }>; +} +// #endregion + +// #region Functions +export declare function createDataInspectorCli(): { + cli: import("cac").CAC; + parse: (_?: string[]) => Promise; +}; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.js new file mode 100644 index 0000000..b52ec11 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/cli.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/cli` + */ +// #region Functions +export function createDataInspectorCli() {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.d.ts new file mode 100644 index 0000000..0b99462 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.d.ts @@ -0,0 +1,22 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/client` + */ +// #region Functions +export declare function connectDataInspector(_?: DevframeRpcClientOptions): Promise; +// #endregion + +// #region Other +export { DataSourceMeta } +export { DevframeConnectionStatus } +export { DevframeRpcClient } +export { FilterOptions } +export { Query } +export { QueryOutcome } +export { QueryStats } +export { SavedQuery } +export { SavedQueryScope } +export { SaveQueryInput } +export { SkeletonOutcome } +export { SuggestItem } +export { SuggestOutcome } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.js new file mode 100644 index 0000000..d9d163e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/client.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/client` + */ +// #region Functions +export function connectDataInspector(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts new file mode 100644 index 0000000..0a34812 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.d.ts @@ -0,0 +1,25 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/engine` + */ +// #region Other +export { DataSourceMeta } +export { FilterOptions } +export { isExcludedKey } +export { normalize } +export { NormalizeOptions } +export { NormalizeStats } +export { NormalizeStatsWire } +export { Query } +export { QueryOutcome } +export { QueryStats } +export { runQuery } +export { SavedQuery } +export { SavedQueryScope } +export { SaveQueryInput } +export { skeletonOf } +export { SkeletonOptions } +export { SkeletonOutcome } +export { suggest } +export { SuggestItem } +export { SuggestOutcome } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js new file mode 100644 index 0000000..de32571 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/engine.snapshot.js @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/engine` + */ +// #region Functions +export function isExcludedKey(_, _) {} +export function normalize(_, _) {} +export function runQuery(_, _, _) {} +export function skeletonOf(_, _) {} +export function suggest(_, _, _, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.d.ts new file mode 100644 index 0000000..f03df31 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.d.ts @@ -0,0 +1,29 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector` + */ +// #region Interfaces +export interface DataInspectorDevframeOptions { + id?: string; + name?: string; + icon?: string; + basePath?: string; + port?: number; + auth?: boolean; +} +// #endregion + +// #region Functions +export declare function createDataInspectorDevframe(_?: DataInspectorDevframeOptions): DevframeDefinition; +// #endregion + +// #region Default Export +declare const _default: DevframeDefinition; +export default _default +// #endregion + +// #region Other +export { DATA_SOURCES_SERVICE_ID } +export { DataSourceEntry } +export { DataSourcesService } +export { registerDataSource } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.js new file mode 100644 index 0000000..1bb7d8e --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/index.snapshot.js @@ -0,0 +1,12 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector` + */ +// #region Default Export +export default src_default +// #endregion + +// #region Other +export { createDataInspectorDevframe } +export { DATA_SOURCES_SERVICE_ID } +export { registerDataSource } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.d.ts new file mode 100644 index 0000000..03e5688 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.d.ts @@ -0,0 +1,124 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/node` + */ +// #region Functions +export declare function setupDataInspector(_: DevframeNodeContext): void; +// #endregion + +// #region Variables +export declare const serverFunctions: readonly [{ + name: "devframes:plugin:data-inspector:sources"; + type?: "query" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: (() => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:query"; + type?: "query" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((sourceId: string, joraQuery: string, options?: ({ + maxDepth?: number; + maxEntries?: number; + } & FilterOptions) | undefined) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[sourceId: string, joraQuery: string, options?: ({ + maxDepth?: number; + maxEntries?: number; + } & FilterOptions) | undefined], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:skeleton"; + type?: "query" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((sourceId: string, options?: FilterOptions | undefined) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[sourceId: string, options?: FilterOptions | undefined], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:suggest"; + type?: "query" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((sourceId: string, joraQuery: string, pos: number) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[sourceId: string, joraQuery: string, pos: number], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:saved:list"; + type?: "query" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: (() => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:saved:save"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((input: SaveQueryInput) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[input: SaveQueryInput], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}, { + name: "devframes:plugin:data-inspector:saved:delete"; + type?: "action" | undefined; + cacheable?: boolean; + args?: undefined; + returns?: undefined; + jsonSerializable?: boolean; + agent?: import("devframe").RpcFunctionAgentOptions; + setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined; + handler?: ((id: string, scope: SavedQueryScope) => Promise) | undefined; + dump?: import("devframe/rpc").RpcDump<[id: string, scope: SavedQueryScope], Promise, import("devframe").DevframeNodeContext> | undefined; + snapshot?: boolean; + __cache?: WeakMap>>> | undefined; + __promise?: import("devframe/rpc").Thenable>> | undefined; +}]; +export declare const SOURCES_CHANGED_EVENT: string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.js new file mode 100644 index 0000000..e61157d --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/node.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/node` + */ +// #region Other +export { serverFunctions } +export { setupDataInspector } +export { SOURCES_CHANGED_EVENT } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.d.ts new file mode 100644 index 0000000..1361433 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.d.ts @@ -0,0 +1,36 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/registry` + */ +// #region Interfaces +export interface DataSourceEntry { + id: string; + title: string; + description?: string; + icon?: string; + data: unknown | (() => unknown | Promise); + static?: boolean; + queries?: Query[]; +} +export interface DataSourcesService { + register: (_: DataSourceEntry) => () => void; + unregister: (_: string) => void; + list: () => DataSourceMeta[]; + get: (_: string) => DataSourceEntry | undefined; + onChanged: (_: () => void) => () => void; +} +// #endregion + +// #region Functions +export declare function createDataSourcesService(): DataSourcesService; +export declare function getDataSource(_: string): DataSourceEntry | undefined; +export declare function listDataSources(): DataSourceMeta[]; +export declare function onDataSourcesChanged(_: () => void): () => void; +export declare function registerDataSource(_: DataSourceEntry): () => void; +export declare function resetDataSources(): void; +export declare function resolveSourceData(_: DataSourceEntry): Promise; +export declare function unregisterDataSource(_: string): void; +// #endregion + +// #region Variables +export declare const DATA_SOURCES_SERVICE_ID: string; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.js new file mode 100644 index 0000000..08d7536 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/registry.snapshot.js @@ -0,0 +1,17 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/registry` + */ +// #region Functions +export function createDataSourcesService() {} +export function getDataSource(_) {} +export function listDataSources() {} +export function onDataSourcesChanged(_) {} +export function registerDataSource(_) {} +export function resetDataSources() {} +export async function resolveSourceData(_) {} +export function unregisterDataSource(_) {} +// #endregion + +// #region Variables +export var DATA_SOURCES_SERVICE_ID /* const */ +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.d.ts new file mode 100644 index 0000000..055a15f --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.d.ts @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/vite` + */ +// #region Functions +export declare function dataInspectorVitePlugin(_?: ViteDevBridgeOptions): DevframeVitePlugin; +// #endregion + +// #region Other +export { ViteDevBridgeOptions } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.js new file mode 100644 index 0000000..f0a1e9b --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-data-inspector/vite.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/plugin-data-inspector/vite` + */ +// #region Functions +export function dataInspectorVitePlugin(_) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index f2b30d7..eecb900 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -40,11 +40,16 @@ export { DevframeRuntime } export { DevframeScopedNodeContext } export { DevframeScopedNodeRpc } export { DevframeScopedStreamingHost } +export { DevframeServiceId } +export { DevframeServiceOf } +export { DevframeServicesHost } +export { DevframeServicesRegistry } export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } +export { DevframeStorageScope } export { DevframeViewHost } export { DevframeWsOptions } export { EntriesToObject } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 7eac313..1b6ad6e 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -56,6 +56,15 @@ export declare class DevframeDiagnosticsHost implements DevframeDiagnosticsHost$ constructor(_: DevframeNodeContext, _?: Array>); register(_: Record): void; } +export declare class DevframeServicesHostImpl implements DevframeServicesHost { + private services; + private listeners; + provide(_: ID, _: DevframeServiceOf): () => void; + get(_: ID): DevframeServiceOf | undefined; + has(_: DevframeServiceId): boolean; + whenAvailable(_: ID, _: (_: DevframeServiceOf) => void): () => void; + keys(): string[]; +} export declare class DevframeViewHost implements DevframeViewHost$1 { readonly context: DevframeNodeContext; buildStaticDirs: { diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index a6f4086..11afe67 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -11,6 +11,7 @@ export { createScopedNodeContext } export { createStorage } export { DevframeAgentHost } export { DevframeDiagnosticsHost } +export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 399512d..0f78abc 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -36,11 +36,16 @@ export { DevframeRuntime } export { DevframeScopedNodeContext } export { DevframeScopedNodeRpc } export { DevframeScopedStreamingHost } +export { DevframeServiceId } +export { DevframeServiceOf } +export { DevframeServicesHost } +export { DevframeServicesRegistry } export { DevframeSettings } export { DevframeSettingsRegistry } export { DevframeSettingsStore } export { DevframeSetupInfo } export { DevframeSpaOptions } +export { DevframeStorageScope } export { DevframeViewHost } export { DevframeWsOptions } export { EntriesToObject } diff --git a/tsconfig.base.json b/tsconfig.base.json index 18f4506..85f1358 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -172,6 +172,9 @@ "@devframes/plugin-git": [ "./plugins/git/src/index.ts" ], + "devframe/recipes/interactive-auth": [ + "./packages/devframe/src/recipes/interactive-auth.ts" + ], "devframe/recipes/open-helpers": [ "./packages/devframe/src/recipes/open-helpers.ts" ], @@ -181,6 +184,30 @@ "devframe": [ "./packages/devframe/src" ], + "@devframes/plugin-data-inspector/client": [ + "./plugins/data-inspector/src/client/index.ts" + ], + "@devframes/plugin-data-inspector/node": [ + "./plugins/data-inspector/src/node/index.ts" + ], + "@devframes/plugin-data-inspector/registry": [ + "./plugins/data-inspector/src/registry/index.ts" + ], + "@devframes/plugin-data-inspector/engine": [ + "./plugins/data-inspector/src/engine/index.ts" + ], + "@devframes/plugin-data-inspector/agent": [ + "./plugins/data-inspector/src/agent/index.ts" + ], + "@devframes/plugin-data-inspector/cli": [ + "./plugins/data-inspector/src/cli.ts" + ], + "@devframes/plugin-data-inspector/vite": [ + "./plugins/data-inspector/src/vite.ts" + ], + "@devframes/plugin-data-inspector": [ + "./plugins/data-inspector/src/index.ts" + ], "@devframes/plugin-inspect/client": [ "./plugins/inspect/src/client/index.ts" ], diff --git a/vitest.config.ts b/vitest.config.ts index 37e9c88..7d9e4e6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ 'packages/devframe', 'packages/hub', 'plugins/code-server', + 'plugins/data-inspector', 'plugins/terminals', 'plugins/inspect', 'examples/files-inspector',