@bytesbrains/cruise 0.0.0-stage → 0.12.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.
Files changed (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -2
  3. package/dist/budget-FIRNSN67.mjs +132 -0
  4. package/dist/chunk-2FB2T3UI.mjs +122 -0
  5. package/dist/chunk-2NVDAK5J.mjs +76 -0
  6. package/dist/chunk-42ERHAJD.mjs +79 -0
  7. package/dist/chunk-4447IUTF.mjs +7 -0
  8. package/dist/chunk-5TVD3ZAI.mjs +69 -0
  9. package/dist/chunk-7NQHBXVZ.mjs +104 -0
  10. package/dist/chunk-BNP63KB2.mjs +42 -0
  11. package/dist/chunk-FGLMW5IY.mjs +575 -0
  12. package/dist/chunk-GUZNI3NS.mjs +267 -0
  13. package/dist/chunk-HQDQTXRX.mjs +57 -0
  14. package/dist/chunk-ICJVJSTC.mjs +42 -0
  15. package/dist/chunk-JZPUTXM7.mjs +236 -0
  16. package/dist/chunk-LALZUKEC.mjs +137 -0
  17. package/dist/chunk-N3YKWKOA.mjs +9 -0
  18. package/dist/chunk-O7TNRKSA.mjs +2 -0
  19. package/dist/chunk-RT4R2W6H.mjs +21 -0
  20. package/dist/chunk-SYTYOTO4.mjs +18 -0
  21. package/dist/chunk-T3ICD43U.mjs +15 -0
  22. package/dist/chunk-THEXUHQC.mjs +98 -0
  23. package/dist/chunk-UNXM6FSM.mjs +83 -0
  24. package/dist/chunk-WS6NFRIU.mjs +66 -0
  25. package/dist/chunk-XOH3XKM3.mjs +126 -0
  26. package/dist/compress-3DWG35BY.mjs +49 -0
  27. package/dist/conformance-NPFDYCQL.mjs +922 -0
  28. package/dist/cruise.mjs +693 -0
  29. package/dist/fallback-P5KK55M4.mjs +139 -0
  30. package/dist/keys-FWPQPMTW.mjs +127 -0
  31. package/dist/ledger-XBGWXY6C.mjs +97 -0
  32. package/dist/member-ZBV4YG2M.mjs +74 -0
  33. package/dist/report-VRYJ3W5L.mjs +414 -0
  34. package/dist/service-key-N2RZ7FMZ.mjs +59 -0
  35. package/dist/smoke-ZNYX6RO5.mjs +105 -0
  36. package/dist/sweep-SZX7OIDK.mjs +33 -0
  37. package/dist/tenant-4YDGI23Q.mjs +122 -0
  38. package/dist/tier-4L3SPLFP.mjs +116 -0
  39. package/package.json +38 -4
  40. package/templates/docker-compose.yml +66 -0
  41. package/templates/env.example +44 -0
@@ -0,0 +1,693 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+ import {
4
+ issueKey,
5
+ revokeKey
6
+ } from "./chunk-2FB2T3UI.mjs";
7
+ import {
8
+ provisionTenant
9
+ } from "./chunk-GUZNI3NS.mjs";
10
+ import "./chunk-N3YKWKOA.mjs";
11
+ import "./chunk-2NVDAK5J.mjs";
12
+ import "./chunk-FGLMW5IY.mjs";
13
+ import "./chunk-4447IUTF.mjs";
14
+ import "./chunk-XOH3XKM3.mjs";
15
+ import "./chunk-42ERHAJD.mjs";
16
+ import "./chunk-BNP63KB2.mjs";
17
+ import "./chunk-ICJVJSTC.mjs";
18
+ import "./chunk-UNXM6FSM.mjs";
19
+ import "./chunk-SYTYOTO4.mjs";
20
+ import "./chunk-O7TNRKSA.mjs";
21
+ import "./chunk-T3ICD43U.mjs";
22
+ import "./chunk-RT4R2W6H.mjs";
23
+ import "./chunk-THEXUHQC.mjs";
24
+ import "./chunk-LALZUKEC.mjs";
25
+ import "./chunk-5TVD3ZAI.mjs";
26
+ import {
27
+ HttpD1,
28
+ LocalD1
29
+ } from "./chunk-JZPUTXM7.mjs";
30
+
31
+ // selfhost/commands.ts
32
+ var COMMANDS = {
33
+ "key:issue": { load: () => import("./keys-FWPQPMTW.mjs"), args: ["issue"] },
34
+ "key:revoke": { load: () => import("./keys-FWPQPMTW.mjs"), args: ["revoke"] },
35
+ "key:list": { load: () => import("./keys-FWPQPMTW.mjs"), args: ["list"] },
36
+ "budget:show": { load: () => import("./budget-FIRNSN67.mjs"), args: ["show"] },
37
+ "budget:set": { load: () => import("./budget-FIRNSN67.mjs"), args: ["set"] },
38
+ "budget:align": { load: () => import("./budget-FIRNSN67.mjs"), args: ["align"] },
39
+ "tenant:provision": { load: () => import("./tenant-4YDGI23Q.mjs"), args: ["provision"] },
40
+ "tenant:list": { load: () => import("./tenant-4YDGI23Q.mjs"), args: ["list"] },
41
+ "member:grant": { load: () => import("./member-ZBV4YG2M.mjs"), args: ["grant"] },
42
+ "member:revoke": { load: () => import("./member-ZBV4YG2M.mjs"), args: ["revoke"] },
43
+ "member:list": { load: () => import("./member-ZBV4YG2M.mjs"), args: ["list"] },
44
+ "service-key:issue": { load: () => import("./service-key-N2RZ7FMZ.mjs"), args: ["issue"] },
45
+ "fallback:show": { load: () => import("./fallback-P5KK55M4.mjs"), args: ["show"] },
46
+ "fallback:set": { load: () => import("./fallback-P5KK55M4.mjs"), args: ["set"] },
47
+ "tier:show": { load: () => import("./tier-4L3SPLFP.mjs"), args: ["show"] },
48
+ "tier:set": { load: () => import("./tier-4L3SPLFP.mjs"), args: ["set"] },
49
+ "compress:show": { load: () => import("./compress-3DWG35BY.mjs"), args: ["show"] },
50
+ "compress:set": { load: () => import("./compress-3DWG35BY.mjs"), args: ["set"] },
51
+ ledger: { load: () => import("./ledger-XBGWXY6C.mjs"), args: [] },
52
+ report: { load: () => import("./report-VRYJ3W5L.mjs"), args: [] },
53
+ conformance: { load: () => import("./conformance-NPFDYCQL.mjs"), args: [] },
54
+ sweep: { load: () => import("./sweep-SZX7OIDK.mjs"), args: [] },
55
+ smoke: { load: () => import("./smoke-ZNYX6RO5.mjs"), args: [] }
56
+ };
57
+
58
+ // selfhost/measure.ts
59
+ import { spawn } from "node:child_process";
60
+
61
+ // selfhost/measure-select.ts
62
+ function credentialName(upstreamId) {
63
+ return `PROVIDER_KEY_${upstreamId.toUpperCase().replace(/-/g, "_")}`;
64
+ }
65
+ async function keyedModels(db, env) {
66
+ if (!Object.keys(env).some((name) => name.startsWith("PROVIDER_KEY_") && env[name])) return null;
67
+ const { results } = await db.prepare(
68
+ `SELECT m.id, m.upstream_id AS upstream, u.credential_required AS needsKey
69
+ FROM models m JOIN upstreams u ON u.id = m.upstream_id
70
+ WHERE m.enabled = 1 AND u.enabled = 1
71
+ ORDER BY m.id`
72
+ ).all();
73
+ return results.filter((m) => m.needsKey === 0 || Boolean(env[credentialName(m.upstream)])).map((m) => m.id);
74
+ }
75
+
76
+ // selfhost/measure.ts
77
+ var ACTOR = "cruise-measure";
78
+ function flag(argv, name) {
79
+ const i = argv.indexOf(`--${name}`);
80
+ return i >= 0 ? argv[i + 1] : void 0;
81
+ }
82
+ async function operatorProject(db) {
83
+ const row = await db.prepare(
84
+ `SELECT p.id AS projectId, p.tenant_id AS tenantId
85
+ FROM projects p JOIN tenants t ON t.id = p.tenant_id
86
+ WHERE t.operator = 1
87
+ ORDER BY p.created_at, p.id LIMIT 1`
88
+ ).first();
89
+ if (!row) throw new Error("No operator tenant with a project. Has the server finished its first boot?");
90
+ return row;
91
+ }
92
+ async function projectById(db, projectId) {
93
+ const row = await db.prepare(`SELECT id AS projectId, tenant_id AS tenantId FROM projects WHERE id = ?`).bind(projectId).first();
94
+ if (!row) throw new Error(`No project ${projectId}`);
95
+ return row;
96
+ }
97
+ function localTarget() {
98
+ return {
99
+ db: new LocalD1(),
100
+ environment: "local",
101
+ base: `http://localhost:${process.env.CRUISE_PORT || 8787}`
102
+ };
103
+ }
104
+ async function measure(argv, target = localTarget()) {
105
+ const { db } = target;
106
+ const named = flag(argv, "project");
107
+ const { tenantId, projectId } = named ? await projectById(db, named) : await operatorProject(db);
108
+ const base = flag(argv, "base") ?? target.base;
109
+ const selection = [];
110
+ if (!argv.includes("--models")) {
111
+ const keyed = await keyedModels(db, process.env);
112
+ if (keyed !== null && keyed.length === 0) {
113
+ throw new Error("No enabled model has a provider key set. Add a PROVIDER_KEY_* to .env and restart.");
114
+ }
115
+ if (keyed !== null) {
116
+ console.log(`${keyed.length} model(s) from the providers you hold keys for`);
117
+ selection.push("--models", keyed.join(","));
118
+ }
119
+ }
120
+ const issued = await issueKey(db, {
121
+ tenantId,
122
+ projectId,
123
+ environment: "live",
124
+ actor: ACTOR,
125
+ name: "cruise measure (revoked after the run)",
126
+ conformance: true
127
+ });
128
+ console.log(`measuring through ${base} as ${projectId}, with a conformance key revoked afterwards`);
129
+ const passthrough = [...selection];
130
+ for (let i = 0; i < argv.length; i++) {
131
+ if (argv[i] === "--project" || argv[i] === "--base") i++;
132
+ else passthrough.push(argv[i]);
133
+ }
134
+ const self = process.argv[1];
135
+ if (!self) throw new Error("cannot find the cruise CLI to run conformance with");
136
+ try {
137
+ const code = await new Promise((resolve, reject) => {
138
+ const child = spawn(
139
+ process.execPath,
140
+ [self, "conformance", "--env", target.environment, "--base", base, "--key", issued.key, ...passthrough],
141
+ { stdio: "inherit" }
142
+ );
143
+ child.on("error", reject);
144
+ child.on("exit", (exit) => resolve(exit ?? 1));
145
+ });
146
+ process.exitCode = code;
147
+ } finally {
148
+ await revokeKey(db, { tenantId, prefix: issued.prefix, actor: ACTOR, reason: "cruise measure finished" });
149
+ }
150
+ }
151
+
152
+ // packages/cli/src/cloudflare.ts
153
+ import { randomBytes as randomBytes2 } from "node:crypto";
154
+ import { spawn as spawn2 } from "node:child_process";
155
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
156
+ import path5 from "node:path";
157
+
158
+ // selfhost/bootstrap.ts
159
+ var DIRECT_BASE_URLS = {
160
+ openai: "https://api.openai.com/v1",
161
+ anthropic: "https://api.anthropic.com/v1",
162
+ "google-ai-studio": "https://generativelanguage.googleapis.com/v1beta/openai",
163
+ deepseek: "https://api.deepseek.com",
164
+ mistral: "https://api.mistral.ai/v1",
165
+ groq: "https://api.groq.com/openai/v1",
166
+ grok: "https://api.x.ai/v1",
167
+ openrouter: "https://openrouter.ai/api/v1"
168
+ };
169
+ var UNREACHABLE_UPSTREAMS = ["workers-ai"];
170
+ var ACTOR2 = "selfhost-bootstrap";
171
+ async function directUpstreams(db) {
172
+ const statements = [
173
+ ...Object.entries(DIRECT_BASE_URLS).map(
174
+ ([id, url]) => db.prepare(`UPDATE upstreams SET base_url = ? WHERE id = ? AND base_url IS NULL`).bind(url, id)
175
+ ),
176
+ ...UNREACHABLE_UPSTREAMS.map(
177
+ (id) => db.prepare(`UPDATE upstreams SET enabled = 0 WHERE id = ? AND enabled = 1`).bind(id)
178
+ )
179
+ ];
180
+ const results = await db.batch(statements);
181
+ return results.reduce((sum, r) => sum + (r.meta.changes ?? 0), 0);
182
+ }
183
+ async function firstTenant(db, request) {
184
+ const { tenantId, name, dailyCapMicros, operatorUid } = request;
185
+ const tenant = await db.prepare(
186
+ `SELECT t.operator, m.uid AS owner
187
+ FROM tenants t
188
+ LEFT JOIN memberships m ON m.tenant_id = t.id AND m.uid = ? AND m.role = 'owner'
189
+ WHERE t.id = ?`
190
+ ).bind(operatorUid, tenantId).first();
191
+ if (!tenant) {
192
+ const existing = await db.prepare(`SELECT COUNT(*) AS n FROM tenants`).first();
193
+ if ((existing?.n ?? 0) > 0) return null;
194
+ await provisionTenant(db, { id: tenantId, name, ownerUid: operatorUid, hardMicros: dailyCapMicros }, ACTOR2);
195
+ } else if (tenant.owner === null) {
196
+ return null;
197
+ }
198
+ if (!tenant || tenant.operator !== 1) {
199
+ await db.prepare(`UPDATE tenants SET operator = 1 WHERE id = ?`).bind(tenantId).run();
200
+ }
201
+ const project = await db.prepare(`SELECT id FROM projects WHERE tenant_id = ? ORDER BY created_at, id LIMIT 1`).bind(tenantId).first();
202
+ if (!project) throw new Error(`tenant ${tenantId} has no project to issue a key for`);
203
+ const issuedBefore = await db.prepare(`SELECT COUNT(*) AS n FROM virtual_keys WHERE project_id = ?`).bind(project.id).first();
204
+ if ((issuedBefore?.n ?? 0) > 0) return null;
205
+ const issued = await issueKey(db, {
206
+ projectId: project.id,
207
+ tenantId,
208
+ environment: "live",
209
+ actor: ACTOR2,
210
+ name: "first key"
211
+ });
212
+ return { tenantId, projectId: project.id, key: issued.key };
213
+ }
214
+
215
+ // selfhost/config.ts
216
+ import { randomBytes } from "node:crypto";
217
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
218
+ import path from "node:path";
219
+ var UNSET_PROJECT_ID = "unset_selfhosted";
220
+
221
+ // packages/cli/src/paths.ts
222
+ import { existsSync as existsSync2 } from "node:fs";
223
+ import { createRequire } from "node:module";
224
+ import { homedir } from "node:os";
225
+ import path2 from "node:path";
226
+ function cruiseHome() {
227
+ return path2.resolve(process.env.CRUISE_HOME || path2.join(homedir(), ".cruise"));
228
+ }
229
+ function packageRoot() {
230
+ return path2.join(import.meta.dirname, "..");
231
+ }
232
+ function templatesDir() {
233
+ return path2.join(packageRoot(), "templates");
234
+ }
235
+ function workerPackageDir() {
236
+ try {
237
+ const require2 = createRequire(import.meta.url);
238
+ return path2.dirname(require2.resolve("@bytesbrains/cruise-worker/package.json"));
239
+ } catch {
240
+ const sibling = path2.join(packageRoot(), "..", "worker");
241
+ if (existsSync2(path2.join(sibling, "package.json"))) return sibling;
242
+ throw new Error("@bytesbrains/cruise-worker is not installed; reinstall @bytesbrains/cruise.");
243
+ }
244
+ }
245
+
246
+ // packages/cli/src/wrangler-config.ts
247
+ import path3 from "node:path";
248
+
249
+ // selfhost/worker-config.ts
250
+ var COMPATIBILITY_DATE = "2026-08-13";
251
+ var COMPATIBILITY_FLAGS = ["nodejs_compat"];
252
+
253
+ // packages/cli/src/wrangler-config.ts
254
+ function wranglerConfig(d) {
255
+ const worker = path3.join(workerPackageDir(), "worker");
256
+ return {
257
+ name: d.name,
258
+ main: path3.join(worker, "index.js"),
259
+ base_dir: worker,
260
+ no_bundle: true,
261
+ find_additional_modules: true,
262
+ rules: [{ type: "Data", globs: ["**/*.png"], fallthrough: true }],
263
+ compatibility_date: COMPATIBILITY_DATE,
264
+ compatibility_flags: COMPATIBILITY_FLAGS,
265
+ account_id: d.accountId,
266
+ workers_dev: true,
267
+ observability: { enabled: true, redact_query_string: true },
268
+ d1_databases: [
269
+ {
270
+ binding: "DB",
271
+ database_name: d.databaseName,
272
+ database_id: d.databaseId,
273
+ migrations_dir: path3.join(workerPackageDir(), "migrations")
274
+ }
275
+ ],
276
+ kv_namespaces: [{ binding: "CACHE", id: d.kvId }],
277
+ r2_buckets: [{ binding: "BATCH_FILES", bucket_name: d.bucket }],
278
+ workflows: [{ name: `${d.name}-batch`, binding: "BATCH_WORKFLOW", class_name: "BatchWorkflow" }],
279
+ analytics_engine_datasets: [{ binding: "METRICS", dataset: `${d.name.replace(/-/g, "_")}_requests` }],
280
+ version_metadata: { binding: "CF_VERSION_METADATA" },
281
+ vars: {
282
+ ENVIRONMENT: "selfhosted",
283
+ CF_ACCOUNT_ID: d.accountId,
284
+ GATEWAY_SENSITIVE: "gw-sensitive",
285
+ GATEWAY_INTERNAL: "gw-internal",
286
+ DASHBOARD_ORIGIN: "http://localhost:5173",
287
+ CACHE_MODE: "lane",
288
+ COMPRESS_MODE: "on",
289
+ BATCH_MODE: "on"
290
+ }
291
+ };
292
+ }
293
+
294
+ // packages/cli/src/version.ts
295
+ import { readFileSync as readFileSync2 } from "node:fs";
296
+ import path4 from "node:path";
297
+ var pkg = JSON.parse(readFileSync2(path4.join(packageRoot(), "package.json"), "utf8"));
298
+ var VERSION = pkg.version;
299
+ var WRANGLER_VERSION = pkg.cruise?.wrangler ?? "4";
300
+
301
+ // packages/cli/src/cloudflare.ts
302
+ var API = "https://api.cloudflare.com/client/v4";
303
+ function token() {
304
+ const value = process.env.CLOUDFLARE_API_TOKEN;
305
+ if (!value) {
306
+ throw new Error(
307
+ "Set CLOUDFLARE_API_TOKEN: a token with Workers Scripts, D1, Workers KV Storage and Workers R2 Storage edit, and Account Settings read (dash.cloudflare.com/profile/api-tokens)."
308
+ );
309
+ }
310
+ return value;
311
+ }
312
+ async function api(route, init2 = {}) {
313
+ const response = await fetch(`${API}${route}`, {
314
+ ...init2,
315
+ headers: { authorization: `Bearer ${token()}`, "content-type": "application/json", ...init2.headers }
316
+ });
317
+ const body = await response.json().catch(() => ({}));
318
+ return {
319
+ status: response.status,
320
+ result: response.ok ? body.result ?? null : null,
321
+ errors: (body.errors ?? []).map((e) => e.message).join("; ") || String(response.status)
322
+ };
323
+ }
324
+ async function must(route, init2 = {}) {
325
+ const { result, errors } = await api(route, init2);
326
+ if (result === null) throw new Error(`Cloudflare API ${init2.method ?? "GET"} ${route}: ${errors}`);
327
+ return result;
328
+ }
329
+ function deploymentDir(name) {
330
+ return path5.join(cruiseHome(), "cloudflare", name);
331
+ }
332
+ function loadDeployment(name) {
333
+ const file = path5.join(deploymentDir(name), "state.json");
334
+ if (!existsSync3(file)) throw new Error(`No deployment named ${name}: run \`cruise cloudflare deploy --name ${name}\` first.`);
335
+ return JSON.parse(readFileSync3(file, "utf8"));
336
+ }
337
+ function save(deployment) {
338
+ writeFileSync2(path5.join(deploymentDir(deployment.name), "state.json"), `${JSON.stringify(deployment, null, 2)}
339
+ `);
340
+ }
341
+ async function account() {
342
+ if (process.env.CLOUDFLARE_ACCOUNT_ID) return process.env.CLOUDFLARE_ACCOUNT_ID;
343
+ const accounts = await must("/accounts?per_page=50");
344
+ if (accounts.length === 1) return accounts[0].id;
345
+ throw new Error(
346
+ `This token reaches ${accounts.length} accounts; set CLOUDFLARE_ACCOUNT_ID to one of: ` + accounts.map((a) => `${a.id} (${a.name})`).join(", ")
347
+ );
348
+ }
349
+ async function ensureDatabase(accountId, name) {
350
+ const found = await must(`/accounts/${accountId}/d1/database?name=${encodeURIComponent(name)}`);
351
+ const match = found.find((db) => db.name === name);
352
+ if (match) return match.uuid;
353
+ const created = await must(`/accounts/${accountId}/d1/database`, {
354
+ method: "POST",
355
+ body: JSON.stringify({ name })
356
+ });
357
+ return created.uuid;
358
+ }
359
+ async function ensureKv(accountId, title) {
360
+ const found = await must(`/accounts/${accountId}/storage/kv/namespaces?per_page=100`);
361
+ const match = found.find((ns) => ns.title === title);
362
+ if (match) return match.id;
363
+ const created = await must(`/accounts/${accountId}/storage/kv/namespaces`, {
364
+ method: "POST",
365
+ body: JSON.stringify({ title })
366
+ });
367
+ return created.id;
368
+ }
369
+ async function ensureBucket(accountId, name) {
370
+ const { status } = await api(`/accounts/${accountId}/r2/buckets/${name}`);
371
+ if (status === 200) return;
372
+ await must(`/accounts/${accountId}/r2/buckets`, { method: "POST", body: JSON.stringify({ name }) });
373
+ }
374
+ function wrangler(args, cwd) {
375
+ return new Promise((resolve, reject) => {
376
+ const child = spawn2("npx", ["--yes", `wrangler@${WRANGLER_VERSION}`, ...args, "-c", path5.join(cwd, "wrangler.jsonc")], {
377
+ cwd,
378
+ stdio: "inherit",
379
+ // CI=true answers wrangler's confirmation prompts, which a migration apply asks.
380
+ env: { ...process.env, CI: "true", WRANGLER_SEND_METRICS: "false" }
381
+ });
382
+ child.on("error", reject);
383
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`wrangler ${args[0]} exited ${code}`)));
384
+ });
385
+ }
386
+ function readEnvFile(file) {
387
+ if (!file) return {};
388
+ const out = {};
389
+ for (const line of readFileSync3(file, "utf8").split("\n")) {
390
+ const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
391
+ if (m && m[2]) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
392
+ }
393
+ return out;
394
+ }
395
+ function secrets(dir, envFile) {
396
+ const file = path5.join(dir, "secrets.json");
397
+ const kept = existsSync3(file) ? JSON.parse(readFileSync3(file, "utf8")) : {};
398
+ const generated = {
399
+ ACCOUNT_ID_SALT: kept.ACCOUNT_ID_SALT ?? randomBytes2(32).toString("hex"),
400
+ WEBHOOK_SIGNING_KEY: kept.WEBHOOK_SIGNING_KEY ?? randomBytes2(32).toString("hex")
401
+ };
402
+ const operatorUid = kept.OPERATOR_UID ?? `selfhosted-${randomBytes2(16).toString("hex")}`;
403
+ writeFileSync2(file, `${JSON.stringify({ ...generated, OPERATOR_UID: operatorUid }, null, 2)}
404
+ `, { mode: 384 });
405
+ chmodSync(file, 384);
406
+ const providers = Object.fromEntries(
407
+ Object.entries({ ...envFile, ...process.env }).filter(
408
+ (entry) => /^PROVIDER_(BILLING_)?KEY_/.test(entry[0]) && Boolean(entry[1])
409
+ )
410
+ );
411
+ return {
412
+ ...generated,
413
+ AI_GATEWAY_TOKEN: "unused-selfhosted",
414
+ FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID || envFile.FIREBASE_PROJECT_ID || UNSET_PROJECT_ID,
415
+ ...providers
416
+ };
417
+ }
418
+ function flag2(argv, name) {
419
+ const i = argv.indexOf(`--${name}`);
420
+ return i >= 0 ? argv[i + 1] : void 0;
421
+ }
422
+ async function deploy(argv) {
423
+ const name = flag2(argv, "name") ?? "cruise";
424
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(name)) throw new Error(`--name must be lowercase letters, digits and dashes: ${name}`);
425
+ const dir = deploymentDir(name);
426
+ mkdirSync2(dir, { recursive: true });
427
+ console.log(`\u203A account`);
428
+ const accountId = await account();
429
+ console.log(`\u203A database, cache and bucket for ${name}`);
430
+ const d = {
431
+ name,
432
+ accountId,
433
+ databaseName: name,
434
+ databaseId: await ensureDatabase(accountId, name),
435
+ kvId: await ensureKv(accountId, `${name}-cache`),
436
+ bucket: `${name}-batch-files`
437
+ };
438
+ await ensureBucket(accountId, d.bucket);
439
+ save(d);
440
+ writeFileSync2(path5.join(dir, "wrangler.jsonc"), `${JSON.stringify(wranglerConfig(d), null, 2)}
441
+ `);
442
+ console.log(`\u203A migrations, before the Worker that reads them`);
443
+ await wrangler(["d1", "migrations", "apply", d.databaseName, "--remote"], dir);
444
+ console.log(`\u203A Worker`);
445
+ await wrangler(["deploy"], dir);
446
+ console.log(`\u203A secrets`);
447
+ const bulk = path5.join(dir, "secrets.upload.json");
448
+ writeFileSync2(bulk, JSON.stringify(secrets(dir, readEnvFile(flag2(argv, "env-file")))), { mode: 384 });
449
+ try {
450
+ await wrangler(["secret", "bulk", bulk], dir);
451
+ } finally {
452
+ rmSync(bulk, { force: true });
453
+ }
454
+ console.log(`\u203A bootstrap`);
455
+ const db = new HttpD1(accountId, d.databaseId, token());
456
+ await directUpstreams(db);
457
+ const { OPERATOR_UID: operatorUid } = JSON.parse(readFileSync3(path5.join(dir, "secrets.json"), "utf8"));
458
+ const first = await firstTenant(db, {
459
+ tenantId: flag2(argv, "tenant") ?? "local",
460
+ name: "Local",
461
+ dailyCapMicros: 5e6,
462
+ operatorUid
463
+ });
464
+ const sub = await api(`/accounts/${accountId}/workers/subdomain`);
465
+ if (sub.result?.subdomain) d.url = `https://${name}.${sub.result.subdomain}.workers.dev`;
466
+ save(d);
467
+ console.log(`
468
+ Deployed ${name}${d.url ? ` at ${d.url}` : " (enable a workers.dev subdomain in the dashboard to reach it)"}.`);
469
+ if (first) {
470
+ console.log(`
471
+ key ${first.key}
472
+
473
+ This is the only time this key is shown. base_url: ${d.url ?? "<your url>"}/v1`);
474
+ }
475
+ console.log(`
476
+ Next: cruise cloudflare measure --name ${name}`);
477
+ }
478
+
479
+ // packages/cli/src/docker.ts
480
+ import { spawn as spawn3, spawnSync } from "node:child_process";
481
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
482
+ import path6 from "node:path";
483
+ import { createInterface } from "node:readline";
484
+ function dockerDir() {
485
+ return path6.join(cruiseHome(), "docker");
486
+ }
487
+ function composeArgs() {
488
+ const dir = dockerDir();
489
+ return ["compose", "--project-directory", dir, "-f", path6.join(dir, "docker-compose.yml")];
490
+ }
491
+ function requireDocker() {
492
+ const probe = spawnSync("docker", ["compose", "version"], { stdio: "ignore" });
493
+ if (probe.status !== 0) {
494
+ throw new Error("Docker with the compose plugin is required: https://docs.docker.com/get-docker/");
495
+ }
496
+ }
497
+ function compose(args, options = {}) {
498
+ return new Promise((resolve, reject) => {
499
+ const child = spawn3("docker", [...composeArgs(), ...args], {
500
+ stdio: options.capture ? ["ignore", "pipe", "inherit"] : "inherit"
501
+ });
502
+ let out = "";
503
+ child.stdout?.on("data", (chunk) => out += chunk.toString());
504
+ child.on("error", reject);
505
+ child.on("exit", (code) => resolve({ code: code ?? 1, out }));
506
+ });
507
+ }
508
+ async function must2(args) {
509
+ const { code } = await compose(args);
510
+ if (code !== 0) throw new Error(`docker ${["compose", ...args].join(" ")} exited ${code}`);
511
+ }
512
+ function askHidden(question) {
513
+ return new Promise((resolve) => {
514
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
515
+ const write = rl._writeToOutput;
516
+ rl._writeToOutput = (s) => {
517
+ if (s.startsWith(question)) write.call(rl, s);
518
+ };
519
+ rl.question(question, (answer) => {
520
+ rl.close();
521
+ process.stdout.write("\n");
522
+ resolve(answer.trim());
523
+ });
524
+ });
525
+ }
526
+ var PROVIDERS = [
527
+ ["PROVIDER_KEY_OPENAI", "OpenAI"],
528
+ ["PROVIDER_KEY_ANTHROPIC", "Anthropic"],
529
+ ["PROVIDER_KEY_GOOGLE_AI_STUDIO", "Google AI Studio"],
530
+ ["PROVIDER_KEY_DEEPSEEK", "DeepSeek"],
531
+ ["PROVIDER_KEY_MISTRAL", "Mistral"],
532
+ ["PROVIDER_KEY_GROQ", "Groq"],
533
+ ["PROVIDER_KEY_GROK", "xAI"]
534
+ ];
535
+ async function init(argv) {
536
+ const dir = dockerDir();
537
+ mkdirSync3(dir, { recursive: true });
538
+ const compose2 = readFileSync4(path6.join(templatesDir(), "docker-compose.yml"), "utf8");
539
+ writeFileSync3(path6.join(dir, "docker-compose.yml"), compose2);
540
+ const envFile = path6.join(dir, ".env");
541
+ if (existsSync4(envFile)) {
542
+ console.log(`${envFile} exists; keeping it.`);
543
+ return;
544
+ }
545
+ let env = readFileSync4(path6.join(templatesDir(), "env.example"), "utf8");
546
+ if (process.stdin.isTTY && !argv.includes("--yes")) {
547
+ console.log("Provider keys (input hidden; press enter to skip any). Add more later in", envFile);
548
+ for (const [name, label] of PROVIDERS) {
549
+ const value = await askHidden(` ${label}: `);
550
+ if (value) env = env.replace(new RegExp(`^${name}=.*$`, "m"), `${name}=${value}`);
551
+ }
552
+ }
553
+ writeFileSync3(envFile, env, { mode: 384 });
554
+ chmodSync2(envFile, 384);
555
+ console.log(`Wrote ${dir}. Next: cruise up`);
556
+ }
557
+ async function waitHealthy() {
558
+ const { out } = await compose(["ps", "-q", "cruise"], { capture: true });
559
+ const id = out.trim();
560
+ if (!id) return false;
561
+ for (let i = 0; i < 60; i++) {
562
+ const probe = spawnSync("docker", ["inspect", "-f", "{{.State.Health.Status}}", id], { encoding: "utf8" });
563
+ if (probe.stdout.trim() === "healthy") return true;
564
+ await new Promise((r) => setTimeout(r, 2e3));
565
+ }
566
+ return false;
567
+ }
568
+ function firstBootBanner(logs) {
569
+ const lines = logs.split("\n");
570
+ const at = lines.findIndex((line) => line.includes("Cruise is set up."));
571
+ if (at < 0) return null;
572
+ const isRule = (line) => line?.trim().startsWith("\u2500\u2500\u2500\u2500") ?? false;
573
+ let from = at;
574
+ while (from > 0 && !isRule(lines[from])) from--;
575
+ let to = at;
576
+ while (to < lines.length - 1 && !isRule(lines[to])) to++;
577
+ return lines.slice(from, to + 1).join("\n");
578
+ }
579
+ async function up(argv) {
580
+ requireDocker();
581
+ if (!existsSync4(path6.join(dockerDir(), "docker-compose.yml"))) await init(argv);
582
+ await must2(["up", "-d"]);
583
+ process.stdout.write("waiting for Cruise to become healthy\u2026 ");
584
+ if (!await waitHealthy()) {
585
+ console.log("not yet.\nIt may still be starting; see `cruise logs`.");
586
+ process.exitCode = 1;
587
+ return;
588
+ }
589
+ console.log("healthy.");
590
+ const { out } = await compose(["logs", "--no-log-prefix", "cruise"], { capture: true });
591
+ const banner = firstBootBanner(out);
592
+ if (banner) {
593
+ console.log(banner);
594
+ } else {
595
+ console.log("Already set up. Your first key was printed on the first `cruise up`; issue more with `cruise key:issue`.");
596
+ }
597
+ console.log("Next: `cruise measure` once you have provider keys in .env (docs: docs/self-hosting.md).");
598
+ }
599
+ async function lifecycle(name, argv) {
600
+ requireDocker();
601
+ switch (name) {
602
+ case "status":
603
+ return must2(["ps"]);
604
+ case "logs":
605
+ return must2(["logs", ...argv.includes("-f") ? ["-f"] : [], "cruise"]);
606
+ case "down":
607
+ return must2(["down"]);
608
+ case "backup":
609
+ return must2(["exec", "cruise", "cruise-server", "backup"]);
610
+ case "upgrade":
611
+ await must2(["exec", "cruise", "cruise-server", "backup"]);
612
+ await must2(["pull"]);
613
+ await must2(["up", "-d"]);
614
+ console.log("Upgraded. The new image migrated the database before serving; a backup was taken first.");
615
+ return;
616
+ }
617
+ }
618
+ async function inContainer(argv) {
619
+ requireDocker();
620
+ const { code } = await compose(["exec", ...process.stdin.isTTY ? [] : ["-T"], "cruise", "cruise", ...argv]);
621
+ process.exitCode = code;
622
+ }
623
+
624
+ // packages/cli/src/main.ts
625
+ var HELP = `cruise ${VERSION} \u2014 one OpenAI-compatible endpoint in front of every model provider
626
+
627
+ Docker, on this machine:
628
+ cruise init write ~/.cruise/docker (compose file, .env) and ask for provider keys
629
+ cruise up start, wait until healthy, print the first key on first boot
630
+ cruise status | logs [-f] | down | backup | upgrade
631
+ cruise measure measure every model you hold a key for (run weekly)
632
+ cruise <admin command> key:issue, budget:set, ledger, report, tenant:provision, \u2026
633
+
634
+ Your own Cloudflare account (needs CLOUDFLARE_API_TOKEN):
635
+ cruise cloudflare deploy [--name cruise] [--env-file .env]
636
+ cruise cloudflare measure [--name cruise]
637
+ cruise cloudflare <admin command> [--name cruise]
638
+
639
+ Admin commands: ${Object.keys(COMMANDS).join(", ")}
640
+ Manual: https://gitlab.com/bytesbrains-oss/cruise/-/blob/main/docs/self-hosting.md`;
641
+ function takeFlag(argv, name) {
642
+ const i = argv.indexOf(`--${name}`);
643
+ if (i < 0) return { value: void 0, rest: argv };
644
+ return { value: argv[i + 1], rest: [...argv.slice(0, i), ...argv.slice(i + 2)] };
645
+ }
646
+ async function runScript(name, rest) {
647
+ const command = COMMANDS[name];
648
+ if (!command) throw new Error(`unknown command: ${name}
649
+
650
+ ${HELP}`);
651
+ process.argv = [process.argv[0] ?? "node", name, ...command.args, ...rest];
652
+ await command.load();
653
+ }
654
+ async function cloudflare(argv) {
655
+ const [sub, ...rest] = argv;
656
+ if (sub === "deploy") return deploy(rest);
657
+ if (!sub) throw new Error(HELP);
658
+ const { value: name, rest: args } = takeFlag(rest, "name");
659
+ const d = loadDeployment(name ?? "cruise");
660
+ process.env.CRUISE_D1_ACCOUNT_ID = d.accountId;
661
+ process.env.CRUISE_D1_DATABASE_ID = d.databaseId;
662
+ if (!process.env.CLOUDFLARE_API_TOKEN) throw new Error("Set CLOUDFLARE_API_TOKEN to administer a Cloudflare deployment.");
663
+ if (sub === "measure") {
664
+ if (!d.url) throw new Error("This deployment has no URL on record; pass --base https://\u2026");
665
+ const db = new HttpD1(d.accountId, d.databaseId, process.env.CLOUDFLARE_API_TOKEN);
666
+ return measure(args, { db, environment: "cloudflare", base: d.url });
667
+ }
668
+ return runScript(sub, ["--env", "cloudflare", ...args]);
669
+ }
670
+ async function main(argv) {
671
+ const [name, ...rest] = argv;
672
+ if (!name || name === "help" || name === "--help" || name === "-h") {
673
+ console.log(HELP);
674
+ return;
675
+ }
676
+ if (name === "--version" || name === "version") {
677
+ console.log(VERSION);
678
+ return;
679
+ }
680
+ if (rest.includes("--env") && rest[rest.indexOf("--env") + 1] === "cloudflare") return runScript(name, rest);
681
+ if (name === "cloudflare") return cloudflare(rest);
682
+ if (name === "init") return init(rest);
683
+ if (name === "up") return up(rest);
684
+ if (["status", "logs", "down", "backup", "upgrade"].includes(name)) return lifecycle(name, rest);
685
+ if (name === "measure" || name in COMMANDS) return inContainer(argv);
686
+ throw new Error(`unknown command: ${name}
687
+
688
+ ${HELP}`);
689
+ }
690
+ main(process.argv.slice(2)).catch((error) => {
691
+ console.error(error instanceof Error ? error.message : error);
692
+ process.exitCode = 1;
693
+ });