Skip to content

Commit 873244a

Browse files
committed
feat: unify API contracts and add contract schema validation
- Config endpoint now returns {name, desc} objects instead of flat strings for packages, casks, and npm arrays - New GET /api/packages endpoint serves full package catalog with installer type (formula/cask/npm) for CLI consumption - Snapshot upload validates packages is structured object format - CI validates responses against openboot-contract JSON schemas - CI triggers on contract-updated repository_dispatch events - Update tests and docs to match new response format - Add post-deploy smoke test script (scripts/smoke-test-api.sh)
1 parent 1a04021 commit 873244a

File tree

12 files changed

+443
-34
lines changed

12 files changed

+443
-34
lines changed

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ on:
55
branches: [main]
66
pull_request:
77
branches: [main]
8+
repository_dispatch:
9+
types: [contract-updated]
810

911
jobs:
1012
check:
@@ -41,6 +43,43 @@ jobs:
4143
- name: Build
4244
run: npm run build
4345

46+
- name: Contract schema validation
47+
run: |
48+
git clone --depth 1 https://github.com/openbootdotdev/openboot-contract.git /tmp/contract
49+
pip install jsonschema
50+
51+
python3 -c "
52+
import json, jsonschema, sys
53+
54+
checks = [
55+
('/tmp/contract/schemas/remote-config.json', '/tmp/contract/fixtures/config-v1.json'),
56+
('/tmp/contract/schemas/snapshot.json', '/tmp/contract/fixtures/snapshot-v1.json'),
57+
]
58+
59+
failed = 0
60+
for schema_path, fixture_path in checks:
61+
schema = json.load(open(schema_path))
62+
data = json.load(open(fixture_path))
63+
try:
64+
jsonschema.validate(data, schema)
65+
print(f' ✓ {fixture_path.split(\"/\")[-1]} matches {schema_path.split(\"/\")[-1]}')
66+
except jsonschema.ValidationError as e:
67+
print(f' ✗ {fixture_path.split(\"/\")[-1]}: {e.message}')
68+
failed += 1
69+
70+
# Also validate the packages schema structure against package-metadata expectations
71+
pkg_schema = json.load(open('/tmp/contract/schemas/packages.json'))
72+
required_fields = set(pkg_schema['properties']['packages']['items']['required'])
73+
expected = {'name', 'desc', 'category', 'type', 'installer'}
74+
if required_fields != expected:
75+
print(f' ✗ packages schema required fields mismatch: {required_fields} vs {expected}')
76+
failed += 1
77+
else:
78+
print(f' ✓ packages schema has correct required fields')
79+
80+
sys.exit(1 if failed else 0)
81+
"
82+
4483
deploy:
4584
needs: check
4685
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ coverage/
2525
vite.config.js.timestamp-*
2626
vite.config.ts.timestamp-*
2727
.claude/
28+
.dev.vars

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Local dev requires `.dev.vars` with `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`,
4242
| `src/lib/server/` | Server-only: `auth.ts` (JWT + OAuth), `install-script.ts`, `rate-limit.ts`, `validation.ts` |
4343
| `src/lib/stores/` | Svelte stores: `auth.ts` (user state), `theme.ts` (light/dark) |
4444
| `src/lib/presets.ts` | Package presets (minimal, developer, full) |
45-
| `src/lib/package-metadata.ts` | Descriptions for 100+ packages |
45+
| `src/lib/package-metadata.ts` | Source of truth for package metadata (100+ packages). Served via `/api/packages` for CLI consumption |
4646
| `src/routes/api/` | REST API endpoints (`+server.ts` convention) |
4747
| `src/routes/[username]/[slug]/` | Config page, install endpoint, config JSON, OG image |
4848
| `src/docs/` | Markdown docs (mdsvex preprocessed) |
@@ -57,7 +57,7 @@ Three auth flows:
5757

5858
### Database
5959

