Skip to content

Commit fd01bb7

Browse files
committed
feat: @devframes/service-git wire service + git plugin refactor
Extract the git plugin's git utilities into a new @devframes/service-git wire service and refactor the plugin to consume it. - services/git: createGitService({ cwd? }) → node API + devframes:service:git:* RPC (status/log/show/diff/branches/stage/unstage/commit) + advertisement. Single repo fixed at install (defaults to ctx.cwd, root discovered once). Exec wrapper + parsers stay internal; typed ops only, no raw passthrough. Write ops always exposed — authorization is the host's connection-trust boundary. Defines no dump/snapshot of its own. - core: DevframeDefinition.snapshotRpc lets a devframe bake an RPC it does not own (a service's) into its static build — string (no-arg) or { method, inputs: tuples | async (ctx) => tuples }. createBuild applies it before collecting; exported applySnapshotRpc; DF0072 warns on a missing target. - plugins/git: drops its node/git.ts + rpc layer and all git logic; declares the service (cwd from repoRoot at factory time) and opts the read ops into snapshotRpc (show enumerated at build time via the service node API). The React SPA calls devframes:service:git:* directly and imports types from @devframes/service-git. Drops --write — writes are always available, protected by the connection trust boundary. - docs: service-git in the built-in services list; snapshotRpc definition field; git plugin README.
1 parent 02d5c42 commit fd01bb7

59 files changed

Lines changed: 1687 additions & 1519 deletions

Some content is hidden

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

alias.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const alias = {
132132
'@devframes/plugin-assets/cli': p('assets/src/cli.ts'),
133133
'@devframes/plugin-assets/vite': p('assets/src/vite.ts'),
134134
'@devframes/plugin-assets': p('assets/src/index.ts'),
135+
'@devframes/service-git': s('git/src/index.ts'),
135136
'@devframes/service-open': s('open/src/index.ts'),
136137
'@devframes/service-shiki': s('shiki/src/index.ts'),
137138
}

docs/guide/devframe-definition.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default defineDevframe({
5454
| `duplicationStrategy` | `'warn' \| 'silent' \| 'throw' \| 'duplicate'` | How a hub reacts when another devframe sharing this `id` is mounted onto the same hub. Defaults to `'warn'`. See [Hub](./hub). Hub adapters consult it; standalone adapters ignore it. |
5555
| `capabilities` | `{ dev?, build? }` | Per-runtime feature flags. A `boolean` applies to the runtime as a whole; an object enables individual features. |
5656
| `services` | `DevframeServiceInput[]` | Wire services this devframe consumes — descriptors (`{ package, version?, required?, options? }`) the adapter imports against the plugin's own dependencies, or ready definitions. See [Cross-Plugin Services](./services#wire-services). |
57+
| `snapshotRpc` | `(string \| { method, inputs })[]` | Opt an RPC function this devframe doesn't own (e.g. a wire service's) into the static build's dump. A bare method id bakes the no-argument call; `{ method, inputs }` bakes one record per argument-tuple, where `inputs` is a list of tuples or an async `(ctx) => tuples` provider (so it can enumerate at build time via the service's node API). The first tuple's result becomes the fallback. |
5758
| `setup` | `(ctx, info?) => void \| Promise<void>` | **Required.** Server-side entry point. Runs in every runtime. The optional second argument carries runtime metadata — most notably the parsed CLI `flags` when running under `createCac`. |
5859
| `cli` | `DevframeCliOptions` | Defaults for the CLI adapter. See [CLI options](#cli-options) below. |
5960

docs/guide/services.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ state.on('updated', render)
155155

156156
**`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated.
157157

158+
**`@devframes/service-git`** (`devframes:service:git`) runs read/write git operations over RPC — `status`, `log`, `show`, `diff`, `branches`, `stage`, `unstage`, `commit` — with parsed, typed results, so a devframe (the git plugin, or any tool) consumes git without shelling out itself. It operates on a single repo fixed at install (`{ cwd? }`, defaulting to the context cwd; root discovered once). Write ops are always exposed — authorization is the host's connection-trust boundary. The service defines no `dump`/`snapshot`; a devframe bakes the read ops it wants into a static build via [`snapshotRpc`](./devframe-definition). Client-supplied revisions are guarded against option injection.
159+
158160
**`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union).
159161

160162
## Services, RPC, or shared state?

packages/devframe/src/adapters/__tests__/build.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { defineDevframe } from 'devframe'
5+
import { s } from 'devframe/utils/simple-schema'
56
import { describe, expect, it } from 'vitest'
67
import { createBuild } from '../build'
78

@@ -60,4 +61,56 @@ describe('adapters/build', () => {
6061
rmSync(outDir, { recursive: true, force: true })
6162
}
6263
})
64+
65+
it('bakes snapshotRpc methods a devframe does not own into the dump', async () => {
66+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
67+
// A dump-less query RPC (as a wire service would register) that the
68+
// devframe opts into baking via `snapshotRpc` — string (no-arg), static
69+
// inputs, and an async provider.
70+
const def = baseDevframe({
71+
setup: (ctx) => {
72+
ctx.rpc.register({ name: 'demo:ping', type: 'query', jsonSerializable: true, handler: () => 'pong' })
73+
ctx.rpc.register({
74+
name: 'demo:echo',
75+
type: 'query',
76+
jsonSerializable: true,
77+
args: [s.object({ value: s.string() })],
78+
returns: s.object({ value: s.string() }),
79+
handler: (input: { value: string }) => input,
80+
})
81+
},
82+
snapshotRpc: [
83+
'demo:ping',
84+
{ method: 'demo:echo', inputs: [[{ value: 'a' }]] },
85+
{ method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] },
86+
],
87+
})
88+
try {
89+
await createBuild(def, { outDir })
90+
const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8'))
91+
// `demo:ping` baked its no-arg call + fallback (string form).
92+
expect(manifest['demo:ping']?.type).toBe('query')
93+
expect(manifest['demo:ping'].fallback).toBeTruthy()
94+
// `demo:echo` baked a record per provided tuple (static + provider merged
95+
// — the last snapshotRpc entry for a method wins).
96+
expect(manifest['demo:echo']?.type).toBe('query')
97+
expect(Object.keys(manifest['demo:echo'].records).length).toBeGreaterThanOrEqual(1)
98+
}
99+
finally {
100+
rmSync(outDir, { recursive: true, force: true })
101+
}
102+
})
103+
104+
it('warns (DF0072) when snapshotRpc names an unregistered method', async () => {
105+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
106+
try {
107+
// Does not throw — a missing target is a warning, the build proceeds.
108+
await expect(
109+
createBuild(baseDevframe({ snapshotRpc: ['does:not:exist'] }), { outDir }),
110+
).resolves.toBeUndefined()
111+
}
112+
finally {
113+
rmSync(outDir, { recursive: true, force: true })
114+
}
115+
})
63116
})

