@calo-design/cli 0.12.4 → 0.13.1
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/bin/backend.js +380 -0
- package/bin/cli.js +8 -1
- package/package.json +1 -1
package/bin/backend.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// calo-design backend <verb> — per-prototype backend primitives (Cloudflare Workers/
|
|
4
|
+
// D1/KV/R2), provisioned and deployed through the broker. The designer's machine never
|
|
5
|
+
// holds a Cloudflare credential: the CLI tars worker/ and the broker does the rest.
|
|
6
|
+
//
|
|
7
|
+
// State on disk (both prototype-root, both git-tracked):
|
|
8
|
+
// backend.json { slug, owner, workerUrl, appKey, deployHash, recipe } — the pointer
|
|
9
|
+
// file. NOT carried by checkpoints (the key is per-owner); a teammate
|
|
10
|
+
// who `open`s a prototype with worker/ reconnects via `backend init`.
|
|
11
|
+
// worker/ the deployable unit: index.js (+ migrations/*.sql for D1 recipes).
|
|
12
|
+
//
|
|
13
|
+
// Verbs are non-interactive and idempotent; --json output follows the house rule
|
|
14
|
+
// (leading newline, machine callers parse the last non-empty stdout line).
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const os = require("node:os");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const { spawnSync } = require("node:child_process");
|
|
21
|
+
|
|
22
|
+
const { ensureSession, loadSession, BROKER } = require("./login");
|
|
23
|
+
const { autoCheckpoint } = require("./checkpoints");
|
|
24
|
+
|
|
25
|
+
const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` };
|
|
26
|
+
const log = (s = "") => console.log(s);
|
|
27
|
+
const ok = (s) => log(`${c.g("✓")} ${s}`);
|
|
28
|
+
const warn = (s) => log(`${c.y("!")} ${s}`);
|
|
29
|
+
const emitJson = (obj) => process.stdout.write("\n" + (typeof obj === "string" ? obj : JSON.stringify(obj)) + "\n");
|
|
30
|
+
|
|
31
|
+
const flag = (args, name) => {
|
|
32
|
+
const eq = args.find((a) => a.startsWith(`--${name}=`));
|
|
33
|
+
if (eq) return eq.slice(name.length + 3);
|
|
34
|
+
const i = args.indexOf(`--${name}`);
|
|
35
|
+
if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) return args[i + 1];
|
|
36
|
+
return undefined;
|
|
37
|
+
};
|
|
38
|
+
const has = (args, name) => args.includes(`--${name}`);
|
|
39
|
+
|
|
40
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
41
|
+
const slugify = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
42
|
+
|
|
43
|
+
// Bounded broker fetch, GET+POST+raw-Buffer bodies (feedback.js's call(), plus a
|
|
44
|
+
// per-call timeout — worker deploys legitimately take a minute).
|
|
45
|
+
async function call(method, pathname, { body, raw, headers, timeoutMs = 30000 } = {}) {
|
|
46
|
+
const session = await ensureSession();
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetch(BROKER + pathname, {
|
|
50
|
+
method,
|
|
51
|
+
headers: {
|
|
52
|
+
authorization: `Bearer ${session}`,
|
|
53
|
+
...(body !== undefined && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
|
|
54
|
+
...(Buffer.isBuffer(body) ? { "content-type": "application/gzip" } : {}),
|
|
55
|
+
...(headers || {}),
|
|
56
|
+
},
|
|
57
|
+
body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
|
58
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
|
|
62
|
+
}
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
let json;
|
|
65
|
+
try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const err = new Error([json.error || `${pathname} → HTTP ${res.status}`, json.detail].filter(Boolean).join("\n "));
|
|
68
|
+
err.status = res.status;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
return raw ? text : json;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- prototype-local state ----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const backendJsonPath = (root) => path.join(root, "backend.json");
|
|
77
|
+
const readBackendJson = (root) => {
|
|
78
|
+
try { return JSON.parse(fs.readFileSync(backendJsonPath(root), "utf8")); } catch { return null; }
|
|
79
|
+
};
|
|
80
|
+
const writeBackendJson = (root, obj) =>
|
|
81
|
+
fs.writeFileSync(backendJsonPath(root), JSON.stringify(obj, null, 2) + "\n");
|
|
82
|
+
|
|
83
|
+
function resolveSlug(args, root) {
|
|
84
|
+
const bj = readBackendJson(root);
|
|
85
|
+
let marker = null;
|
|
86
|
+
try { marker = JSON.parse(fs.readFileSync(path.join(root, ".calo-checkpoint.json"), "utf8")); } catch {}
|
|
87
|
+
let pkg = null;
|
|
88
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch {}
|
|
89
|
+
const slug = slugify(flag(args, "slug") || (bj && bj.slug) || (marker && marker.slug) || (pkg && pkg.name) || path.basename(root));
|
|
90
|
+
if (!SLUG_RE.test(slug)) throw new Error("can't derive a valid slug from this folder — pass --slug <name>");
|
|
91
|
+
return slug;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The recipes corpus ships in @calo/backend — prototype-local node_modules first, then
|
|
95
|
+
// the shared runtime (the DS two-step path rule; a folder with no @calo is normal).
|
|
96
|
+
function recipesDir(root) {
|
|
97
|
+
const home = process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
|
|
98
|
+
for (const base of [path.join(root, "node_modules"), path.join(home, "runtime", "node_modules")]) {
|
|
99
|
+
const p = path.join(base, "@calo", "backend", "recipes");
|
|
100
|
+
if (fs.existsSync(p)) return p;
|
|
101
|
+
}
|
|
102
|
+
throw new Error("@calo/backend isn't in the shared runtime yet — run `calo-design update` first");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const listRecipes = (dir) => fs.readdirSync(dir).filter((r) => fs.existsSync(path.join(dir, r, "recipe.json")));
|
|
106
|
+
|
|
107
|
+
// --- verbs ----------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
// Scaffold (recipe → worker/) + provision + write backend.json + glue. One command in
|
|
110
|
+
// three situations: fresh start, reconnect (worker/ exists, backend.json missing —
|
|
111
|
+
// checkpoints carry the code but never the pointer file), and re-run (fully idempotent).
|
|
112
|
+
async function cmdInit(args) {
|
|
113
|
+
const root = process.cwd();
|
|
114
|
+
if (!fs.existsSync(path.join(root, "package.json"))) {
|
|
115
|
+
throw new Error("no package.json here — run `calo-design backend init` from a prototype folder");
|
|
116
|
+
}
|
|
117
|
+
const slug = resolveSlug(args, root);
|
|
118
|
+
const workerDir = path.join(root, "worker");
|
|
119
|
+
const hadWorker = fs.existsSync(workerDir);
|
|
120
|
+
const existing = readBackendJson(root);
|
|
121
|
+
|
|
122
|
+
let recipe = flag(args, "recipe");
|
|
123
|
+
let recipeResources = [];
|
|
124
|
+
if (!hadWorker) {
|
|
125
|
+
const dir = recipesDir(root);
|
|
126
|
+
const available = listRecipes(dir);
|
|
127
|
+
if (!recipe) {
|
|
128
|
+
throw new Error(`pick a recipe: calo-design backend init --recipe <${available.join("|")}>`);
|
|
129
|
+
}
|
|
130
|
+
if (!available.includes(recipe)) {
|
|
131
|
+
throw new Error(`unknown recipe "${recipe}" — available: ${available.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
const meta = JSON.parse(fs.readFileSync(path.join(dir, recipe, "recipe.json"), "utf8"));
|
|
134
|
+
recipeResources = meta.resources || [];
|
|
135
|
+
fs.cpSync(path.join(dir, recipe, "worker"), workerDir, { recursive: true });
|
|
136
|
+
ok(`worker/ scaffolded from the ${c.b(recipe)} recipe`);
|
|
137
|
+
} else {
|
|
138
|
+
log(c.dim(`worker/ already exists — ${existing ? "re-checking provisioning" : "reconnecting it to a provisioned backend"}`));
|
|
139
|
+
recipe = recipe || (existing && existing.recipe) || undefined;
|
|
140
|
+
// Infer the resource need from the code we have: migrations ⇒ D1.
|
|
141
|
+
if (fs.existsSync(path.join(workerDir, "migrations"))) recipeResources = ["d1"];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Provision. When we hold no backend.json we always ask for a key: for a brand-new
|
|
145
|
+
// prototype one is minted anyway; for an existing row (reconnect) this mints a
|
|
146
|
+
// replacement — the old plaintext is gone with the old backend.json, by design.
|
|
147
|
+
const r = await call("POST", "/v1/backend/provision", {
|
|
148
|
+
body: { slug, kind: "worker", recipe, needKey: !existing || undefined },
|
|
149
|
+
});
|
|
150
|
+
for (const kind of recipeResources) {
|
|
151
|
+
await call("POST", "/v1/backend/provision", { body: { slug, kind } });
|
|
152
|
+
ok(`${kind} provisioned`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const email = (loadSession() || {}).email || null;
|
|
156
|
+
writeBackendJson(root, {
|
|
157
|
+
slug,
|
|
158
|
+
owner: r.appKey ? email : (existing && existing.owner) || email,
|
|
159
|
+
workerUrl: r.workerUrl,
|
|
160
|
+
appKey: r.appKey || (existing && existing.appKey),
|
|
161
|
+
deployHash: r.deployHash || null,
|
|
162
|
+
recipe: recipe || r.recipe || null,
|
|
163
|
+
});
|
|
164
|
+
ok(`backend.json written ${c.dim("(git-tracked — commit it)")}`);
|
|
165
|
+
if (r.appKey && r.deployHash) {
|
|
166
|
+
warn("app key was re-minted for this reconnect — run `calo-design backend deploy` to activate it");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Glue: the one sanctioned way app code reaches the backend.
|
|
170
|
+
const glue = path.join(root, "src", "backend.ts");
|
|
171
|
+
if (fs.existsSync(path.join(root, "src")) && !fs.existsSync(glue)) {
|
|
172
|
+
fs.writeFileSync(
|
|
173
|
+
glue,
|
|
174
|
+
`// Generated by \`calo-design backend init\` — app code talks to this prototype's\n` +
|
|
175
|
+
`// backend through here (auth + request-ids handled by @calo/backend).\n` +
|
|
176
|
+
`import { createBackend } from "@calo/backend";\n` +
|
|
177
|
+
`import config from "../backend.json";\n\n` +
|
|
178
|
+
`// user tags rows server-side (x-calo-user; self-reported in v1)\n` +
|
|
179
|
+
`export const backend = createBackend({ ...config, user: config.owner });\n` +
|
|
180
|
+
`export const useBackend = () => backend;\n`
|
|
181
|
+
);
|
|
182
|
+
ok("src/backend.ts glue written");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
log(`\n${c.b("Next:")} edit worker/, then ${c.b("calo-design backend deploy")} → ${c.b("backend check")}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function cmdAdd(args) {
|
|
189
|
+
const kind = (args[0] || "").toLowerCase();
|
|
190
|
+
if (!["d1", "kv", "r2"].includes(kind)) throw new Error("usage: calo-design backend add <d1|kv|r2>");
|
|
191
|
+
const root = process.cwd();
|
|
192
|
+
const slug = resolveSlug(args.slice(1), root);
|
|
193
|
+
const r = await call("POST", "/v1/backend/provision", { body: { slug, kind } });
|
|
194
|
+
ok(`${kind} ready ${c.dim(`(${(r.resources.find((x) => x.kind === kind) || {}).name || ""})`)}`);
|
|
195
|
+
log(c.dim(" binding lands on the next `calo-design backend deploy`"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function cmdDeploy(args) {
|
|
199
|
+
const root = process.cwd();
|
|
200
|
+
const bj = readBackendJson(root);
|
|
201
|
+
if (!bj || !bj.appKey) throw new Error("no backend.json here — run `calo-design backend init` first");
|
|
202
|
+
const workerDir = path.join(root, "worker");
|
|
203
|
+
if (!fs.existsSync(path.join(workerDir, "index.js"))) {
|
|
204
|
+
throw new Error("no worker/index.js — run `calo-design backend init --recipe <name>` to scaffold one");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const tgz = path.join(os.tmpdir(), `calo-backend-${bj.slug}-${process.pid}.tgz`);
|
|
208
|
+
const r = spawnSync("tar", ["-czf", tgz, "-C", workerDir, "."]);
|
|
209
|
+
if (r.status !== 0) throw new Error("tar failed packaging worker/");
|
|
210
|
+
|
|
211
|
+
log(c.b("\n[backend] Deploying worker"));
|
|
212
|
+
try {
|
|
213
|
+
const out = await call("POST", "/v1/backend/deploy", {
|
|
214
|
+
body: fs.readFileSync(tgz),
|
|
215
|
+
timeoutMs: 180000,
|
|
216
|
+
headers: {
|
|
217
|
+
"x-calo-slug": bj.slug,
|
|
218
|
+
"x-calo-parent": bj.deployHash || "none",
|
|
219
|
+
"x-calo-app-key": bj.appKey,
|
|
220
|
+
...(flag(args, "note") ? { "x-calo-note": encodeURIComponent(flag(args, "note")) } : {}),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
writeBackendJson(root, { ...bj, workerUrl: out.url, deployHash: out.deployHash });
|
|
224
|
+
ok(`${out.verified ? "Live" : "deployed (not yet verified — give it a few seconds)"} → ${c.b(out.url)}`);
|
|
225
|
+
if (out.migrations && out.migrations.ran) log(c.dim(" migrations applied"));
|
|
226
|
+
log(c.dim(` next: calo-design backend check`));
|
|
227
|
+
// Pair the deploy with its exact source — best-effort by contract, never fails the deploy.
|
|
228
|
+
await autoCheckpoint({ root, note: `backend deploy ${out.deployHash.slice(0, 8)}`, publishedUrl: out.url });
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(tgz, { force: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function cmdStatus(args) {
|
|
235
|
+
const root = process.cwd();
|
|
236
|
+
const slug = resolveSlug(args, root);
|
|
237
|
+
const text = await call("GET", `/v1/backend/status?slug=${encodeURIComponent(slug)}`, { raw: true });
|
|
238
|
+
if (has(args, "json")) return emitJson(text);
|
|
239
|
+
const s = JSON.parse(text);
|
|
240
|
+
log(`${c.b(s.slug)} ${c.dim(`(${s.base})`)}`);
|
|
241
|
+
log(` worker ${s.workerUrl}${s.deployHash ? "" : c.dim(" (never deployed)")}`);
|
|
242
|
+
if (s.deployHash) log(` deploy ${s.deployHash.slice(0, 12)} ${c.dim(new Date(s.deployedAt).toISOString())}`);
|
|
243
|
+
for (const r of s.resources) log(` ${r.kind.padEnd(8)} ${r.name}`);
|
|
244
|
+
if (!s.resources.length) log(c.dim(" no resources — `calo-design backend add <d1|kv|r2>`"));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const parseSince = (v) => {
|
|
248
|
+
if (!v) return Date.now() - 15 * 60_000;
|
|
249
|
+
const m = /^(\d+)(s|m|h|d)$/.exec(v);
|
|
250
|
+
if (!m) throw new Error("--since wants 30s | 10m | 2h | 1d");
|
|
251
|
+
return Date.now() - Number(m[1]) * { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 }[m[2]];
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
async function cmdLogs(args) {
|
|
255
|
+
const root = process.cwd();
|
|
256
|
+
const slug = resolveSlug(args, root);
|
|
257
|
+
const level = has(args, "errors") ? "error" : undefined;
|
|
258
|
+
const fetchLogs = async (fromMs) => {
|
|
259
|
+
const q = new URLSearchParams({ slug, since: String(fromMs), ...(level ? { level } : {}) });
|
|
260
|
+
return call("GET", `/v1/backend/logs?${q}`, { raw: true });
|
|
261
|
+
};
|
|
262
|
+
if (has(args, "tail")) {
|
|
263
|
+
log(c.dim("tailing (5s poll, Ctrl-C to stop; ingestion lags a few seconds)…"));
|
|
264
|
+
let from = parseSince(flag(args, "since") || "1m");
|
|
265
|
+
for (;;) {
|
|
266
|
+
const body = JSON.parse(await fetchLogs(from));
|
|
267
|
+
for (const e of body.events || []) {
|
|
268
|
+
log(`${c.dim(e.ts ? new Date(e.ts).toISOString() : "")} ${e.level === "error" ? c.y(e.level) : c.dim(e.level || "log")} ${e.message}`);
|
|
269
|
+
if (e.ts) from = Math.max(from, e.ts + 1);
|
|
270
|
+
}
|
|
271
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const text = await fetchLogs(parseSince(flag(args, "since")));
|
|
275
|
+
if (has(args, "json")) return emitJson(text);
|
|
276
|
+
const body = JSON.parse(text);
|
|
277
|
+
if (!body.events.length) return log(c.dim(body.note || "no events"));
|
|
278
|
+
for (const e of body.events) {
|
|
279
|
+
log(`${c.dim(e.ts ? new Date(e.ts).toISOString() : "")} ${e.level === "error" ? c.y(e.level) : c.dim(e.level || "log")} ${e.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function cmdSql(args) {
|
|
284
|
+
const query = args.find((a) => !a.startsWith("--"));
|
|
285
|
+
if (!query) throw new Error('usage: calo-design backend sql "SELECT * FROM items"');
|
|
286
|
+
const root = process.cwd();
|
|
287
|
+
const slug = resolveSlug(args.filter((a) => a !== query), root);
|
|
288
|
+
const text = await call("POST", "/v1/backend/sql", { body: { slug, query }, raw: true });
|
|
289
|
+
if (has(args, "json")) return emitJson(text);
|
|
290
|
+
const body = JSON.parse(text);
|
|
291
|
+
log(JSON.stringify(body.results, null, 2));
|
|
292
|
+
if (body.meta) log(c.dim(` ${body.meta.rows_read ?? "?"} read / ${body.meta.rows_written ?? "?"} written`));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// End-to-end gate: hits the worker's own /__health (directly, not via the broker) with
|
|
296
|
+
// the app key — proving deploy + auth + bindings + migrations + seeds in one round trip.
|
|
297
|
+
async function cmdCheck(args) {
|
|
298
|
+
const root = process.cwd();
|
|
299
|
+
const bj = readBackendJson(root);
|
|
300
|
+
if (!bj || !bj.workerUrl) throw new Error("no backend.json here — run `calo-design backend init` first");
|
|
301
|
+
let res, body;
|
|
302
|
+
// Right after a deploy, two kinds of lag are normal and NOT failures: network-level
|
|
303
|
+
// (route/DNS propagation) and 401/5xx while the fresh APP_KEY secret version reaches
|
|
304
|
+
// running isolates. Retry through both with the reason on screen; a state that
|
|
305
|
+
// persists past the retries is a real failure.
|
|
306
|
+
const TRIES = 6;
|
|
307
|
+
for (let i = 1; ; i++) {
|
|
308
|
+
try {
|
|
309
|
+
res = await fetch(`${bj.workerUrl}/__health`, {
|
|
310
|
+
headers: { authorization: `Bearer ${bj.appKey}` },
|
|
311
|
+
signal: AbortSignal.timeout(15000),
|
|
312
|
+
});
|
|
313
|
+
body = await res.json().catch(() => ({}));
|
|
314
|
+
if (res.ok || i >= TRIES) break;
|
|
315
|
+
log(c.dim(` not healthy yet (HTTP ${res.status}) — retrying ${i}/${TRIES - 1} in 5s (fresh deploys propagate for a few seconds)`));
|
|
316
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
317
|
+
} catch (e) {
|
|
318
|
+
if (i >= TRIES) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`worker unreachable at ${bj.workerUrl} (${e.message}) — deployed yet? try \`calo-design backend deploy\`, ` +
|
|
321
|
+
"then re-run `calo-design backend check`"
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
log(c.dim(` worker not reachable yet (${e.message}) — retrying ${i}/${TRIES - 1} in 5s`));
|
|
325
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (has(args, "json")) return emitJson(JSON.stringify({ status: res.status, ...body }));
|
|
329
|
+
const checks = body.checks || {};
|
|
330
|
+
for (const [k, v] of Object.entries(checks)) {
|
|
331
|
+
if (v === null) log(` ${c.dim("–")} ${k} ${c.dim("(not provisioned)")}`);
|
|
332
|
+
else if (v === false) log(` ${c.y("✗")} ${k}`);
|
|
333
|
+
else log(` ${c.g("✓")} ${k}${typeof v === "number" ? c.dim(` (${v})`) : ""}`);
|
|
334
|
+
}
|
|
335
|
+
if (res.ok && body.ok) ok("backend healthy");
|
|
336
|
+
else {
|
|
337
|
+
warn(`health check failed (HTTP ${res.status}) — \`calo-design backend logs --errors\` has the story`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function cmdSecrets(args) {
|
|
343
|
+
if (args[0] !== "set") throw new Error("usage: calo-design backend secrets set <KEY> --value <v>");
|
|
344
|
+
const key = args[1];
|
|
345
|
+
const value = flag(args, "value");
|
|
346
|
+
if (!key || !value) throw new Error("usage: calo-design backend secrets set <KEY> --value <v>");
|
|
347
|
+
const root = process.cwd();
|
|
348
|
+
const slug = resolveSlug(args.slice(2), root);
|
|
349
|
+
await call("POST", "/v1/backend/secret", { body: { slug, key, value } });
|
|
350
|
+
ok(`${key} set ${c.dim("(worker reads it as env." + key + ")")}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function cmdRotateKey(args) {
|
|
354
|
+
const root = process.cwd();
|
|
355
|
+
const bj = readBackendJson(root);
|
|
356
|
+
if (!bj) throw new Error("no backend.json here — run `calo-design backend init` first");
|
|
357
|
+
const r = await call("POST", "/v1/backend/rotate-key", { body: { slug: bj.slug } });
|
|
358
|
+
writeBackendJson(root, { ...bj, appKey: r.appKey });
|
|
359
|
+
ok(`app key rotated${r.live ? "" : " (activates on next deploy)"}`);
|
|
360
|
+
warn("published web shares still carry the OLD key baked into their bundle — re-run `calo-design share` to fix them");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function cmdBackend(args) {
|
|
364
|
+
const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
|
|
365
|
+
const rest = args.slice(1);
|
|
366
|
+
if (sub === "init") return cmdInit(rest);
|
|
367
|
+
if (sub === "add") return cmdAdd(rest);
|
|
368
|
+
if (sub === "deploy") return cmdDeploy(rest);
|
|
369
|
+
if (sub === "status") return cmdStatus(rest);
|
|
370
|
+
if (sub === "logs") return cmdLogs(rest);
|
|
371
|
+
if (sub === "sql") return cmdSql(rest);
|
|
372
|
+
if (sub === "check") return cmdCheck(rest);
|
|
373
|
+
if (sub === "secrets") return cmdSecrets(rest);
|
|
374
|
+
if (sub === "rotate-key") return cmdRotateKey(rest);
|
|
375
|
+
throw new Error(
|
|
376
|
+
"unknown backend command — one of: init, add <d1|kv|r2>, deploy, status, logs, sql, check, secrets set, rotate-key"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = { cmdBackend, _resolveSlug: resolveSlug, _parseSince: parseSince, _readBackendJson: readBackendJson };
|
package/bin/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ const { cmdImage } = require("./imagegen");
|
|
|
22
22
|
const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
|
|
23
23
|
const { cmdFeedback, cmdTasks } = require("./feedback");
|
|
24
24
|
const { cmdSave, cmdOpen, cmdProjects } = require("./checkpoints");
|
|
25
|
+
const { cmdBackend } = require("./backend");
|
|
25
26
|
|
|
26
27
|
const ORG = "Calo-Design";
|
|
27
28
|
const SKILL_REPO = `${ORG}/calo-design`;
|
|
@@ -52,7 +53,8 @@ const PEERS = [
|
|
|
52
53
|
const PKG_SPECS = [
|
|
53
54
|
`git+https://github.com/${ORG}/calo-design-system.git`,
|
|
54
55
|
`git+https://github.com/${ORG}/calo-flows.git`,
|
|
55
|
-
`git+https://github.com/${ORG}/calo-creatives.git
|
|
56
|
+
`git+https://github.com/${ORG}/calo-creatives.git`,
|
|
57
|
+
`git+https://github.com/${ORG}/calo-backend.git`
|
|
56
58
|
];
|
|
57
59
|
|
|
58
60
|
const args = process.argv.slice(2);
|
|
@@ -150,6 +152,8 @@ function runtimeExists() {
|
|
|
150
152
|
fs.existsSync(runtimeManifestPath()) &&
|
|
151
153
|
fs.existsSync(path.join(rt, "node_modules", "@calo", "design-system", "package.json")) &&
|
|
152
154
|
fs.existsSync(path.join(rt, "node_modules", "@calo", "flows", "package.json")) &&
|
|
155
|
+
fs.existsSync(path.join(rt, "node_modules", "@calo", "creatives", "package.json")) &&
|
|
156
|
+
fs.existsSync(path.join(rt, "node_modules", "@calo", "backend", "package.json")) &&
|
|
153
157
|
fs.existsSync(path.join(rt, "node_modules", "expo", "package.json"))
|
|
154
158
|
);
|
|
155
159
|
}
|
|
@@ -786,6 +790,8 @@ function help() {
|
|
|
786
790
|
${c.dim(" save --note \"what changed\" --slug x --force --json")}
|
|
787
791
|
${c.dim("open <owner>/<slug>[@v] [folder]")} unpack a teammate's checkpoint; your first save becomes your own fork
|
|
788
792
|
${c.dim("projects")} all shared prototypes (--mine, --json)
|
|
793
|
+
${c.dim("backend init --recipe <name>")} provision THIS prototype a real backend (Cloudflare worker + data; login only)
|
|
794
|
+
${c.dim(" backend add <d1|kv|r2> deploy status logs --since 10m --errors sql \"…\" check secrets set K --value v rotate-key")}
|
|
789
795
|
${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
|
|
790
796
|
${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
|
|
791
797
|
${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
|
|
@@ -819,6 +825,7 @@ function help() {
|
|
|
819
825
|
else if (cmd === "save") await cmdSave(args.slice(1));
|
|
820
826
|
else if (cmd === "open") await cmdOpen(args.slice(1));
|
|
821
827
|
else if (cmd === "projects") await cmdProjects(args.slice(1));
|
|
828
|
+
else if (cmd === "backend") await cmdBackend(args.slice(1));
|
|
822
829
|
else help();
|
|
823
830
|
} catch (err) {
|
|
824
831
|
// Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calo-design/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"calo-design": "bin/cli.js"
|