@ryuhq/sdk 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +179 -0
- package/README.md +31 -0
- package/dist/agent.cjs +761 -0
- package/dist/agent.d.cts +3 -0
- package/dist/agent.d.ts +3 -0
- package/dist/agent.js +23 -0
- package/dist/chunk-GXHL5CO7.js +353 -0
- package/dist/chunk-KPKMMGVC.js +671 -0
- package/dist/chunk-ODFEUVPW.js +100 -0
- package/dist/cli.cjs +858 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +454 -0
- package/dist/index-CEbS1SlS.d.cts +988 -0
- package/dist/index-DAxq7Y0R.d.ts +988 -0
- package/dist/index.cjs +1900 -0
- package/dist/index.d.cts +759 -0
- package/dist/index.d.ts +759 -0
- package/dist/index.js +771 -0
- package/dist/manifest.cjs +399 -0
- package/dist/manifest.d.cts +355 -0
- package/dist/manifest.d.ts +355 -0
- package/dist/manifest.js +38 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +208 -0
- package/src/agent/index.ts +51 -0
- package/src/agent/loop.test.ts +261 -0
- package/src/agent/loop.ts +259 -0
- package/src/agent/model-call.ts +190 -0
- package/src/agent/query.ts +40 -0
- package/src/agent/tools.ts +295 -0
- package/src/builder.ts +473 -0
- package/src/cli/dev.test.ts +178 -0
- package/src/cli/dev.ts +425 -0
- package/src/cli.ts +390 -0
- package/src/contracts-lockstep.test.ts +77 -0
- package/src/generated/plugin-manifest.ts +1121 -0
- package/src/index.ts +141 -0
- package/src/manifest.test.ts +610 -0
- package/src/manifest.ts +589 -0
- package/src/mcp/bridge.test.ts +196 -0
- package/src/mcp/client.ts +253 -0
- package/src/mcp/fixture-server.ts +23 -0
- package/src/mcp/server.ts +351 -0
- package/src/model/client.test.ts +107 -0
- package/src/model/client.ts +179 -0
- package/src/model/gateway.ts +41 -0
- package/src/plugin/ryu-plugin.ts +191 -0
- package/src/runnable/agent.ts +338 -0
- package/src/runnable/app.ts +233 -0
- package/src/runnable/index.ts +61 -0
- package/src/runnable/primitives-hostapi.test.ts +73 -0
- package/src/runnable/primitives.test.ts +286 -0
- package/src/runnable/primitives.ts +610 -0
- package/src/runnable/runnable-types.ts +113 -0
- package/src/runnable/runnable.test.ts +397 -0
- package/src/runnable/skill.ts +60 -0
- package/src/runnable/tool.ts +260 -0
- package/src/runnable/turn-hook.test.ts +81 -0
- package/src/runnable/turn-hook.ts +191 -0
- package/src/runnable/workflow.ts +76 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `ryu` CLI — entry-point for the Ryu developer SDK command-line tool.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bunx ryu pack <dir>
|
|
7
|
+
* bunx ryu publish <dir>
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* pack <dir> Validate the plugin.json in <dir> and emit a publish-ready
|
|
11
|
+
* Plugin bundle at <dir>/dist/plugin.bundle.json.
|
|
12
|
+
* Exits 0 on success; exits 1 with the failing field on error.
|
|
13
|
+
* publish <dir> Validate the plugin.json and POST it to the Ryu Marketplace
|
|
14
|
+
* publish endpoint with the author's auth token. The item is
|
|
15
|
+
* stored as `pending` until a moderator approves it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { commandDev } from "./cli/dev.ts";
|
|
22
|
+
import { PluginManifestSchema } from "./manifest.ts";
|
|
23
|
+
|
|
24
|
+
// Lower-case hex `sha256(utf8_bytes(code))`. This is the EXACT encoding Core
|
|
25
|
+
// recomputes on install (`hex::encode(Sha256::digest(utf8))`), so the hash written
|
|
26
|
+
// into the signed manifest verifies byte-for-byte on the Rust side. The `code`
|
|
27
|
+
// passed here MUST be the same UTF-8 string stored/served/fetched, so the two ends
|
|
28
|
+
// hash identical bytes (never re-minify between pack/publish and install).
|
|
29
|
+
function uiCodeSha256(code: string): string {
|
|
30
|
+
return createHash("sha256").update(code, "utf8").digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function printUsage(): void {
|
|
36
|
+
process.stderr.write(
|
|
37
|
+
[
|
|
38
|
+
"Ryu dev SDK",
|
|
39
|
+
"",
|
|
40
|
+
"Usage:",
|
|
41
|
+
" bunx ryu pack <dir> Validate and bundle a plugin.json Plugin",
|
|
42
|
+
" bunx ryu publish <dir> Validate and publish a plugin.json Plugin to the Ryu Marketplace",
|
|
43
|
+
" bunx ryu dev <entry> Run a Runnable locally with an interactive chat loop",
|
|
44
|
+
"",
|
|
45
|
+
].join("\n")
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function exitError(message: string): never {
|
|
50
|
+
process.stderr.write(`error: ${message}\n`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── shared manifest loading ─────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
type LoadedManifest = ReturnType<typeof PluginManifestSchema.parse>;
|
|
57
|
+
|
|
58
|
+
// Read + parse + validate the plugin.json in `dir`. Exits with the failing
|
|
59
|
+
// field on any error. Shared by pack and publish so both validate identically.
|
|
60
|
+
function loadManifest(dir: string): LoadedManifest {
|
|
61
|
+
const manifestPath = join(dir, "plugin.json");
|
|
62
|
+
if (!existsSync(manifestPath)) {
|
|
63
|
+
exitError(`plugin.json not found in: ${dir}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw: string;
|
|
67
|
+
try {
|
|
68
|
+
raw = readFileSync(manifestPath, "utf8");
|
|
69
|
+
} catch (err) {
|
|
70
|
+
exitError(`could not read ${manifestPath}: ${String(err)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
exitError(`plugin.json is not valid JSON: ${manifestPath}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = PluginManifestSchema.safeParse(parsed);
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
const first = result.error.issues[0];
|
|
83
|
+
const field = first?.path.join(".") ?? "unknown";
|
|
84
|
+
const message = first?.message ?? "validation failed";
|
|
85
|
+
exitError(`plugin.json validation failed at '${field}': ${message}`);
|
|
86
|
+
}
|
|
87
|
+
return result.data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── pack command ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// Resolve the plugin's sandboxed-UI entry module — the source `ryu pack` bundles
|
|
93
|
+
// into `ui_code`. Two authoring shapes carry one:
|
|
94
|
+
// 1. A `companion` runnable's `config.ui_entry` (companion surface plugins).
|
|
95
|
+
// 2. A Ryu App's `contributes.widgets[].ui_entry` (widget apps via `defineApp`).
|
|
96
|
+
// Companion runnables take precedence; the first non-empty entry wins. Returns
|
|
97
|
+
// null for a manifest-only plugin (no bundled UI) so packing stays
|
|
98
|
+
// backward-compatible in that case.
|
|
99
|
+
function resolveUiEntry(manifest: LoadedManifest): string | null {
|
|
100
|
+
for (const runnable of manifest.runnables) {
|
|
101
|
+
if (runnable.kind !== "companion") {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const entry = (runnable.config as Record<string, unknown> | undefined)
|
|
105
|
+
?.ui_entry;
|
|
106
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
107
|
+
return entry;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const widget of manifest.contributes?.widgets ?? []) {
|
|
111
|
+
const entry = widget.ui_entry;
|
|
112
|
+
if (typeof entry === "string" && entry.trim().length > 0) {
|
|
113
|
+
return entry;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Resolve a companion's `ui_format` discriminator. `"html"` (Path B) means the
|
|
120
|
+
// `ui_entry` file is ALREADY a self-contained HTML document (a
|
|
121
|
+
// vite-plugin-singlefile build for a heavy app like the whiteboard) and must be
|
|
122
|
+
// shipped VERBATIM as `ui_code` — NOT run through `Bun.build`, which would try to
|
|
123
|
+
// bundle an HTML file as an ESM entry and fail. Anything else (absent / `"js"`) is
|
|
124
|
+
// the default: `ui_entry` is an ESM module `Bun.build` bundles into `ui_code`.
|
|
125
|
+
function resolveUiFormat(manifest: LoadedManifest): "html" | "js" {
|
|
126
|
+
for (const runnable of manifest.runnables) {
|
|
127
|
+
if (runnable.kind !== "companion") {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const fmt = (runnable.config as Record<string, unknown> | undefined)
|
|
131
|
+
?.ui_format;
|
|
132
|
+
if (typeof fmt === "string" && fmt.trim().toLowerCase() === "html") {
|
|
133
|
+
return "html";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return "js";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read a Path B (`ui_format:"html"`) companion's prebuilt HTML entry verbatim. The
|
|
140
|
+
// file is the finished, self-contained document (CSS/JS/fonts already inlined by
|
|
141
|
+
// the singlefile bundler); `ryu pack` ships it as `ui_code` untouched so its
|
|
142
|
+
// sha256 matches byte-for-byte on install.
|
|
143
|
+
function readUiEntryHtml(dir: string, uiEntry: string): string {
|
|
144
|
+
const entryPath = resolve(dir, uiEntry);
|
|
145
|
+
if (!existsSync(entryPath)) {
|
|
146
|
+
exitError(`companion ui_entry (html) not found: ${entryPath}`);
|
|
147
|
+
}
|
|
148
|
+
return readFileSync(entryPath, "utf8");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Bundle the plugin's UI entry into ONE self-contained browser ESM module string.
|
|
152
|
+
// No external imports are emitted: the `RyuPlugin` API is INJECTED at runtime by
|
|
153
|
+
// the host bootstrap (the plugin calls `activate(context)`), not imported, so the
|
|
154
|
+
// bundle carries only the plugin's own code. Throws on a build error so `pack`
|
|
155
|
+
// fails loudly rather than emitting a half-built bundle.
|
|
156
|
+
async function bundleUiEntry(dir: string, uiEntry: string): Promise<string> {
|
|
157
|
+
const entryPath = resolve(dir, uiEntry);
|
|
158
|
+
if (!existsSync(entryPath)) {
|
|
159
|
+
exitError(`companion ui_entry not found: ${entryPath}`);
|
|
160
|
+
}
|
|
161
|
+
const result = await Bun.build({
|
|
162
|
+
entrypoints: [entryPath],
|
|
163
|
+
target: "browser",
|
|
164
|
+
format: "esm",
|
|
165
|
+
minify: false,
|
|
166
|
+
});
|
|
167
|
+
if (!result.success) {
|
|
168
|
+
const messages = result.logs.map((l) => String(l.message)).join("; ");
|
|
169
|
+
exitError(`failed to bundle ui_entry '${uiEntry}': ${messages}`);
|
|
170
|
+
}
|
|
171
|
+
const output = result.outputs[0];
|
|
172
|
+
if (!output) {
|
|
173
|
+
exitError(`bundling ui_entry '${uiEntry}' produced no output`);
|
|
174
|
+
}
|
|
175
|
+
return await output.text();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function commandPack(rawDir: string): Promise<void> {
|
|
179
|
+
const dir = resolve(rawDir);
|
|
180
|
+
const manifest = loadManifest(dir);
|
|
181
|
+
|
|
182
|
+
// Bundle the companion UI entry, if any. Manifest-only plugins skip this and
|
|
183
|
+
// emit exactly the previous shape (no `ui_code`). A `ui_format:"html"` companion
|
|
184
|
+
// (Path B) ships its prebuilt HTML verbatim; otherwise the ESM entry is bundled.
|
|
185
|
+
const uiEntry = resolveUiEntry(manifest);
|
|
186
|
+
const uiCode = uiEntry
|
|
187
|
+
? resolveUiFormat(manifest) === "html"
|
|
188
|
+
? readUiEntryHtml(dir, uiEntry)
|
|
189
|
+
: await bundleUiEntry(dir, uiEntry)
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
// Bind the bundled code to the manifest by its sha256. The hash goes INTO the
|
|
193
|
+
// manifest (the surface Core signs on publish, and the corruption self-check on
|
|
194
|
+
// local install-bundle reads); the `ui_code` blob rides alongside as payload.
|
|
195
|
+
const manifestWithHash = uiCode
|
|
196
|
+
? { ...manifest, ui_code_sha256: uiCodeSha256(uiCode) }
|
|
197
|
+
: manifest;
|
|
198
|
+
|
|
199
|
+
// Emit bundle into <dir>/dist/plugin.bundle.json
|
|
200
|
+
const outDir = join(dir, "dist");
|
|
201
|
+
if (!existsSync(outDir)) {
|
|
202
|
+
mkdirSync(outDir, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
const outPath = join(outDir, "plugin.bundle.json");
|
|
205
|
+
const bundle = uiCode
|
|
206
|
+
? { ...manifestWithHash, ui_code: uiCode }
|
|
207
|
+
: manifestWithHash;
|
|
208
|
+
writeFileSync(outPath, JSON.stringify(bundle, null, 2), "utf8");
|
|
209
|
+
|
|
210
|
+
const codeNote = uiCode ? ` (+${uiCode.length}B ui_code)` : "";
|
|
211
|
+
process.stdout.write(
|
|
212
|
+
`packed ${manifest.id}@${manifest.version}${codeNote} → ${outPath}\n`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── publish command ─────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
const TRAILING_SLASHES = /\/+$/;
|
|
219
|
+
|
|
220
|
+
// Resolve the publish base URL: env override, else the dev control-plane server.
|
|
221
|
+
function publishBaseUrl(): string {
|
|
222
|
+
const raw = (process.env.RYU_MARKETPLACE_API_URL ?? "").trim();
|
|
223
|
+
return (raw || "http://localhost:3000").replace(TRAILING_SLASHES, "");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Resolve the author's auth token: env (RYU_AUTH_TOKEN), sent as a Bearer token
|
|
227
|
+
// the control plane's createContext accepts (Better Auth session JWT or OAuth
|
|
228
|
+
// access token). Never read from a committed file.
|
|
229
|
+
function authToken(): string {
|
|
230
|
+
const token = (process.env.RYU_AUTH_TOKEN ?? "").trim();
|
|
231
|
+
if (!token) {
|
|
232
|
+
exitError(
|
|
233
|
+
"publish requires an auth token: set RYU_AUTH_TOKEN to your Ryu access token"
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return token;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// An SDK-authored Plugin always publishes as a `plugin` (a plugin.json bundle of
|
|
240
|
+
// runnables). It is deliberately NOT published as `skill`: Core's skill install
|
|
241
|
+
// path needs a `descriptor.raw.install_source` (a from-source owner/repo), which
|
|
242
|
+
// a plugin.json manifest does not carry, so a skill-kind publish would be
|
|
243
|
+
// uninstallable. Model / mcp items are published through their own tools.
|
|
244
|
+
const SDK_PUBLISH_KIND = "plugin" as const;
|
|
245
|
+
|
|
246
|
+
async function commandPublish(rawDir: string): Promise<void> {
|
|
247
|
+
const dir = resolve(rawDir);
|
|
248
|
+
const manifest = loadManifest(dir);
|
|
249
|
+
const token = authToken();
|
|
250
|
+
const kind = SDK_PUBLISH_KIND;
|
|
251
|
+
|
|
252
|
+
// Compute the carriage payload the SAME way `pack` does — bundle the companion
|
|
253
|
+
// UI entry and hash it INLINE (never depend on a possibly-stale dist/). The
|
|
254
|
+
// hash is injected into the manifest object BEFORE it is sent for signing, so
|
|
255
|
+
// the Gateway signs a manifest that already binds the code; the `ui_code` blob
|
|
256
|
+
// is sent as a sibling (unsigned payload, integrity via the signed hash).
|
|
257
|
+
const uiEntry = resolveUiEntry(manifest);
|
|
258
|
+
const uiCode = uiEntry
|
|
259
|
+
? resolveUiFormat(manifest) === "html"
|
|
260
|
+
? readUiEntryHtml(dir, uiEntry)
|
|
261
|
+
: await bundleUiEntry(dir, uiEntry)
|
|
262
|
+
: null;
|
|
263
|
+
const manifestWithHash = uiCode
|
|
264
|
+
? { ...manifest, ui_code_sha256: uiCodeSha256(uiCode) }
|
|
265
|
+
: manifest;
|
|
266
|
+
|
|
267
|
+
// Phase 1.5 rich listing metadata forwarded FLAT into the publish body (not
|
|
268
|
+
// inside the signed manifest blob) so the control plane stores + serves it on
|
|
269
|
+
// detail. Each field is only sent when the author declared it. `developer`
|
|
270
|
+
// resolves the Claude-style `author` (string or `{name}`); `website` maps from
|
|
271
|
+
// the Claude `homepage`; the DISPLAY `runnables` array is derived from the
|
|
272
|
+
// manifest's authored runnables (id/name/kind) with a default enabled state.
|
|
273
|
+
const developer =
|
|
274
|
+
typeof manifest.author === "string"
|
|
275
|
+
? manifest.author
|
|
276
|
+
: manifest.author?.name;
|
|
277
|
+
const runnablesForDisplay = manifest.runnables.map((r) => ({
|
|
278
|
+
id: r.id,
|
|
279
|
+
kind: r.kind,
|
|
280
|
+
name: r.name,
|
|
281
|
+
enabled: true,
|
|
282
|
+
}));
|
|
283
|
+
const listingMetadata = {
|
|
284
|
+
...(manifest.description ? { description: manifest.description } : {}),
|
|
285
|
+
...(manifest.tagline ? { tagline: manifest.tagline } : {}),
|
|
286
|
+
...(developer ? { developer } : {}),
|
|
287
|
+
...(manifest.category ? { category: manifest.category } : {}),
|
|
288
|
+
...(manifest.iconUrl ? { iconUrl: manifest.iconUrl } : {}),
|
|
289
|
+
...(manifest.screenshots?.length
|
|
290
|
+
? { screenshots: manifest.screenshots }
|
|
291
|
+
: {}),
|
|
292
|
+
...(manifest.homepage ? { website: manifest.homepage } : {}),
|
|
293
|
+
...(manifest.privacyPolicyUrl
|
|
294
|
+
? { privacyPolicyUrl: manifest.privacyPolicyUrl }
|
|
295
|
+
: {}),
|
|
296
|
+
...(manifest.termsOfServiceUrl
|
|
297
|
+
? { termsOfServiceUrl: manifest.termsOfServiceUrl }
|
|
298
|
+
: {}),
|
|
299
|
+
...(manifest.capabilities?.length
|
|
300
|
+
? { capabilities: manifest.capabilities }
|
|
301
|
+
: {}),
|
|
302
|
+
...(manifest.examplePrompts?.length
|
|
303
|
+
? { examplePrompts: manifest.examplePrompts }
|
|
304
|
+
: {}),
|
|
305
|
+
...(manifest.setup ? { setup: manifest.setup } : {}),
|
|
306
|
+
...(runnablesForDisplay.length ? { runnables: runnablesForDisplay } : {}),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const url = `${publishBaseUrl()}/api/marketplace/publish`;
|
|
310
|
+
const body = {
|
|
311
|
+
id: manifest.id,
|
|
312
|
+
kind,
|
|
313
|
+
name: manifest.name,
|
|
314
|
+
version: manifest.version,
|
|
315
|
+
manifest: manifestWithHash,
|
|
316
|
+
// The descriptor is the manifest itself for a plugin/skill Plugin; Core maps
|
|
317
|
+
// it on install. Grants are read from the manifest server-side too.
|
|
318
|
+
descriptor: manifestWithHash,
|
|
319
|
+
grants: manifest.permission_grants ?? [],
|
|
320
|
+
// Rich listing metadata (Phase 1.5) forwarded flat; see above.
|
|
321
|
+
...listingMetadata,
|
|
322
|
+
// Per-item affiliate terms (optional): the commission a referrer earns when
|
|
323
|
+
// a referred user buys this paid item. The server re-validates the rule and
|
|
324
|
+
// stores it as the item's override (else the seller default applies).
|
|
325
|
+
...(manifest.affiliate?.enabled ? { affiliate: manifest.affiliate } : {}),
|
|
326
|
+
// The bundled UI code rides OUTSIDE the signed manifest as payload; the
|
|
327
|
+
// server stores it and serves it on detail. Omitted for manifest-only.
|
|
328
|
+
...(uiCode ? { ui_code: uiCode } : {}),
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
let resp: Response;
|
|
332
|
+
try {
|
|
333
|
+
resp = await fetch(url, {
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
"content-type": "application/json",
|
|
337
|
+
authorization: `Bearer ${token}`,
|
|
338
|
+
},
|
|
339
|
+
body: JSON.stringify(body),
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
exitError(`could not reach ${url}: ${String(err)}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const text = await resp.text();
|
|
346
|
+
if (!resp.ok) {
|
|
347
|
+
exitError(`publish failed (${resp.status}): ${text}`);
|
|
348
|
+
}
|
|
349
|
+
process.stdout.write(
|
|
350
|
+
`published ${manifest.id}@${manifest.version} (${kind}) → pending moderation\n${text}\n`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
const [, , command, ...args] = process.argv;
|
|
357
|
+
|
|
358
|
+
if (!command) {
|
|
359
|
+
printUsage();
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (command === "pack") {
|
|
364
|
+
const dir = args[0];
|
|
365
|
+
if (!dir) {
|
|
366
|
+
exitError("pack requires a directory argument: bunx ryu pack <dir>");
|
|
367
|
+
}
|
|
368
|
+
commandPack(dir).catch((err: unknown) => {
|
|
369
|
+
exitError(String(err));
|
|
370
|
+
});
|
|
371
|
+
} else if (command === "publish") {
|
|
372
|
+
const dir = args[0];
|
|
373
|
+
if (!dir) {
|
|
374
|
+
exitError("publish requires a directory argument: bunx ryu publish <dir>");
|
|
375
|
+
}
|
|
376
|
+
commandPublish(dir).catch((err: unknown) => {
|
|
377
|
+
exitError(String(err));
|
|
378
|
+
});
|
|
379
|
+
} else if (command === "dev") {
|
|
380
|
+
const entry = args[0];
|
|
381
|
+
if (!entry) {
|
|
382
|
+
exitError("dev requires an entry argument: bunx ryu dev <entry>");
|
|
383
|
+
}
|
|
384
|
+
commandDev(entry).catch((err: unknown) => {
|
|
385
|
+
exitError(String(err));
|
|
386
|
+
});
|
|
387
|
+
} else {
|
|
388
|
+
printUsage();
|
|
389
|
+
exitError(`unknown command: ${command}`);
|
|
390
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts lockstep guard.
|
|
3
|
+
*
|
|
4
|
+
* The Rust crate `crates/ryu-kernel-contracts` blesses
|
|
5
|
+
* `schemas/plugin-manifest.schema.json` (its snapshot test regenerates it from
|
|
6
|
+
* the Rust types), and `bun run generate:contracts` compiles that schema into
|
|
7
|
+
* `src/generated/plugin-manifest.ts`. This test is the cheap structural guard
|
|
8
|
+
* that the SDK's deliberately simpler zod authoring schema, the blessed JSON
|
|
9
|
+
* Schema, and the generated types stay describing the same manifest:
|
|
10
|
+
*
|
|
11
|
+
* 1. a known-good repo manifest (apps-store/mail/ui/plugin.json — the first
|
|
12
|
+
* fully manifest-driven app) parses with the zod `PluginManifestSchema`;
|
|
13
|
+
* 2. every key the schema marks required exists in the schema, the generated
|
|
14
|
+
* TS, and the fixture;
|
|
15
|
+
* 3. every top-level key the fixture uses is a key the wire model knows.
|
|
16
|
+
*
|
|
17
|
+
* Deterministic, filesystem-only, no network.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from "bun:test";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { PluginManifestSchema } from "./manifest";
|
|
24
|
+
|
|
25
|
+
const FIXTURE_PATH = join(
|
|
26
|
+
import.meta.dir,
|
|
27
|
+
"../../../apps-store/mail/ui/plugin.json"
|
|
28
|
+
);
|
|
29
|
+
const SCHEMA_PATH = join(
|
|
30
|
+
import.meta.dir,
|
|
31
|
+
"../../../crates/core/kernel-contracts/schemas/plugin-manifest.schema.json"
|
|
32
|
+
);
|
|
33
|
+
const GENERATED_PATH = join(import.meta.dir, "generated/plugin-manifest.ts");
|
|
34
|
+
|
|
35
|
+
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as Record<
|
|
36
|
+
string,
|
|
37
|
+
unknown
|
|
38
|
+
>;
|
|
39
|
+
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as {
|
|
40
|
+
title: string;
|
|
41
|
+
required: string[];
|
|
42
|
+
properties: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
const generatedSource = readFileSync(GENERATED_PATH, "utf8");
|
|
45
|
+
|
|
46
|
+
describe("contracts lockstep (zod ↔ blessed JSON Schema ↔ generated TS)", () => {
|
|
47
|
+
test("the known-good mail plugin.json parses with the zod authoring schema", () => {
|
|
48
|
+
const parsed = PluginManifestSchema.parse(fixture);
|
|
49
|
+
expect(parsed.id).toBe("com.ryu.mail");
|
|
50
|
+
expect(parsed.runnables.length).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("blessed schema describes PluginManifest with the required identity keys", () => {
|
|
54
|
+
expect(schema.title).toBe("PluginManifest");
|
|
55
|
+
for (const key of ["id", "name", "version", "runnables"]) {
|
|
56
|
+
expect(schema.required).toContain(key);
|
|
57
|
+
expect(Object.keys(schema.properties)).toContain(key);
|
|
58
|
+
// The fixture (and thus anything zod accepts as known-good) carries them.
|
|
59
|
+
expect(fixture).toHaveProperty(key);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("generated TS declares PluginManifest with the required keys", () => {
|
|
64
|
+
expect(generatedSource).toContain("export interface PluginManifest {");
|
|
65
|
+
for (const key of ["id", "name", "version", "runnables"]) {
|
|
66
|
+
// Required schema keys must appear as non-optional generated fields.
|
|
67
|
+
expect(generatedSource).toMatch(new RegExp(`^\\t${key}[?]?:`, "m"));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("every top-level fixture key is a key the wire model knows", () => {
|
|
72
|
+
const wireKeys = new Set(Object.keys(schema.properties));
|
|
73
|
+
for (const key of Object.keys(fixture)) {
|
|
74
|
+
expect(wireKeys).toContain(key);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|