Skip to content

Commit 01d8d08

Browse files
authored
feat: @devframes/service-git wire service + git plugin refactor (#263)
1 parent af27cb0 commit 01d8d08

60 files changed

Lines changed: 1713 additions & 1518 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
@@ -133,6 +133,7 @@ export const alias = {
133133
'@devframes/plugin-assets/cli': p('assets/src/cli.ts'),
134134
'@devframes/plugin-assets/vite': p('assets/src/vite.ts'),
135135
'@devframes/plugin-assets': p('assets/src/index.ts'),
136+
'@devframes/service-git': s('git/src/index.ts'),
136137
'@devframes/service-open': s('open/src/index.ts'),
137138
'@devframes/service-shiki': s('shiki/src/index.ts'),
138139
}

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+
| `rpc` | `{ snapshot?: (string \| { method, inputs })[] }` | RPC-level config. `rpc.snapshot` opts 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 [`rpc.snapshot`](./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: 56 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,58 @@ describe('adapters/build', () => {
6061
rmSync(outDir, { recursive: true, force: true })
6162
}
6263
})
64+
65+
it('bakes rpc.snapshot 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 `rpc.snapshot` — 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+
rpc: {
83+
snapshot: [
84+
'demo:ping',
85+
{ method: 'demo:echo', inputs: [[{ value: 'a' }]] },
86+
{ method: 'demo:echo', inputs: async () => [[{ value: 'b' }]] },
87+
],
88+
},
89+
})
90+
try {
91+
await createBuild(def, { outDir })
92+
const manifest = JSON.parse(readFileSync(join(outDir, '__rpc-dump/index.json'), 'utf-8'))
93+
// `demo:ping` baked its no-arg call + fallback (string form).
94+
expect(manifest['demo:ping']?.type).toBe('query')
95+
expect(manifest['demo:ping'].fallback).toBeTruthy()
96+
// `demo:echo` baked a record per provided tuple (static + provider merged
97+
// — the last rpc.snapshot entry for a method wins).
98+
expect(manifest['demo:echo']?.type).toBe('query')
99+
expect(Object.keys(manifest['demo:echo'].records).length).toBeGreaterThanOrEqual(1)
100+
}
101+
finally {
102+
rmSync(outDir, { recursive: true, force: true })
103+
}
104+
})
105+
106+
it('warns (DF0072) when rpc.snapshot names an unregistered method', async () => {
107+
const outDir = mkdtempSync(join(tmpdir(), 'devframe-build-test-out-'))
108+
try {
109+
// Does not throw — a missing target is a warning, the build proceeds.
110+
await expect(
111+
createBuild(baseDevframe({ rpc: { snapshot: ['does:not:exist'] } }), { outDir }),
112+
).resolves.toBeUndefined()
113+
}
114+
finally {
115+
rmSync(outDir, { recursive: true, force: true })
116+
}
117+
})
63118
})

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