Skip to content

Commit d871e74

Browse files
committed
Add installation script and update README for Render OpenCode plugin
1 parent 8a3bdca commit d871e74

4 files changed

Lines changed: 333 additions & 1 deletion

File tree

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,38 @@ Use Render from OpenCode to deploy apps, validate Blueprints, debug failed deplo
1313

1414
## Installing the plugin
1515

16+
### Install from GitHub
17+
18+
Until the package is published to npm, install the local OpenCode plugin, skills, commands, and agent directly from GitHub:
19+
20+
```bash
21+
curl -fsSL https://raw.githubusercontent.com/render-oss/render-opencode-plugin/main/install.sh | bash
22+
```
23+
24+
This writes files to `~/.config/opencode/`:
25+
26+
- `plugins/render.ts`
27+
- `skills/render-*/SKILL.md`
28+
- `commands/deploy-to-render.md`
29+
- `commands/check-render-status.md`
30+
- `agents/render.md`
31+
32+
The installer doesn't overwrite existing files unless you pass `--force`:
33+
34+
```bash
35+
curl -fsSL https://raw.githubusercontent.com/render-oss/render-opencode-plugin/main/install.sh | bash -s -- --force
36+
```
37+
38+
Preview changes without writing files:
39+
40+
```bash
41+
curl -fsSL https://raw.githubusercontent.com/render-oss/render-opencode-plugin/main/install.sh | bash -s -- --dry-run
42+
```
43+
44+
Restart OpenCode after installation.
45+
46+
### Install from npm
47+
1648
Add the npm package to your OpenCode config:
1749

1850
```json
@@ -22,7 +54,7 @@ Add the npm package to your OpenCode config:
2254
}
2355
```
2456

25-
OpenCode installs npm plugins with Bun at startup and caches them in `~/.cache/opencode/node_modules/`.
57+
OpenCode installs npm plugins with Bun at startup and caches them in `~/.cache/opencode/node_modules/`. This flow requires the package to be published to npm.
2658

2759
## Installing skills, commands, and the agent
2860

