@oxy-hq/cli 0.1.0 → 0.4.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 +310 -116
- package/dist/main.mjs +1359 -91
- 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/package.json +8 -8
- package/skills/oxy-custom-apps/SKILL.md +10 -10
- 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/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy-hq/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "oxyc
|
|
3
|
+
"version": "0.4.0",
|
|
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": {
|
|
7
|
-
"oxyc": "
|
|
7
|
+
"oxyc": "dist/main.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"README.md",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"directory": "sdk/cli"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"codegen": "node scripts/sync-schemas.mjs && node scripts/embed-assets.mjs",
|
|
26
|
+
"codegen": "node scripts/emit-version.mjs && node scripts/sync-schemas.mjs && node scripts/embed-assets.mjs",
|
|
27
27
|
"prebuild": "pnpm run codegen",
|
|
28
28
|
"build": "tsdown",
|
|
29
29
|
"build:watch": "tsdown --watch",
|
|
@@ -44,20 +44,20 @@
|
|
|
44
44
|
],
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@clack/prompts": "^
|
|
47
|
+
"@clack/prompts": "^1.7.0",
|
|
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
53
|
"yaml": "^2.8.1",
|
|
54
54
|
"zod": "^3.25.28 || ^4"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@types/node": "^26.
|
|
57
|
+
"@types/node": "^26.4.1",
|
|
58
58
|
"tsdown": "^0.22.14",
|
|
59
59
|
"typescript": "^7.0.2",
|
|
60
|
-
"vitest": "^
|
|
60
|
+
"vitest": "^5.0.0"
|
|
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
|
|
@@ -111,7 +111,7 @@ qualified.)
|
|
|
111
111
|
`ctx` bridges to the data plane: `ctx.query` / `ctx.queryStream`,
|
|
112
112
|
`ctx.semantic.query`, `ctx.warehouse.{insert,exec,upsert}`,
|
|
113
113
|
`ctx.airway.run`, `ctx.fetch` (SSRF-allowlisted), `ctx.env`,
|
|
114
|
-
`ctx.user`, `ctx.log`. `
|
|
114
|
+
`ctx.user`, `ctx.log`. `oxyc publish` bundles `functions/*.ts` with
|
|
115
115
|
esbuild — no separate backend to stand up. Worked example +
|
|
116
116
|
author-facing `ctx` types: `oxy-hq/customer-apps:
|
|
117
117
|
examples/hello-oxy/functions/`.
|
|
@@ -119,7 +119,7 @@ qualified.)
|
|
|
119
119
|
3. **`OXY_APP_BASE_PATH` must match the path the bundle is linked
|
|
120
120
|
under.** `@oxy-hq/vite-plugin` resolves it from (in order):
|
|
121
121
|
`OXY_APP_BASE_PATH` env → `orgSlug + slug` in `oxy-app.json` →
|
|
122
|
-
`/`. `
|
|
122
|
+
`/`. `oxyc publish` sets that env to `/customer-apps/<org>/<slug>/`
|
|
123
123
|
for the build automatically, and the vite-plugin derives the same
|
|
124
124
|
from the manifest for `pnpm dev` / `pnpm build`. Mismatch → asset
|
|
125
125
|
404s → blank dashboard.
|
|
@@ -270,16 +270,16 @@ asset base baked in.
|
|
|
270
270
|
|
|
271
271
|
## Publishing
|
|
272
272
|
|
|
273
|
-
Ship with `
|
|
273
|
+
Ship with `oxyc publish` — **no CI, no S3 sync, no folder picker**. From
|
|
274
274
|
the app directory (`oxy-hq/customer-apps: examples/hello-oxy/` is the worked
|
|
275
275
|
shell; note its manifest deliberately carries no `orgSlug`, leaving the org to
|
|
276
276
|
be resolved at publish time):
|
|
277
277
|
|
|
278
278
|
```bash
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
279
|
+
oxyc login --env production # once; browser flow, caches a token,
|
|
280
|
+
# and prints whether you're an app-admin
|
|
281
|
+
oxyc publish --env production # build → tar → upload (draft channel)
|
|
282
|
+
oxyc publish --env production --promote # …straight to live
|
|
283
283
|
```
|
|
284
284
|
|
|
285
285
|
- **Identity** is the manifest's `slug` + `orgSlug` (override with
|
|
@@ -301,7 +301,7 @@ Full hands-on guide: `oxy-hq/customer-apps: docs/local-development.md`.
|
|
|
301
301
|
## Reading data while vibe-coding
|
|
302
302
|
|
|
303
303
|
You don't need an `X-API-Key` to poke at the data API by hand — your
|
|
304
|
-
`
|
|
304
|
+
`oxyc login` token authenticates as a bearer. From an app directory:
|
|
305
305
|
|
|
306
306
|
```bash
|
|
307
307
|
oxyc login --env local # once; caches a token per host
|
|
@@ -319,7 +319,7 @@ curl -H "Authorization: Bearer $(oxyc token --env local)" \
|
|
|
319
319
|
`oxyc api <path>` takes the path relative to `/api/`; `-X` sets the method
|
|
320
320
|
(POST when a body is given), `-f`/`-F` set the body (string / JSON-typed),
|
|
321
321
|
`--input` sends a raw one, `-i` includes response headers. `--env` resolves the
|
|
322
|
-
same targets as `
|
|
322
|
+
same targets as `oxyc publish` (`local` → the Vite dev server `:5173`, which
|
|
323
323
|
proxies `/api` → oxy `:3000`).
|
|
324
324
|
|
|
325
325
|
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
|
;;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Build and publish __NAME__'s custom apps with `
|
|
1
|
+
# Build and publish __NAME__'s custom apps with `oxyc publish`.
|
|
2
2
|
#
|
|
3
3
|
# On day one this repo has no apps, and this workflow is a deliberate no-op:
|
|
4
4
|
# discovery finds nothing, every later step is skipped, the publish JOB is
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# compiles every bundle, needs no credential to do it, and so still catches a
|
|
15
15
|
# bundle that stopped building before anyone ships it by hand — and the publish
|
|
16
16
|
# JOB is skipped, green, with a notice naming the self-serve command
|
|
17
|
-
# (`
|
|
17
|
+
# (`oxyc login --env <env>`, then `oxyc publish --env <env>`). That is how the
|
|
18
18
|
# rest of this org already ships: `customer-apps`, Oxy's own monorepo of custom
|
|
19
19
|
# apps, deleted its publish CI and its engineers publish from their laptops. A
|
|
20
20
|
# scaffolded repo should behave like that on day one rather than arriving with a
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
# Deliberately NOT modelled on pokehouse-oxy's `franchise-report-app.yml`
|
|
33
33
|
# (Docker -> ECR -> ArgoCD). That is the only real, green CI on disk in this
|
|
34
34
|
# org, which is exactly what makes it tempting; it is a legacy path for the
|
|
35
|
-
# one app still on Next.js and has nothing to do with `
|
|
35
|
+
# one app still on Next.js and has nothing to do with `oxyc publish`.
|
|
36
36
|
#
|
|
37
37
|
# Auth is a static `OXY_TOKEN` secret, not GitHub OIDC trusted publishing.
|
|
38
38
|
# Both appear in this org's examples and a workflow that MIXES them 403s.
|
|
@@ -43,13 +43,14 @@
|
|
|
43
43
|
# `build` runs the bundles' own postinstall and build scripts — arbitrary
|
|
44
44
|
# third-party code out of node_modules — and holds no publish credential.
|
|
45
45
|
# `publish` holds `OXY_TOKEN`, downloads what `build` produced, and runs no
|
|
46
|
-
# package script at all
|
|
47
|
-
#
|
|
48
|
-
#
|
|
46
|
+
# package script at all. That includes Oxy Functions: `build` bundles them with
|
|
47
|
+
# `oxyc publish --build-only`, and `publish` runs `oxyc publish --prebuilt`,
|
|
48
|
+
# which checks they arrived rather than running esbuild — so the credential and
|
|
49
|
+
# the untrusted code are never in the same job, on the same runner, at the same
|
|
49
50
|
# time.
|
|
50
51
|
#
|
|
51
52
|
# TWO ENVIRONMENTS, and `--env` is passed EXPLICITLY on every publish. This is
|
|
52
|
-
# not tidiness either: `
|
|
53
|
+
# not tidiness either: `oxyc publish` defaults `--env` to **production**, so the
|
|
53
54
|
# earlier version of this workflow — which passed no `--env` at all — published
|
|
54
55
|
# every push to main straight at the customer's live environment. Only ever as
|
|
55
56
|
# a draft, so nothing went live, but into the wrong environment, and dev/prod
|
|
@@ -79,8 +80,8 @@ name: publish
|
|
|
79
80
|
on:
|
|
80
81
|
push:
|
|
81
82
|
branches: [main]
|
|
82
|
-
# Without this filter every README edit would
|
|
83
|
-
# publish nothing. `workflow_dispatch` has no path filter, so the no-op
|
|
83
|
+
# Without this filter every README edit would install and build every app
|
|
84
|
+
# to publish nothing. `workflow_dispatch` has no path filter, so the no-op
|
|
84
85
|
# path is still reachable by hand on a repo with no apps.
|
|
85
86
|
paths:
|
|
86
87
|
- "apps/**"
|
|
@@ -114,8 +115,7 @@ on:
|
|
|
114
115
|
inputs:
|
|
115
116
|
ref:
|
|
116
117
|
# Explicit, and required, because of what Promote below actually
|
|
117
|
-
# does. There is no promote-an-existing-build command in this CLI
|
|
118
|
-
# `oxy apps` offers show/create/list/delete and nothing else — so
|
|
118
|
+
# does. There is no promote-an-existing-build command in this CLI, so
|
|
119
119
|
# "promote" can only mean "build this ref again and publish it live".
|
|
120
120
|
# Leaving the ref implicit would let an operator who reviewed a draft
|
|
121
121
|
# yesterday ship whatever landed on main since, believing they were
|
|
@@ -137,17 +137,11 @@ permissions:
|
|
|
137
137
|
contents: read
|
|
138
138
|
|
|
139
139
|
env:
|
|
140
|
-
# The
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
# This pin is also load-bearing for `--build-id`. Run-unique build ids
|
|
146
|
-
# arrived in 0.5.121; an older CLI defaults to a bare `$GITHUB_SHA`, which
|
|
147
|
-
# collides on a re-run or a promote and is rejected with HTTP 409. The
|
|
148
|
-
# publish step passes `--build-id` explicitly, so an older CLI would still
|
|
149
|
-
# work — the pin is the belt to that pair of braces.
|
|
150
|
-
OXY_VERSION: "0.5.126"
|
|
140
|
+
# The `@oxy-hq/cli` (`oxyc`) both jobs install, exactly. Its own pin, not
|
|
141
|
+
# validate.yaml's `OXY_VERSION`: that one is the Rust `oxy` that validates the
|
|
142
|
+
# workspace, a different tool on a different release line. `--build-only` and
|
|
143
|
+
# `--prebuilt`, which the two jobs below depend on, arrived in 0.4.0.
|
|
144
|
+
OXYC_VERSION: "0.4.0"
|
|
151
145
|
|
|
152
146
|
jobs:
|
|
153
147
|
build:
|
|
@@ -205,7 +199,7 @@ jobs:
|
|
|
205
199
|
# keeps printf's own exit status, so a failing script inside the
|
|
206
200
|
# substitution would be swallowed and this step would write an EMPTY
|
|
207
201
|
# environment name — which downstream would read as "no environment"
|
|
208
|
-
# and, without the guard in the publish job, as `
|
|
202
|
+
# and, without the guard in the publish job, as `oxyc publish`'s own
|
|
209
203
|
# production default. An assignment carries the substitution's
|
|
210
204
|
# status, so errexit catches it here.
|
|
211
205
|
name="$(bash .github/scripts/publish-env.sh)"
|
|
@@ -248,7 +242,7 @@ jobs:
|
|
|
248
242
|
# the dropdown alone builds the right code while GITHUB_SHA names
|
|
249
243
|
# main's HEAD: the build id and the notice below would both be
|
|
250
244
|
# confidently wrong, which is worse than vague. `git rev-parse HEAD`
|
|
251
|
-
# is also exactly what `
|
|
245
|
+
# is also exactly what `oxyc publish` records as the build's source
|
|
252
246
|
# commit, so the two agree by construction.
|
|
253
247
|
sha="$(git rev-parse HEAD)"
|
|
254
248
|
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
|
|
@@ -309,6 +303,27 @@ jobs:
|
|
|
309
303
|
# exists and is non-empty before anything is uploaded.
|
|
310
304
|
run: pnpm -r build
|
|
311
305
|
|
|
306
|
+
- name: Bundle each app's Oxy Functions
|
|
307
|
+
if: steps.discover.outputs.count != '0'
|
|
308
|
+
# HERE, in the job with no credential, because bundling runs the app's
|
|
309
|
+
# own esbuild out of node_modules. `--build-only --dir` skips the build
|
|
310
|
+
# that just ran, checks `functions/` is not holding frontend output, and
|
|
311
|
+
# writes `functions/<name>.js` into the out dir for every function the
|
|
312
|
+
# manifest declares — a no-op for an app that declares none. The publish
|
|
313
|
+
# job then checks they arrived instead of bundling again.
|
|
314
|
+
run: |
|
|
315
|
+
set -euo pipefail
|
|
316
|
+
npm install --prefix "$RUNNER_TEMP/oxyc" --no-audit --no-fund "@oxy-hq/cli@$OXYC_VERSION"
|
|
317
|
+
oxyc="$RUNNER_TEMP/oxyc/node_modules/.bin/oxyc"
|
|
318
|
+
status=0
|
|
319
|
+
while IFS="$(printf '\t')" read -r app_dir out; do
|
|
320
|
+
[ -n "$app_dir" ] || continue
|
|
321
|
+
echo "::group::oxyc publish --build-only $app_dir"
|
|
322
|
+
( cd "$app_dir" && "$oxyc" publish --build-only --dir "$out" ) || status=1
|
|
323
|
+
echo "::endgroup::"
|
|
324
|
+
done < "$RUNNER_TEMP/apps.tsv"
|
|
325
|
+
exit "$status"
|
|
326
|
+
|
|
312
327
|
- name: Stage the built bundles for the publish job
|
|
313
328
|
if: steps.discover.outputs.count != '0'
|
|
314
329
|
run: |
|
|
@@ -365,13 +380,13 @@ jobs:
|
|
|
365
380
|
retention-days: 1
|
|
366
381
|
|
|
367
382
|
publish:
|
|
368
|
-
name:
|
|
383
|
+
name: oxyc publish
|
|
369
384
|
runs-on: ubuntu-latest
|
|
370
385
|
needs: build
|
|
371
386
|
# Two gates, and they answer different questions. The first: does this repo
|
|
372
387
|
# publish from CI at all? Absent `vars.OXY_CI_PUBLISH` it does not, and this
|
|
373
388
|
# job is skipped with the notice the build job printed — the bundles were
|
|
374
|
-
# still built, and shipping them is `
|
|
389
|
+
# still built, and shipping them is `oxyc publish`, run by a person. The
|
|
375
390
|
# second: day one, or any repo with no apps, where discovery found nothing.
|
|
376
391
|
# Either way a SKIP, which is green, not red, and does not prompt for
|
|
377
392
|
# whatever approval the environment below may require.
|
|
@@ -387,7 +402,7 @@ jobs:
|
|
|
387
402
|
if: needs.build.outputs.ci-publish == 'on' && needs.build.outputs.count != '0'
|
|
388
403
|
|
|
389
404
|
# The gate, and the credential boundary. `dev` on a push, `production` on
|
|
390
|
-
# a manual dispatch — the SAME string this job passes to `
|
|
405
|
+
# a manual dispatch — the SAME string this job passes to `oxyc publish
|
|
391
406
|
# --env` below, resolved once in `build`, so the destination this job
|
|
392
407
|
# publishes to and the environment it draws its secrets from are the same
|
|
393
408
|
# thing by construction. Each environment holds its own `OXY_TOKEN`, and a
|
|
@@ -432,7 +447,7 @@ jobs:
|
|
|
432
447
|
run: |
|
|
433
448
|
set -euo pipefail
|
|
434
449
|
# Put each bundle back exactly where the build left it, so
|
|
435
|
-
# `
|
|
450
|
+
# `oxyc publish` sees the tree it would have seen in a single job:
|
|
436
451
|
# oxy-app.json beside its own build output, git provenance intact.
|
|
437
452
|
# Nothing is executed to do this — it is a copy.
|
|
438
453
|
staging="$RUNNER_TEMP/bundles"
|
|
@@ -446,72 +461,45 @@ jobs:
|
|
|
446
461
|
cp -R "$staging/$app_dir/$out/." "$app_dir/$out/"
|
|
447
462
|
done < "$staging/apps.tsv"
|
|
448
463
|
|
|
449
|
-
- name: Install
|
|
464
|
+
- name: Install Node
|
|
465
|
+
uses: actions/setup-node@v4
|
|
466
|
+
with:
|
|
467
|
+
node-version-file: .nvmrc
|
|
468
|
+
|
|
469
|
+
- name: Install the pinned oxyc CLI
|
|
470
|
+
env:
|
|
471
|
+
# This job holds the publish credential (step-scoped, below), so the
|
|
472
|
+
# CLI's own dependencies run no install scripts here.
|
|
473
|
+
npm_config_ignore_scripts: "true"
|
|
450
474
|
run: |
|
|
451
475
|
set -euo pipefail
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
# failure is the step's.
|
|
457
|
-
curl -sSfL https://get.oxy.tech -o "$RUNNER_TEMP/get-oxy.sh"
|
|
458
|
-
OXY_VERSION="$OXY_VERSION" bash "$RUNNER_TEMP/get-oxy.sh"
|
|
459
|
-
|
|
460
|
-
# And check the installer left something behind: it downloads the
|
|
461
|
-
# binary with `curl -L` (no -f) and exits 0 regardless.
|
|
462
|
-
if [ ! -x "$HOME/.local/bin/oxy" ]; then
|
|
463
|
-
echo "::error::the installer exited 0 but left no executable at $HOME/.local/bin/oxy." >&2
|
|
464
|
-
exit 1
|
|
465
|
-
fi
|
|
476
|
+
# Into a prefix of its own rather than -g: nothing else on the
|
|
477
|
+
# runner's global npm is in play, and the path below is exact.
|
|
478
|
+
npm install --prefix "$RUNNER_TEMP/oxyc" --no-audit --no-fund "@oxy-hq/cli@$OXYC_VERSION"
|
|
479
|
+
echo "$RUNNER_TEMP/oxyc/node_modules/.bin" >> "$GITHUB_PATH"
|
|
466
480
|
|
|
467
|
-
|
|
468
|
-
# user and edits shell rc files, which a GitHub step never reads.
|
|
469
|
-
# Put it on PATH explicitly instead of hoping.
|
|
470
|
-
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
471
|
-
|
|
472
|
-
- name: Verify the oxy CLI
|
|
481
|
+
- name: Verify the oxyc CLI
|
|
473
482
|
run: |
|
|
474
483
|
set -euo pipefail
|
|
475
|
-
#
|
|
476
|
-
#
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
# chmod +x's it, and still exits 0;
|
|
481
|
-
# 2. this CLI falls through to `--help` with exit 0 on a
|
|
482
|
-
# subcommand it does not have, so an old binary "succeeds" at
|
|
483
|
-
# doing nothing.
|
|
484
|
-
if ! raw="$(oxy --version 2>&1)"; then
|
|
485
|
-
echo "::error::'oxy --version' failed — the installed binary is not runnable." >&2
|
|
486
|
-
printf '%s\n' "$raw" >&2
|
|
484
|
+
# Exact, not a floor: npm installed exactly the pin, so anything else
|
|
485
|
+
# means PATH found a different oxyc first.
|
|
486
|
+
have="$(oxyc --version)"
|
|
487
|
+
if [ "$have" != "$OXYC_VERSION" ]; then
|
|
488
|
+
echo "::error::oxyc $have is on PATH, not the pinned $OXYC_VERSION." >&2
|
|
487
489
|
exit 1
|
|
488
490
|
fi
|
|
489
|
-
|
|
490
|
-
have="$(printf '%s\n' "$raw" | sed -n 's/^oxy-app version: \([0-9][0-9.]*\).*/\1/p' | head -1)"
|
|
491
|
-
if [ -z "$have" ]; then
|
|
492
|
-
echo "::error::could not read a version out of 'oxy --version' — the install probably wrote a 404 page." >&2
|
|
493
|
-
printf '%s\n' "$raw" >&2
|
|
494
|
-
exit 1
|
|
495
|
-
fi
|
|
496
|
-
|
|
497
|
-
oldest="$(printf '%s\n%s\n' "$OXY_VERSION" "$have" | sort -t. -k1,1n -k2,2n -k3,3n | head -1)"
|
|
498
|
-
if [ "$oldest" != "$OXY_VERSION" ]; then
|
|
499
|
-
echo "::error::oxy $have is older than the pinned $OXY_VERSION." >&2
|
|
500
|
-
exit 1
|
|
501
|
-
fi
|
|
502
|
-
|
|
503
|
-
echo "oxy $have (pin $OXY_VERSION)"
|
|
491
|
+
echo "oxyc $have (pin $OXYC_VERSION)"
|
|
504
492
|
|
|
505
493
|
- name: Publish each bundle
|
|
506
494
|
env:
|
|
507
495
|
# Step-scoped as well as job-isolated. The job split is what keeps
|
|
508
496
|
# this token away from the bundles' own build scripts — those ran
|
|
509
497
|
# in `build`, on a different runner, and nothing in THIS job runs a
|
|
510
|
-
# package script (`
|
|
511
|
-
# directory as
|
|
512
|
-
# step scope is the cheap second
|
|
513
|
-
# the
|
|
514
|
-
#
|
|
498
|
+
# package script (`oxyc publish --prebuilt --dir` publishes the
|
|
499
|
+
# directory as built, functions included, rather than running the
|
|
500
|
+
# manifest's build or esbuild). The step scope is the cheap second
|
|
501
|
+
# layer: it keeps the token out of the environment of the install
|
|
502
|
+
# and verify steps above, which run code fetched from npm.
|
|
515
503
|
# Resolved against the ENVIRONMENT named above, where `dev` and
|
|
516
504
|
# `production` each hold their own.
|
|
517
505
|
#
|
|
@@ -531,7 +519,7 @@ jobs:
|
|
|
531
519
|
# repository-level `OXY_TOKEN` after adding the environment ones.
|
|
532
520
|
OXY_TOKEN: ${{ secrets.OXY_TOKEN }}
|
|
533
521
|
# `dev` or `production`, resolved once in the build job. Passed to
|
|
534
|
-
# `
|
|
522
|
+
# `oxyc publish --env` below; the identical string selected the
|
|
535
523
|
# environment this job is running in.
|
|
536
524
|
PUBLISH_ENV: ${{ needs.build.outputs.publish-env }}
|
|
537
525
|
# Optional, and it OVERRIDES `--env` rather than refining it (the
|
|
@@ -553,7 +541,7 @@ jobs:
|
|
|
553
541
|
set -euo pipefail
|
|
554
542
|
|
|
555
543
|
# Fail closed on the environment before anything else. An empty
|
|
556
|
-
# PUBLISH_ENV would mean `
|
|
544
|
+
# PUBLISH_ENV would mean `oxyc publish` fell back to its own default
|
|
557
545
|
# — production — while every log line here said nothing at all, and
|
|
558
546
|
# that silent fallback is the whole bug this workflow was fixed for.
|
|
559
547
|
# It can only be empty if the build job's resolution step was
|
|
@@ -615,8 +603,12 @@ jobs:
|
|
|
615
603
|
# publishes at the customer's live environment. It is the FIRST
|
|
616
604
|
# argument for the same reason: anything appended below is a
|
|
617
605
|
# refinement of a destination that has already been named.
|
|
618
|
-
args=(--env "$PUBLISH_ENV" --dir "$out" --build-id "$build_id")
|
|
619
|
-
#
|
|
606
|
+
args=(--env "$PUBLISH_ENV" --dir "$out" --prebuilt --build-id "$build_id")
|
|
607
|
+
# `--prebuilt`: the build job bundled this app's functions into the
|
|
608
|
+
# out dir. The CLI refuses when a declared one did not arrive,
|
|
609
|
+
# rather than publishing a bundle whose function 404s at runtime.
|
|
610
|
+
#
|
|
611
|
+
# --org/--app/--project are deliberately absent: oxyc publish
|
|
620
612
|
# reads the app's identity from the oxy-app.json in this
|
|
621
613
|
# directory (discovery has already checked it is there and
|
|
622
614
|
# complete) and resolves the project from the target's
|
|
@@ -629,8 +621,8 @@ jobs:
|
|
|
629
621
|
# default — this workflow is not overriding platform behaviour.
|
|
630
622
|
#
|
|
631
623
|
# `--promote` publishes THIS build straight to live. It does not
|
|
632
|
-
# promote a build that already exists: nothing in this CLI can
|
|
633
|
-
#
|
|
624
|
+
# promote a build that already exists: nothing in this CLI can.
|
|
625
|
+
# So the run rebuilt the
|
|
634
626
|
# ref the operator named and is shipping that, under a new build
|
|
635
627
|
# id — said out loud above, because "promote" invites the other
|
|
636
628
|
# reading and the difference is a wrong version in front of
|
|
@@ -639,11 +631,11 @@ jobs:
|
|
|
639
631
|
args+=(--promote)
|
|
640
632
|
fi
|
|
641
633
|
|
|
642
|
-
echo "::group::
|
|
634
|
+
echo "::group::oxyc publish $app_dir"
|
|
643
635
|
# A subshell so the cd cannot leak into the next iteration.
|
|
644
|
-
#
|
|
636
|
+
# oxyc publish reads oxy-app.json, resolves --dir, and captures
|
|
645
637
|
# git provenance relative to its working directory.
|
|
646
|
-
( cd "$app_dir" &&
|
|
638
|
+
( cd "$app_dir" && oxyc publish "${args[@]}" ) || status=1
|
|
647
639
|
echo "::endgroup::"
|
|
648
640
|
done < "$RUNNER_TEMP/bundles/apps.tsv"
|
|
649
641
|
|