@oxy-hq/cli 0.2.0 → 0.5.0
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 +459 -115
- package/dist/main.mjs +3343 -231
- package/dist/main.mjs.map +1 -1
- package/dist/{mcp-VdontkUr.mjs → mcp-CIsyezzU.mjs} +2 -2
- package/dist/mcp-CIsyezzU.mjs.map +1 -0
- package/json-schemas/config.json +2 -4
- package/package.json +8 -8
- package/skills/oxy-custom-apps/SKILL.md +78 -12
- package/template/.github/scripts/ci-publish.sh +3 -3
- package/template/.github/scripts/discover-apps.sh +9 -13
- package/template/.github/scripts/publish-env.sh +3 -3
- package/template/.github/workflows/publish.yaml +82 -90
- package/template/README.md +17 -15
- package/template/_gitignore +1 -1
- package/template/scripts/dev.sh +23 -19
- package/dist/mcp-VdontkUr.mjs.map +0 -1
|
@@ -32,7 +32,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
32
32
|
* this package was published.
|
|
33
33
|
*
|
|
34
34
|
* Auth, target resolution and placeholder substitution are the CLI's — the
|
|
35
|
-
* same `Context`, so a token cached by `oxyc login`
|
|
35
|
+
* same `Context`, so a token cached by `oxyc login` works
|
|
36
36
|
* here with no separate setup, and `{org}` / `{workspace}` resolve the same
|
|
37
37
|
* way.
|
|
38
38
|
*/
|
|
@@ -313,4 +313,4 @@ async function runMcp(ctx) {
|
|
|
313
313
|
|
|
314
314
|
//#endregion
|
|
315
315
|
export { runMcp };
|
|
316
|
-
//# sourceMappingURL=mcp-
|
|
316
|
+
//# sourceMappingURL=mcp-CIsyezzU.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-CIsyezzU.mjs","names":[],"sources":["../src/commands/mcp.ts"],"sourcesContent":["/**\n * `oxyc mcp` — the Oxy API as MCP tools, over stdio.\n *\n * NOT THE SAME THING AS `oxy mcp`. That one is workspace tooling: it takes a\n * local checkout and exposes the semantic model, automations and `.sql` files\n * in it. This one takes a TOKEN and exposes the deployment's HTTP API — the\n * same surface `oxyc api` reaches, for an agent that would rather call a tool\n * than shell out. Different input, different audience, no overlap.\n *\n * FOUR TOOLS, NOT SIX HUNDRED — the design decision that makes this affordable.\n *\n * The obvious shape is one tool per endpoint, and it is the wrong one: the API\n * has ~670 routes, an agent runtime ships every tool's JSON schema in every\n * request, and that is tens of kilobytes of context spent on each turn before\n * a single question is asked. It would also go stale on every deploy, since\n * the tool list would be baked into this package rather than read from the\n * deployment.\n *\n * So discovery stays a QUESTION the agent asks (`routes`, `schema`) rather\n * than a payload it carries, exactly as the CLI does it. Four tools cost a few\n * hundred bytes per turn and reach every endpoint, including ones added after\n * this package was published.\n *\n * Auth, target resolution and placeholder substitution are the CLI's — the\n * same `Context`, so a token cached by `oxyc login` works\n * here with no separate setup, and `{org}` / `{workspace}` resolve the same\n * way.\n */\n\nimport { existsSync, readFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { loadCatalog, loadOpenApi, searchRoutes } from \"../api/catalog.js\";\nimport { paramsToQuery, parseFields } from \"../api/fields.js\";\nimport { runJq } from \"../api/output.js\";\nimport { isExternalSurface, normalizePath, substitutePlaceholders } from \"../api/paths.js\";\nimport { parseJson, request } from \"../api/request.js\";\nimport type { Context } from \"../context/resolve.js\";\nimport { CliError, ExitCode } from \"../util/errors.js\";\nimport { comparablePath } from \"./discover.js\";\n\n/**\n * Package version, reported in the MCP handshake.\n *\n * Read from `package.json` rather than restated, so a release bump cannot\n * leave the server announcing a version nobody shipped.\n */\nconst VERSION: string = (() => {\n try {\n const here = dirname(fileURLToPath(import.meta.url));\n for (const candidate of [\n resolve(here, \"..\", \"package.json\"),\n resolve(here, \"..\", \"..\", \"package.json\")\n ]) {\n if (existsSync(candidate)) {\n return (\n (JSON.parse(readFileSync(candidate, \"utf8\")) as { version?: string }).version ?? \"0.0.0\"\n );\n }\n }\n } catch {\n // A packaging shape we did not anticipate. The handshake needs a string,\n // not an accurate one.\n }\n return \"0.0.0\";\n})();\n\n/**\n * A tool result. MCP wants content blocks; everything here is text, because\n * the caller is a language model and the payloads are JSON or markdown.\n */\nfunction text(body: string, isError = false) {\n return { content: [{ type: \"text\" as const, text: body }], isError };\n}\n\nconst TOOLS = [\n {\n name: \"oxy_routes\",\n description:\n \"List the API endpoints this Oxy deployment mounts, with what each one does. \" +\n \"ALWAYS call this before oxy_request rather than guessing a path. \" +\n \"Pass a filter (matched against method, path, surface and description) to narrow it — \" +\n \"unfiltered is ~670 routes. Above 60 matches the descriptions are dropped and only \" +\n \"method, path and credential come back, so narrow enough to stay under that when you \" +\n \"need to know what an endpoint does; past ~400 it is refused outright.\",\n inputSchema: {\n type: \"object\",\n properties: {\n filter: {\n type: \"string\",\n description: \"Substring to narrow by, e.g. 'threads', 'sql', 'admin', 'semantic'.\"\n },\n all: {\n type: \"boolean\",\n description:\n \"Include ide-only and worker-only routes. Off by default: those are mounted on one \" +\n \"instance of the fleet, so a caller hitting the load balancer cannot reach them directly.\"\n }\n }\n }\n },\n {\n name: \"oxy_schema\",\n description:\n \"The request and response schema for ONE endpoint, so a body can be built correctly. \" +\n \"Covers the data plane (SQL, semantic query, and the org/workspace lookups); a blank \" +\n \"result means undocumented, not nonexistent — use oxy_routes to confirm the endpoint exists.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Endpoint path, e.g. '{workspace}/sql/query'.\" },\n method: { type: \"string\", description: \"Narrow to one HTTP method.\" }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_request\",\n description:\n \"Make an authenticated request to the Oxy API. The credential is picked from the path \" +\n \"(/api/** takes a bearer, /external/api/** an API key). Placeholders {org}, {workspace}, \" +\n \"{project}, {customer} and {me} are substituted from context. \" +\n \"Read freely; ask the human before any mutating request against production.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: { type: \"string\", description: \"Path relative to /api, e.g. '{org}/workspaces'.\" },\n method: {\n type: \"string\",\n description: \"HTTP method. Defaults to GET, or POST with fields.\"\n },\n fields: {\n type: \"object\",\n description:\n \"Request parameters. Sent as a JSON body on POST/PUT/PATCH, or as query parameters \" +\n \"on GET/HEAD/DELETE. Values keep their JSON type.\"\n },\n jq: {\n type: \"string\",\n description:\n \"A jq program to reduce the response before it is returned. Use it — a full list \" +\n \"response is far more context than the two fields you need.\"\n }\n },\n required: [\"path\"]\n }\n },\n {\n name: \"oxy_whoami\",\n description:\n \"Which deployment this is pointed at and who the token belongs to. \" +\n \"Use it when a call returns 401/403, or a 200 with a null body — on this API an expired \" +\n \"session answers 200 null rather than 401, and this tells the two apart.\",\n inputSchema: { type: \"object\", properties: {} }\n }\n];\n\n/**\n * Above this many matches, `oxy_routes` drops the descriptions.\n *\n * DEGRADES, IT DOES NOT REFUSE — the same shape as `oxyc routes`, which picks\n * `renderDetailed` under 40 matches and the compact method+path table above it\n * (`discover.ts`). The expensive field is `description`, not the row: a trimmed\n * row is a few tens of bytes, so several hundred still fit in a result an agent\n * can read, while the descriptions are what turn a broad match into hundreds of\n * KB of context spent before a question is asked.\n *\n * The threshold is on the COUNT, not on whether a `filter` was supplied — a\n * filter is not evidence of narrowing. `searchRoutes` matches a substring of\n * method, path, surface or description, so `\"admin\"`, `\"workspace\"` and\n * `\"query\"` are all filters a model reaches for in good faith that clear this.\n */\nconst MAX_DESCRIBED_ROUTES = 60;\n\n/**\n * And above THIS many, there is nothing useful to return at all.\n *\n * Deliberately far above the threshold that drops descriptions: refusing is the\n * answer only when even the trimmed listing would be a context dump, which for\n * a deployment of ~670 routes means \"you asked for effectively all of them\".\n */\nconst MAX_ROUTES_PER_RESULT = 400;\n\n/** `{ a: 1 }` → the `-f`/`-F` pairs `parseFields` understands. */\nfunction fieldPairs(fields: Record<string, unknown> | undefined): string[] {\n if (!fields) return [];\n return Object.entries(fields).map(([k, v]) => `${k}=${JSON.stringify(v)}`);\n}\n\n/**\n * Check the arguments the schema says are required.\n *\n * `inputSchema.required` is ADVISORY on the low-level `Server` — it is handed\n * to the model, not enforced by the transport. Without this, `oxy_schema` with\n * no `path` matched every path and returned the whole OpenAPI document, and\n * `oxy_request` with none built a request to `/api/`.\n */\nfunction requireArgs(name: string, args: Record<string, unknown>, required: string[]): void {\n const missing = required.filter((k) => {\n const v = args[k];\n return v === undefined || v === null || (typeof v === \"string\" && v.trim() === \"\");\n });\n if (missing.length > 0) {\n throw new CliError(`${name} needs ${missing.join(\", \")}`, { code: ExitCode.USAGE });\n }\n}\n\nasync function callTool(ctx: Context, name: string, args: Record<string, unknown>) {\n switch (name) {\n case \"oxy_routes\": {\n const catalog = await loadCatalog({ target: ctx.target(), bearer: ctx.maybeBearer() });\n const filter = (args.filter as string | undefined)?.trim();\n let matches = searchRoutes(catalog, filter || undefined);\n if (!args.all) matches = matches.filter((r) => r.role === \"fleet-ok\");\n\n // A cache stale enough to be wrong is worth saying INSIDE the result:\n // `loadCatalog` warns on stderr, which an MCP client never shows anyone.\n const staleNote = catalog.stale\n ? \"NOTE: this route table came from a stale local cache; the deployment was unreachable.\\n\\n\"\n : \"\";\n\n const asked = filter ? ` ${JSON.stringify(filter)}` : \"\";\n\n // Only a match set too large even to LIST is refused. Below that the\n // result degrades instead, because a filter like \"admin\" or \"query\" can\n // clear the description threshold in good faith and \"guess something\n // narrower\" is a dead end for a model with no way to see what it hit.\n if (matches.length > MAX_ROUTES_PER_RESULT) {\n return text(\n `${staleNote}${matches.length} routes match${asked}, which is effectively the whole ` +\n `deployment. Call oxy_routes again with a narrower \\`filter\\` (matched against ` +\n `method, path, surface and description), e.g. \"threads\", \"sql\", \"semantic\", \"admin\".`,\n true\n );\n }\n\n if (matches.length === 0) {\n // An empty array reads as \"this deployment has no such endpoint\",\n // which is usually false — the filter was wrong, or the route is\n // ide-only and hidden by default.\n return text(\n `${staleNote}No route matches ${JSON.stringify(filter ?? \"\")}. Try a broader filter, ` +\n `or pass all=true to include ide-only and worker-only mounts.`,\n true\n );\n }\n // Trimmed to the fields that help a caller build a request. The full\n // record carries the handler path and mount notes, which are for a\n // maintainer reading source, not for an agent composing a call.\n const described = matches.length <= MAX_DESCRIBED_ROUTES;\n // `credential` SURVIVES THE TRIM. It is what separates the bearer mount\n // from the `/external/api` API-key one, so a caller composing a request\n // needs it in either form — and `renderCompact`, the CLI shape this\n // mirrors, keeps it as a per-surface heading. It is also not the\n // expensive field: ~25 bytes a row, ~10 KB across the whole ceiling,\n // against descriptions that run to hundreds of KB.\n const trimmed = matches.map((r) =>\n described\n ? { method: r.method, path: r.path, credential: r.credential, description: r.description }\n : { method: r.method, path: r.path, credential: r.credential }\n );\n // The note says the listing is trimmed AND how to get the descriptions\n // back. Without it a model reads a description-less row as an endpoint\n // that has no documentation, rather than one it did not ask narrowly\n // enough to see.\n const trimNote = described\n ? \"\"\n : `NOTE: ${matches.length} routes match${asked} — too many to describe, so each row is ` +\n `method, path and credential WITHOUT its description. Narrow the \\`filter\\` to ` +\n `${MAX_DESCRIBED_ROUTES} or fewer matches to get the descriptions back.\\n\\n`;\n return text(`${staleNote}${trimNote}${JSON.stringify(trimmed)}`);\n }\n\n case \"oxy_schema\": {\n requireArgs(\"oxy_schema\", args, [\"path\"]);\n const doc = (await loadOpenApi({ target: ctx.target(), bearer: ctx.maybeBearer() })) as {\n paths?: Record<string, Record<string, unknown>>;\n components?: unknown;\n };\n const wanted = String(args.path ?? \"\");\n const method = args.method as string | undefined;\n // `comparablePath` is imported from `discover.ts` rather than copied:\n // a second reduction of the same two spellings is a second thing to keep\n // in step, and the copy here was untested. Exact first, then substring —\n // the order `runSchema` uses, so the two commands answer alike.\n const paths = Object.entries(doc.paths ?? {});\n const exact = paths.filter(([p]) => comparablePath(p) === comparablePath(wanted));\n const hit =\n exact.length > 0\n ? exact\n : paths.filter(([p]) => comparablePath(p).includes(comparablePath(wanted)));\n if (hit.length === 0) {\n return text(\n `No documented schema for ${wanted}. The OpenAPI document covers the data plane only — ` +\n `call oxy_routes with a filter to confirm the endpoint exists.`,\n true\n );\n }\n const selected = Object.fromEntries(\n hit.map(([p, ops]) => [\n p,\n method\n ? Object.fromEntries(\n Object.entries(ops).filter(([verb]) => verb.toLowerCase() === method.toLowerCase())\n )\n : ops\n ])\n );\n return text(JSON.stringify({ paths: selected, components: doc.components }, null, 2));\n }\n\n case \"oxy_request\": {\n requireArgs(\"oxy_request\", args, [\"path\"]);\n const raw = String(args.path ?? \"\");\n const method = String(args.method ?? \"\").toUpperCase() || undefined;\n const fields = parseFields([], fieldPairs(args.fields as Record<string, unknown>));\n const verb = method ?? (fields.present ? \"POST\" : \"GET\");\n const carriesBody = ![\"GET\", \"HEAD\", \"DELETE\"].includes(verb);\n\n let path = substitutePlaceholders(normalizePath(raw), ctx.placeholders());\n let body: string | undefined;\n if (fields.present) {\n if (carriesBody) body = JSON.stringify(fields.params);\n else {\n const query = paramsToQuery(fields.params);\n if (query) path += (path.includes(\"?\") ? \"&\" : \"?\") + query;\n }\n }\n\n const apiKey = ctx.apiKey();\n const external = isExternalSurface(path);\n const response = await request({\n target: ctx.target(),\n path,\n method: verb,\n body,\n bearer: external && apiKey ? ctx.maybeBearer() : ctx.bearer(),\n apiKey\n });\n\n if (response.status < 200 || response.status >= 300) {\n // The STATUS and the body, not a thrown error: the model can act on\n // \"403, here is what the server said\" and cannot act on a transport\n // exception. It is marked `isError` so the runtime shows it as one.\n return text(`HTTP ${response.status} ${response.statusText}\\n${response.body}`, true);\n }\n\n if (response.body.trim() === \"null\") {\n return text(\n \"null\\n\\n(NOTE: a 200 with a null body can mean an expired session on this API, \" +\n \"not 'no such thing'. Call oxy_whoami to tell them apart.)\"\n );\n }\n\n const jq = args.jq as string | undefined;\n if (jq) return text(runJq(response.body, jq));\n return text(response.body);\n }\n\n case \"oxy_whoami\": {\n const target = ctx.target();\n const response = await request({\n target,\n path: \"/api/user\",\n method: \"GET\",\n bearer: ctx.bearer()\n });\n const payload = parseJson(response.body);\n if (payload === null || payload === undefined) {\n return text(\n `target: ${target}\\nThe token no longer resolves to a user — the session has expired. ` +\n `Ask the human to run \\`oxyc login\\`.`,\n true\n );\n }\n return text(JSON.stringify({ target, user: payload }, null, 2));\n }\n\n default:\n return text(`unknown tool: ${name}`, true);\n }\n}\n\n/**\n * Serve MCP on stdio until the client disconnects.\n *\n * STDIO IS THE WHOLE TRANSPORT, deliberately. An HTTP/SSE server would need a\n * port, a lifetime and an auth story of its own; stdio is what every agent\n * runtime already launches, and the process inherits the caller's environment\n * — which is where the token and the target come from.\n *\n * NOTHING MAY BE WRITTEN TO STDOUT except protocol frames. `log.info` and\n * friends already go to stderr, which is why this can share the CLI's own\n * plumbing without corrupting the stream.\n */\nexport async function runMcp(ctx: Context): Promise<void> {\n const server = new Server({ name: \"oxyc\", version: VERSION }, { capabilities: { tools: {} } });\n\n server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));\n\n server.setRequestHandler(CallToolRequestSchema, async (req) => {\n try {\n return await callTool(\n ctx,\n req.params.name,\n (req.params.arguments ?? {}) as Record<string, unknown>\n );\n } catch (cause) {\n // A throw here would kill the session. The model can act on a message;\n // it cannot act on a dead transport.\n const message = (cause as Error).message ?? String(cause);\n // BOTH CHANNELS. A model gets one string, so there is no marker to\n // distinguish them — but reading `hint` alone drops the one line that\n // says what to DO, which is the half a model can act on.\n const { hint, remedy } = cause as { hint?: string; remedy?: string };\n // Blank line between them, the way every other renderer of these two\n // separates them — a model given two consecutive imperatives with no\n // boundary loses the distinction the second field exists to make.\n // `!== undefined` for the narrowing TS gives it, and a length check\n // because an empty string would open the block with a blank line —\n // `filter(Boolean)` covered that and cost the narrowing, so both.\n const extra = [hint, remedy].filter((line) => line !== undefined && line !== \"\").join(\"\\n\\n\");\n return text(extra ? `${message}\\n\\n${extra}` : message, true);\n }\n });\n\n await server.connect(new StdioServerTransport());\n\n // `connect` returns once the transport is wired; the process must stay up\n // until the client closes stdin.\n await new Promise<void>((resolve) => {\n process.stdin.on(\"close\", resolve);\n server.onclose = resolve;\n });\n}\n"],"mappings":"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"}
|
package/json-schemas/config.json
CHANGED
|
@@ -2,10 +2,6 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"title": "Config",
|
|
4
4
|
"type": "object",
|
|
5
|
-
"required": [
|
|
6
|
-
"databases",
|
|
7
|
-
"models"
|
|
8
|
-
],
|
|
9
5
|
"properties": {
|
|
10
6
|
"base_branch": {
|
|
11
7
|
"description": "Branch that new worktrees fork from when saving on a protected branch. Defaults to the currently checked-out branch (usually the default branch) when not set. Use this when Oxy serves from a \"deployment\" branch but new work should fork from an \"integration\" branch (e.g. serve from `deploy`, fork new work from `main`).\n\nExample config.yml: base_branch: main",
|
|
@@ -25,6 +21,7 @@
|
|
|
25
21
|
]
|
|
26
22
|
},
|
|
27
23
|
"databases": {
|
|
24
|
+
"default": [],
|
|
28
25
|
"type": "array",
|
|
29
26
|
"items": {
|
|
30
27
|
"$ref": "#/definitions/Database"
|
|
@@ -70,6 +67,7 @@
|
|
|
70
67
|
]
|
|
71
68
|
},
|
|
72
69
|
"models": {
|
|
70
|
+
"default": [],
|
|
73
71
|
"type": "array",
|
|
74
72
|
"items": {
|
|
75
73
|
"$ref": "#/definitions/Model"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy-hq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "oxyc — the Oxy CLI: a gh-api-style client for the Oxy HTTP API, plus the customer-account tooling that scopes a session to one customer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,20 +44,20 @@
|
|
|
44
44
|
],
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@clack/prompts": "^
|
|
47
|
+
"@clack/prompts": "^1.8.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"ajv": "^8.20.0",
|
|
50
50
|
"ajv-formats": "^3.0.1",
|
|
51
|
-
"commander": "^
|
|
51
|
+
"commander": "^15.0.0",
|
|
52
52
|
"picocolors": "^1.1.1",
|
|
53
|
-
"yaml": "^2.
|
|
54
|
-
"zod": "^
|
|
53
|
+
"yaml": "^2.9.1",
|
|
54
|
+
"zod": "^4.6.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@types/node": "^26.2
|
|
58
|
-
"tsdown": "^0.
|
|
57
|
+
"@types/node": "^26.6.2",
|
|
58
|
+
"tsdown": "^0.23.0",
|
|
59
59
|
"typescript": "^7.0.2",
|
|
60
|
-
"vitest": "^
|
|
60
|
+
"vitest": "^5.0.1"
|
|
61
61
|
},
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=20"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: oxy-custom-apps
|
|
3
|
-
description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `
|
|
3
|
+
description: Use when building, debugging, reviewing, or shipping an Oxy custom-app bundle (a code-first React/Vite app served by oxy), including writing Oxy Functions and driving them from the UI. Encodes the contract rules, pitfalls, performance guardrails, and the self-serve `oxyc publish` ship flow.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Customer-app development
|
|
@@ -109,9 +109,15 @@ qualified.)
|
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
`ctx` bridges to the data plane: `ctx.query` / `ctx.queryStream`,
|
|
112
|
-
`ctx.semantic.query`, `ctx.warehouse.
|
|
113
|
-
`ctx.
|
|
114
|
-
|
|
112
|
+
`ctx.semantic.query`, `ctx.warehouse.query`,
|
|
113
|
+
`ctx.warehouse.{insert,exec,upsert}` (to Airhouse, or to a customer
|
|
114
|
+
warehouse the function names in `customerWarehouseWrites`), `ctx.airway.run`,
|
|
115
|
+
`ctx.fetch` (SSRF-allowlisted), `ctx.env`, `ctx.user`, `ctx.log`.
|
|
116
|
+
What the app STORES picks its surface by shape — facts through
|
|
117
|
+
`ctx.airhouse`, records through `ctx.oltp`, files through
|
|
118
|
+
`ctx.storage` — and customer warehouses are read-only. See
|
|
119
|
+
**Where data goes** below before writing anything.
|
|
120
|
+
`oxyc publish` bundles `functions/*.ts` with
|
|
115
121
|
esbuild — no separate backend to stand up. Worked example +
|
|
116
122
|
author-facing `ctx` types: `oxy-hq/customer-apps:
|
|
117
123
|
examples/hello-oxy/functions/`.
|
|
@@ -119,11 +125,70 @@ qualified.)
|
|
|
119
125
|
3. **`OXY_APP_BASE_PATH` must match the path the bundle is linked
|
|
120
126
|
under.** `@oxy-hq/vite-plugin` resolves it from (in order):
|
|
121
127
|
`OXY_APP_BASE_PATH` env → `orgSlug + slug` in `oxy-app.json` →
|
|
122
|
-
`/`. `
|
|
128
|
+
`/`. `oxyc publish` sets that env to `/customer-apps/<org>/<slug>/`
|
|
123
129
|
for the build automatically, and the vite-plugin derives the same
|
|
124
130
|
from the manifest for `pnpm dev` / `pnpm build`. Mismatch → asset
|
|
125
131
|
404s → blank dashboard.
|
|
126
132
|
|
|
133
|
+
## Where data goes (the shape of the data picks the store)
|
|
134
|
+
|
|
135
|
+
| The data | Store | In a function | In `oxy-app.json` |
|
|
136
|
+
| --- | --- | --- | --- |
|
|
137
|
+
| **Facts** — something happened and won't change: an order, a completed checklist, a delivery, a reading | The workspace's Airhouse, in your schema `app_<writer>` | `ctx.airhouse.append(table, rows)` · `ctx.airhouse.query(sql)` · `ctx.airhouse.exec(sql)` | function: `"airhouse": { "enabled": true }` · app: `"airhouseMigrations": { "dir": "airhouse-migrations" }` |
|
|
138
|
+
| **Records** your app edits — bookings, assignments, templates, counts | The org's OLTP Postgres, in your schema `app_<writer>` | `ctx.oltp.query/exec(sql, params)` · `ctx.oltp.tx(async (tx) => …)` | function: `"oltp": { "enabled": true }` · app: `"migrations": { "dir": "migrations" }` |
|
|
139
|
+
| **Files** — photos, PDFs, exports | Your app's storage silo | `ctx.storage` | function: `"storage": { "read": true, "write": true }` |
|
|
140
|
+
| **The customer's warehouse** — ClickHouse, Snowflake, BigQuery, their Postgres | Theirs | `ctx.query` · `ctx.warehouse.query(db, sql)` | read-only: a write is refused unless the function declares `"customerWarehouseWrites": { "<db>": "<why>" }` |
|
|
141
|
+
|
|
142
|
+
**Availability.** `ctx.airhouse`, `ctx.oltp.tx`, `airhouseMigrations`, the
|
|
143
|
+
read-only rule for customer warehouses and `oxyc validate`'s data-placement
|
|
144
|
+
and manifest checks arrive with the Oxy release carrying oxygen-internal#3187,
|
|
145
|
+
together with the `@oxy-hq/sdk` and `@oxy-hq/cli` published from it. Against
|
|
146
|
+
an older Oxy, `ctx.airhouse` is `undefined` and the new manifest fields are
|
|
147
|
+
ignored; vendored `oxy.d.ts` copies (`examples/hello-oxy/functions/oxy.d.ts`)
|
|
148
|
+
gain the types when they are re-synced from that SDK.
|
|
149
|
+
|
|
150
|
+
Three questions, in order: **Is it bytes?** → storage (keep the key in a record
|
|
151
|
+
or fact). **Does it change after it's written?** No → a fact; yes → a record;
|
|
152
|
+
both when you need history (the record says what is true now, the fact says
|
|
153
|
+
what changed). **Is it already in the customer's warehouse?** → read it there.
|
|
154
|
+
|
|
155
|
+
`<writer>` is your slug with `-` → `_` (`store-ops` → `app_store_ops`); a slug
|
|
156
|
+
with `_` can't back a schema. Both stores are written **as the app**, whoever
|
|
157
|
+
invoked the function, so a scheduled run writes like a click.
|
|
158
|
+
|
|
159
|
+
**Facts, in practice.** Airhouse is DuckLake: no `PRIMARY KEY`, `UNIQUE`,
|
|
160
|
+
indexes or foreign keys — publish refuses a migration carrying one. So:
|
|
161
|
+
|
|
162
|
+
- Give every fact the id its source assigned and a `recorded_at`. A retried
|
|
163
|
+
function appends twice; readers keep one row per id:
|
|
164
|
+
`QUALIFY row_number() OVER (PARTITION BY completion_id ORDER BY recorded_at DESC) = 1`.
|
|
165
|
+
- A correction is a new fact (`voided`, `amended`), not an `UPDATE`.
|
|
166
|
+
- Name tables `app_<writer>.<table>` in migrations and in `exec`/`query`
|
|
167
|
+
(`ctx.airhouse.schema` has the name). `append` takes the bare table name.
|
|
168
|
+
- One statement per call, nothing bound: request data goes through `append`,
|
|
169
|
+
never into `exec` SQL. `exec` runs no DDL — tables come from migrations.
|
|
170
|
+
|
|
171
|
+
**Records, in practice.** Bound parameters (`$1`, `$2`) always. Several
|
|
172
|
+
statements that must land together go in one `ctx.oltp.tx`. The store is
|
|
173
|
+
provisioned per org by an operator; until then calls fail naming that step.
|
|
174
|
+
|
|
175
|
+
**Not a store:** `ctx.secrets` holds credentials you rotate. A cursor, a
|
|
176
|
+
counter or a JSON blob is a record. `oxyc validate` warns about it, about
|
|
177
|
+
keys in Airhouse migrations, and lists every customer-warehouse write exception.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
// A checklist is finished: the record changes, and a fact is appended.
|
|
181
|
+
async function completeChecklist(ctx, runId: string, storeGuid: string) {
|
|
182
|
+
await ctx.oltp.tx(async (tx) => {
|
|
183
|
+
await tx.exec("UPDATE checklist_runs SET status = 'done', completed_at = now() WHERE id = $1", [runId]);
|
|
184
|
+
await tx.exec("DELETE FROM open_reminders WHERE run_id = $1", [runId]);
|
|
185
|
+
});
|
|
186
|
+
await ctx.airhouse.append("checklist_completions", [
|
|
187
|
+
{ completion_id: runId, store_guid: storeGuid, completed_by: ctx.user.id, recorded_at: new Date().toISOString() },
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
127
192
|
## Four pitfalls
|
|
128
193
|
|
|
129
194
|
- **CORS errors are usually wrong-URL errors in disguise.** The SDK's
|
|
@@ -270,16 +335,17 @@ asset base baked in.
|
|
|
270
335
|
|
|
271
336
|
## Publishing
|
|
272
337
|
|
|
273
|
-
Ship with `
|
|
338
|
+
Ship with `oxyc publish` — **no CI, no S3 sync, no folder picker**. From
|
|
274
339
|
the app directory (`oxy-hq/customer-apps: examples/hello-oxy/` is the worked
|
|
275
340
|
shell; note its manifest deliberately carries no `orgSlug`, leaving the org to
|
|
276
341
|
be resolved at publish time):
|
|
277
342
|
|
|
278
343
|
```bash
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
344
|
+
oxyc login --env production # once; browser flow, caches a token,
|
|
345
|
+
# and prints whether you're an app-admin
|
|
346
|
+
oxyc validate # data-placement and manifest checks first
|
|
347
|
+
oxyc publish --env production # build → tar → upload (draft channel)
|
|
348
|
+
oxyc publish --env production --promote # …straight to live
|
|
283
349
|
```
|
|
284
350
|
|
|
285
351
|
- **Identity** is the manifest's `slug` + `orgSlug` (override with
|
|
@@ -301,7 +367,7 @@ Full hands-on guide: `oxy-hq/customer-apps: docs/local-development.md`.
|
|
|
301
367
|
## Reading data while vibe-coding
|
|
302
368
|
|
|
303
369
|
You don't need an `X-API-Key` to poke at the data API by hand — your
|
|
304
|
-
`
|
|
370
|
+
`oxyc login` token authenticates as a bearer. From an app directory:
|
|
305
371
|
|
|
306
372
|
```bash
|
|
307
373
|
oxyc login --env local # once; caches a token per host
|
|
@@ -319,7 +385,7 @@ curl -H "Authorization: Bearer $(oxyc token --env local)" \
|
|
|
319
385
|
`oxyc api <path>` takes the path relative to `/api/`; `-X` sets the method
|
|
320
386
|
(POST when a body is given), `-f`/`-F` set the body (string / JSON-typed),
|
|
321
387
|
`--input` sends a raw one, `-i` includes response headers. `--env` resolves the
|
|
322
|
-
same targets as `
|
|
388
|
+
same targets as `oxyc publish` (`local` → the Vite dev server `:5173`, which
|
|
323
389
|
proxies `/api` → oxy `:3000`).
|
|
324
390
|
|
|
325
391
|
Do not guess a path: `oxyc routes <filter>` lists what exists and
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
#
|
|
4
4
|
# OFF by default, and the default is the whole point. `customer-apps` — the
|
|
5
5
|
# monorepo Oxy ships its own custom apps from — deleted its publish CI outright
|
|
6
|
-
# and its engineers publish from their machines with `
|
|
7
|
-
# `
|
|
6
|
+
# and its engineers publish from their machines with `oxyc login` and
|
|
7
|
+
# `oxyc publish`; its own docs say "there is no GitHub Actions / CI". A repo
|
|
8
8
|
# scaffolded from this template therefore behaves the same way unless somebody
|
|
9
9
|
# asks for CI publishing, rather than arriving demanding a long-lived token the
|
|
10
10
|
# rest of the org has already decided it does not want.
|
|
@@ -64,7 +64,7 @@ case "$value" in
|
|
|
64
64
|
# misreading of a switch that costs something.
|
|
65
65
|
''|false|no|off|0)
|
|
66
66
|
printf 'off\n'
|
|
67
|
-
printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "
|
|
67
|
+
printf '::notice::CI publishing is off in this repo, which is the default. This run built the app bundles and published nothing. Publishing here is self-serve: from an app directory under apps/, run "oxyc login --env dev" once and then "oxyc publish --env dev" (or --env production). To publish from CI instead, add a repository VARIABLE named OXY_CI_PUBLISH with the value true — Settings, then Secrets and variables, then Actions, then the Variables tab — and give each GitHub environment its own OXY_TOKEN.\n' >&2
|
|
68
68
|
;;
|
|
69
69
|
true|yes|on|1)
|
|
70
70
|
printf 'on\n'
|
|
@@ -101,20 +101,16 @@ while IFS= read -r manifest; do
|
|
|
101
101
|
app_dir="${manifest%/oxy-app.json}"
|
|
102
102
|
|
|
103
103
|
if ! jq -e 'type == "object"' "$root/$manifest" >/dev/null 2>&1; then
|
|
104
|
-
printf '::error file=%s::%s is not a JSON object.
|
|
104
|
+
printf '::error file=%s::%s is not a JSON object. oxyc publish reads this file for the app identity and will not get past it.\n' \
|
|
105
105
|
"$manifest" "$manifest" >&2
|
|
106
106
|
bad=1
|
|
107
107
|
continue
|
|
108
108
|
fi
|
|
109
109
|
|
|
110
|
-
# `
|
|
111
|
-
# manifest, then the `<app>` segment of an `apps/<org>/<app>/` path —
|
|
112
|
-
#
|
|
113
|
-
# `
|
|
114
|
-
# code is what runs, and its own comment agrees with the code. It makes no
|
|
115
|
-
# difference here either way: this workflow sets neither the flag nor the
|
|
116
|
-
# env var, and both readings end `manifest, then path` — the only two links
|
|
117
|
-
# this check depends on.
|
|
110
|
+
# `oxyc publish` resolves the app slug as `--app`, then OXY_APP, then the
|
|
111
|
+
# manifest, then the `<app>` segment of an `apps/<org>/<app>/` path — its
|
|
112
|
+
# `--help` says the same. This workflow sets neither the flag nor the env
|
|
113
|
+
# var, so `manifest, then path` are the only two links this check depends on.
|
|
118
114
|
slug="$(jq -r '.slug // ""' "$root/$manifest")"
|
|
119
115
|
if [ -z "$slug" ] || [ "$slug" = "null" ]; then
|
|
120
116
|
printf '::error file=%s::%s has no "slug". That is the app'"'"'s identity on the platform and there is nowhere else to get it.\n' \
|
|
@@ -123,11 +119,11 @@ while IFS= read -r manifest; do
|
|
|
123
119
|
continue
|
|
124
120
|
fi
|
|
125
121
|
|
|
126
|
-
# Same resolution chain for the org
|
|
122
|
+
# Same resolution chain for the org, with one
|
|
127
123
|
# difference that matters: the path fallback only fires for the nested
|
|
128
124
|
# `apps/<org>/<app>/` layout. In the flat `apps/<app>/` layout there is no
|
|
129
125
|
# `<org>` segment, so a manifest with no `orgSlug` leaves the org
|
|
130
|
-
# unresolvable — and `
|
|
126
|
+
# unresolvable — and `oxyc publish` needs it to look the project up on the
|
|
131
127
|
# build-config endpoint.
|
|
132
128
|
org="$(jq -r '.orgSlug // ""' "$root/$manifest")"
|
|
133
129
|
nested=0
|
|
@@ -135,13 +131,13 @@ while IFS= read -r manifest; do
|
|
|
135
131
|
apps/*/*) nested=1 ;;
|
|
136
132
|
esac
|
|
137
133
|
if { [ -z "$org" ] || [ "$org" = "null" ]; } && [ "$nested" -eq 0 ]; then
|
|
138
|
-
printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for
|
|
134
|
+
printf '::error file=%s::%s has no "orgSlug", and %s is not an apps/<org>/<app>/ path for oxyc publish to infer one from. Add "orgSlug" to the manifest.\n' \
|
|
139
135
|
"$manifest" "$manifest" "$app_dir" >&2
|
|
140
136
|
bad=1
|
|
141
137
|
continue
|
|
142
138
|
fi
|
|
143
139
|
|
|
144
|
-
# `build.outDir`, defaulting to `out` — the same default `
|
|
140
|
+
# `build.outDir`, defaulting to `out` — the same default `oxyc publish`
|
|
145
141
|
# applies. Guarded with a type check because a manifest whose `build` is a
|
|
146
142
|
# string (or a number) would make plain `.build.outDir` a jq error, and a jq
|
|
147
143
|
# error under `set -e` would abort the whole scan on one malformed file
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# unexecuted.
|
|
9
9
|
#
|
|
10
10
|
# It also has exactly one caller and one answer ON PURPOSE. The name printed
|
|
11
|
-
# here is used TWICE by publish.yaml — as `
|
|
11
|
+
# here is used TWICE by publish.yaml — as `oxyc publish --env <name>` and as the
|
|
12
12
|
# GitHub Actions `environment:` whose secrets that publish authenticates with —
|
|
13
13
|
# and those two must never disagree. Two ternaries in the YAML would have been
|
|
14
14
|
# shorter and would have drifted the first time one of them was edited; one
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
# exit 0 only for a trigger this repo has a mapping for.
|
|
22
22
|
# exit 1 for anything else, INCLUDING an empty trigger.
|
|
23
23
|
#
|
|
24
|
-
# The refusal is the point. `
|
|
24
|
+
# The refusal is the point. `oxyc publish` defaults `--env` to **production**,
|
|
25
25
|
# so a workflow that omits the flag ships a customer's live environment while
|
|
26
26
|
# looking like it did nothing in particular — which is exactly what this repo's
|
|
27
27
|
# CI did before this file existed. Every publish now names its environment out
|
|
@@ -48,7 +48,7 @@ case "$trigger" in
|
|
|
48
48
|
exit 1
|
|
49
49
|
;;
|
|
50
50
|
*)
|
|
51
|
-
printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "
|
|
51
|
+
printf '::error::this repo publishes on "push" and "workflow_dispatch" only, and has no oxy environment mapped for "%s". Add one to .github/scripts/publish-env.sh — deliberately, because the alternative is a trigger nobody reviewed publishing to production, which is what "oxyc publish" does when nothing names an environment.\n' \
|
|
52
52
|
"$trigger" >&2
|
|
53
53
|
exit 1
|
|
54
54
|
;;
|