packages/devframe/src/adapters/build.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable no-console */
2-
import type { DevframeDefinition } from '../types/devframe'
2+
import type { DevframeNodeContext } from '../types/context'
3+
import type { DevframeDefinition, DevframeSnapshotRpcEntry } from '../types/devframe'
34
import type { StaticAssetsSource } from '../types/remote-assets'
45
import { existsSync } from 'node:fs'
56
import fs from 'node:fs/promises'
@@ -95,6 +96,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
9596
await ctx.services.ready()
9697
await d.setup(ctx)
9798

99+
// Bake declared `snapshotRpc` methods (typically a wire service's RPC the
100+
// devframe doesn't own) into the static dump by attaching a `dump` to their
101+
// registered definitions — the service itself defines none.
102+
applySnapshotRpc(ctx, d.snapshotRpc)
103+
98104
await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })
99105

100106
const jsonSerializableMethods: string[] = []
@@ -134,3 +140,34 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
134140

135141
console.log(c.green`[devframe] built "${d.id}" -> ${outDir}`)
136142
}
143+
144+
/**
145+
* Attach a `dump` to each {@link DevframeDefinition.snapshotRpc} target so the
146+
* static collector bakes it, even though the (service-owned) definition
147+
* declares no dump of its own. A bare method id becomes `snapshot: true`
148+
* (bakes the no-arg call); `{ method, inputs }` bakes one record per resolved
149+
* argument-tuple by running the target's own handler, with the first tuple's
150+
* output as the fallback.
151+
*/
152+
export function applySnapshotRpc(ctx: DevframeNodeContext, entries: readonly DevframeSnapshotRpcEntry[] | undefined): void {
153+
for (const entry of entries ?? []) {
154+
const method = typeof entry === 'string' ? entry : entry.method
155+
const def = ctx.rpc.definitions.get(method)
156+
if (!def) {
157+
diagnostics.DF0072({ method })
158+
continue
159+
}
160+
if (typeof entry === 'string') {
161+
def.snapshot = true
162+
continue
163+
}
164+
const inputsSpec = entry.inputs
165+
def.dump = async (dumpCtx: DevframeNodeContext, handler: (...args: any[]) => any) => {
166+
const tuples = typeof inputsSpec === 'function' ? await inputsSpec(dumpCtx) : inputsSpec
167+
const records = []
168+
for (const input of tuples)
169+
records.push({ inputs: [...input] as any[], output: await handler(...input) })
170+
return { records, fallback: records[0]?.output }
171+
}
172+
}
173+
}

