mizan-webview-transport + webview-channels: VSCode webview as Mizan frontend
Two new frontend packages let a VSCode webview consume a Mizan backend
through its postMessage channel — peer transports to `mizan-tauri-transport`
and the default `httpTransport()`.
- `@mizan/webview-transport` implements `MizanTransport` (call/fetch)
over postMessage with correlation-id pairing. Drop-in for `configure({
transport: webviewTransport() })`; codegen output and React adapter
are unchanged.
- `@mizan/webview-channels` mirrors mizan-react's WebSocket-based
ChannelConnection — RPC + subscribe over the same postMessage channel
for long-running ops where short request/reply isn't enough.
Both expect an extension-host-side dispatcher that reads envelopes via
`webview.onDidReceiveMessage` and routes them through mizan-ts's
`handleMutationCall` / `handleContextFetch`. First consumer is the
holomorphic VSCode extension.
mizan-codegen: new `[source.script]` generic source. Spawns an arbitrary
command and reads stdout as KDL IR. Keeps mizan-codegen out of the
business of knowing every possible backend language while preserving
the "subprocess emits KDL" contract every other source already follows.
Holomorphic uses it to invoke `python -m holomorphic.emit_ir` against
the mizan_core registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
frontends/mizan-webview-channels/package.json
Normal file
11
frontends/mizan-webview-channels/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@mizan/webview-channels",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Webview-side channel connection for Mizan — RPC + pub/sub over a VSCode webview's postMessage channel. Mirror of the WebSocket-based ChannelConnection in mizan-react/channels.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
252
frontends/mizan-webview-channels/src/index.ts
Normal file
252
frontends/mizan-webview-channels/src/index.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* @mizan/webview-channels — RPC + pub/sub over a VSCode webview's
|
||||||
|
* postMessage channel. Mirror of mizan-react/src/channels (WebSocket-based)
|
||||||
|
* adapted for the in-process bidirectional channel a webview shares with
|
||||||
|
* its extension host.
|
||||||
|
*
|
||||||
|
* The webview process can only acquire the VSCode API once, so this module
|
||||||
|
* and `@mizan/webview-transport` coexist by sharing the singleton:
|
||||||
|
* whichever loads first acquires the API. Both packages post their own
|
||||||
|
* envelope kinds and filter incoming messages by the `kind` field.
|
||||||
|
*
|
||||||
|
* Envelope shapes (this side ↔ extension-host):
|
||||||
|
*
|
||||||
|
* webview → host:
|
||||||
|
* { kind: 'rpc', id, fn, args }
|
||||||
|
* { kind: 'subscribe', id, channel, params? }
|
||||||
|
* { kind: 'unsubscribe', id }
|
||||||
|
*
|
||||||
|
* host → webview:
|
||||||
|
* { kind: 'rpc-response', id, ok: true, data }
|
||||||
|
* { kind: 'rpc-response', id, ok: false, error: { code, message, details? } }
|
||||||
|
* { kind: 'event', subscription, payload }
|
||||||
|
* { kind: 'close', subscription, reason? }
|
||||||
|
*
|
||||||
|
* Long-running ops (e.g. kata's claude-p shell-out) live as channel
|
||||||
|
* subscriptions — the host streams events as work progresses, then closes
|
||||||
|
* the subscription on completion. RPC is reserved for short request/reply.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
function acquireVsCodeApi(): VsCodeApi
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VsCodeApi {
|
||||||
|
postMessage(msg: unknown): void
|
||||||
|
setState(state: unknown): void
|
||||||
|
getState(): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
let _api: VsCodeApi | null = null
|
||||||
|
|
||||||
|
function api(): VsCodeApi {
|
||||||
|
if (_api === null) {
|
||||||
|
if (typeof acquireVsCodeApi !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'@mizan/webview-channels: acquireVsCodeApi is not defined. ' +
|
||||||
|
'This module runs inside a VSCode webview only.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_api = acquireVsCodeApi()
|
||||||
|
}
|
||||||
|
return _api
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Errors ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class WebviewRPCError extends Error {
|
||||||
|
code: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
|
||||||
|
constructor(code: string, message: string, details?: Record<string, unknown>) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'WebviewRPCError'
|
||||||
|
this.code = code
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Envelope types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RPCEnvelope = { kind: 'rpc'; id: string; fn: string; args: unknown }
|
||||||
|
type SubscribeEnvelope = { kind: 'subscribe'; id: string; channel: string; params?: Record<string, unknown> }
|
||||||
|
type UnsubscribeEnvelope = { kind: 'unsubscribe'; id: string }
|
||||||
|
|
||||||
|
type RPCResponseOk = { kind: 'rpc-response'; id: string; ok: true; data: unknown }
|
||||||
|
type RPCResponseErr = {
|
||||||
|
kind: 'rpc-response'
|
||||||
|
id: string
|
||||||
|
ok: false
|
||||||
|
error: { code: string; message: string; details?: Record<string, unknown> }
|
||||||
|
}
|
||||||
|
type EventEnvelope = { kind: 'event'; subscription: string; payload: unknown }
|
||||||
|
type CloseEnvelope = { kind: 'close'; subscription: string; reason?: string }
|
||||||
|
|
||||||
|
type Incoming = RPCResponseOk | RPCResponseErr | EventEnvelope | CloseEnvelope
|
||||||
|
|
||||||
|
// ─── Subscription handle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
readonly id: string
|
||||||
|
readonly channel: string
|
||||||
|
readonly params: Record<string, unknown> | undefined
|
||||||
|
/** Called for every event the host pushes on this subscription. */
|
||||||
|
onEvent(handler: (payload: unknown) => void): () => void
|
||||||
|
/** Called once when the host closes the subscription. */
|
||||||
|
onClose(handler: (reason?: string) => void): () => void
|
||||||
|
/** Send an unsubscribe envelope to the host; local handlers stop firing. */
|
||||||
|
unsubscribe(): void
|
||||||
|
/** True until the host has closed the subscription or the consumer has unsubscribed. */
|
||||||
|
readonly active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connection ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class WebviewChannelConnection {
|
||||||
|
private pendingRPCs = new Map<string, {
|
||||||
|
resolve: (data: unknown) => void
|
||||||
|
reject: (err: WebviewRPCError) => void
|
||||||
|
}>()
|
||||||
|
private subscriptions = new Map<string, SubscriptionImpl>()
|
||||||
|
private installed = false
|
||||||
|
private counter = 0
|
||||||
|
|
||||||
|
private install(): void {
|
||||||
|
if (this.installed) return
|
||||||
|
this.installed = true
|
||||||
|
window.addEventListener('message', (e: MessageEvent) => this.handle(e.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextId(prefix: string): string {
|
||||||
|
return `${prefix}_${++this.counter}_${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(msg: unknown): void {
|
||||||
|
if (!msg || typeof msg !== 'object') return
|
||||||
|
const m = msg as Incoming
|
||||||
|
switch (m.kind) {
|
||||||
|
case 'rpc-response': {
|
||||||
|
const pen = this.pendingRPCs.get(m.id)
|
||||||
|
if (!pen) return
|
||||||
|
this.pendingRPCs.delete(m.id)
|
||||||
|
if (m.ok) {
|
||||||
|
pen.resolve(m.data)
|
||||||
|
} else {
|
||||||
|
pen.reject(new WebviewRPCError(
|
||||||
|
m.error.code,
|
||||||
|
m.error.message,
|
||||||
|
m.error.details,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'event': {
|
||||||
|
const sub = this.subscriptions.get(m.subscription)
|
||||||
|
if (sub) sub.dispatchEvent(m.payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'close': {
|
||||||
|
const sub = this.subscriptions.get(m.subscription)
|
||||||
|
if (sub) {
|
||||||
|
sub.dispatchClose(m.reason)
|
||||||
|
this.subscriptions.delete(m.subscription)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call a host-registered RPC function. Resolves with the function's
|
||||||
|
* return value or throws `WebviewRPCError` on failure.
|
||||||
|
*/
|
||||||
|
rpc<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> {
|
||||||
|
this.install()
|
||||||
|
return new Promise<TOutput>((resolve, reject) => {
|
||||||
|
const id = this.nextId('rpc')
|
||||||
|
this.pendingRPCs.set(id, {
|
||||||
|
resolve: resolve as (data: unknown) => void,
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
const env: RPCEnvelope = { kind: 'rpc', id, fn, args }
|
||||||
|
api().postMessage(env)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a long-running subscription on a host-registered channel.
|
||||||
|
* The returned Subscription handle receives events until the host
|
||||||
|
* closes it or the consumer calls `unsubscribe()`.
|
||||||
|
*/
|
||||||
|
subscribe(channel: string, params?: Record<string, unknown>): Subscription {
|
||||||
|
this.install()
|
||||||
|
const id = this.nextId('sub')
|
||||||
|
const sub = new SubscriptionImpl(id, channel, params, () => {
|
||||||
|
this.subscriptions.delete(id)
|
||||||
|
const env: UnsubscribeEnvelope = { kind: 'unsubscribe', id }
|
||||||
|
api().postMessage(env)
|
||||||
|
})
|
||||||
|
this.subscriptions.set(id, sub)
|
||||||
|
const env: SubscribeEnvelope = { kind: 'subscribe', id, channel, params }
|
||||||
|
api().postMessage(env)
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionImpl implements Subscription {
|
||||||
|
private eventHandlers = new Set<(payload: unknown) => void>()
|
||||||
|
private closeHandlers = new Set<(reason?: string) => void>()
|
||||||
|
private _active = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly id: string,
|
||||||
|
public readonly channel: string,
|
||||||
|
public readonly params: Record<string, unknown> | undefined,
|
||||||
|
private readonly onLocalUnsubscribe: () => void,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get active(): boolean {
|
||||||
|
return this._active
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: (payload: unknown) => void): () => void {
|
||||||
|
this.eventHandlers.add(handler)
|
||||||
|
return () => this.eventHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(handler: (reason?: string) => void): () => void {
|
||||||
|
this.closeHandlers.add(handler)
|
||||||
|
return () => this.closeHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(): void {
|
||||||
|
if (!this._active) return
|
||||||
|
this._active = false
|
||||||
|
this.onLocalUnsubscribe()
|
||||||
|
this.closeHandlers.forEach(h => h('unsubscribed'))
|
||||||
|
this.eventHandlers.clear()
|
||||||
|
this.closeHandlers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(payload: unknown): void {
|
||||||
|
if (!this._active) return
|
||||||
|
this.eventHandlers.forEach(h => h(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchClose(reason?: string): void {
|
||||||
|
if (!this._active) return
|
||||||
|
this._active = false
|
||||||
|
this.closeHandlers.forEach(h => h(reason))
|
||||||
|
this.eventHandlers.clear()
|
||||||
|
this.closeHandlers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Singleton ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _default: WebviewChannelConnection | null = null
|
||||||
|
|
||||||
|
export function getDefaultChannelConnection(): WebviewChannelConnection {
|
||||||
|
if (_default === null) _default = new WebviewChannelConnection()
|
||||||
|
return _default
|
||||||
|
}
|
||||||
14
frontends/mizan-webview-transport/package.json
Normal file
14
frontends/mizan-webview-transport/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@mizan/webview-transport",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mizan transport adapter routing calls through a VSCode webview's postMessage channel. Paired with the extension-host-side Mizan dispatcher.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@mizan/base": "*"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
141
frontends/mizan-webview-transport/src/index.ts
Normal file
141
frontends/mizan-webview-transport/src/index.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* @mizan/webview-transport — routes Mizan calls through a VSCode webview's
|
||||||
|
* postMessage channel instead of HTTP fetch or Tauri IPC. Paired with the
|
||||||
|
* extension-host-side Mizan dispatcher (e.g. `MizanHost` in the holomorphic
|
||||||
|
* extension), which receives envelopes via `webview.onDidReceiveMessage`
|
||||||
|
* and posts responses back via `webview.postMessage`.
|
||||||
|
*
|
||||||
|
* Usage (webview side, inside the bundled React/TS app):
|
||||||
|
*
|
||||||
|
* import { configure } from '@mizan/base'
|
||||||
|
* import { webviewTransport } from '@mizan/webview-transport'
|
||||||
|
*
|
||||||
|
* configure({ transport: webviewTransport() })
|
||||||
|
*
|
||||||
|
* The transport keeps the same protocol surface as the HTTP and Tauri
|
||||||
|
* transports (call/fetch envelopes, {result, invalidate, merge} response
|
||||||
|
* shape), so the codegen output and React adapter are unchanged — only
|
||||||
|
* the wire channel differs.
|
||||||
|
*
|
||||||
|
* Envelope shapes (this side ↔ extension-host):
|
||||||
|
*
|
||||||
|
* webview → host:
|
||||||
|
* { kind: 'call', id, fn, args }
|
||||||
|
* { kind: 'fetch', id, context, params? }
|
||||||
|
*
|
||||||
|
* host → webview:
|
||||||
|
* { kind: 'response', id, ok: true, body }
|
||||||
|
* { kind: 'response', id, ok: false, error: { status, body } }
|
||||||
|
*
|
||||||
|
* Correlation by `id` lets multiple in-flight calls share the one
|
||||||
|
* postMessage channel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MizanError, type MizanCallResponse, type MizanTransport } from '@mizan/base'
|
||||||
|
|
||||||
|
// VSCode's webview API — present at runtime, declared globally so the
|
||||||
|
// transport can be authored without pulling vscode types into a generic
|
||||||
|
// frontend package. Returned by acquireVsCodeApi() exactly once per
|
||||||
|
// webview load; subsequent calls throw.
|
||||||
|
declare global {
|
||||||
|
function acquireVsCodeApi(): VsCodeApi
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VsCodeApi {
|
||||||
|
postMessage(msg: unknown): void
|
||||||
|
setState(state: unknown): void
|
||||||
|
getState(): unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = {
|
||||||
|
resolve: (data: unknown) => void
|
||||||
|
reject: (err: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallEnvelope = { kind: 'call'; id: string; fn: string; args: Record<string, any> }
|
||||||
|
type FetchEnvelope = { kind: 'fetch'; id: string; context: string; params?: Record<string, any> }
|
||||||
|
type Envelope = CallEnvelope | FetchEnvelope
|
||||||
|
|
||||||
|
type ResponseOk = { kind: 'response'; id: string; ok: true; body: any }
|
||||||
|
type ResponseErr = { kind: 'response'; id: string; ok: false; error: { status: number; body: string } }
|
||||||
|
type ResponseEnvelope = ResponseOk | ResponseErr
|
||||||
|
|
||||||
|
let _api: VsCodeApi | null = null
|
||||||
|
|
||||||
|
function api(): VsCodeApi {
|
||||||
|
if (_api === null) {
|
||||||
|
if (typeof acquireVsCodeApi !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
'@mizan/webview-transport: acquireVsCodeApi is not defined. ' +
|
||||||
|
'This transport runs inside a VSCode webview only.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_api = acquireVsCodeApi()
|
||||||
|
}
|
||||||
|
return _api
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, Pending>()
|
||||||
|
let installed = false
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
function install(): void {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
window.addEventListener('message', (e: MessageEvent) => {
|
||||||
|
const msg = e.data as ResponseEnvelope | undefined
|
||||||
|
if (!msg || msg.kind !== 'response') return
|
||||||
|
const pen = pending.get(msg.id)
|
||||||
|
if (!pen) return
|
||||||
|
pending.delete(msg.id)
|
||||||
|
if (msg.ok) {
|
||||||
|
pen.resolve(msg.body)
|
||||||
|
} else {
|
||||||
|
pen.reject(new MizanError(msg.error.status, msg.error.body))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextId(): string {
|
||||||
|
return `mz_${++counter}_${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function send<T>(env: Envelope): Promise<T> {
|
||||||
|
install()
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
pending.set(env.id, {
|
||||||
|
resolve: resolve as (data: unknown) => void,
|
||||||
|
reject,
|
||||||
|
})
|
||||||
|
api().postMessage(env)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Mizan transport that routes through a VSCode webview's
|
||||||
|
* postMessage channel. Install via:
|
||||||
|
*
|
||||||
|
* import { configure } from '@mizan/base'
|
||||||
|
* import { webviewTransport } from '@mizan/webview-transport'
|
||||||
|
* configure({ transport: webviewTransport() })
|
||||||
|
*/
|
||||||
|
export function webviewTransport(): MizanTransport {
|
||||||
|
return {
|
||||||
|
async call(fn, args): Promise<MizanCallResponse> {
|
||||||
|
return send<MizanCallResponse>({
|
||||||
|
kind: 'call',
|
||||||
|
id: nextId(),
|
||||||
|
fn,
|
||||||
|
args,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async fetch(context, params) {
|
||||||
|
return send<any>({
|
||||||
|
kind: 'fetch',
|
||||||
|
id: nextId(),
|
||||||
|
context,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,16 @@ pub struct SourceConfig {
|
|||||||
/// usage (no Pydantic) just omits the sub-block.
|
/// usage (no Pydantic) just omits the sub-block.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub rust: Option<RustSource>,
|
pub rust: Option<RustSource>,
|
||||||
|
|
||||||
|
/// `[source.script]` — generic source. Spawn an arbitrary command and
|
||||||
|
/// read its stdout as KDL IR. Use when none of the language-specific
|
||||||
|
/// sources fit — e.g. a Python module that walks `mizan_core.registry`
|
||||||
|
/// for a non-Django/non-FastAPI consumer, or a custom IR emitter.
|
||||||
|
/// Keeps mizan-codegen out of the business of knowing every possible
|
||||||
|
/// backend language while preserving the "subprocess emits KDL"
|
||||||
|
/// contract every other source already follows.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script: Option<ScriptSource>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -206,6 +216,35 @@ fn default_pydantic_derives() -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// `[source.script]` — generic stdout-of-arbitrary-command source.
|
||||||
|
///
|
||||||
|
/// Spawns `command` with `args`, reads its stdout, and parses it as KDL
|
||||||
|
/// Mizan IR. The same contract every other source follows; this one just
|
||||||
|
/// doesn't bake in any language-specific assumptions.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// ```toml
|
||||||
|
/// [source.script]
|
||||||
|
/// command = ["uv", "run", "python", "-m", "holomorphic.emit_ir"]
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ScriptSource {
|
||||||
|
/// Full command vector. First entry is the program; rest are argv.
|
||||||
|
/// Must be non-empty.
|
||||||
|
pub command: Vec<String>,
|
||||||
|
|
||||||
|
/// Working directory for the subprocess, relative to the codegen
|
||||||
|
/// config directory. Defaults to the config directory itself.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Environment overrides.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
pub enum RustKernelSpec {
|
pub enum RustKernelSpec {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::process::{Command, Stdio};
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource};
|
use crate::config::{Config, DjangoSource, FastapiSource, PydanticPreStep, RustSource, ScriptSource};
|
||||||
use crate::ir::{parse_ir, MizanIR};
|
use crate::ir::{parse_ir, MizanIR};
|
||||||
|
|
||||||
|
|
||||||
@@ -43,9 +43,11 @@ pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
|
|||||||
run_fastapi(fa, config_dir)?
|
run_fastapi(fa, config_dir)?
|
||||||
} else if let Some(dj) = &config.source.django {
|
} else if let Some(dj) = &config.source.django {
|
||||||
run_django(dj, config_dir)?
|
run_django(dj, config_dir)?
|
||||||
|
} else if let Some(sc) = &config.source.script {
|
||||||
|
run_script(sc, config_dir)?
|
||||||
} else {
|
} else {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"config.source must declare one of [source.rust], [source.fastapi], or [source.django]"
|
"config.source must declare one of [source.rust], [source.fastapi], [source.django], or [source.script]"
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,6 +72,18 @@ fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn run_script(src: &ScriptSource, config_dir: &Path) -> Result<String> {
|
||||||
|
let cwd = match &src.cwd {
|
||||||
|
Some(rel) => config_dir.join(rel),
|
||||||
|
None => config_dir.to_path_buf(),
|
||||||
|
};
|
||||||
|
let (program, args) = src.command.split_first().ok_or_else(|| {
|
||||||
|
anyhow!("[source.script]: command must be non-empty")
|
||||||
|
})?;
|
||||||
|
run_subprocess(program, args, &cwd, &src.env, "script IR export")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
|
fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
|
||||||
let manage_path = config_dir.join(&src.manage_path);
|
let manage_path = config_dir.join(&src.manage_path);
|
||||||
let manage_dir = manage_path
|
let manage_dir = manage_path
|
||||||
|
|||||||
Reference in New Issue
Block a user