create-ryu-app 0.0.5 → 0.0.17
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/README.md +16 -4
- package/dist/index.js +63 -15
- package/package.json +3 -3
- package/template/app/README.md +58 -0
- package/template/app/manifest.json +45 -0
- package/template/app/sidecar/package.json +20 -0
- package/template/app/sidecar/src/main/control.ts +291 -0
- package/template/app/sidecar/src/main/index.ts +36 -0
- package/template/app/sidecar/tsconfig.json +20 -0
- package/template/companion-plugin/{plugin.json → manifest.json} +1 -1
- package/template/companion-plugin/src/app.ts +1 -1
- package/template/hook-plugin/src/plugin.ts +1 -1
- package/template/ryu-app/{plugin.json → manifest.json} +1 -1
- package/template/ryu-app/src/app.ts +2 -2
- /package/template/agent/{plugin.json → manifest.json} +0 -0
- /package/template/hook-plugin/{plugin.json → manifest.json} +0 -0
package/README.md
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
[](../../README.md)
|
|
7
7
|
|
|
8
|
-
`create-ryu-app` is the project scaffolder for
|
|
8
|
+
`create-ryu-app` is the project scaffolder for Ryu extensions. Running it generates a starter project with a `manifest.json` validated against the PluginManifest schema, so it installs out of the box.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Ryu extensions come in two shapes, and `--template` picks which one you get.
|
|
11
|
+
|
|
12
|
+
**Apps** are self-contained `apps-store/<app>` satellites: a manifest plus an out-of-process `sidecar/`, driven through the generic ext-proxy (`/api/ext/<plugin_id>/*`). Shipping one never requires a change to Ryu Core or the Gateway.
|
|
13
|
+
|
|
14
|
+
| Template | Emits | Runtime |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `app` | A manifest declaring a lazy local sidecar + a grant-gated capability, and the loopback HTTP sidecar that serves it | Bun/Node, dependency-free |
|
|
17
|
+
|
|
18
|
+
**Plugins** are manifest contributions Ryu renders in-process — runnables, turn hooks, widgets, composer controls, a companion panel. No sidecar, no port. They are authored against `@ryuhq/sdk` and shipped with `ryu pack`.
|
|
11
19
|
|
|
12
20
|
| Template | Emits | Factory |
|
|
13
21
|
|---|---|---|
|
|
@@ -27,6 +35,9 @@ bunx create-ryu-app <name>
|
|
|
27
35
|
# scaffold a specific template
|
|
28
36
|
bunx create-ryu-app <name> --template ryu-app
|
|
29
37
|
|
|
38
|
+
# scaffold an apps-store satellite (manifest + sidecar)
|
|
39
|
+
bunx create-ryu-app <name> --template app
|
|
40
|
+
|
|
30
41
|
# build from source
|
|
31
42
|
bun run build # tsup → dist/
|
|
32
43
|
bun test
|
|
@@ -35,8 +46,9 @@ bun test
|
|
|
35
46
|
## What it provides
|
|
36
47
|
|
|
37
48
|
- A one-command scaffolder (`create-ryu-app <name> [--template <t>]`) bundled with a `template/<name>/` tree per starter.
|
|
38
|
-
-
|
|
39
|
-
-
|
|
49
|
+
- For plugins: a starter Runnable plus a gateway-pointed model config, and a manifest ready for `ryu pack`.
|
|
50
|
+
- For apps: a satellite tree — `manifest.json` (sidecar + capability + grant) and a fail-closed, bearer-gated loopback sidecar that owns no dependency on this repo.
|
|
51
|
+
- A `manifest.json` validated against the PluginManifest schema at scaffold time.
|
|
40
52
|
|
|
41
53
|
## License
|
|
42
54
|
|
package/dist/index.js
CHANGED
|
@@ -16,17 +16,38 @@ var RE_WORD_SEPARATOR = /[-_\s]+/;
|
|
|
16
16
|
var RE_VALID_NAME = /^[a-z0-9][a-z0-9-_]*$/i;
|
|
17
17
|
var RE_LABEL_IMPERSONATES = /ryu|system/i;
|
|
18
18
|
var SAFE_COMPANION_LABEL = "App Panel";
|
|
19
|
-
var SDK_DEPENDENCY_RANGE = "^0.0.
|
|
19
|
+
var SDK_DEPENDENCY_RANGE = "^0.0.17";
|
|
20
20
|
var TEMPLATES = {
|
|
21
|
-
agent: {
|
|
22
|
-
|
|
21
|
+
agent: {
|
|
22
|
+
kind: "plugin",
|
|
23
|
+
summary: "a loop-owning Runnable agent (Agent + ryuTool)",
|
|
24
|
+
devEntry: "src/agent.ts"
|
|
25
|
+
},
|
|
26
|
+
"hook-plugin": {
|
|
27
|
+
kind: "plugin",
|
|
28
|
+
summary: "a post-assistant-turn hook (definePlugin + defineTurnHook)",
|
|
29
|
+
devEntry: "src/plugin.ts"
|
|
30
|
+
},
|
|
23
31
|
"ryu-app": {
|
|
32
|
+
kind: "plugin",
|
|
33
|
+
summary: "an interactive in-chat widget (defineApp + a sandboxed widget)",
|
|
24
34
|
devEntry: "src/app.ts",
|
|
25
35
|
extraDependencies: { react: "^19.2.0", "react-dom": "^19.2.0" }
|
|
26
36
|
},
|
|
27
37
|
"companion-plugin": {
|
|
38
|
+
kind: "plugin",
|
|
39
|
+
summary: "a widget that calls a companion tool, plus a panel surface",
|
|
28
40
|
devEntry: "src/app.ts",
|
|
29
41
|
extraDependencies: { react: "^19.2.0", "react-dom": "^19.2.0" }
|
|
42
|
+
},
|
|
43
|
+
app: {
|
|
44
|
+
kind: "app",
|
|
45
|
+
summary: "an apps-store satellite: manifest + a loopback HTTP sidecar, driven through the ext-proxy",
|
|
46
|
+
devEntry: "sidecar/src/main/index.ts",
|
|
47
|
+
extraScripts: {
|
|
48
|
+
build: "bun run --cwd sidecar build",
|
|
49
|
+
"check-types": "bun run --cwd sidecar check-types"
|
|
50
|
+
}
|
|
30
51
|
}
|
|
31
52
|
};
|
|
32
53
|
var DEFAULT_TEMPLATE = "agent";
|
|
@@ -36,11 +57,16 @@ function exitError(message) {
|
|
|
36
57
|
`);
|
|
37
58
|
process.exit(1);
|
|
38
59
|
}
|
|
60
|
+
function templateLines(kind) {
|
|
61
|
+
const width = Math.max(...Object.keys(TEMPLATES).map((n) => n.length));
|
|
62
|
+
return Object.entries(TEMPLATES).filter(([, spec]) => spec.kind === kind).map(
|
|
63
|
+
([name, spec]) => ` ${name.padEnd(width)} ${spec.summary}${name === DEFAULT_TEMPLATE ? " (default)" : ""}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
39
66
|
function printUsage() {
|
|
40
|
-
const templates = Object.keys(TEMPLATES).join(" | ");
|
|
41
67
|
process.stderr.write(
|
|
42
68
|
[
|
|
43
|
-
"create-ryu-app \u2014 scaffold a starter Ryu
|
|
69
|
+
"create-ryu-app \u2014 scaffold a starter Ryu app or plugin",
|
|
44
70
|
"",
|
|
45
71
|
"Usage:",
|
|
46
72
|
" bunx create-ryu-app <name> [--template <template>]",
|
|
@@ -49,7 +75,17 @@ function printUsage() {
|
|
|
49
75
|
" <name> Project directory name (also used as the app id slug)",
|
|
50
76
|
"",
|
|
51
77
|
"Options:",
|
|
52
|
-
|
|
78
|
+
" --template Which starter to emit. Two shapes:",
|
|
79
|
+
"",
|
|
80
|
+
" APP \u2014 a self-contained satellite: manifest.json + an out-of-process",
|
|
81
|
+
" sidecar/, reached through the generic ext-proxy (/api/ext/<id>/*).",
|
|
82
|
+
" Ships without any change to Ryu Core or the Gateway.",
|
|
83
|
+
...templateLines("app"),
|
|
84
|
+
"",
|
|
85
|
+
" PLUGIN \u2014 manifest contributions Ryu renders in-process: runnables,",
|
|
86
|
+
" turn hooks, widgets, composer controls, a companion panel. No sidecar,",
|
|
87
|
+
" no port.",
|
|
88
|
+
...templateLines("plugin"),
|
|
53
89
|
""
|
|
54
90
|
].join("\n")
|
|
55
91
|
);
|
|
@@ -60,6 +96,9 @@ function toDisplayName(slug) {
|
|
|
60
96
|
function toCompanionLabel(displayName) {
|
|
61
97
|
return RE_LABEL_IMPERSONATES.test(displayName) ? SAFE_COMPANION_LABEL : displayName;
|
|
62
98
|
}
|
|
99
|
+
function toEnvPrefix(slug) {
|
|
100
|
+
return `RYU_${slug.toUpperCase().replaceAll("-", "_")}`;
|
|
101
|
+
}
|
|
63
102
|
function stampTemplate(filePath, replacements) {
|
|
64
103
|
let content = readFileSync(filePath, "utf8");
|
|
65
104
|
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
@@ -115,33 +154,41 @@ function scaffold(name, outDir, template = DEFAULT_TEMPLATE) {
|
|
|
115
154
|
}
|
|
116
155
|
const templateDir = resolveTemplateDir(template);
|
|
117
156
|
const displayName = toDisplayName(slug);
|
|
157
|
+
const envPrefix = toEnvPrefix(slug);
|
|
118
158
|
mkdirSync(projectDir, { recursive: true });
|
|
119
159
|
cpSync(templateDir, projectDir, { recursive: true });
|
|
120
160
|
stampTree(projectDir, {
|
|
121
161
|
__APP_NAME__: slug,
|
|
122
162
|
__APP_DISPLAY_NAME__: displayName,
|
|
123
|
-
__COMPANION_LABEL__: toCompanionLabel(displayName)
|
|
163
|
+
__COMPANION_LABEL__: toCompanionLabel(displayName),
|
|
164
|
+
__APP_BIN_ENV__: `${envPrefix}_BIN`,
|
|
165
|
+
__APP_PORT_ENV__: `${envPrefix}_PORT`,
|
|
166
|
+
__APP_TOKEN_ENV__: `${envPrefix}_TOKEN`
|
|
124
167
|
});
|
|
125
|
-
const manifestPath = join(projectDir, "
|
|
168
|
+
const manifestPath = join(projectDir, "manifest.json");
|
|
126
169
|
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
127
170
|
const validation = PluginManifestSchema.safeParse(parsed);
|
|
128
171
|
if (!validation.success) {
|
|
129
172
|
const first = validation.error.issues[0];
|
|
130
173
|
const field = first?.path.join(".") ?? "unknown";
|
|
131
174
|
const msg = first?.message ?? "validation failed";
|
|
132
|
-
exitError(`generated
|
|
175
|
+
exitError(`generated manifest.json is invalid at '${field}': ${msg}`);
|
|
133
176
|
}
|
|
177
|
+
const isApp = spec.kind === "app";
|
|
134
178
|
const pkgJson = {
|
|
135
179
|
name: slug,
|
|
136
180
|
version: "0.1.0",
|
|
137
181
|
type: "module",
|
|
138
182
|
scripts: {
|
|
139
183
|
dev: `bun run ${spec.devEntry}`,
|
|
140
|
-
pack: "bunx ryu pack ."
|
|
184
|
+
...isApp ? {} : { pack: "bunx ryu pack ." },
|
|
185
|
+
...spec.extraScripts
|
|
141
186
|
},
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
187
|
+
...isApp ? {} : {
|
|
188
|
+
dependencies: {
|
|
189
|
+
"@ryuhq/sdk": SDK_DEPENDENCY_RANGE,
|
|
190
|
+
...spec.extraDependencies
|
|
191
|
+
}
|
|
145
192
|
}
|
|
146
193
|
};
|
|
147
194
|
writeFileSync(
|
|
@@ -191,6 +238,7 @@ if (import.meta.main) {
|
|
|
191
238
|
exitError("name argument is required");
|
|
192
239
|
}
|
|
193
240
|
const created = scaffold(parsed.name, process.cwd(), parsed.template);
|
|
241
|
+
const isApp = TEMPLATES[parsed.template]?.kind === "app";
|
|
194
242
|
process.stdout.write(
|
|
195
243
|
[
|
|
196
244
|
"",
|
|
@@ -199,8 +247,8 @@ if (import.meta.main) {
|
|
|
199
247
|
" next steps:",
|
|
200
248
|
` cd ${parsed.name}`,
|
|
201
249
|
" bun install",
|
|
202
|
-
" bun dev
|
|
203
|
-
" bun run
|
|
250
|
+
" bun dev # runs the template entry",
|
|
251
|
+
isApp ? " bun run build # compile the sidecar binary the manifest names" : " bun run pack # validate and bundle manifest.json",
|
|
204
252
|
""
|
|
205
253
|
].join("\n")
|
|
206
254
|
);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ryu-app",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Scaffold a starter Ryu SDK project with a Runnable, gateway-pointed model config, and
|
|
5
|
+
"description": "Scaffold a starter Ryu SDK project with a Runnable, gateway-pointed model config, and manifest.json manifest",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-ryu-app": "./dist/index.js"
|
|
8
8
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"clean": "rm -rf dist"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ryuhq/sdk": "^0.0.
|
|
23
|
+
"@ryuhq/sdk": "^0.0.17"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "^1.3.4",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# __APP_DISPLAY_NAME__
|
|
2
|
+
|
|
3
|
+
A Ryu **app**: a self-contained satellite that ships a manifest and an
|
|
4
|
+
out-of-process sidecar. It is not a plugin — there is no widget, no turn hook and
|
|
5
|
+
no bundled UI code. Everything a Ryu node needs to run it is in this directory.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
manifest.json the whole integration surface (sidecar + capability + grant)
|
|
9
|
+
sidecar/ the backend process, dependency-free (node:http only)
|
|
10
|
+
src/main/control.ts the loopback HTTP control server (pure router + auth)
|
|
11
|
+
src/main/index.ts entrypoint
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The contract
|
|
15
|
+
|
|
16
|
+
An app owns **only** its `manifest.json` and its `sidecar/`. It must never require
|
|
17
|
+
a change to Ryu Core or the Gateway — no route module, no reserved MCP server, no
|
|
18
|
+
hardcoded id or port anywhere but this manifest. Control flows through the generic
|
|
19
|
+
ext-proxy:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
client → GET /api/ext/com.example.__APP_NAME__/items → Core → 127.0.0.1:<port>/items
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Core forwards **only** the sub-paths declared in `sidecars[].http.routes[]` and
|
|
26
|
+
404s everything else, so that list is a security boundary, not documentation. Keep
|
|
27
|
+
it in sync with the router in `sidecar/src/main/control.ts`.
|
|
28
|
+
|
|
29
|
+
`provides[]` publishes the sidecar as the `__APP_NAME__.control` capability behind
|
|
30
|
+
the `__APP_NAME__:control` grant, so another app can depend on the *capability*
|
|
31
|
+
rather than on this app's id.
|
|
32
|
+
|
|
33
|
+
## Run it
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun install --cwd sidecar
|
|
37
|
+
__APP_TOKEN_ENV__=dev-token bun run dev
|
|
38
|
+
curl -s localhost:7899/health
|
|
39
|
+
curl -s -H 'Authorization: Bearer dev-token' localhost:7899/items
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every route except `GET /health` is bearer-gated and **fails closed**: with no
|
|
43
|
+
`RYU_EXT_TOKEN` (injected by Core at spawn) and no `__APP_TOKEN_ENV__` override,
|
|
44
|
+
every protected route 401s. Do not relax that — loopback is not a trust boundary.
|
|
45
|
+
|
|
46
|
+
## Before you ship
|
|
47
|
+
|
|
48
|
+
- **Pick a free port.** There is no port registry. `7899` is the scaffold default;
|
|
49
|
+
avoid Core (`7980`), the Gateway (`7981`), the built-in sidecar band
|
|
50
|
+
(`7990`–`8003`) and the local engines (`8080`–`8087`). Core injects the
|
|
51
|
+
profile-shifted port through `__APP_PORT_ENV__`, so read it — never hardcode.
|
|
52
|
+
- **Build the binary.** `sidecars[].process.command` names `ryu-__APP_NAME__` on
|
|
53
|
+
`PATH`; `bun run --cwd sidecar build` compiles it. `__APP_BIN_ENV__` points a
|
|
54
|
+
node at a local build instead.
|
|
55
|
+
- **Rename the demo domain.** `/items` is a placeholder for whatever this app
|
|
56
|
+
actually does. Change it in `control.ts` *and* in the manifest's `routes[]`.
|
|
57
|
+
- **Keep `sidecar/` self-contained.** No workspace imports, no Ryu SDK — this tree
|
|
58
|
+
is published on its own.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "com.example.__APP_NAME__",
|
|
3
|
+
"name": "__APP_DISPLAY_NAME__",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "A self-contained Ryu app: a loopback HTTP sidecar Ryu spawns lazily and exposes as the grant-gated `__APP_NAME__.control` capability. Clients reach it through the generic ext-proxy at /api/ext/com.example.__APP_NAME__/*; no Core or Gateway code knows this app exists.",
|
|
6
|
+
"tagline": "A sidecar-backed Ryu app",
|
|
7
|
+
"category": "Automation",
|
|
8
|
+
"engines": {
|
|
9
|
+
"ryu": ">=0.0.1"
|
|
10
|
+
},
|
|
11
|
+
"runnables": [],
|
|
12
|
+
"sidecars": [
|
|
13
|
+
{
|
|
14
|
+
"name": "__APP_NAME__",
|
|
15
|
+
"process": {
|
|
16
|
+
"kind": "local",
|
|
17
|
+
"command": "ryu-__APP_NAME__",
|
|
18
|
+
"command_env": "__APP_BIN_ENV__",
|
|
19
|
+
"port_env": "__APP_PORT_ENV__"
|
|
20
|
+
},
|
|
21
|
+
"port": 7899,
|
|
22
|
+
"health_path": "/health",
|
|
23
|
+
"lazy": true,
|
|
24
|
+
"idle_stop_secs": 300,
|
|
25
|
+
"http": {
|
|
26
|
+
"routes": [
|
|
27
|
+
{ "path": "/" },
|
|
28
|
+
{ "path": "/health" },
|
|
29
|
+
{ "path": "/items" },
|
|
30
|
+
{ "path": "/items/:id" }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"provides": [
|
|
36
|
+
{
|
|
37
|
+
"capability": "__APP_NAME__.control",
|
|
38
|
+
"version": "1.0.0",
|
|
39
|
+
"sidecar": "__APP_NAME__",
|
|
40
|
+
"route": "/",
|
|
41
|
+
"grant": "__APP_NAME__:control"
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"permission_grants": ["__APP_NAME__:control"]
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@example/__APP_NAME__-sidecar",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ryu-__APP_NAME__": "./src/main/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/main/index.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun run src/main/index.ts",
|
|
12
|
+
"build": "bun build src/main/index.ts --compile --outfile dist/ryu-__APP_NAME__",
|
|
13
|
+
"check-types": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "^1.3.14",
|
|
17
|
+
"@types/node": "^22",
|
|
18
|
+
"typescript": "^5"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Loopback control server for the __APP_DISPLAY_NAME__ sidecar.
|
|
2
|
+
//
|
|
3
|
+
// Ryu spawns this as a `local` manifest sidecar (see `../../../../manifest.json`,
|
|
4
|
+
// `SidecarProcess::Local`). It exposes a small HTTP control surface bound to
|
|
5
|
+
// loopback so Core — and, through Core's generic ext-proxy
|
|
6
|
+
// (`/api/ext/com.example.__APP_NAME__/*`), every Ryu client — can drive the app.
|
|
7
|
+
// Nothing in Core or the Gateway knows this app exists: the manifest's
|
|
8
|
+
// `sidecars[]` + `provides[]` are the entire integration.
|
|
9
|
+
//
|
|
10
|
+
// SECURITY
|
|
11
|
+
// --------
|
|
12
|
+
// * Bound to 127.0.0.1 only. Never bind 0.0.0.0 — the ext-proxy is the only
|
|
13
|
+
// intended caller and it dials loopback.
|
|
14
|
+
// * Every route except `GET /health` requires `Authorization: Bearer <token>` —
|
|
15
|
+
// the per-plugin secret Core mints and injects at spawn (`RYU_EXT_TOKEN`);
|
|
16
|
+
// `__APP_TOKEN_ENV__` overrides it for standalone/dev runs. Neither set ⇒
|
|
17
|
+
// FAIL-CLOSED (every protected route 401s). A sidecar that skips this check is
|
|
18
|
+
// an unauthenticated RCE surface for anything else running on the machine —
|
|
19
|
+
// loopback is not a trust boundary on a multi-user or agent-laden host.
|
|
20
|
+
//
|
|
21
|
+
// The router (`handleRequest`) is a pure function over an injected `ItemStore`,
|
|
22
|
+
// so it unit-tests with a fake — no sockets, no process.
|
|
23
|
+
|
|
24
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
25
|
+
import { createServer, type Server } from "node:http";
|
|
26
|
+
|
|
27
|
+
/** Default loopback port. There is NO port registry: picking a free one is the
|
|
28
|
+
* app author's job. Stay clear of Core (:7980), the Gateway (:7981), the
|
|
29
|
+
* built-in sidecar band (:7990–:8003), and the local engines (:8080–:8087). */
|
|
30
|
+
const CONTROL_BASE_PORT = 7899;
|
|
31
|
+
|
|
32
|
+
/** A Core running under `RYU_PROFILE=dev` shifts every port by this much so a dev
|
|
33
|
+
* node and a release node coexist. Core injects the shifted value through
|
|
34
|
+
* `__APP_PORT_ENV__`; this fallback only matters when the sidecar is run by hand. */
|
|
35
|
+
const DEV_PORT_OFFSET = 1000;
|
|
36
|
+
|
|
37
|
+
const PACKAGE_VERSION = "0.1.0";
|
|
38
|
+
|
|
39
|
+
/** The capability this sidecar serves — must equal `provides[].capability` in the
|
|
40
|
+
* manifest, which is what a consuming app names in its `requires.capabilities`. */
|
|
41
|
+
export const CAPABILITY = "__APP_NAME__.control";
|
|
42
|
+
|
|
43
|
+
/** One `/items` row. Replace this with whatever your app actually owns. */
|
|
44
|
+
export interface Item {
|
|
45
|
+
createdAt: string;
|
|
46
|
+
id: string;
|
|
47
|
+
text: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The domain the router drives. Injected so the router can be tested against a
|
|
51
|
+
* fake without touching the real backing store. */
|
|
52
|
+
export interface ItemStore {
|
|
53
|
+
create(text: string): Item;
|
|
54
|
+
get(id: string): Item | null;
|
|
55
|
+
list(): Item[];
|
|
56
|
+
remove(id: string): boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The default store — in-memory, so the scaffold runs with zero setup. Swap it
|
|
60
|
+
* for a real one (SQLite, a file, a vendor SDK) without touching the router. */
|
|
61
|
+
export class MemoryItemStore implements ItemStore {
|
|
62
|
+
private readonly items = new Map<string, Item>();
|
|
63
|
+
|
|
64
|
+
list(): Item[] {
|
|
65
|
+
return [...this.items.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get(id: string): Item | null {
|
|
69
|
+
return this.items.get(id) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
create(text: string): Item {
|
|
73
|
+
const item: Item = {
|
|
74
|
+
id: randomUUID(),
|
|
75
|
+
text,
|
|
76
|
+
createdAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
this.items.set(item.id, item);
|
|
79
|
+
return item;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
remove(id: string): boolean {
|
|
83
|
+
return this.items.delete(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The bind port: whatever Core injected, else the profile-aware default. */
|
|
88
|
+
export function resolveControlPort(
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env
|
|
90
|
+
): number {
|
|
91
|
+
const explicit = Number.parseInt(env.__APP_PORT_ENV__ ?? "", 10);
|
|
92
|
+
if (Number.isInteger(explicit) && explicit > 0) {
|
|
93
|
+
return explicit;
|
|
94
|
+
}
|
|
95
|
+
const isDev = (env.RYU_PROFILE ?? "").trim().toLowerCase() === "dev";
|
|
96
|
+
return isDev ? CONTROL_BASE_PORT + DEV_PORT_OFFSET : CONTROL_BASE_PORT;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The expected bearer, or `null` when unset — which fails every protected route
|
|
100
|
+
* closed rather than serving the surface unauthenticated. */
|
|
101
|
+
export function resolveControlToken(
|
|
102
|
+
env: NodeJS.ProcessEnv = process.env
|
|
103
|
+
): string | null {
|
|
104
|
+
const raw = env.RYU_EXT_TOKEN ?? env.__APP_TOKEN_ENV__ ?? "";
|
|
105
|
+
const trimmed = raw.trim();
|
|
106
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Constant-time bearer check. `null`/empty `expected` ⇒ fail-closed (reject all).
|
|
110
|
+
* Compared with `timingSafeEqual`, not `===`: a byte-at-a-time comparison leaks
|
|
111
|
+
* the token one character per request to a caller that can time the loopback. */
|
|
112
|
+
export function bearerOk(
|
|
113
|
+
authHeader: string | undefined,
|
|
114
|
+
expected: string | null
|
|
115
|
+
): boolean {
|
|
116
|
+
if (!expected) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const presented = authHeader?.startsWith("Bearer ")
|
|
120
|
+
? authHeader.slice("Bearer ".length)
|
|
121
|
+
: null;
|
|
122
|
+
if (!presented) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const a = Buffer.from(presented, "utf8");
|
|
126
|
+
const b = Buffer.from(expected, "utf8");
|
|
127
|
+
if (a.length !== b.length) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return timingSafeEqual(a, b);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ControlResponse {
|
|
134
|
+
json?: unknown;
|
|
135
|
+
status: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface RequestDeps {
|
|
139
|
+
store: ItemStore;
|
|
140
|
+
token: string | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `/items/<id>` — a top-level literal (lint/performance/useTopLevelRegex). */
|
|
144
|
+
const RE_ITEM_ID = /^\/items\/([^/]+)$/;
|
|
145
|
+
|
|
146
|
+
function notFound(): ControlResponse {
|
|
147
|
+
return { status: 404, json: { ok: false, error: "not found" } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function badRequest(error: string): ControlResponse {
|
|
151
|
+
return { status: 400, json: { ok: false, error } };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Parse a JSON body. `""` (no body) is an empty object; malformed is `null`. */
|
|
155
|
+
function parseJsonBody(raw: string): Record<string, unknown> | null {
|
|
156
|
+
if (!raw) {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const parsed: unknown = JSON.parse(raw);
|
|
161
|
+
return typeof parsed === "object" && parsed !== null
|
|
162
|
+
? (parsed as Record<string, unknown>)
|
|
163
|
+
: null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleItemsCollection(
|
|
170
|
+
method: string,
|
|
171
|
+
body: string,
|
|
172
|
+
store: ItemStore
|
|
173
|
+
): ControlResponse {
|
|
174
|
+
if (method === "GET") {
|
|
175
|
+
return { status: 200, json: { ok: true, items: store.list() } };
|
|
176
|
+
}
|
|
177
|
+
if (method !== "POST") {
|
|
178
|
+
return notFound();
|
|
179
|
+
}
|
|
180
|
+
const payload = parseJsonBody(body);
|
|
181
|
+
if (!payload) {
|
|
182
|
+
return badRequest("body must be a JSON object");
|
|
183
|
+
}
|
|
184
|
+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
185
|
+
if (!text) {
|
|
186
|
+
return badRequest("missing text");
|
|
187
|
+
}
|
|
188
|
+
return { status: 201, json: { ok: true, item: store.create(text) } };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function handleItem(
|
|
192
|
+
method: string,
|
|
193
|
+
id: string,
|
|
194
|
+
store: ItemStore
|
|
195
|
+
): ControlResponse {
|
|
196
|
+
if (method === "GET") {
|
|
197
|
+
const item = store.get(id);
|
|
198
|
+
return item
|
|
199
|
+
? { status: 200, json: { ok: true, item } }
|
|
200
|
+
: { status: 404, json: { ok: false, error: "unknown item" } };
|
|
201
|
+
}
|
|
202
|
+
if (method === "DELETE") {
|
|
203
|
+
return store.remove(id)
|
|
204
|
+
? { status: 200, json: { ok: true } }
|
|
205
|
+
: { status: 404, json: { ok: false, error: "unknown item" } };
|
|
206
|
+
}
|
|
207
|
+
return notFound();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Pure request router. `path` carries no query string; `body` is the raw request
|
|
212
|
+
* body. Every route except `GET /health` is bearer-gated.
|
|
213
|
+
*
|
|
214
|
+
* The paths handled here MUST stay in sync with `sidecars[].http.routes[]` in the
|
|
215
|
+
* manifest: Core 404s any sub-path the manifest does not declare (undeclared paths
|
|
216
|
+
* are never forwarded), so a route added here but not there is simply unreachable
|
|
217
|
+
* through the ext-proxy — and one declared there but missing here 404s from the
|
|
218
|
+
* sidecar instead.
|
|
219
|
+
*/
|
|
220
|
+
export function handleRequest(
|
|
221
|
+
method: string,
|
|
222
|
+
path: string,
|
|
223
|
+
authHeader: string | undefined,
|
|
224
|
+
body: string,
|
|
225
|
+
deps: RequestDeps
|
|
226
|
+
): ControlResponse {
|
|
227
|
+
// Unauthenticated on purpose: Core's health monitor probes this before the
|
|
228
|
+
// plugin's token is in play, and it reveals nothing but liveness.
|
|
229
|
+
if (path === "/health") {
|
|
230
|
+
return { status: 200, json: { ok: true, version: PACKAGE_VERSION } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!bearerOk(authHeader, deps.token)) {
|
|
234
|
+
return { status: 401, json: { ok: false, error: "unauthorized" } };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The capability root — `provides[].route` points here, so the broker hits it
|
|
238
|
+
// to describe the capability.
|
|
239
|
+
if (path === "/" && method === "GET") {
|
|
240
|
+
return {
|
|
241
|
+
status: 200,
|
|
242
|
+
json: {
|
|
243
|
+
ok: true,
|
|
244
|
+
capability: CAPABILITY,
|
|
245
|
+
version: PACKAGE_VERSION,
|
|
246
|
+
routes: ["/health", "/items", "/items/:id"],
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (path === "/items") {
|
|
252
|
+
return handleItemsCollection(method, body, deps.store);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const match = RE_ITEM_ID.exec(path);
|
|
256
|
+
if (match?.[1]) {
|
|
257
|
+
return handleItem(method, decodeURIComponent(match[1]), deps.store);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return notFound();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Start the loopback control server. A bind failure logs and leaves the process
|
|
264
|
+
* up so Core's health probe reports it unhealthy instead of racing a respawn. */
|
|
265
|
+
export function startControlServer(deps: RequestDeps, port: number): Server {
|
|
266
|
+
const server = createServer((req, res) => {
|
|
267
|
+
const chunks: Buffer[] = [];
|
|
268
|
+
req.on("data", (c) => chunks.push(c as Buffer));
|
|
269
|
+
req.on("end", () => {
|
|
270
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
271
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
272
|
+
const resp = handleRequest(
|
|
273
|
+
req.method ?? "GET",
|
|
274
|
+
path,
|
|
275
|
+
req.headers.authorization,
|
|
276
|
+
body,
|
|
277
|
+
deps
|
|
278
|
+
);
|
|
279
|
+
res.writeHead(resp.status, { "Content-Type": "application/json" });
|
|
280
|
+
res.end(JSON.stringify(resp.json ?? {}));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
server.on("error", (err) => {
|
|
284
|
+
// biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
|
|
285
|
+
console.warn(
|
|
286
|
+
`[ryu-__APP_NAME__] control server unavailable: ${err.message}`
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
server.listen(port, "127.0.0.1");
|
|
290
|
+
return server;
|
|
291
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// __APP_DISPLAY_NAME__ sidecar — entrypoint.
|
|
3
|
+
//
|
|
4
|
+
// A dependency-free Node/Bun process Core spawns as a `local` manifest sidecar.
|
|
5
|
+
// It owns ONE loopback HTTP control server (`control.ts`) and nothing else: no
|
|
6
|
+
// import from `apps/core`, no Ryu SDK, no shared workspace package. That is the
|
|
7
|
+
// satellite contract — this directory must build and ship from its own tree.
|
|
8
|
+
//
|
|
9
|
+
// bun run src/main/index.ts # standalone (set __APP_TOKEN_ENV__ first)
|
|
10
|
+
// bun run build # → dist/ryu-__APP_NAME__, the `command` the
|
|
11
|
+
// # manifest's sidecars[].process names
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
MemoryItemStore,
|
|
15
|
+
resolveControlPort,
|
|
16
|
+
resolveControlToken,
|
|
17
|
+
startControlServer,
|
|
18
|
+
} from "./control.ts";
|
|
19
|
+
|
|
20
|
+
function main(): void {
|
|
21
|
+
const port = resolveControlPort();
|
|
22
|
+
const token = resolveControlToken();
|
|
23
|
+
if (!token) {
|
|
24
|
+
// Fail-closed is enforced per-request; warn once so a misconfigured spawn is
|
|
25
|
+
// diagnosable rather than silently 401ing every call.
|
|
26
|
+
// biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
|
|
27
|
+
console.warn(
|
|
28
|
+
"[ryu-__APP_NAME__] no RYU_EXT_TOKEN/__APP_TOKEN_ENV__ set — all control routes will 401"
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
startControlServer({ store: new MemoryItemStore(), token }, port);
|
|
32
|
+
// biome-ignore lint/suspicious/noConsole: main-process diagnostic, no renderer.
|
|
33
|
+
console.log(`[ryu-__APP_NAME__] control server on 127.0.0.1:${port}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"noUnusedLocals": true,
|
|
14
|
+
"noUnusedParameters": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"baseUrl": ".",
|
|
17
|
+
"types": ["node", "bun"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"]
|
|
20
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* can never impersonate first-party Ryu/system chrome.
|
|
14
14
|
*
|
|
15
15
|
* bun run src/app.ts # prints the assembled manifest
|
|
16
|
-
* bunx ryu pack . # bundles src/widget.tsx into ui_code + writes
|
|
16
|
+
* bunx ryu pack . # bundles src/widget.tsx into ui_code + writes manifest.json
|
|
17
17
|
*
|
|
18
18
|
* v1 boundary: DECLARATIVE PASS-THROUGH only — there is no `run` handler for the
|
|
19
19
|
* tools. The render widget draws from `window.openai.toolInput` / `toolOutput`;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* variables — reference only `ctx`, `host`, and language built-ins.
|
|
16
16
|
*
|
|
17
17
|
* bun run src/plugin.ts # prints the assembled manifest
|
|
18
|
-
* bunx ryu pack . # serializes the hook + writes
|
|
18
|
+
* bunx ryu pack . # serializes the hook + writes manifest.json
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { definePlugin, defineTurnHook } from "@ryuhq/sdk";
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A Ryu App bundles a "render" tool whose result mounts a self-contained widget
|
|
5
5
|
* inline in the chat reply. This module is the AUTHORING source of truth: it uses
|
|
6
|
-
* `defineApp` to assemble the `
|
|
6
|
+
* `defineApp` to assemble the `manifest.json` manifest that ships alongside it.
|
|
7
7
|
*
|
|
8
8
|
* bun run src/app.ts # prints the assembled manifest
|
|
9
|
-
* bunx ryu pack . # bundles src/widget.tsx into ui_code + writes
|
|
9
|
+
* bunx ryu pack . # bundles src/widget.tsx into ui_code + writes manifest.json
|
|
10
10
|
*
|
|
11
11
|
* v1 boundary: this is DECLARATIVE PASS-THROUGH only. There is no `run` handler —
|
|
12
12
|
* the widget renders from `window.openai.toolInput` / `toolOutput` (the arguments
|
|
File without changes
|
|
File without changes
|