packages/devframe/src/node/diagnostics.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,5 +194,10 @@ export const diagnostics = defineDiagnostics({
194194
`Invalid service "${p.package}": ${p.reason}`,
195195
fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.',
196196
},
197+
DF0072: {
198+
why: (p: { method: string }) =>
199+
`\`snapshotRpc\` names "${p.method}", but no RPC function is registered under that id — nothing to bake into the static build.`,
200+
fix: 'Check the method id, and ensure the service/plugin that registers it is installed (e.g. declared in `services`) before the build collects the dump.',
201+
},
197202
},
198203
})

packages/devframe/src/types/devframe.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,35 @@ export interface DevframeDefinition {
363363
* `client.services.has(pkg)` and degrade.
364364
*/
365365
services?: DevframeServiceInput[]
366+
/**
367+
* Opt an RPC function into the static-build snapshot **without owning its
368+
* definition** — the mechanism a devframe uses to bake a wire service's
369+
* RPC (e.g. `@devframes/service-git`'s `status`/`log`/`show`) into its
370+
* `build` export, since the service itself defines no `dump`/`snapshot`.
371+
*
372+
* Each entry is either a bare method id (bakes the no-argument call, like
373+
* `snapshot: true`) or `{ method, inputs }` where `inputs` is the list of
374+
* argument-tuples to bake — or an async provider given the node context
375+
* (so it can enumerate at build time, e.g. read commit hashes via the
376+
* service's node API). `createBuild` resolves these after setup and
377+
* executes the target's own handler per tuple; the first tuple's result
378+
* becomes the fallback so any call variant resolves to a baked value.
379+
*/
380+
snapshotRpc?: DevframeSnapshotRpcEntry[]
366381
/** Server-side setup — the primary entrypoint. Runs in every runtime. */
367382
setup: (ctx: DevframeNodeContext, info?: DevframeSetupInfo) => void | Promise<void>
368383
cli?: DevframeCliOptions
369384
}
385+
386+
/** Argument-tuples to bake for a {@link DevframeSnapshotRpcEntry}, or a provider that computes them at build time. */
387+
export type DevframeSnapshotRpcInputs
388+
= readonly (readonly unknown[])[]
389+
| ((ctx: DevframeNodeContext) => readonly (readonly unknown[])[] | Promise<readonly (readonly unknown[])[]>)
390+
391+
/**
392+
* One {@link DevframeDefinition.snapshotRpc} entry: a bare method id (bakes
393+
* the no-argument call) or a method plus the argument-tuples to bake.
394+
*/
395+
export type DevframeSnapshotRpcEntry
396+
= string
397+
| { method: string, inputs: DevframeSnapshotRpcInputs }

plugins/git/README.md

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ repository dashboard with a **Next.js App Router + shadcn/ui** SPA over
99
type-safe RPC. The host process shells out to `git` and exposes the repository;
1010
the same bundle runs as a live dev server or a fully static deployment.
1111

12-
Status, a SourceTree-style **commit graph**, branches, and diffs are read-only;
13-
staging, unstaging, and committing are available when write mode is enabled. The
14-
UI follows the system **light/dark** preference with a manual toggle.
12+
Status, a SourceTree-style **commit graph**, branches, and diffs, plus staging,
13+
unstaging, and committing — all through the shared
14+
[`@devframes/service-git`](../../services/git) wire service. The UI follows the
15+
system **light/dark** preference with a manual toggle.
1516

1617
## Install
1718

@@ -25,7 +26,6 @@ Run the dashboard against the current repository:
2526