60-
D1 (SQLite), no ORM — direct parameterized SQL via `env.DB.prepare(sql).bind(...)`. Four tables: `users`, `configs`, `api_tokens`, `cli_auth_codes`. The `configs.packages` field is a JSON array of `{name, type, desc}`. Config visibility: `public` (discoverable), `unlisted` (accessible but not listed), `private` (owner-only, 403 on install).
60+
D1 (SQLite), no ORM — direct parameterized SQL via `env.DB.prepare(sql).bind(...)`. Four tables: `users`, `configs`, `api_tokens`, `cli_auth_codes`. The `configs.packages` field is a JSON array (stored as `{name, type}` objects; the config endpoint transforms to `{name, desc}` on read, filling descriptions from `package-metadata.ts`). Config visibility: `public` (discoverable), `unlisted` (accessible but not listed), `private` (owner-only, 403 on install).
6161

6262
**D1 limitation**: No `ALTER TABLE DROP COLUMN`. Plan column removals via new table + data migration.
6363

scripts/smoke-test-api.sh

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/bin/bash
2+
# Post-deployment smoke test for openboot.dev API.
3+
# Tests that critical endpoints return the expected response shape.
4+
#
5+
# Usage:
6+
# ./scripts/smoke-test-api.sh # test production
7+
# ./scripts/smoke-test-api.sh http://localhost:5173 # test local
8+
#
9+
set -euo pipefail
10+
11+
BASE_URL="${1:-https://openboot.dev}"
12+
PASS=0
13+
FAIL=0
14+
15+
pass() { PASS=$((PASS + 1)); echo "$1"; }
16+
fail() { FAIL=$((FAIL + 1)); echo "$1: $2"; }
17+
18+
echo "Smoke testing $BASE_URL"
19+
echo ""
20+
21+
# --- Health ---
22+
echo "=== /api/health ==="
23+
HEALTH=$(curl -sf "$BASE_URL/api/health" 2>/dev/null || echo '{}')
24+
if echo "$HEALTH" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('status') in ('healthy','degraded')" 2>/dev/null; then
25+
pass "health endpoint responds"
26+
else
27+
fail "health endpoint" "unexpected response"
28+
fi
29+
30+
# --- /api/packages ---
31+
echo ""
32+
echo "=== /api/packages ==="
33+
PKGS=$(curl -sf "$BASE_URL/api/packages" 2>/dev/null || echo '{}')
34+
35+
# Has packages array
36+
if echo "$PKGS" | python3 -c "import sys,json; d=json.load(sys.stdin); assert len(d['packages']) > 50" 2>/dev/null; then
37+
pass "returns 50+ packages"
38+
else
39+
fail "package count" "expected 50+ packages"
40+
fi
41+
42+
# Each package has required fields
43+
if echo "$PKGS" | python3 -c "
44+
import sys,json
45+
d=json.load(sys.stdin)
46+
p=d['packages'][0]
47+
for f in ('name','desc','category','type','installer'):
48+
assert f in p, f'missing {f}'
49+
assert p['installer'] in ('formula','cask','npm'), f'bad installer: {p[\"installer\"]}'
50+
" 2>/dev/null; then
51+
pass "packages have name, desc, category, type, installer"
52+
else
53+
fail "package shape" "missing required fields"
54+
fi
55+
56+
# Installer breakdown has all three types
57+
if echo "$PKGS" | python3 -c "
58+
import sys,json
59+
d=json.load(sys.stdin)
60+
types = set(p['installer'] for p in d['packages'])
61+
assert 'formula' in types and 'cask' in types and 'npm' in types
62+
" 2>/dev/null; then
63+
pass "has formula, cask, and npm installers"
64+
else
65+
fail "installer types" "missing formula/cask/npm"
66+
fi
67+
68+
# --- /api/homebrew/search ---
69+
echo ""
70+
echo "=== /api/homebrew/search ==="
71+
SEARCH=$(curl -sf "$BASE_URL/api/homebrew/search?q=git" 2>/dev/null || echo '{}')
72+
if echo "$SEARCH" | python3 -c "
73+
import sys,json
74+
d=json.load(sys.stdin)
75+
assert 'formulae' in d or 'results' in d
76+
" 2>/dev/null; then
77+
pass "homebrew search responds"
78+
else
79+
fail "homebrew search" "unexpected response shape"
80+
fi
81+
82+
# --- Config endpoint (using a known public config if available) ---
83+
echo ""
84+
echo "=== Config endpoint ==="
85+
# Try the official openboot config first, fall back to any public config.
86+
CONFIG=$(curl -sf "$BASE_URL/openboot/developer/config" 2>/dev/null || echo '')
87+
if [ -z "$CONFIG" ]; then
88+
# Try fetching public configs list.
89+
CONFIG=$(curl -sf "$BASE_URL/api/configs/public" 2>/dev/null | python3 -c "
90+
import sys,json
91+
configs=json.load(sys.stdin)
92+
if configs and len(configs)>0:
93+
c=configs[0]
94+
print(c.get('username',''),c.get('slug',''))
95+
" 2>/dev/null || echo '')
96+
if [ -n "$CONFIG" ]; then
97+
read -r USER SLUG <<< "$CONFIG"
98+
CONFIG=$(curl -sf "$BASE_URL/$USER/$SLUG/config" 2>/dev/null || echo '')
99+
fi
100+
fi
101+
102+
if [ -n "$CONFIG" ] && [ "$CONFIG" != "{}" ]; then
103+
# packages should be objects with name+desc
104+
if echo "$CONFIG" | python3 -c "
105+
import sys,json
106+
d=json.load(sys.stdin)
107+
assert 'packages' in d
108+
assert 'casks' in d
109+
assert 'taps' in d
110+
assert 'npm' in d
111+
if len(d['packages']) > 0:
112+
p = d['packages'][0]
113+
assert isinstance(p, dict), f'expected object, got {type(p).__name__}'
114+
assert 'name' in p, 'missing name'
115+
assert 'desc' in p, 'missing desc'
116+
" 2>/dev/null; then
117+
pass "config returns {name, desc} objects for packages"
118+
else
119+
fail "config format" "packages not in expected {name, desc} format"
120+
fi
121+
122+
# taps should be plain strings
123+
if echo "$CONFIG" | python3 -c "
124+
import sys,json
125+
d=json.load(sys.stdin)
126+
if len(d.get('taps',[])) > 0:
127+
assert isinstance(d['taps'][0], str), 'taps should be strings'
128+
" 2>/dev/null; then
129+
pass "taps are plain strings"
130+
else
131+
fail "taps format" "expected string array"
132+
fi
133+
else
134+
echo " - skipped (no public config found)"
135+
fi
136+
137+
# --- Summary ---
138+
echo ""
139+
TOTAL=$((PASS + FAIL))
140+
echo "Results: $PASS/$TOTAL passed"
141+
if [ "$FAIL" -gt 0 ]; then
142+
echo "FAILED"
143+
exit 1
144+
else
145+
echo "ALL PASSED"
146+
fi

src/docs/api-reference.md

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,7 @@ GET /api/configs/:slug
7676
"description": "Personal development environment",
7777
"base_preset": "developer",
7878
"packages": [
79-
{ "name": "node", "type": "formula" },
80-
{ "name": "visual-studio-code", "type": "cask" }
79+
{ "name": "node", "type": "formula", "desc": "JavaScript runtime built on V8 engine" }
8180
],
8281
"custom_script": "mkdir -p ~/projects",
8382
"dotfiles_repo": "https://github.com/user/dotfiles.git",
@@ -217,6 +216,57 @@ GET /:username/:slug/install
217216

218217
---
219218

219+
## Package Catalog
220+
221+
### List All Packages
222+
223+
Returns the complete package catalog with metadata. Used by the CLI to fetch package descriptions and installer types. Responses are cached (1h client, 24h CDN).
224+
225+
```
226+
GET /api/packages
227+
```
228+
229+
**Auth required:** No
230+
231+
**Response:**
232+
233+
```json
234+
{
235+
"packages": [
236+
{
237+
"name": "git",
238+
"desc": "Distributed version control system",
239+
"category": "essential",
240+
"type": "cli",
241+
"installer": "formula"
242+
},
243+
{
244+
"name": "visual-studio-code",
245+
"desc": "Code editor with extensions and debugging",
246+
"category": "essential",
247+
"type": "gui",
248+
"installer": "cask"
249+
},
250+
{
251+
"name": "typescript",
252+
"desc": "Typed superset of JavaScript",
253+
"category": "essential",
254+
"type": "language",
255+
"installer": "npm"
256+
}
257+
]
258+
}
259+
```
260+
261+
**Fields:**
262+
- `name` — Package identifier
263+
- `desc` — Human-readable description
264+
- `category``essential`, `development`, `productivity`, or `optional`
265+
- `type` — Package kind: `cli`, `gui`, `language`, `devops`, or `database`
266+
- `installer` — Install method: `formula` (Homebrew), `cask` (Homebrew Cask), or `npm`
267+
268+
---
269+
220270
## Package Search
221271

222272
### Search Homebrew Packages
@@ -400,8 +450,8 @@ curl -X POST https://openboot.dev/api/configs \
400450
"name": "My Setup",
401451
"base_preset": "developer",
402452
"packages": [
403-
{"name": "node", "type": "formula"},
404-
{"name": "visual-studio-code", "type": "cask"}
453+
{"name": "node", "type": "formula", "desc": "JavaScript runtime"},
454+
{"name": "visual-studio-code", "type": "cask", "desc": "Code editor"}
405455
]
406456
}'
407457
```

src/routes/[username]/[slug]/config/+server.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { json } from '@sveltejs/kit';
22
import type { RequestHandler } from './$types';
3+
import { getPackageDescription } from '$lib/package-metadata';
34

45
export const GET: RequestHandler = async ({ platform, params, request }) => {
56
const env = platform?.env;
@@ -75,30 +76,33 @@ export const GET: RequestHandler = async ({ platform, params, request }) => {
7576
}
7677

7778
const rawPackages: any[] = JSON.parse(config.packages || '[]');
78-
const packageNames: string[] = [];
79-
const caskNames: string[] = [];
80-
const npmNames: string[] = [];
79+
const formulae: { name: string; desc: string }[] = [];
80+
const casks: { name: string; desc: string }[] = [];
81+
const npms: { name: string; desc: string }[] = [];
8182

8283
for (const pkg of rawPackages) {
84+
const name = typeof pkg === 'string' ? pkg : pkg.name;
85+
const desc = (typeof pkg === 'object' && pkg.desc) || getPackageDescription(name);
86+
8387
if (typeof pkg === 'string') {
8488
if (snapshotCasks.has(pkg)) {
85-
caskNames.push(pkg);
89+
casks.push({ name, desc });
8690
} else {
87-
packageNames.push(pkg);
91+
formulae.push({ name, desc });
8892
}
8993
} else {
9094
if (pkg.type === 'npm') {
91-
npmNames.push(pkg.name);
95+
npms.push({ name, desc });
9296
} else if (pkg.type === 'cask') {
93-
caskNames.push(pkg.name);
97+
casks.push({ name, desc });
9498
} else {
95-
packageNames.push(pkg.name);
99+
formulae.push({ name, desc });
96100
}
97101
}
98102
}
99103

100-
for (const pkg of packageNames) {
101-
const parts = pkg.split('/');
104+
for (const pkg of formulae) {
105+
const parts = pkg.name.split('/');
102106
if (parts.length === 3) {
103107
tapsSet.add(`${parts[0]}/${parts[1]}`);
104108
}
@@ -111,10 +115,10 @@ export const GET: RequestHandler = async ({ platform, params, request }) => {
111115
slug: config.slug,
112116
name: config.name,
113117
preset: config.base_preset,
114-
packages: packageNames,
115-
casks: caskNames,
116-
taps: taps,
117-
npm: npmNames,
118+
packages: formulae,
119+
casks,
120+
taps,
121+
npm: npms,
118122
dotfiles_repo: config.dotfiles_repo || '',
119123
post_install: config.custom_script
120124
? config.custom_script.split('\n').map((s: string) => s.trim()).filter((s: string) => s.length > 0)

0 commit comments

Comments
 (0)