assets/opencode/plugins/render.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { execFile } from "node:child_process"
2+
import { basename, dirname, resolve } from "node:path"
3+
import { promisify } from "node:util"
4+
5+
import type { Plugin } from "@opencode-ai/plugin"
6+
import { tool } from "@opencode-ai/plugin"
7+
8+
const execFileAsync = promisify(execFile)
9+
10+
type ValidationResult = {
11+
ok: boolean
12+
command: string
13+
cwd: string
14+
output: string
15+
error?: string
16+
}
17+
18+
export const RenderPlugin: Plugin = async ({ client }) => {
19+
await client.app.log({
20+
body: {
21+
service: "render-opencode-plugin",
22+
level: "info",
23+
message: "Render OpenCode plugin initialized",
24+
},
25+
})
26+
27+
return {
28+
tool: {
29+
render_validate_blueprint: tool({
30+
description: "Validate a Render Blueprint file with the Render CLI.",
31+
args: {
32+
path: tool.schema.string().describe("Path to render.yaml or render.yml."),
33+
},
34+
async execute(args) {
35+
const result = await validateBlueprint(args.path)
36+
return {
37+
title: result.ok ? "Render Blueprint valid" : "Render Blueprint validation failed",
38+
output: result.output,
39+
metadata: {
40+
command: result.command,
41+
cwd: result.cwd,
42+
ok: result.ok,
43+
error: result.error,
44+
},
45+
}
46+
},
47+
}),
48+
},
49+
50+
"tool.execute.after": async (input, output) => {
51+
const blueprintPath = extractTouchedFiles(input.args).find(isBlueprintFile)
52+
if (!blueprintPath) {
53+
return
54+
}
55+
56+
const result = await validateBlueprint(blueprintPath)
57+
if (result.ok) {
58+
await client.app.log({
59+
body: {
60+
service: "render-opencode-plugin",
61+
level: "info",
62+
message: "Validated Render Blueprint",
63+
extra: {
64+
path: blueprintPath,
65+
cwd: result.cwd,
66+
},
67+
},
68+
})
69+
return
70+
}
71+
72+
output.title = "Render Blueprint validation"
73+
output.output = [output.output, result.output].filter(Boolean).join("\n\n")
74+
output.metadata = {
75+
...output.metadata,
76+
renderBlueprintValidation: {
77+
ok: false,
78+
command: result.command,
79+
cwd: result.cwd,
80+
error: result.error,
81+
},
82+
}
83+
},
84+
}
85+
}
86+
87+
function isBlueprintFile(filePath: string) {
88+
const name = basename(filePath)
89+
return name === "render.yaml" || name === "render.yml"
90+
}
91+
92+
function getBlueprintWorkingDirectory(filePath: string) {
93+
return dirname(resolve(filePath))
94+
}
95+
96+
async function validateBlueprint(filePath: string): Promise<ValidationResult> {
97+
const cwd = getBlueprintWorkingDirectory(filePath)
98+
const command = "render blueprints validate"
99+
100+
try {
101+
const result = await execFileAsync("render", ["blueprints", "validate"], { cwd })
102+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim()
103+
return {
104+
ok: true,
105+
command,
106+
cwd,
107+
output: output || "render blueprints validate completed successfully.",
108+
}
109+
} catch (error) {
110+
if (isNodeError(error) && error.code === "ENOENT") {
111+
return {
112+
ok: false,
113+
command,
114+
cwd,
115+
output: "Render CLI not found. Install it to validate render.yaml:\n macOS: brew install render\n Linux: curl -fsSL https://raw.githubusercontent.com/render-oss/cli/main/bin/install.sh | sh",
116+
error: "render-cli-not-found",
117+
}
118+
}
119+
120+
const output = getExecErrorOutput(error)
121+
return {
122+
ok: false,
123+
command,
124+
cwd,
125+
output: output || String(error),
126+
error: "validation-failed",
127+
}
128+
}
129+
}
130+
131+
function extractTouchedFiles(value: unknown): string[] {
132+
const files: string[] = []
133+
collectTouchedFiles(value, files)
134+
return files
135+
}
136+
137+
const filePathKeys = new Set(["file", "filePath", "file_path", "filename", "path"])
138+
139+
function collectTouchedFiles(value: unknown, files: string[], key?: string) {
140+
if (typeof value === "string") {
141+
if (key && filePathKeys.has(key)) {
142+
files.push(value)
143+
}
144+
return
145+
}
146+
147+
if (Array.isArray(value)) {
148+
for (const item of value) {
149+
collectTouchedFiles(item, files)
150+
}
151+
return
152+
}
153+
154+
if (typeof value !== "object" || value === null) {
155+
return
156+
}
157+
158+
for (const [entryKey, entryValue] of Object.entries(value)) {
159+
collectTouchedFiles(entryValue, files, entryKey)
160+
}
161+
}
162+
163+
function getExecErrorOutput(error: unknown) {
164+
if (typeof error === "object" && error !== null) {
165+
const withOutput = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
166+
return [withOutput.stdout, withOutput.stderr, withOutput.message]
167+
.filter((value): value is string => typeof value === "string" && value.length > 0)
168+
.join("\n")
169+
.trim()
170+
}
171+
172+
return ""
173+
}
174+
175+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
176+
return typeof error === "object" && error !== null && "code" in error
177+
}
178+
179+
export default RenderPlugin