2627
```sh
2728
pnpx @devframes/plugin-git # dev server (live RPC over WebSocket)
28-
pnpx @devframes/plugin-git --write # also enable staging / committing from the UI
2929
pnpx @devframes/plugin-git build # static deploy → dist-static/
3030
pnpx @devframes/plugin-git --port 4000
3131
```
@@ -48,30 +48,32 @@ await createCac(createGitDevframe({ repoRoot: process.cwd() })).parse()
4848
| `basePath` | adapter-resolved | Mount path (`/` standalone, `/__git/` hosted). |
4949
| `distDir` | bundled SPA | Override the served SPA directory. |
5050
| `port` | `9710` | Preferred dev-server port. |
51-
| `write` | `false` | Enable staging, unstaging, and committing from the UI. |
5251

5352
## RPC surface
5453

55-
The read functions are each a `query` with `snapshot: true`: resolved live over
56-
WebSocket in dev, and served from a snapshot baked at build time for static
57-
deploys. Each degrades to an empty, `isRepo: false` result outside a git
58-
repository.
59-
60-
- `devframes:plugin:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged /
61-
untracked files, parsed from `git status --porcelain=v2`. Reports `canWrite`.
62-
- `devframes:plugin:git:log` — paginated commit history (`limit` / `skip`) including parent
54+
All git work runs through the [`@devframes/service-git`](../../services/git)
55+
wire service, which this devframe declares (`services`) and its SPA calls
56+
directly over `devframes:service:git:*`. The read functions are `query`
57+
functions that degrade to an empty, `isRepo: false` result outside a git
58+
repository; the definition opts them into the static build via `snapshotRpc`
59+
(resolved live over WebSocket in dev, served from a build-time snapshot for
60+
static deploys).
61+
62+
- `devframes:service:git:status` — branch, upstream tracking (ahead/behind), staged / unstaged /
63+
untracked files, parsed from `git status --porcelain=v2`.
64+
- `devframes:service:git:log` — paginated commit history (`limit` / `skip`) including parent
6365
hashes, which drive the commit graph.
64-
- `devframes:plugin:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject.
65-
- `devframes:plugin:git:diff` — per-file added/deleted counts for the working tree or index, plus
66+
- `devframes:service:git:branches` — local branches with SHA, upstream, ahead/behind, tip subject.
67+
- `devframes:service:git:diff` — per-file added/deleted counts for the working tree or index, plus
6668
a unified patch for a selected file.
6769

68-
Write actions are `action` functions, registered only when write mode is enabled
69-
(`createGitDevframe({ write: true })` or the `--write` flag) and gated behind
70-
`status.canWrite` in the UI. Each returns fresh status (commit returns a result):
70+
Write actions are `action` functions — always exposed by the service, with
71+
write authorization governed by the host's connection-trust boundary. Each
72+
returns fresh status (commit returns a result):
7173

72-
- `devframes:plugin:git:stage``git add` the given paths.
73-
- `devframes:plugin:git:unstage``git restore --staged` the given paths.
74-
- `devframes:plugin:git:commit` — commit the staged changes with a message.
74+
- `devframes:service:git:stage``git add` the given paths.
75+
- `devframes:service:git:unstage``git restore --staged` the given paths.
76+
- `devframes:service:git:commit` — commit the staged changes with a message.
7577

7678
## Develop
7779

plugins/git/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
}
5656
},
5757
"dependencies": {
58+
"@devframes/service-git": "workspace:*",
5859
"cac": "catalog:deps",
5960
"devframe": "workspace:*",
6061
"pathe": "catalog:deps"

plugins/git/src/client/components/commit-details-panel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

3+
import type { CommitDetail } from '@devframes/service-git'
34
import type { DevframeRpcClient } from 'devframe/client'
4-
import type { CommitDetail } from '../../index'
55
import { useCallback } from 'react'
66
import { useRpcResource } from './use-rpc-resource'
77
import { CommitDetailsView } from './views/commit-details-view'
@@ -13,7 +13,7 @@ export interface CommitDetailsPanelProps {
1313

1414
export function CommitDetailsPanel({ hash, onClose }: CommitDetailsPanelProps) {
1515
const loader = useCallback(
16-
(rpc: DevframeRpcClient): Promise<CommitDetail> => rpc.call('devframes:plugin:git:show', { hash }),
16+
(rpc: DevframeRpcClient): Promise<CommitDetail> => rpc.call('devframes:service:git:show', { hash }),
1717
[hash],
1818
)
1919
const { data, loading, error } = useRpcResource<CommitDetail>(loader)

0 commit comments

Comments
 (0)