apiblaze 0.21.56 → 0.21.58
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 +7 -0
- package/dist/index.js +159 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -52,6 +52,13 @@ npx apiblaze create --target https://petstore3.swagger.io/api/v3/openapi.json
|
|
|
52
52
|
# Same thing with zero prompts: both doors, every lockable resource locked.
|
|
53
53
|
npx apiblaze create --target ./openapi.yaml --auto
|
|
54
54
|
|
|
55
|
+
# A compliant Claude connector, directory-ready in one command: the MCP server on your own
|
|
56
|
+
# hostname (one CNAME), OAuth sign-in, listing answers pre-filled from the spec and cut to the
|
|
57
|
+
# portal's limits, the reviewer account, and the submission packet. It ends with a table of every
|
|
58
|
+
# requirement and why it is met. `--compliant chatgpt` makes a ChatGPT plugin to OpenAI's
|
|
59
|
+
# policies; `--compliant all` does both. You submit the packet to Anthropic or OpenAI yourself.
|
|
60
|
+
npx apiblaze create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com
|
|
61
|
+
|
|
55
62
|
# Teach Claude Code to do all of the above for you when you say "ship this API"
|
|
56
63
|
npx apiblaze skill --install
|
|
57
64
|
|
package/dist/index.js
CHANGED
|
@@ -34,7 +34,7 @@ var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__
|
|
|
34
34
|
var version;
|
|
35
35
|
var init_package = __esm({
|
|
36
36
|
"package.json"() {
|
|
37
|
-
version = "0.21.
|
|
37
|
+
version = "0.21.58";
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
@@ -7621,7 +7621,7 @@ var init_files = __esm({
|
|
|
7621
7621
|
"src/demo-app/files.ts"() {
|
|
7622
7622
|
"use strict";
|
|
7623
7623
|
DEMO_FILES = {
|
|
7624
|
-
"README.md": "# Nino's Pizza: the APIblaze demo\n\nA tiny table-booking app for pizzerias, used by `npx apiblaze@latest demo` to show what APIblaze does.\n\n```\nnpx apiblaze@latest demo\n```\n\nThat one command writes this folder to `./apiblaze-demo`, puts APIblaze in front of the backend,\nstarts everything and opens the page. No login, no prompts, no dependencies.\n\n## What the page shows\n\n1. **Your API is live on the internet.** The backend runs on your laptop; APIblaze gives it a public\n URL and an MCP address for AI assistants. A live ticker shows each request reaching your laptop.\n2. **An AI assistant uses it.** Ask it to book a table and watch the request arrive.\n3. **Included, no code:** only the owner can change a booking, each customer (Nino Pizza,\n Gino Pizza) is separate, rate limits, sign-in, and drop-in widgets for API keys and roles.\n\n## The files\n\n| File | What it is |\n| --- | --- |\n| `backend.js` | The pizzeria's API. No login, no keys, no permission checks. |\n| `openapi.yaml` | Its endpoints. APIblaze builds the AI tools and the ownership rules from it. |\n| `app.js` | The website's server. Calls the API with one key and says who is acting. |\n| `public/index.html` | The page. |\n| `start.js` | Starts the backend (port 3001) and the website (port 3000). |\n\n`npx apiblaze@latest demo` also adds `lib/apiblaze-server.js` and `public/apiblaze-widgets.js`, the same\nhelpers you get from `npm i apiblaze`. Stop the demo with `npx apiblaze@latest demo stop`.\n",
|
|
7624
|
+
"README.md": "# Nino's Pizza: the APIblaze demo\n\nA tiny table-booking app for pizzerias, used by `npx apiblaze@latest demo` to show what APIblaze does.\n\n```\nnpx apiblaze@latest demo\n```\n\nThat one command writes this folder to `./apiblaze-demo`, puts APIblaze in front of the backend,\nstarts everything and opens the page. No login, no prompts, no dependencies.\n\n## What the page shows\n\n1. **Your API is live on the internet.** The backend runs on your laptop; APIblaze gives it a public\n URL and an MCP address for AI assistants. A live ticker shows each request reaching your laptop.\n2. **An AI assistant uses it.** Ask it to book a table and watch the request arrive.\n3. **Included, no code:** only the owner can change a booking, each customer (Nino Pizza,\n Gino Pizza) is separate, rate limits, sign-in, and drop-in widgets for API keys and roles.\n\n## The files\n\n| File | What it is |\n| --- | --- |\n| `backend.js` | The pizzeria's API. No login, no keys, no permission checks. |\n| `openapi.yaml` | Its endpoints. APIblaze builds the AI tools and the ownership rules from it. |\n| `app.js` | The website's server. Calls the API with one key and says who is acting. |\n| `public/index.html` | The page. |\n| `start.js` | Starts the backend (port 3001) and the website (port 3000). |\n\n`npx apiblaze@latest demo` also adds `lib/apiblaze-server.js` and `public/apiblaze-widgets.js`, the same\nhelpers you get from `npm i apiblaze`. Stop the demo with `npx apiblaze@latest demo stop`.\n\n## Measurement\n\nThe page asks, once, whether it may send anonymous usage events to Google Analytics \u2014 which\nbuttons you press here, so we can see what people actually try. It never includes your bookings,\nyour messages to the assistant, or anything you type.\n\nNothing is loaded from Google and nothing is sent until you press **Allow**. Your answer is kept\nin this browser. If your browser sends Do Not Track (or Global Privacy Control), the question is\nnot asked and nothing is sent. To remove it entirely:\n\n```\nAPIBLAZE_DEMO_ANALYTICS=0 npx apiblaze@latest demo\n```\n",
|
|
7625
7625
|
"openapi.yaml": `openapi: 3.0.3
|
|
7626
7626
|
info:
|
|
7627
7627
|
title: ResiResi API
|
|
@@ -7717,7 +7717,7 @@ components:
|
|
|
7717
7717
|
id: { type: string, example: r1 }
|
|
7718
7718
|
`,
|
|
7719
7719
|
"backend.js": "// ResiResi backend: restaurants (tenants, like Nino Pizza and Gino Pizza) let their customers\n// book, list and cancel tables. It runs on YOUR laptop.\n//\n// `npx apiblaze@latest dev` (which the demo runs for you) puts it on the internet\n// at a public URL and gives AI assistants an MCP address for it. Everything else\n// (API keys, sign-in, who may change what, rate limits) happens in APIblaze, in\n// front of this file. Look at what is NOT here: no login, no keys, no permission\n// checks, no AI code.\n//\n// What APIblaze tells this backend about each call, in two headers:\n// x-abz-tenant-id which customer (pizzeria) is calling\n// x-abz-user-id which person\n// and one shared secret it adds to every call it forwards (x-target-api-key), so a\n// caller who skips APIblaze and hits localhost:3001 directly is refused.\n//\n// Zero dependencies. Run it alone with `node backend.js` (port 3001).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst bookings = new Map();\nlet next = 1;\n// The people the demo provisioned up front, per pizzeria: { tenant: { handle: stable id } }.\n// Through APIblaze every call already names the stable id (x-abz-user-id); a call straight\n// to this port still says \"ana\", and must mean the same person.\nconst USER_IDS = (() => { try { return JSON.parse(process.env.DEMO_USER_IDS || '{}'); } catch { return {}; } })();\nconst idOf = (tenant, handle) => (USER_IDS[tenant] || {})[handle] || handle;\n// Bumps on every booking made or cancelled. The page asks the app (on this laptop, free)\n// whether it moved before re-listing through APIblaze, so an open, idle page spends nothing.\nlet changes = 0;\n// The last requests this laptop answered \u2014 the demo page shows them live.\nconst recent = [];\nfunction remember(entry) {\n recent.unshift({ at: Date.now(), ...entry });\n recent.length = Math.min(recent.length, 50);\n // One line per request in the terminal (`npx apiblaze@latest demo logs` follows it).\n const who = entry.who ? `${entry.who}${entry.tenant ? ` @ ${entry.tenant}` : ''}` : '-';\n console.log(`${new Date().toISOString().slice(11, 19)} ${entry.status} ${entry.method.padEnd(6)} ${entry.path.padEnd(22)} ${who}`);\n}\n\n// The restaurant's day, YYYY-MM-DD, on this laptop's clock: a booking without a date is for today.\nfunction today() {\n const d = new Date();\n return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;\n}\n// A real calendar day written YYYY-MM-DD (2026-02-30 is not one).\nfunction isDay(s) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(s)) return false;\n const [y, m, d] = s.split('-').map(Number);\n const t = new Date(Date.UTC(y, m - 1, d));\n return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;\n}\n\n// The restaurant picks the table: the lowest one still free that day at that time, at that restaurant.\nfunction freeTable(tenant, date, time) {\n const taken = new Set([...bookings.values()].filter((r) => r.tenant === tenant && r.date === date && r.time === time).map((r) => r.table));\n let t = 1;\n while (taken.has(t)) t++;\n return t;\n}\n\nfunction send(res, status, body) {\n res.writeHead(status, { 'content-type': 'application/json' });\n res.end(body === undefined ? '' : JSON.stringify(body));\n}\n\nfunction handle(req, res, body) {\n const url = new URL(req.url, 'http://x');\n if (url.pathname === '/openapi.yaml') {\n res.writeHead(200, { 'content-type': 'text/yaml' });\n return res.end(fs.readFileSync(path.join(__dirname, 'openapi.yaml')));\n }\n const tenant = req.headers['x-abz-tenant-id'] || '';\n const who = idOf(tenant, req.headers['x-abz-user-id'] || '');\n // Shown in the ticker and the terminal: the name the caller gave (APIblaze forwards it as\n // x-end-user-id next to the stable id), never the opaque id.\n const shown = req.headers['x-end-user-id'] || req.headers['x-abz-user-id'] || '';\n const reply = (status, data) => { remember({ method: req.method, path: url.pathname, who: shown, tenant, status }); send(res, status, data); };\n const secret = process.env.BACKEND_SECRET || '';\n if (secret && req.headers['x-target-api-key'] !== secret) return reply(401, { error: 'missing or wrong backend secret (APIblaze adds it to every call it forwards)' });\n if (!who) return reply(401, { error: 'say who is calling (x-abz-user-id)' });\n\n const m = /^\\/reservations(?:\\/([^/]+))?$/.exec(url.pathname);\n if (!m) return reply(404, { error: 'not found' });\n const id = m[1];\n\n if (!id && req.method === 'POST') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const date = input.date ? String(input.date) : today();\n if (!isDay(date)) return reply(400, { error: 'date must be a day, YYYY-MM-DD' });\n const time = String(input.time || '19:30');\n const row = {\n id: `r${next++}`, name: String(input.name || who), table: Number(input.table) || freeTable(tenant, date, time),\n date, time, guests: Number(input.guests) || 2, owner: who, tenant,\n };\n bookings.set(row.id, row); changes++;\n return reply(201, row);\n }\n if (!id && req.method === 'GET') {\n // Lists are the backend's job: each person sees their own bookings, at their own pizzeria.\n return reply(200, [...bookings.values()].filter((r) => r.owner === who && r.tenant === tenant)\n .sort((a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`)));\n }\n const row = bookings.get(id);\n if (!row) return reply(404, { error: 'no such booking' });\n if (req.method === 'GET') return reply(200, row);\n if (req.method === 'PATCH') {\n let input = {};\n try { input = JSON.parse(body || '{}'); } catch { return reply(400, { error: 'body must be JSON' }); }\n const date = input.date ? String(input.date) : row.date;\n if (!isDay(date)) return reply(400, { error: 'date must be a day, YYYY-MM-DD' });\n const time = input.time ? String(input.time) : row.time;\n const moved = date !== row.date || time !== row.time;\n const table = input.table ? Number(input.table) : (moved ? freeTable(row.tenant, date, time) : row.table);\n const clash = [...bookings.values()].find((r) => r.id !== row.id && r.tenant === row.tenant && r.date === date && r.time === time && r.table === table);\n if (clash) return reply(409, { error: `table ${table} is already booked on ${date} at ${time}` });\n Object.assign(row, { date, time, table, ...(input.guests ? { guests: Number(input.guests) } : {}) }); changes++;\n return reply(200, row);\n }\n if (req.method === 'DELETE') { bookings.delete(id); changes++; return reply(204); }\n return reply(405, { error: 'method not allowed' });\n}\n\nfunction startBackend(port = Number(process.env.BACKEND_PORT) || 3001) {\n const server = http.createServer((req, res) => {\n let body = '';\n req.on('data', (c) => { body += c; });\n req.on('end', () => {\n try { handle(req, res, body); } catch (e) { send(res, 500, { error: String((e && e.message) || e) }); }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startBackend, recent, changeCount: () => changes };\nif (require.main === module) startBackend().then(() => console.log('backend on http://localhost:3001'));\n",
|
|
7720
|
-
"app.js": "// ResiResi website server: serves the page and makes its calls.\n//\n// PROTECTED (the default): every call goes to the PUBLIC API \u2014 through APIblaze \u2014 with the\n// pizzeria's server key (kept here, never in the browser) and X-End-User-Id naming the\n// person. APIblaze checks the key, applies the rules and forwards to the backend.\n//\n// DIRECT (the page's \"unprotected\" switch): the same calls go straight to the backend on\n// localhost, with the shared backend secret and whatever user the page claims. No APIblaze:\n// nobody checks who may do what. That is how most apps ship.\n//\n// The chat and the two admin widgets use apiblaze's own server helpers\n// (lib/apiblaze-server.js, copied in by `npx apiblaze@latest demo` \u2014 the same code as\n// `import \u2026 from 'apiblaze/server'`).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst env = (name, fallback = '') => process.env[name] || fallback;\nconst json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// The two pizzerias (tenants) and the people the demo lets you be. A real app takes\n// \"who is this\" from its own login.\nconst PIZZERIAS = {\n nino: { label: 'Nino Pizza', tenant: () => env('APIBLAZE_TENANT', env('DEMO_NINO_TENANT')), key: () => env('APIBLAZE_SERVER_KEY'), admin: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example' } },\n gino: { label: 'Gino Pizza', tenant: () => env('APIBLAZE_GINO_TENANT', env('DEMO_GINO_TENANT')), key: () => env('APIBLAZE_GINO_KEY'), admin: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example' } },\n};\n// Each pizzeria (tenant) has its own MCP address: https://{proxy}-{tenant}.mcp.\u2026\nconst portalFor = (tenant) => (env('APIBLAZE_PORTAL_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_PORTAL_URL').replace(`${env('APIBLAZE_TENANT')}.portal`, `${tenant}.portal`) : '');\nconst mcpFor = (tenant) => (env('APIBLAZE_MCP_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_MCP_URL').replace(`-${env('APIBLAZE_TENANT')}.`, `-${tenant}.`) : '');\nconst CUSTOMERS = { ana: 'Ana', ben: 'Ben' };\nconst pizzeriaOf = (id) => PIZZERIAS[id] || PIZZERIAS.nino;\nfunction queryOf(req) { return new URL(req.url, 'http://x').searchParams; }\n\nasync function call(op, { pizzeria = 'nino', as = 'ana', id, body, direct = false } = {}) {\n const pz = pizzeriaOf(pizzeria);\n const base = direct ? `http://127.0.0.1:${env('BACKEND_PORT', '3001')}` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (direct) {\n // Straight to localhost: the backend trusts whatever these headers say.\n headers['x-target-api-key'] = env('BACKEND_SECRET');\n headers['x-abz-user-id'] = as;\n headers['x-abz-tenant-id'] = pz.tenant();\n } else {\n headers['x-api-key'] = pz.key();\n headers['x-end-user-id'] = as;\n }\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: CUSTOMERS[as] || as, ...(body || {}) }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : { method: 'GET', url: `${base}/reservations` };\n const r = await fetch(req.url, { method: req.method, headers, body: req.body });\n const text = await r.text();\n let data = null;\n try { data = text ? JSON.parse(text) : null; } catch { data = text; }\n return { status: r.status, body: data };\n}\n\n// Node http <-> the web-standard handlers apiblaze/server exports.\nasync function viaHandler(handler, req, res, raw) {\n const request = new Request(`http://localhost${req.url}`, {\n method: req.method,\n headers: { 'content-type': req.headers['content-type'] || 'application/json' },\n body: ['GET', 'HEAD'].includes(req.method) ? undefined : raw,\n });\n const out = await handler(request);\n const headers = {};\n out.headers.forEach((v, k) => { headers[k] = v; });\n res.writeHead(out.status, headers);\n if (!out.body) return res.end();\n const reader = out.body.getReader();\n for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }\n res.end();\n}\n\nfunction widgets() {\n if (!lib) return {};\n const out = { chat: {} };\n for (const [id, pz] of Object.entries(PIZZERIAS)) {\n if (!pz.tenant() || !pz.key()) continue;\n // The assistant acts as the signed-in customer, at that customer's pizzeria.\n out.chat[id] = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${pz.tenant()}`, apiKey: pz.key(),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: queryOf(req).get('as') || 'ana' }),\n });\n }\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n // Admin widgets: the admin of the chosen pizzeria.\n const admin = (req) => { const pz = pizzeriaOf(queryOf(req).get('pizzeria')); return { tenant: pz.tenant(), ...pz.admin }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin });\n }\n return out;\n}\n\n/** One of the two websites: `direct` = straight to the backend (unprotected), else through APIblaze. */\nfunction startApp(port = Number(env('APP_PORT', '3000')), { direct = false } = {}) {\n const w = direct ? {} : widgets();\n const pub = path.join(__dirname, 'public');\n const server = http.createServer((req, res) => {\n let raw = '';\n req.on('data', (c) => { raw += c; });\n req.on('end', async () => {\n try {\n const url = new URL(req.url, 'http://x');\n const p = url.pathname;\n if (req.method === 'GET' && (p === '/' || p === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'index.html')));\n }\n for (const asset of ['apiblaze-widgets.js', 'apiblaze-logo.svg']) {\n if (req.method === 'GET' && p === `/${asset}` && fs.existsSync(path.join(pub, asset))) {\n res.writeHead(200, { 'content-type': asset.endsWith('.svg') ? 'image/svg+xml' : 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, asset)));\n }\n }\n if (p === '/api/config') {\n return json(res, 200, {\n direct, apiUrl: env('APIBLAZE_URL'), portalUrl: env('APIBLAZE_PORTAL_URL'),\n backendPort: env('BACKEND_PORT', '3001'),\n protectedUrl: env('APIBLAZE_URL') ? `http://localhost:${env('APP_PORT', '3000')}` : '',\n directUrl: `http://localhost:${env('DIRECT_PORT', '3002')}`,\n logsCommand: `npx apiblaze@latest logs ${env('APIBLAZE_NAME')} --tenant ${env('APIBLAZE_TENANT')}`,\n pizzerias: Object.entries(PIZZERIAS).filter(([, pz]) => pz.tenant()).map(([id, pz]) => ({ id, label: pz.label, admin: pz.admin.label, mcpUrl: mcpFor(pz.tenant()), portalUrl: portalFor(pz.tenant()) })),\n widgets: { chat: Object.keys(w.chat || {}), keys: !!w.keys, groups: !!w.groups },\n });\n }\n if (p === '/api/changes') {\n // Local and free: has any booking changed since the page last listed? The page\n // re-lists through APIblaze only when it has (the backend runs in this process).\n let n = 0;\n try { n = require('./backend').changeCount(); } catch { /* backend runs elsewhere */ }\n return json(res, 200, { n });\n }\n if (p === '/api/apiblaze/chat') {\n const h = (w.chat || {})[url.searchParams.get('pizzeria') || 'nino'];\n if (h) return viaHandler(h.handler, req, res, raw);\n }\n if (p === '/api/apiblaze/keys' && w.keys) return viaHandler(w.keys.handler, req, res, raw);\n if (p === '/api/apiblaze/groups' && w.groups) return viaHandler(w.groups.handler, req, res, raw);\n if (req.method === 'POST' && p === '/api/act') {\n const b = JSON.parse(raw || '{}');\n return json(res, 200, await call(String(b.op || 'list'), { pizzeria: b.pizzeria, as: String(b.as || 'ana'), id: b.id, body: b.body, direct }));\n }\n json(res, 404, { error: 'not found' });\n } catch (e) {\n json(res, 502, { error: `Could not reach the API: ${String((e && e.message) || e)}` });\n }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startApp };\n",
|
|
7720
|
+
"app.js": "// ResiResi website server: serves the page and makes its calls.\n//\n// PROTECTED (the default): every call goes to the PUBLIC API \u2014 through APIblaze \u2014 with the\n// pizzeria's server key (kept here, never in the browser) and X-End-User-Id naming the\n// person. APIblaze checks the key, applies the rules and forwards to the backend.\n//\n// DIRECT (the page's \"unprotected\" switch): the same calls go straight to the backend on\n// localhost, with the shared backend secret and whatever user the page claims. No APIblaze:\n// nobody checks who may do what. That is how most apps ship.\n//\n// The chat and the two admin widgets use apiblaze's own server helpers\n// (lib/apiblaze-server.js, copied in by `npx apiblaze@latest demo` \u2014 the same code as\n// `import \u2026 from 'apiblaze/server'`).\n\nconst http = require('http');\nconst fs = require('fs');\nconst path = require('path');\n\nconst env = (name, fallback = '') => process.env[name] || fallback;\nconst json = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };\n\nlet lib = null;\ntry { lib = require('./lib/apiblaze-server.js'); } catch { /* run without the widgets */ }\n\n// The two pizzerias (tenants) and the people the demo lets you be. A real app takes\n// \"who is this\" from its own login.\nconst PIZZERIAS = {\n nino: { label: 'Nino Pizza', tenant: () => env('APIBLAZE_TENANT', env('DEMO_NINO_TENANT')), key: () => env('APIBLAZE_SERVER_KEY'), admin: { userId: 'ana', label: 'Ana', email: 'ana@ninopizza.example' } },\n gino: { label: 'Gino Pizza', tenant: () => env('APIBLAZE_GINO_TENANT', env('DEMO_GINO_TENANT')), key: () => env('APIBLAZE_GINO_KEY'), admin: { userId: 'gina', label: 'Gina', email: 'gina@ginopizza.example' } },\n};\n// Each pizzeria (tenant) has its own MCP address: https://{proxy}-{tenant}.mcp.\u2026\nconst portalFor = (tenant) => (env('APIBLAZE_PORTAL_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_PORTAL_URL').replace(`${env('APIBLAZE_TENANT')}.portal`, `${tenant}.portal`) : '');\nconst mcpFor = (tenant) => (env('APIBLAZE_MCP_URL') && env('APIBLAZE_TENANT') && tenant ? env('APIBLAZE_MCP_URL').replace(`-${env('APIBLAZE_TENANT')}.`, `-${tenant}.`) : '');\nconst CUSTOMERS = { ana: 'Ana', ben: 'Ben' };\nconst pizzeriaOf = (id) => PIZZERIAS[id] || PIZZERIAS.nino;\nfunction queryOf(req) { return new URL(req.url, 'http://x').searchParams; }\n\nasync function call(op, { pizzeria = 'nino', as = 'ana', id, body, direct = false } = {}) {\n const pz = pizzeriaOf(pizzeria);\n const base = direct ? `http://127.0.0.1:${env('BACKEND_PORT', '3001')}` : env('APIBLAZE_URL').replace(/\\/$/, '');\n const headers = { 'content-type': 'application/json' };\n if (direct) {\n // Straight to localhost: the backend trusts whatever these headers say.\n headers['x-target-api-key'] = env('BACKEND_SECRET');\n headers['x-abz-user-id'] = as;\n headers['x-abz-tenant-id'] = pz.tenant();\n } else {\n headers['x-api-key'] = pz.key();\n headers['x-end-user-id'] = as;\n }\n const req = op === 'book' ? { method: 'POST', url: `${base}/reservations`, body: JSON.stringify({ name: CUSTOMERS[as] || as, ...(body || {}) }) }\n : op === 'cancel' ? { method: 'DELETE', url: `${base}/reservations/${encodeURIComponent(id || '')}` }\n : { method: 'GET', url: `${base}/reservations` };\n const r = await fetch(req.url, { method: req.method, headers, body: req.body });\n const text = await r.text();\n let data = null;\n try { data = text ? JSON.parse(text) : null; } catch { data = text; }\n return { status: r.status, body: data };\n}\n\n// Node http <-> the web-standard handlers apiblaze/server exports.\nasync function viaHandler(handler, req, res, raw) {\n const request = new Request(`http://localhost${req.url}`, {\n method: req.method,\n headers: { 'content-type': req.headers['content-type'] || 'application/json' },\n body: ['GET', 'HEAD'].includes(req.method) ? undefined : raw,\n });\n const out = await handler(request);\n const headers = {};\n out.headers.forEach((v, k) => { headers[k] = v; });\n res.writeHead(out.status, headers);\n if (!out.body) return res.end();\n const reader = out.body.getReader();\n for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }\n res.end();\n}\n\nfunction widgets() {\n if (!lib) return {};\n const out = { chat: {} };\n for (const [id, pz] of Object.entries(PIZZERIAS)) {\n if (!pz.tenant() || !pz.key()) continue;\n // The assistant acts as the signed-in customer, at that customer's pizzeria.\n out.chat[id] = lib.createApiblazeChat({\n project: `${env('APIBLAZE_NAME')}-${pz.tenant()}`, apiKey: pz.key(),\n host: 'tryabz.run', environment: 'dev', getUser: (req) => ({ userId: queryOf(req).get('as') || 'ana' }),\n });\n }\n if (env('APIBLAZE_CP_KEY') && env('APIBLAZE_WIDGETS') === '1') {\n // Admin widgets: the admin of the chosen pizzeria.\n const admin = (req) => { const pz = pizzeriaOf(queryOf(req).get('pizzeria')); return { tenant: pz.tenant(), ...pz.admin }; };\n out.keys = lib.createApiblazeKeys({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin, environment: 'dev' });\n out.groups = lib.createApiblazeGroups({ cpKey: env('APIBLAZE_CP_KEY'), getUser: admin });\n }\n return out;\n}\n\n/** One of the two websites: `direct` = straight to the backend (unprotected), else through APIblaze. */\nfunction startApp(port = Number(env('APP_PORT', '3000')), { direct = false } = {}) {\n const w = direct ? {} : widgets();\n const pub = path.join(__dirname, 'public');\n const server = http.createServer((req, res) => {\n let raw = '';\n req.on('data', (c) => { raw += c; });\n req.on('end', async () => {\n try {\n const url = new URL(req.url, 'http://x');\n const p = url.pathname;\n if (req.method === 'GET' && (p === '/' || p === '/index.html')) {\n res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, 'index.html')));\n }\n for (const asset of ['apiblaze-widgets.js', 'apiblaze-logo.svg']) {\n if (req.method === 'GET' && p === `/${asset}` && fs.existsSync(path.join(pub, asset))) {\n res.writeHead(200, { 'content-type': asset.endsWith('.svg') ? 'image/svg+xml' : 'text/javascript; charset=utf-8' });\n return res.end(fs.readFileSync(path.join(pub, asset)));\n }\n }\n if (p === '/api/config') {\n return json(res, 200, {\n direct, apiUrl: env('APIBLAZE_URL'), portalUrl: env('APIBLAZE_PORTAL_URL'),\n backendPort: env('BACKEND_PORT', '3001'),\n protectedUrl: env('APIBLAZE_URL') ? `http://localhost:${env('APP_PORT', '3000')}` : '',\n directUrl: `http://localhost:${env('DIRECT_PORT', '3002')}`,\n logsCommand: `npx apiblaze@latest logs ${env('APIBLAZE_NAME')} --tenant ${env('APIBLAZE_TENANT')}`,\n pizzerias: Object.entries(PIZZERIAS).filter(([, pz]) => pz.tenant()).map(([id, pz]) => ({ id, label: pz.label, admin: pz.admin.label, mcpUrl: mcpFor(pz.tenant()), portalUrl: portalFor(pz.tenant()) })),\n widgets: { chat: Object.keys(w.chat || {}), keys: !!w.keys, groups: !!w.groups },\n // Measurement off-switch for whoever runs the demo: start it with\n // APIBLAZE_DEMO_ANALYTICS=0 and the page never asks and never loads\n // anything from Google. Anything else leaves the opt-in question to\n // the visitor (public/index.html).\n analytics: env('APIBLAZE_DEMO_ANALYTICS', '1') !== '0',\n });\n }\n if (p === '/api/changes') {\n // Local and free: has any booking changed since the page last listed? The page\n // re-lists through APIblaze only when it has (the backend runs in this process).\n let n = 0;\n try { n = require('./backend').changeCount(); } catch { /* backend runs elsewhere */ }\n return json(res, 200, { n });\n }\n if (p === '/api/apiblaze/chat') {\n const h = (w.chat || {})[url.searchParams.get('pizzeria') || 'nino'];\n if (h) return viaHandler(h.handler, req, res, raw);\n }\n if (p === '/api/apiblaze/keys' && w.keys) return viaHandler(w.keys.handler, req, res, raw);\n if (p === '/api/apiblaze/groups' && w.groups) return viaHandler(w.groups.handler, req, res, raw);\n if (req.method === 'POST' && p === '/api/act') {\n const b = JSON.parse(raw || '{}');\n return json(res, 200, await call(String(b.op || 'list'), { pizzeria: b.pizzeria, as: String(b.as || 'ana'), id: b.id, body: b.body, direct }));\n }\n json(res, 404, { error: 'not found' });\n } catch (e) {\n json(res, 502, { error: `Could not reach the API: ${String((e && e.message) || e)}` });\n }\n });\n });\n return new Promise((resolve, reject) => {\n server.once('error', reject);\n server.listen(port, '127.0.0.1', () => resolve(server));\n });\n}\n\nmodule.exports = { startApp };\n",
|
|
7721
7721
|
"start.js": "// Starts ResiResi on this laptop: the backend (3001), the unprotected front-end straight to it\n// (3002) and, once APIblaze is set up, the protected website through APIblaze (3000).\n// No APIblaze involved here \u2014 the tunnel is `npx apiblaze@latest demo start|stop|restart`.\n//\n// node start.js run in the foreground (Ctrl-C stops)\n// npm run start run in the background\n// npm run stop stop the background one\n// npm run restart stop, then start again\n\nconst fs = require('fs');\nconst path = require('path');\nconst { spawn } = require('child_process');\n\nconst PID = path.join(__dirname, '.demo', 'app.pid');\n\nfunction loadEnv(file = path.join(__dirname, '.env.local')) {\n if (!fs.existsSync(file)) return;\n for (const line of fs.readFileSync(file, 'utf8').split('\\n')) {\n const m = /^\\s*([A-Z0-9_]+)\\s*=\\s*(.*)\\s*$/.exec(line);\n if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^\"(.*)\"$/, '$1');\n }\n}\n\nasync function start() {\n loadEnv();\n const { startBackend } = require('./backend');\n const { startApp } = require('./app');\n const backendPort = Number(process.env.BACKEND_PORT) || 3001;\n const appPort = Number(process.env.APP_PORT) || 3000;\n const directPort = Number(process.env.DIRECT_PORT) || 3002;\n await startBackend(backendPort);\n // The unprotected front-end: straight to the backend, no APIblaze.\n await startApp(directPort, { direct: true });\n // The protected website: through APIblaze (once the demo has written its keys).\n if (process.env.APIBLAZE_URL) await startApp(appPort);\n return { backendPort, appPort, directPort };\n}\n\nconst alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };\nfunction runningPid() {\n try { const pid = Number(fs.readFileSync(PID, 'utf8')); return pid && alive(pid) ? pid : null; } catch { return null; }\n}\nasync function stop() {\n const pid = runningPid();\n if (!pid) { console.log('ResiResi is not running.'); return; }\n process.kill(pid, 'SIGTERM');\n for (let i = 0; i < 30 && alive(pid); i++) await new Promise((r) => setTimeout(r, 100));\n try { fs.unlinkSync(PID); } catch { /* gone */ }\n console.log('Stopped ResiResi (backend and websites).');\n}\nfunction background() {\n if (runningPid()) { console.log('ResiResi is already running.'); return; }\n fs.mkdirSync(path.dirname(PID), { recursive: true });\n const log = fs.openSync(path.join(__dirname, '.demo', 'app.log'), 'a');\n const child = spawn(process.execPath, [__filename], { cwd: __dirname, detached: true, stdio: ['ignore', log, log] });\n child.unref();\n fs.writeFileSync(PID, String(child.pid));\n loadEnv();\n console.log(`Started ResiResi: backend localhost:${process.env.BACKEND_PORT || 3001}, unprotected front-end http://localhost:${process.env.DIRECT_PORT || 3002}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${process.env.APP_PORT || 3000}` : ''}.`);\n}\n\nmodule.exports = { start, loadEnv };\nif (require.main === module) {\n const arg = process.argv[2];\n if (arg === '--stop') stop();\n else if (arg === '--background') background();\n else if (arg === '--restart') stop().then(() => setTimeout(background, 300));\n else {\n start().then(({ appPort, backendPort, directPort }) => console.log(`ResiResi running: backend localhost:${backendPort}, unprotected front-end http://localhost:${directPort}${process.env.APIBLAZE_URL ? `, protected website http://localhost:${appPort}` : ''}. Ctrl-C stops.`))\n .catch((e) => { console.error(`Could not start: ${e.message}`); process.exit(1); });\n }\n}\n",
|
|
7722
7722
|
"public/index.html": `<!doctype html>
|
|
7723
7723
|
<html lang="en">
|
|
@@ -7794,7 +7794,38 @@ components:
|
|
|
7794
7794
|
.stack { display: grid; gap: 16px; }
|
|
7795
7795
|
button.copy { font-size: 11.5px; padding: 1px 8px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--ink); cursor: pointer; vertical-align: 1px; }
|
|
7796
7796
|
button.copy:hover { border-color: var(--muted); }
|
|
7797
|
+
|
|
7798
|
+
/* The measurement choice. One line, at the bottom, asked once. */
|
|
7799
|
+
.consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; flex-wrap: wrap; align-items: center;
|
|
7800
|
+
gap: 10px 16px; padding: 12px 16px; background: var(--card); border-top: 1px solid var(--line); font-size: 13.5px; color: var(--muted); }
|
|
7801
|
+
.consent b { color: var(--ink); }
|
|
7802
|
+
.consent .sp { flex: 1 1 auto; }
|
|
7797
7803
|
</style>
|
|
7804
|
+
|
|
7805
|
+
<!--
|
|
7806
|
+
Google Analytics, opt-in.
|
|
7807
|
+
|
|
7808
|
+
This page runs on the visitor's OWN laptop (\`npx apiblaze@latest demo\`), which
|
|
7809
|
+
makes measurement a different question than it is on apiblaze.com. So:
|
|
7810
|
+
\xB7 every consent signal starts denied, right here, before anything else runs;
|
|
7811
|
+
\xB7 nothing is fetched from Google until the visitor presses Allow \u2014 no tag,
|
|
7812
|
+
no cookie, and no cookieless ping either;
|
|
7813
|
+
\xB7 Do Not Track (or Global Privacy Control) is read as "no" without asking;
|
|
7814
|
+
\xB7 \`APIBLAZE_DEMO_ANALYTICS=0\` when starting the demo removes the question
|
|
7815
|
+
entirely (app.js stops advertising measurement in /api/config).
|
|
7816
|
+
|
|
7817
|
+
Only \`demo_*\` EVENTS are sent, never a page view: a page view from here would
|
|
7818
|
+
land in the same property as apiblaze.com under the hostname "localhost" and
|
|
7819
|
+
quietly ruin every page report there.
|
|
7820
|
+
-->
|
|
7821
|
+
<script>
|
|
7822
|
+
window.dataLayer = window.dataLayer || [];
|
|
7823
|
+
function gtag() { dataLayer.push(arguments); }
|
|
7824
|
+
gtag('consent', 'default', {
|
|
7825
|
+
analytics_storage: 'denied', ad_storage: 'denied',
|
|
7826
|
+
ad_user_data: 'denied', ad_personalization: 'denied',
|
|
7827
|
+
});
|
|
7828
|
+
</script>
|
|
7798
7829
|
</head>
|
|
7799
7830
|
<body>
|
|
7800
7831
|
<main>
|
|
@@ -7861,6 +7892,16 @@ components:
|
|
|
7861
7892
|
<p class="next">Each pizzeria only sees its own keys and people: switch pizzeria above. Developers can also sign in and get a key at the <a id="portal" target="_blank" rel="noopener">developer portal</a>.</p>
|
|
7862
7893
|
</section>
|
|
7863
7894
|
</main>
|
|
7895
|
+
|
|
7896
|
+
<div class="consent" id="consent" hidden>
|
|
7897
|
+
<span>May this demo send <b>anonymous usage events</b> \u2014 which buttons you press on this page \u2014 to
|
|
7898
|
+
Google Analytics? It helps us see what people actually try. Your bookings, your messages to the
|
|
7899
|
+
assistant and anything you type are never included, and nothing is sent unless you say yes.</span>
|
|
7900
|
+
<span class="sp"></span>
|
|
7901
|
+
<button class="act" id="consent-no">No thanks</button>
|
|
7902
|
+
<button class="act primary" id="consent-yes">Allow</button>
|
|
7903
|
+
</div>
|
|
7904
|
+
|
|
7864
7905
|
<script src="/apiblaze-widgets.js"></script>
|
|
7865
7906
|
<script>
|
|
7866
7907
|
const $ = (s) => document.querySelector(s);
|
|
@@ -7884,10 +7925,49 @@ function reloadWith(next) {
|
|
|
7884
7925
|
const p = new URLSearchParams({ pizzeria: next.pizzeria ?? state.pizzeria, as: next.as ?? $('#as').value, tab: next.tab ?? state.tab });
|
|
7885
7926
|
location.assign(\`\${location.pathname}?\${p}\`);
|
|
7886
7927
|
}
|
|
7928
|
+
// \u2500\u2500 measurement (opt-in; see the comment in <head>) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
7929
|
+
// \`track()\` is a no-op until the visitor allows it, so every call site below can
|
|
7930
|
+
// be written as if measurement were always on.
|
|
7931
|
+
const GA_ID = 'G-M0KZHLJ0ZD';
|
|
7932
|
+
const CONSENT_KEY = 'abz_demo_consent';
|
|
7933
|
+
const doNotTrack = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.globalPrivacyControl === true;
|
|
7934
|
+
const consentRead = () => { try { return localStorage.getItem(CONSENT_KEY); } catch { return null; } };
|
|
7935
|
+
const consentWrite = (v) => { try { localStorage.setItem(CONSENT_KEY, v); } catch { /* private mode: the choice holds for this page */ } };
|
|
7936
|
+
let gaOn = false;
|
|
7937
|
+
function startGa() {
|
|
7938
|
+
if (gaOn) return;
|
|
7939
|
+
gaOn = true;
|
|
7940
|
+
gtag('js', new Date());
|
|
7941
|
+
gtag('config', GA_ID, { send_page_view: false });
|
|
7942
|
+
gtag('consent', 'update', { analytics_storage: 'granted' });
|
|
7943
|
+
const s = document.createElement('script');
|
|
7944
|
+
s.async = true;
|
|
7945
|
+
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
|
|
7946
|
+
document.head.append(s);
|
|
7947
|
+
}
|
|
7948
|
+
/**
|
|
7949
|
+
* One demo event. \`beacon\` transport because half of these fire on a button that
|
|
7950
|
+
* immediately navigates (the mode switch, the tabs) \u2014 a normal request would be
|
|
7951
|
+
* cancelled by the page unload and the event lost.
|
|
7952
|
+
*/
|
|
7953
|
+
function track(name, params) {
|
|
7954
|
+
if (!gaOn) return;
|
|
7955
|
+
gtag('event', name, Object.assign(
|
|
7956
|
+
{ app: 'resiresi-demo', mode: state.direct ? 'direct' : 'protected', pizzeria: state.pizzeria, transport_type: 'beacon' },
|
|
7957
|
+
params || {},
|
|
7958
|
+
));
|
|
7959
|
+
}
|
|
7960
|
+
|
|
7887
7961
|
let cfg = null;
|
|
7888
7962
|
const ready = fetch('/api/config').then((r) => r.json()).then((c) => { cfg = c; return c; });
|
|
7889
7963
|
const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) }).then((r) => r.json()).catch(() => ({ status: 0 }));
|
|
7890
|
-
|
|
7964
|
+
// When the PAGE last changed a booking. The poller below uses it to tell its own
|
|
7965
|
+
// work apart from the assistant's (see demo_assistant_booking).
|
|
7966
|
+
let lastSelfAct = 0;
|
|
7967
|
+
const act = (op, extra) => {
|
|
7968
|
+
if (op !== 'list') lastSelfAct = Date.now();
|
|
7969
|
+
return post('/api/act', { op, pizzeria: state.pizzeria, as: me(), ...extra });
|
|
7970
|
+
};
|
|
7891
7971
|
const me = () => $('#as').value;
|
|
7892
7972
|
const pz = () => (cfg?.pizzerias || []).find((p) => p.id === state.pizzeria) || { label: 'Nino Pizza', admin: 'Ana' };
|
|
7893
7973
|
const anaBooking = {};
|
|
@@ -7899,6 +7979,7 @@ document.querySelectorAll('button.copy').forEach((b) => b.onclick = async () =>
|
|
|
7899
7979
|
try { await navigator.clipboard.writeText(text); }
|
|
7900
7980
|
catch { const r = document.createRange(); r.selectNodeContents(el); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); document.execCommand('copy'); sel.removeAllRanges(); }
|
|
7901
7981
|
b.textContent = 'Copied'; setTimeout(() => { b.textContent = 'Copy'; }, 1200);
|
|
7982
|
+
track('demo_copy', { what: (b.dataset.copy || '').replace('#', '') });
|
|
7902
7983
|
});
|
|
7903
7984
|
|
|
7904
7985
|
// \u2500\u2500 mode (which site this is), pizzeria, tab \u2500\u2500
|
|
@@ -7911,17 +7992,17 @@ function setMode(direct) {
|
|
|
7911
7992
|
$('#foot-protected').hidden = direct; $('#foot-direct').hidden = !direct;
|
|
7912
7993
|
}
|
|
7913
7994
|
function goTo(url) { if (url) location.assign(url + location.search); }
|
|
7914
|
-
$('#mode-protected').onclick = () => { if (state.direct) goTo(cfg.protectedUrl); };
|
|
7915
|
-
$('#mode-direct').onclick = () => { if (!state.direct) goTo(cfg.directUrl); };
|
|
7995
|
+
$('#mode-protected').onclick = () => { if (state.direct) { track('demo_mode', { to: 'protected' }); goTo(cfg.protectedUrl); } };
|
|
7996
|
+
$('#mode-direct').onclick = () => { if (!state.direct) { track('demo_mode', { to: 'direct' }); goTo(cfg.directUrl); } };
|
|
7916
7997
|
function setTab(tab) {
|
|
7917
7998
|
state.tab = tab;
|
|
7918
7999
|
$('#app').hidden = tab !== 'app'; $('#admin').hidden = tab !== 'admin';
|
|
7919
8000
|
$('#tab-app').setAttribute('aria-pressed', tab === 'app'); $('#tab-admin').setAttribute('aria-pressed', tab === 'admin');
|
|
7920
8001
|
}
|
|
7921
|
-
$('#tab-app').onclick = () => { if (state.tab !== 'app') reloadWith({ tab: 'app' }); };
|
|
7922
|
-
$('#tab-admin').onclick = () => { if (state.tab !== 'admin') reloadWith({ tab: 'admin' }); };
|
|
7923
|
-
$('#go-admin').onclick = () => reloadWith({ tab: 'admin' });
|
|
7924
|
-
function setPizzeria(id) { if (id !== state.pizzeria) reloadWith({ pizzeria: id }); }
|
|
8002
|
+
$('#tab-app').onclick = () => { if (state.tab !== 'app') { track('demo_tab', { to: 'app' }); reloadWith({ tab: 'app' }); } };
|
|
8003
|
+
$('#tab-admin').onclick = () => { if (state.tab !== 'admin') { track('demo_tab', { to: 'admin' }); reloadWith({ tab: 'admin' }); } };
|
|
8004
|
+
$('#go-admin').onclick = () => { track('demo_tab', { to: 'admin', from: 'footer-link' }); reloadWith({ tab: 'admin' }); };
|
|
8005
|
+
function setPizzeria(id) { if (id !== state.pizzeria) { track('demo_pizzeria', { to: id }); reloadWith({ pizzeria: id }); } }
|
|
7925
8006
|
|
|
7926
8007
|
async function render() {
|
|
7927
8008
|
await ready;
|
|
@@ -7956,7 +8037,7 @@ async function refresh() {
|
|
|
7956
8037
|
const t = document.createElement('span');
|
|
7957
8038
|
t.textContent = \`\${b.guests || 2} people \xB7 \${dayLabel(b.date)} \${b.time} \xB7 table \${b.table} \xB7 #\${b.id}\`;
|
|
7958
8039
|
const x = document.createElement('button'); x.className = 'act'; x.textContent = 'Cancel';
|
|
7959
|
-
x.onclick = async () => { await act('cancel', { id: b.id }); refresh(); };
|
|
8040
|
+
x.onclick = async () => { track('demo_cancel'); await act('cancel', { id: b.id }); refresh(); };
|
|
7960
8041
|
li.append(t, x); ul.append(li);
|
|
7961
8042
|
}
|
|
7962
8043
|
$('#try-ben').hidden = !(me() === 'ben' && anaBooking[state.pizzeria]);
|
|
@@ -7965,11 +8046,15 @@ $('#book').onclick = async () => {
|
|
|
7965
8046
|
const guests = Math.max(1, Number($('#guests').value) || 2);
|
|
7966
8047
|
const date = $('#date').value || localDay();
|
|
7967
8048
|
const time = $('#time').value || '19:30';
|
|
7968
|
-
await act('book', { body: { guests, date, time } }); // the restaurant picks the table
|
|
8049
|
+
const r = await act('book', { body: { guests, date, time } }); // the restaurant picks the table
|
|
8050
|
+
track('demo_book', { guests, status: r.status || 0 });
|
|
7969
8051
|
refresh();
|
|
7970
8052
|
};
|
|
7971
8053
|
$('#ben-cancel').onclick = async () => {
|
|
8054
|
+
lastSelfAct = Date.now();
|
|
7972
8055
|
const r = await post('/api/act', { op: 'cancel', pizzeria: state.pizzeria, as: 'ben', id: anaBooking[state.pizzeria] });
|
|
8056
|
+
// The point of the whole demo: protected refuses Ben, direct lets him through.
|
|
8057
|
+
track('demo_ownership_check', { blocked: r.status === 403, status: r.status || 0 });
|
|
7973
8058
|
const d = document.createElement('div');
|
|
7974
8059
|
if (r.status === 403) { d.className = 'out good'; d.textContent = "\u{1F6E1} Blocked. Only Ana can cancel Ana's booking. APIblaze decided, not the app's code."; }
|
|
7975
8060
|
else if (r.status === 204) { d.className = 'out bad'; d.textContent = "Cancelled. Straight to localhost, nothing stopped Ben."; anaBooking[state.pizzeria] = null; }
|
|
@@ -7985,7 +8070,13 @@ setInterval(async () => {
|
|
|
7985
8070
|
if (state.tab !== 'app' || document.hidden) return;
|
|
7986
8071
|
const c = await fetch('/api/changes').then((x) => x.json()).catch(() => null);
|
|
7987
8072
|
if (!c) return;
|
|
7988
|
-
if (lastChange !== null && c.n !== lastChange)
|
|
8073
|
+
if (lastChange !== null && c.n !== lastChange) {
|
|
8074
|
+
// A booking moved that this page did not move. On this demo the only other
|
|
8075
|
+
// actor is the AI assistant, so that is what we count \u2014 with a 5s window so a
|
|
8076
|
+
// click of our own that is still settling is never miscounted as the assistant.
|
|
8077
|
+
if (Date.now() - lastSelfAct > 5000) track('demo_assistant_booking');
|
|
8078
|
+
refresh();
|
|
8079
|
+
}
|
|
7989
8080
|
lastChange = c.n;
|
|
7990
8081
|
}, 2000);
|
|
7991
8082
|
|
|
@@ -8018,7 +8109,25 @@ function mountAdmin() {
|
|
|
8018
8109
|
if (cfg.widgets.groups) W.groups('#groups', { endpoint: '/api/apiblaze/groups?pizzeria=' + state.pizzeria, theme, title: \`People and groups \xB7 \${pz().label}\`, defaultOpenAdmins: true });
|
|
8019
8110
|
}
|
|
8020
8111
|
|
|
8112
|
+
for (const id of ['#portal-top', '#portal']) {
|
|
8113
|
+
$(id).addEventListener('click', () => track('demo_portal_open', { from: id.replace('#', '') }));
|
|
8114
|
+
}
|
|
8115
|
+
|
|
8021
8116
|
ready.then((c) => {
|
|
8117
|
+
// Ask once, and only when there is something to ask about. A visitor who has
|
|
8118
|
+
// already answered is never asked again; one who sends Do Not Track, or who
|
|
8119
|
+
// started the demo with APIBLAZE_DEMO_ANALYTICS=0, is not asked at all.
|
|
8120
|
+
if (c.analytics !== false && !doNotTrack) {
|
|
8121
|
+
const choice = consentRead();
|
|
8122
|
+
const begin = () => { startGa(); track('demo_open', { tab: state.tab }); };
|
|
8123
|
+
if (choice === 'granted') begin();
|
|
8124
|
+
else if (choice !== 'denied') {
|
|
8125
|
+
$('#consent').hidden = false;
|
|
8126
|
+
$('#consent-yes').onclick = () => { consentWrite('granted'); $('#consent').hidden = true; begin(); };
|
|
8127
|
+
$('#consent-no').onclick = () => { consentWrite('denied'); $('#consent').hidden = true; };
|
|
8128
|
+
}
|
|
8129
|
+
}
|
|
8130
|
+
|
|
8022
8131
|
$('#api').textContent = c.apiUrl || 'not connected';
|
|
8023
8132
|
setMode(!!c.direct);
|
|
8024
8133
|
if (c.direct && !c.protectedUrl) { $('#mode-protected').disabled = true; $('#mode-protected').querySelector('small').textContent = 'starts once APIblaze is set up'; }
|
|
@@ -11735,7 +11844,7 @@ ${steps.map((t, i) => `${i + 1}. ${t}`).join("\n")}
|
|
|
11735
11844
|
})() : "";
|
|
11736
11845
|
const frontMatter = found ? "" : `---
|
|
11737
11846
|
name: ${SKILL_NAME}
|
|
11738
|
-
description: Use when the user asks what ${P} (or ${B.cli}) does or can do for them; says yes to a demo or asks to see / try / demo it; wants to ship, share or expose an API; wants API keys, sign-in, per-user data, rate limits or quotas; wants AI agents to use their app safely; or says users can reach each other's records.
|
|
11847
|
+
description: Use when the user asks what ${P} (or ${B.cli}) does or can do for them; says yes to a demo or asks to see / try / demo it; wants to ship, share or expose an API; wants API keys, sign-in, per-user data, rate limits or quotas; wants AI agents to use their app safely; wants a Claude connector or a ChatGPT plugin, or their API listed in the Claude or ChatGPT directory; or says users can reach each other's records.
|
|
11739
11848
|
---
|
|
11740
11849
|
|
|
11741
11850
|
`;
|
|
@@ -11788,12 +11897,19 @@ ${blockedMarkdown()}
|
|
|
11788
11897
|
On this machine \u2192 \`${run2} dev\`; deployed \u2192 \`${run2} create\`. \`${run2} skills ship\` has the
|
|
11789
11898
|
whole procedure.
|
|
11790
11899
|
|
|
11900
|
+
## A Claude connector or a ChatGPT plugin
|
|
11901
|
+
|
|
11902
|
+
\`${run2} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`
|
|
11903
|
+
makes the ${B.thingShort} a Claude connector that meets the Claude directory's policies; \`--compliant chatgpt\`
|
|
11904
|
+
makes a ChatGPT plugin to OpenAI's policies, \`--compliant all\` does both. It ends with a table of
|
|
11905
|
+
every requirement and why it is met, and the submission packet. \`${run2} skills connector\` has the page.
|
|
11906
|
+
|
|
11791
11907
|
## One thing at a time
|
|
11792
11908
|
|
|
11793
11909
|
Each topic has its own page: \`${run2} skills keys\` (API keys) \xB7 \`signin\` (OAuth sign-in and the
|
|
11794
11910
|
developer portal) \xB7 \`rules\` (who can change what) \xB7 \`limits\` (rate limits and quotas) \xB7 \`mcp\`
|
|
11795
|
-
(AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`
|
|
11796
|
-
their own app).
|
|
11911
|
+
(AI agents) \xB7 \`widgets\` (keys, users and chat on their site) \xB7 \`connector\` (a compliant Claude
|
|
11912
|
+
connector or ChatGPT plugin) \xB7 \`ship\` (the whole procedure for their own app).
|
|
11797
11913
|
`;
|
|
11798
11914
|
}
|
|
11799
11915
|
function shipMarkdown() {
|
|
@@ -12036,7 +12152,7 @@ ${heading}`);
|
|
|
12036
12152
|
const next = md.indexOf("\n## ", at + heading.length + 1);
|
|
12037
12153
|
return md.slice(at + 1, next < 0 ? void 0 : next).trim();
|
|
12038
12154
|
}
|
|
12039
|
-
var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets"];
|
|
12155
|
+
var TOPICS = ["ship", "keys", "signin", "rules", "limits", "mcp", "widgets", "connector"];
|
|
12040
12156
|
function topicMarkdown(topic) {
|
|
12041
12157
|
const P = B.product;
|
|
12042
12158
|
const cli = B.cli;
|
|
@@ -12106,6 +12222,27 @@ of 3,000 a day. Say so in those words (rate limit, quota).
|
|
|
12106
12222
|
${sectionOf(legacy, "## No shell? The control-plane MCP")}`);
|
|
12107
12223
|
case "widgets":
|
|
12108
12224
|
return page(`Keys, users and chat on their site`, sectionOf(legacy, "## Wiring a real site").replace(/^## Wiring a real site\n+/, ""));
|
|
12225
|
+
case "connector":
|
|
12226
|
+
return page(`A compliant Claude connector or ChatGPT plugin with ${P}`, `
|
|
12227
|
+
A connector (Claude) or a plugin (ChatGPT) is the ${B.thingShort}'s MCP server, listed in that
|
|
12228
|
+
directory. Each directory has its own policies: a custom MCP hostname, OAuth sign-in with dynamic client
|
|
12229
|
+
registration, tool descriptions and listing texts within its limits, a privacy policy, a reviewer account,
|
|
12230
|
+
and attestations. \`--compliant\` makes the ${B.thingShort} meet them to the letter.
|
|
12231
|
+
|
|
12232
|
+
- Which directory: \`--compliant\` alone is Claude. \`--compliant chatgpt\` is OpenAI's, \`--compliant all\` is both.
|
|
12233
|
+
- From an OpenAPI URL: \`npx ${cli} create --compliant --target https://yourdomain.com/openapi.yaml --domain mcp.yourdomain.com\`.
|
|
12234
|
+
On an existing ${B.thingShort}: \`npx ${cli} connector <name> --compliant --domain mcp.yourdomain.com\`.
|
|
12235
|
+
- It asks for the listing answers with every field pre-filled from the spec and cut to the portal's limits
|
|
12236
|
+
(Enter accepts), and waits for ONE CNAME (mcp.yourdomain.com \u2192 proxy.abz.run) \u2014 the user adds it at their DNS
|
|
12237
|
+
provider; relay the exact record it prints. For ChatGPT it also asks for the challenge token the OpenAI portal
|
|
12238
|
+
issues for the domain (\`--openai-challenge <token>\`).
|
|
12239
|
+
- When every check passes it prints the compliance table (one row per requirement: met or not, why, and the
|
|
12240
|
+
policy page it comes from) and writes the submission packet. The user submits the packet to Anthropic or
|
|
12241
|
+
OpenAI themselves; listing decisions are theirs.
|
|
12242
|
+
- \`npx ${cli} check --directory-ready <name> --compliant\` is the report alone, with every blocker and its fix.
|
|
12243
|
+
Exit 1 while a blocker remains. Say "meets the requirements ${P} can verify", never "certified".
|
|
12244
|
+
|
|
12245
|
+
${sectionOf(legacy, "## Listing it in the Claude or ChatGPT directory")}`);
|
|
12109
12246
|
default:
|
|
12110
12247
|
return null;
|
|
12111
12248
|
}
|
|
@@ -20078,7 +20215,7 @@ init_auth();
|
|
|
20078
20215
|
init_brand();
|
|
20079
20216
|
installBrandFilter();
|
|
20080
20217
|
var program = new import_commander.Command();
|
|
20081
|
-
program.name(B.cli).description(B.cli === "mcpblaze" ? "MCPblaze CLI \u2014 turn any API into an MCP server your agents can use, with sign-in and per-user rules" : "APIblaze CLI \u2014 create & manage API proxies and run dev tunnels").version(version).option("-v, --verbose", "Print the exact series of API calls each command makes (curl-equivalent you could run yourself)");
|
|
20218
|
+
program.name(B.cli).description(B.cli === "mcpblaze" ? "MCPblaze CLI \u2014 turn any API into an MCP server your agents can use, with sign-in and per-user rules. New: `create --compliant` builds a Claude connector or a ChatGPT plugin that meets the directory policies, and prints the table of why." : "APIblaze CLI \u2014 create & manage API proxies and run dev tunnels. New: `create --compliant` builds a Claude connector or a ChatGPT plugin that meets the directory policies, and prints the table of why.").version(version).option("-v, --verbose", "Print the exact series of API calls each command makes (curl-equivalent you could run yourself)");
|
|
20082
20219
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
20083
20220
|
if (program.opts().verbose) setVerbose(true);
|
|
20084
20221
|
const chain = [];
|
|
@@ -20108,7 +20245,7 @@ program.command("login").description(`Authenticate with ${B.product}`).option("-
|
|
|
20108
20245
|
process.exit(1);
|
|
20109
20246
|
}
|
|
20110
20247
|
});
|
|
20111
|
-
program.command("create").description(`Put an app that is already DEPLOYED behind ${B.product}: a public URL and an MCP address for AI agents in front of your URL or OpenAPI spec. Included: API keys, sign-in, ownership rules, rate limits. No login needed. (App on this machine? Use \`dev\`.)
|
|
20248
|
+
program.command("create").description(`Put an app that is already DEPLOYED behind ${B.product}: a public URL and an MCP address for AI agents in front of your URL or OpenAPI spec. Included: API keys, sign-in, ownership rules, rate limits. No login needed. (App on this machine? Use \`dev\`.) Add --compliant to make it a Claude connector, or a ChatGPT plugin with --compliant chatgpt, that meets the directory's policies.`).option("--name <name>", B.cli === "mcpblaze" ? "MCP server name (becomes <name>-<tenant>.mcpblaze.com)" : "Proxy name (becomes <name>.abz.run)").option("--target <url|file>", "What to proxy \u2014 pass ANY of: a target server base URL (https://httpbin.org), a local OpenAPI file (./openapi.yaml), or a remote OpenAPI URL (https://acme.com/openapi.yaml). Spec inputs are detected automatically; routes, API version and environments then come from the spec").addOption(new import_commander.Option("--openapi <file|url>", "Deprecated alias \u2014 --target now detects spec files/URLs itself").hideHelp()).addOption(new import_commander.Option("--openapispec <file|url>", "Deprecated alias for --openapi").hideHelp()).option("--team <id|name>", "Team to create under (defaults to your active team)").option("--auth <type>", "Open ONE door only: api_key | oauth (sign in with GitHub, APIblaze-hosted) | none. Omitted = both doors.").option("--apikey", "API-key door only (no sign-in): the bootstrap keys are minted and printed; callers send X-API-Key, agents connect with a key.").option("--oauth [config]", `Sign-in door only (no API key). Bare = APIblaze-hosted GitHub sign-in. '{"iss","aud","jwks"}' = trust YOUR hosted login's JWTs. '{"provider","clientId","clientSecret"?}' = APIblaze-hosted login page with YOUR OAuth app (github \xB7 google \xB7 microsoft \xB7 facebook \xB7 auth0 \xB7 oidc); the secret is optional except for github and facebook. '{"provider":"oidc","issuer":"https://acme.okta.com/oauth2/default","clientId":"\u2026"}' = any OpenID Connect IdP by issuer (Okta \xB7 Entra \xB7 Keycloak \xB7 Cognito \xB7 \u2026).`).option("--identified", "Require every call to identify its end user (X-End-User-Id from a backend key, or a login token \u2014 a client key alone cannot); unattributed calls are rejected").option("--iam", `Turn IAM on for the ${B.thingShort}'s tenant so users & groups apply to identified calls`).option("--apiversion <version>", `API version to create (e.g. 2.0.0). Creating a new version of a ${B.thingShort} you own adds a version to the existing project.`).option("--tenant <slug>", `Tenant to attach the ${B.thingShort} to (created if new). Omitted \u2192 your team's most-recently-used tenant.`).option("--product <slug>", "Product tag to group this project under in the portal (team-scoped; anonymous create). Defaults to your team's existing/placeholder tag.").option("--display-name <name>", "Human-friendly display name").option("--subdomain <slug>", "Explicit subdomain (defaults to --name)").option("--config <file>", "JSON file with the full request body (anonymous create): requests_auth, login providers + client/server token types, scopes, callback URLs, etc. See apiblaze_anonymous.yaml. Flags override its fields.").option("-y, --yes", "Skip the confirmation prompt").option("--auto", `Zero prompts, start to finish: generates the ${B.thingShort} name when --name is omitted (from the target host, salted), takes every confirmation as yes, auto-picks the tenant. Implies --yes.`).option("--new-session", "Start a fresh anonymous session (do not group with prior anonymous creates)").option("--json", "Output machine-readable JSON (non-interactive)").option("--compliant [directories...]", `Directory-ready in one command, to the letter of that directory's policies: create the ${B.thingShort} from --target (an OpenAPI URL/file), apply the safe fixes, set up the custom MCP domain (--domain, one CNAME), ask for every listing answer with the spec's proposal pre-filled (cut to the portal's limits), create the reviewer account, write the submission packet. Bare = claude; or claude \xB7 chatgpt \xB7 all. Ends with the compliance table: every rule the listing satisfies and the policy page it comes from. Same as \`${B.cli} connector <spec> --compliant \u2026\`.`).addOption(new import_commander.Option("--policy [directories...]", "Older spelling of --compliant").hideHelp()).option("--domain <host>", "With --compliant: the MCP server's custom hostname (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "With --compliant: how long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--directory-ready", "Make it directory-ready for the Claude and ChatGPT directories: OAuth door, custom-domain checks, listing fields; the readiness report runs after the create and a blocker exits 1").option("--privacy-url <url>", "Directory listing: public https privacy policy").option("--support <email|url>", "Directory listing: support contact").option("--docs-url <url>", "Directory listing: public documentation URL (the developer portal by default)").option("--prompt <text>", "Directory listing: an example prompt (repeat the flag; three are needed)", collect, []).option("--server-name <name>", 'Directory listing: the name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "Directory listing: one-line tagline").option("--description <text>", "Directory listing: description").option("--icon-url <url>", "Directory listing: square icon (https)").option("--vuln-contact <email|url>", "Directory listing: vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT)", collect, []).option("--allowed-origin <origin>", "Directory mode: an extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server").action(async (opts) => {
|
|
20112
20249
|
try {
|
|
20113
20250
|
const compliant = opts.compliant ?? opts.policy;
|
|
20114
20251
|
if (compliant !== void 0 && compliant !== false) {
|
|
@@ -20127,8 +20264,8 @@ program.command("create").description(`Put an app that is already DEPLOYED behin
|
|
|
20127
20264
|
function collect(value, previous) {
|
|
20128
20265
|
return [...previous ?? [], value];
|
|
20129
20266
|
}
|
|
20130
|
-
program.command("check").description(`Readiness report for the Claude and ChatGPT directories (--directory-ready): what passes, every blocker with its fix, and the ${B.thingShort}'s published URL. Exit 1 while a blocker remains.`).argument("[project]", `The ${B.thingShort} (project name)`).option("--directory-ready", "The directory readiness checks (the only mode today)").option("--compliant
|
|
20131
|
-
var connector = program.command("connector").description(`
|
|
20267
|
+
program.command("check").description(`Readiness report for the Claude and ChatGPT directories (--directory-ready): what passes, every blocker with its fix, and the ${B.thingShort}'s published URL. Exit 1 while a blocker remains.`).argument("[project]", `The ${B.thingShort} (project name)`).option("--directory-ready", "The directory readiness checks (the only mode today)").option("--compliant [directory]", "Which directory's policies to check against: bare = claude; or claude \xB7 chatgpt \xB7 all (repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--policy <directory>", "Older spelling of --compliant").hideHelp()).addOption(new import_commander.Option("--platform <list>", "Older spelling of --compliant").hideHelp()).option("--stage <stage>", "create (relaxed: docs URL is a warning) \xB7 ready (the submission gate, default)").option("--refresh-snapshot", "Re-take the listing snapshot (after you edited the listing in the portals)").option("--no-probe", "Skip the live probes from this machine (report what the server can see)").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Print the raw report JSON (report, rendered text, target)").action(action((project, opts) => runCheck(project, { ...opts, noProbe: opts.probe === false })));
|
|
20268
|
+
var connector = program.command("connector").description(`A compliant Claude connector (--compliant chatgpt: a ChatGPT plugin), directory-ready in one command: from a project or straight from an OpenAPI URL \u2014 creates the ${B.thingShort} if needed, applies the safe fixes, sets up the custom MCP domain (one CNAME), pre-fills every listing answer from the spec, creates the reviewer account, and writes the submission packet when every check passes (you submit it to Anthropic and OpenAI yourself)`).argument("[project-or-spec]", `The ${B.thingShort} (project name), or an OpenAPI URL/file to create a directory-ready ${B.thingShort} from first`).option("--domain <host>", "Custom hostname for the MCP server (mcp.<your domain>); asked for when missing, suggested from the spec").option("--wait <minutes>", "How long to wait for the custom domain to activate once its CNAME is in place (default 15; 0 = do not wait)").option("--name <name>", `When creating from a spec: the ${B.thingShort} name (default: derived from the spec host)`).option("--privacy-url <url>", "Public https privacy policy").option("--support <email|url>", "Support contact").option("--docs-url <url>", "Public documentation URL").option("--prompt <text>", "An example prompt (repeatable; three are needed)", collect, []).option("--server-name <name>", 'Listing name (2\u201340 chars, no "MCP"/"Plugin" suffix)').option("--tagline <text>", "One-line tagline").option("--description <text>", "Description").option("--icon-url <url>", "Square icon (https)").option("--vuln-contact <email|url>", "Vulnerability-disclosure contact").option("--attest <id>", "Attest (repeatable): guidelines \xB7 own-backend \xB7 no-financial \xB7 no-genai-media \xB7 no-prompt-injection \xB7 no-conversation-data \xB7 public-docs (Claude's seven) \xB7 no-digital-goods \xB7 content-13-no-ads \xB7 no-restricted-data (ChatGPT). Alone, this only records the attestation.", collect, []).option("--allowed-origin <origin>", "An extra browser origin allowed on the MCP host (repeatable)", collect, []).option("--openai-challenge <token>", "ChatGPT: the domain-verification token the OpenAI portal shows under Plugins \u2192 Connect MCP server; served verbatim at /.well-known/openai-apps-challenge on your MCP domain").option("--full", "Run the full flow even when only --attest was given").option("--compliant [directory]", "Which directory's policies this run must satisfy: bare = claude; or claude \xB7 chatgpt \xB7 all (repeat or comma-separate)", collect, []).addOption(new import_commander.Option("--policy <directory>", "Older spelling of --compliant").hideHelp()).addOption(new import_commander.Option("--platform <list>", "Older spelling of --compliant").hideHelp()).option("--out <dir>", "Where to write connector-packet-<project>.md/.json (default: current directory)").option("-y, --yes", "No questions: apply the flags, report, exit 1 on a blocker").option("--team <id|name>", "Team the project is in (defaults to your active team)").option("--apiversion <version>", "API version (defaults to the project's)").option("--json", "Output machine-readable JSON").action(action(async (project, opts) => {
|
|
20132
20269
|
if (!project) throw new Error(`Which ${B.thingShort}? ${B.cli} connector <project> \xB7 ${B.cli} connector https://\u2026/openapi.yaml \xB7 ${B.cli} connector reviewer <project>`);
|
|
20133
20270
|
await runConnector(project, opts);
|
|
20134
20271
|
}));
|
|
@@ -20144,7 +20281,7 @@ program.command("demo").description(`See what ${B.product} does in a minute: a s
|
|
|
20144
20281
|
}
|
|
20145
20282
|
await runDemo(opts);
|
|
20146
20283
|
});
|
|
20147
|
-
program.command("skill").alias("skills").description(`What ${B.product} does, for you or your AI assistant: checks this folder for a backend and offers the next step (put it on the internet, or a sample app)`).argument("[page]", "One page instead: ship \xB7 keys \xB7 signin \xB7 rules \xB7 limits \xB7 mcp \xB7 widgets").option("--agent", "Print what an AI assistant reads, even at a terminal").option("--static", "Print the installed text, with no folder check (for generated docs)").option("--install", "Write it to .claude/skills/ship-safely/SKILL.md in the current project (instead of printing it)").option("--force", "Overwrite an existing SKILL.md").option("--ship", "Same as the ship page: the steps for putting it in front of your own app").action(action((page, opts) => runSkill({ ...opts, topic: page })));
|
|
20284
|
+
program.command("skill").alias("skills").description(`What ${B.product} does, for you or your AI assistant: checks this folder for a backend and offers the next step (put it on the internet, or a sample app)`).argument("[page]", "One page instead: ship \xB7 keys \xB7 signin \xB7 rules \xB7 limits \xB7 mcp \xB7 widgets \xB7 connector (a compliant Claude connector or ChatGPT plugin)").option("--agent", "Print what an AI assistant reads, even at a terminal").option("--static", "Print the installed text, with no folder check (for generated docs)").option("--install", "Write it to .claude/skills/ship-safely/SKILL.md in the current project (instead of printing it)").option("--force", "Overwrite an existing SKILL.md").option("--ship", "Same as the ship page: the steps for putting it in front of your own app").action(action((page, opts) => runSkill({ ...opts, topic: page })));
|
|
20148
20285
|
var agent = program.command("agent").description("Chat with an assistant that builds and runs your APIs (billed per turn)").option("--team <id|name>", "Team to work in (defaults to your active team)").option("--seed-for <project>", "Open with a first message that seeds realistic data through that proxy (for a directory reviewer account)").action(action((opts) => runAgent(opts)));
|
|
20149
20286
|
agent.command("authz").description("Chat to design and turn on access rules for an API").argument("<project>", "Project name or id").argument("[apiVersion]", "API version (defaults to the project's)").action(action((project, apiVersion) => runAuthz(project, apiVersion)));
|
|
20150
20287
|
program.command("rule").description("Lock resources to the person who created them \u2014 or describe any access rule in plain English (that one is billed per turn)").argument("<project>", "Project name or id").argument("[sentence]", 'Optional rule in plain English, e.g. "users see only their own rows". Omit to pick from what the spec can protect.').option("--enforce", "Turn enforcement on right after saving (default: save in shadow mode, then ask)").option("--apiversion <version>", "API version (defaults to the project's)").action(action((project, sentence, opts) => runRule(project, sentence, opts)));
|