install.sh

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
REPO_TARBALL_URL="https://github.com/render-oss/render-opencode-plugin/archive/refs/heads/main.tar.gz"
5+
CONFIG_DIR="${OPENCODE_CONFIG_DIR:-${HOME:-}/.config/opencode}"
6+
SOURCE_DIR=""
7+
FORCE="0"
8+
DRY_RUN="0"
9+
10+
usage() {
11+
cat <<'EOF'
12+
Install the Render OpenCode plugin from GitHub.
13+
14+
Usage:
15+
install.sh [options]
16+
17+
Options:
18+
--config-dir <path> Target OpenCode config directory. Defaults to ~/.config/opencode.
19+
--source <path> Use a local repo checkout instead of downloading from GitHub.
20+
--force Overwrite existing files.
21+
--dry-run Print what would change without writing files.
22+
-h, --help Show this help.
23+
EOF
24+
}
25+
26+
while [[ $# -gt 0 ]]; do
27+
case "$1" in
28+
--config-dir)
29+
CONFIG_DIR="$2"
30+
shift 2
31+
;;
32+
--source)
33+
SOURCE_DIR="$2"
34+
shift 2
35+
;;
36+
--force)
37+
FORCE="1"
38+
shift
39+
;;
40+
--dry-run)
41+
DRY_RUN="1"
42+
shift
43+
;;
44+
-h|--help)
45+
usage
46+
exit 0
47+
;;
48+
*)
49+
echo "Unknown option: $1" >&2
50+
usage >&2
51+
exit 1
52+
;;
53+
esac
54+
done
55+
56+
if [[ -z "$CONFIG_DIR" || "$CONFIG_DIR" == "/.config/opencode" ]]; then
57+
echo "Cannot determine OpenCode config directory. Set HOME or pass --config-dir." >&2
58+
exit 1
59+
fi
60+
61+
TMPDIR="$(mktemp -d)"
62+
cleanup() {
63+
rm -rf "$TMPDIR"
64+
}
65+
trap cleanup EXIT
66+
67+
if [[ -z "$SOURCE_DIR" ]]; then
68+
ARCHIVE="$TMPDIR/render-opencode-plugin.tar.gz"
69+
if command -v curl >/dev/null 2>&1; then
70+
curl -fsSL "$REPO_TARBALL_URL" -o "$ARCHIVE"
71+
elif command -v wget >/dev/null 2>&1; then
72+
wget -qO "$ARCHIVE" "$REPO_TARBALL_URL"
73+
else
74+
echo "Install requires curl or wget." >&2
75+
exit 1
76+
fi
77+
78+
tar -xzf "$ARCHIVE" -C "$TMPDIR"
79+
SOURCE_DIR="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
80+
fi
81+
82+
ASSETS_DIR="$SOURCE_DIR/assets/opencode"
83+
if [[ ! -d "$ASSETS_DIR" ]]; then
84+
echo "Could not find assets/opencode in $SOURCE_DIR." >&2
85+
exit 1
86+
fi
87+
88+
install_file() {
89+
local src="$1"
90+
local dest="$2"
91+
92+
if [[ -e "$dest" && "$FORCE" != "1" ]]; then
93+
echo "skipped existing $dest"
94+
return
95+
fi
96+
97+
if [[ "$DRY_RUN" == "1" ]]; then
98+
echo "would write $dest"
99+
return
100+
fi
101+
102+
mkdir -p "$(dirname "$dest")"
103+
cp "$src" "$dest"
104+
echo "wrote $dest"
105+
}
106+
107+
for dir in plugins skills commands agents; do
108+
[[ -d "$ASSETS_DIR/$dir" ]] || continue
109+
while IFS= read -r -d '' src; do
110+
rel="${src#"$ASSETS_DIR/$dir/"}"
111+
install_file "$src" "$CONFIG_DIR/$dir/$rel"
112+
done < <(find "$ASSETS_DIR/$dir" -type f -print0)
113+
done
114+
115+
cat <<EOF
116+
117+
Render OpenCode files installed.
118+
119+
Restart OpenCode so it can load new plugins, skills, commands, and agents.
120+
EOF

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"files": [
3535
"dist",
3636
"assets",
37+
"install.sh",
3738
"README.md",
3839
"LICENSE"
3940
],

0 commit comments

Comments
 (0)