@agilesyndrome/cf-genai-base 5.0.0 → 5.0.2

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/src/cli/cli.js ADDED
@@ -0,0 +1,133 @@
1
+ import { backupD1, checkConfig, checkD1, migrateD1, refreshLocalD1, refreshStagingD1, restoreD1, statusD1 } from "./d1.js";
2
+ import { runUserCommand } from "./users.js";
3
+ import { runTenantCommand } from "./tenants.js";
4
+ import { runOperationalCommand } from "./operations.js";
5
+ import { runProjectCommand } from "./project.js";
6
+ import { runVersionCommand } from "./version.js";
7
+ import { siteStatus } from "./status.js";
8
+
9
+ const usage = `Usage:
10
+ cf-genai check|test|build|ci|ci:lint
11
+ cf-genai lint data-access
12
+ cf-genai dev [options]
13
+ cf-genai upgrade <base|auth|llm|messaging> <latest|VERSION>
14
+ cf-genai release [--confirm] [--first] [--add-trust] [--dry-run] [--bypass-lint] [--type patch|minor|major] [--version MAJOR.MINOR]
15
+ cf-genai release-status [--wait MINUTES] [--json]
16
+ cf-genai status [--env local|staging|production] [--json]
17
+ cf-genai version
18
+ cf-genai d1 refresh|backup|restore|migrate|status|check local|staging|production [options]
19
+ cf-genai admin status|features|users|tenants|scopes|groups|healthchecks|circuit-breakers [options]
20
+ cf-genai config check [options]
21
+ cf-genai user list|get|update [username] [options]
22
+ cf-genai tenant list|get|create|update [tenant-id] [options]
23
+
24
+ Options:
25
+ --database NAME D1 binding or database name (default: DB)
26
+ --production-database NAME Production D1 binding or database name
27
+ --production-env NAME Wrangler production environment (default: none)
28
+ --staging-env NAME Wrangler staging environment (default: staging)
29
+ --config PATH Wrangler config path (default: wrangler.jsonc)
30
+ --env VALUE Environment alias: local, staging, or prod
31
+ --output PATH Backup output file
32
+ --file PATH Restore input file
33
+ --wrangler COMMAND Wrangler command (default: npx wrangler)
34
+ --yes Confirm a remote staging refresh
35
+ --confirm-production Explicitly permit production migration or breaker changes
36
+ --confirm Confirm a destructive or release operation
37
+ --dry-run Show release checks without changing Git or npm
38
+ --version MAJOR.MINOR Explicit release target; patch is assigned as .0
39
+ --bypass-lint Skip the release preflight lint (use sparingly)
40
+ --json Return machine-readable output
41
+ --help Show this help
42
+ `;
43
+
44
+ function splitCommand(value = "npx wrangler") {
45
+ return value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^(["'])(.*)\1$/, "$2")) ?? ["npx", "wrangler"];
46
+ }
47
+
48
+ function parseOptions(args, env = process.env) {
49
+ const options = {
50
+ config: env.CF_GENAI_WRANGLER_CONFIG || "wrangler.jsonc",
51
+ database: env.CF_GENAI_DATABASE || "DB",
52
+ productionEnv: env.CF_GENAI_PRODUCTION_ENV || "",
53
+ stagingEnv: env.CF_GENAI_STAGING_ENV || "staging",
54
+ wranglerCommand: splitCommand(env.CF_GENAI_WRANGLER || "npx wrangler"),
55
+ yes: false,
56
+ confirmProduction: false,
57
+ target: env.CF_GENAI_TARGET || "local",
58
+ roles: undefined,
59
+ scopes: undefined,
60
+ groups: undefined,
61
+ tenants: undefined,
62
+ name: undefined,
63
+ json: false,
64
+ };
65
+ const positional = [];
66
+ for (let index = 0; index < args.length; index += 1) {
67
+ const arg = args[index];
68
+ if (arg === "--help" || arg === "-h") return { ...options, help: true, positional };
69
+ if (arg === "--yes") options.yes = true;
70
+ else if (arg === "--json") options.json = true
71
+ else if (arg === "--confirm-production") options.confirmProduction = true;
72
+ else if (arg.startsWith("--")) {
73
+ const [key, inline] = arg.slice(2).split("=", 2);
74
+ const names = {
75
+ database: "database", "production-database": "productionDatabase",
76
+ "production-env": "productionEnv", "staging-env": "stagingEnv",
77
+ config: "config", wrangler: "wrangler", target: "target", env: "target", roles: "roles", scopes: "scopes", groups: "groups", tenants: "tenants", name: "name", output: "output", file: "file",
78
+ };
79
+ const optionName = names[key];
80
+ if (!optionName) throw new Error(`Unknown option: ${arg}`);
81
+ const value = inline ?? args[++index];
82
+ if (value === undefined) throw new Error(`Missing value for ${arg}.`);
83
+ if (optionName === "wrangler") options.wranglerCommand = splitCommand(value);
84
+ else options[optionName] = value;
85
+ } else positional.push(arg);
86
+ }
87
+ return { ...options, positional, productionDatabase: options.productionDatabase || options.database };
88
+ }
89
+
90
+ export async function main(args = process.argv.slice(2), env = process.env) {
91
+ if (args[0]?.includes(":")) { const [domain, action] = args[0].split(":", 2); if (["user", "tenant", "healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) args = [domain, action, ...args.slice(1)]; }
92
+ if (args[0] === "version") return runVersionCommand();
93
+ const projectCommand = args[0];
94
+ if (["check", "test", "build", "ci", "ci:lint", "lint", "dev", "upgrade", "release", "release-status"].includes(projectCommand)) {
95
+ const result = runProjectCommand(projectCommand, args.slice(1));
96
+ if (result === null) throw new Error(`Unknown command.\n\n${usage}`);
97
+ return result;
98
+ }
99
+ const options = parseOptions(args, env);
100
+ if (options.positional[0] === "status") return siteStatus(options);
101
+ if (options.help || options.positional.length === 0) {
102
+ console.log(usage);
103
+ return;
104
+ }
105
+ let [domain, action, target] = options.positional;
106
+ if (domain === "admin") { const adminAction = { user: "users", tenant: "tenants", healthcheck: "healthchecks", "circuit-breaker": "circuit-breakers", circuit: "circuit-breakers" }[action] || action; if (["local", "staging", "prod", "production"].includes(target)) options.target = target === "prod" ? "production" : target; if (adminAction === "tenants") return runTenantCommand({ action: options.positional[2] || "list", identifier: options.positional[3], name: options.name, options }); if (["healthchecks", "circuit-breakers"].includes(adminAction)) return runOperationalCommand({ domain: adminAction, action: options.positional[2] || "list", identifier: options.positional[3], value: options.positional[4], options }); return runOperationalCommand({ domain: "admin", action: adminAction, identifier: options.positional[3], value: options.positional[4], options }); }
107
+ if (domain === "backup" || domain === "restore") { target = action || options.target; action = domain; return runD1FileCommand(action, target, options); }
108
+ if (domain === "user") return runUserCommand({ action, username: target, options });
109
+ if (domain === "tenant") return runTenantCommand({ action, identifier: target, name: options.name, options });
110
+ if (["healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) return runOperationalCommand({ domain, action, identifier: target, value: options.positional[3], options });
111
+ if (domain === "config" && action === "check") return checkConfig(options);
112
+ if (domain !== "d1" || !["refresh", "backup", "restore", "migrate", "status", "check"].includes(action)) throw new Error(`Unknown command.\n\n${usage}`);
113
+ target = target === "prod" ? "production" : target;
114
+ if (!["local", "staging", "production"].includes(target)) throw new Error("Target must be local, staging, or production.");
115
+ if (action === "backup" || action === "restore") return runD1FileCommand(action, target, options);
116
+ if (action === "refresh") {
117
+ if (target === "staging" && !options.yes) throw new Error("Staging refresh replaces remote data; rerun with --yes.");
118
+ if (target === "local") return refreshLocalD1({ database: options.database, productionDatabase: options.productionDatabase, options });
119
+ if (target === "staging") return refreshStagingD1({ database: options.database, productionDatabase: options.productionDatabase, options });
120
+ throw new Error(`Unknown command.\n\n${usage}`);
121
+ }
122
+ if (action === "migrate" && target === "production" && !options.confirmProduction) throw new Error("Production migration requires --confirm-production.");
123
+ if (action === "migrate") return migrateD1({ target, database: options.database, options });
124
+ if (action === "status") return statusD1({ target, database: options.database, options });
125
+ return checkD1({ target, database: options.database, options });
126
+ }
127
+
128
+ function runD1FileCommand(action, target, options) {
129
+ if (action === "backup") return backupD1({ target, database: options.database, options });
130
+ return restoreD1({ target, database: options.database, options });
131
+ }
132
+
133
+ export { parseOptions, usage };
package/src/cli/d1.js ADDED
@@ -0,0 +1,165 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const INTERNAL_TABLE_SQL = `
7
+ SELECT name
8
+ FROM sqlite_schema
9
+ WHERE type = 'table'
10
+ AND name NOT LIKE 'sqlite_%'
11
+ AND name NOT LIKE '_cf_%'
12
+ AND name <> 'd1_migrations'
13
+ ORDER BY name;
14
+ `;
15
+
16
+ export function shellCommand(value = "npx wrangler") {
17
+ return value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^(["'])(.*)\1$/, "$2")) ?? ["npx", "wrangler"];
18
+ }
19
+
20
+ export function targetArgs(target, options = {}) {
21
+ if (target === "local") return ["--local"];
22
+ const args = ["--remote"];
23
+ const environment = target === "staging" ? options.stagingEnv : options.productionEnv;
24
+ if (environment) args.push("--env", environment);
25
+ return args;
26
+ }
27
+
28
+ export function extractRows(value) {
29
+ if (Array.isArray(value)) return value.flatMap(extractRows);
30
+ if (!value || typeof value !== "object") return [];
31
+ const rows = [];
32
+ for (const [key, child] of Object.entries(value)) {
33
+ if (key === "results" && Array.isArray(child)) rows.push(...child);
34
+ rows.push(...extractRows(child));
35
+ }
36
+ return rows;
37
+ }
38
+
39
+ export function parseJsonRows(output) {
40
+ try {
41
+ return extractRows(JSON.parse(output));
42
+ } catch {
43
+ throw new Error("Wrangler returned invalid JSON while inspecting D1.");
44
+ }
45
+ }
46
+
47
+ export function tableNamesFromJson(output) {
48
+ return parseJsonRows(output)
49
+ .map((row) => row?.name)
50
+ .filter((name) => typeof name === "string" && name.length > 0);
51
+ }
52
+
53
+ export function clearSql(tableNames) {
54
+ const identifiers = tableNames.map((name) => `"${name.replaceAll('"', '""')}"`);
55
+ return [
56
+ "PRAGMA defer_foreign_keys = ON;",
57
+ ...identifiers.map((name) => `DELETE FROM ${name};`),
58
+ "PRAGMA defer_foreign_keys = OFF;",
59
+ "",
60
+ ].join("\n");
61
+ }
62
+
63
+ export function stripInternalRows(sql) {
64
+ return sql
65
+ .split("\n")
66
+ .filter((line) => !/^\s*INSERT\s+INTO\s+["`]?((d1_migrations)|(sqlite_[^"`\s]*)|(_cf_[^"`\s]*))["`]?/i.test(line))
67
+ .join("\n");
68
+ }
69
+
70
+ function run(wrangler, args, options = {}) {
71
+ const result = spawnSync(wrangler[0], [...wrangler.slice(1), ...args], {
72
+ cwd: options.cwd,
73
+ env: options.env,
74
+ encoding: "utf8",
75
+ stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
76
+ });
77
+ if (result.error) throw result.error;
78
+ if (result.status !== 0) throw new Error(`${wrangler.join(" ")} exited with status ${result.status ?? "unknown"}.`);
79
+ return result.stdout ?? "";
80
+ }
81
+
82
+ function execute(wrangler, database, target, sqlFile, options) {
83
+ run(wrangler, ["d1", "execute", database, ...targetArgs(target, options), "--file", sqlFile, "--yes", "--config", options.config], options);
84
+ }
85
+
86
+ export function executeJson(wrangler, database, target, command, options) {
87
+ return run(wrangler, ["d1", "execute", database, ...targetArgs(target, options), "--command", command, "--json", "--config", options.config], { ...options, capture: true });
88
+ }
89
+
90
+ function migrations(wrangler, database, target, options) {
91
+ run(wrangler, ["d1", "migrations", "apply", database, ...targetArgs(target, options), "--config", options.config], options);
92
+ }
93
+
94
+ async function refreshD1(target, { database, productionDatabase, options }) {
95
+ const directory = await mkdtemp(join(tmpdir(), "cf-genai-d1-refresh-"));
96
+ const exportPath = join(directory, "production-data.sql");
97
+ const importPath = join(directory, "application-data.sql");
98
+ try {
99
+ run(options.wranglerCommand, [
100
+ "d1", "export", productionDatabase, "--remote",
101
+ ...(options.productionEnv ? ["--env", options.productionEnv] : []),
102
+ "--no-schema", "--output", exportPath, "--skip-confirmation", "--config", options.config,
103
+ ], options);
104
+ migrations(options.wranglerCommand, database, target, options);
105
+ const tableOutput = executeJson(options.wranglerCommand, database, target, INTERNAL_TABLE_SQL, options);
106
+ const tables = tableNamesFromJson(tableOutput);
107
+ if (tables.length === 0) throw new Error(`No application tables found in ${target} D1 database.`);
108
+ const exported = await readFile(exportPath, "utf8");
109
+ await writeFile(importPath, `${clearSql(tables)}${stripInternalRows(exported)}`);
110
+ execute(options.wranglerCommand, database, target, importPath, options);
111
+ await mkdir(options.cwd || process.cwd(), { recursive: true });
112
+ await writeFile(join(options.cwd || process.cwd(), ".cf-genai-last-prod-refresh"), new Date().toISOString() + "\n");
113
+ } finally {
114
+ await rm(directory, { recursive: true, force: true });
115
+ }
116
+ }
117
+
118
+ export function refreshLocalD1(args) {
119
+ return refreshD1("local", args);
120
+ }
121
+
122
+ export function refreshStagingD1(args) {
123
+ return refreshD1("staging", args);
124
+ }
125
+
126
+ export function migrateD1({ target, database, options }) {
127
+ migrations(options.wranglerCommand, database, target, options);
128
+ }
129
+
130
+ export function statusD1({ target, database, options }) {
131
+ if (target !== "local") {
132
+ run(options.wranglerCommand, ["d1", "info", database, ...(target === "staging" && options.stagingEnv ? ["--env", options.stagingEnv] : []), ...(target === "production" && options.productionEnv ? ["--env", options.productionEnv] : []), "--config", options.config], options);
133
+ return;
134
+ }
135
+ const output = executeJson(options.wranglerCommand, database, target, "SELECT name, type FROM sqlite_schema WHERE type IN ('table', 'index') ORDER BY type, name;", options);
136
+ console.log(output);
137
+ }
138
+
139
+ export function checkD1({ target, database, options }) {
140
+ const output = executeJson(options.wranglerCommand, database, target, "PRAGMA foreign_key_check; PRAGMA integrity_check;", options);
141
+ const rows = parseJsonRows(output);
142
+ const failures = rows.filter((row) => !(Object.values(row ?? {}).length === 1 && Object.values(row)[0] === "ok"));
143
+ if (failures.length > 0) throw new Error(`D1 integrity checks failed: ${JSON.stringify(failures)}`);
144
+ console.log(`${target} D1 integrity checks passed.`);
145
+ }
146
+
147
+ export function backupD1({ target, database, options }) {
148
+ if (!options.output) throw new Error("Backup requires --output PATH.");
149
+ if (target === "production" && !options.confirmProduction) throw new Error("Production backup requires --confirm-production.");
150
+ run(options.wranglerCommand, ["d1", "export", database, ...targetArgs(target, options), "--output", options.output, "--skip-confirmation", "--config", options.config], options);
151
+ console.log("Backed up " + database + " (" + target + ") to " + options.output);
152
+ }
153
+
154
+ export async function restoreD1({ target, database, options }) {
155
+ if (!options.file) throw new Error("Restore requires --file PATH.");
156
+ try { await readFile(options.file); } catch { throw new Error("Restore file not found: " + options.file); }
157
+ if (target !== "local" && !options.yes) throw new Error("Remote restore replaces data; rerun with --yes.");
158
+ if (target === "production" && !options.confirmProduction) throw new Error("Production restore requires --confirm-production.");
159
+ execute(options.wranglerCommand, database, target, options.file, options);
160
+ console.log("Restored " + database + " (" + target + ") from " + options.file);
161
+ }
162
+
163
+ export function checkConfig(options) {
164
+ run(options.wranglerCommand, ["deploy", "--dry-run", "--config", options.config], options);
165
+ }
@@ -0,0 +1,34 @@
1
+ import { executeJson, parseJsonRows } from "./d1.js";
2
+
3
+ function query(sql, options) { return parseJsonRows(executeJson(options.wranglerCommand, options.database, normalizeTarget(options.target), sql, options)); }
4
+ function normalizeTarget(target) { return target === "prod" ? "production" : target || "local"; }
5
+ function output(value) { console.log(JSON.stringify(value, null, 2)); }
6
+
7
+ export function runOperationalCommand({ domain, action, identifier, value, options }) {
8
+ const target = normalizeTarget(options.target);
9
+ if (!["local", "staging", "production"].includes(target)) throw new Error("Environment must be local, staging, or prod/production.");
10
+ if (domain === "admin" && action === "status") return output(query("SELECT feature,MAX(CASE state WHEN 'red' THEN 2 WHEN 'yellow' THEN 1 ELSE 0 END) AS severity,COUNT(*) AS healthchecks FROM core_healthchecks GROUP BY feature ORDER BY feature;", { ...options, target }));
11
+ if (domain === "admin" && action === "features") return output(query("SELECT feature,COUNT(*) AS healthchecks FROM core_healthchecks GROUP BY feature ORDER BY feature;", { ...options, target }));
12
+ if (domain === "admin" && action === "users") return output(query("SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM auth_users ORDER BY email COLLATE NOCASE;", { ...options, target }));
13
+ if (domain === "admin" && action === "scopes") return output(query("SELECT name,label,description,system FROM auth_scopes ORDER BY name;", { ...options, target }));
14
+ if (domain === "admin" && action === "groups") return output(query("SELECT name,display_name,description,created_at,updated_at FROM auth_groups ORDER BY display_name COLLATE NOCASE;", { ...options, target }));
15
+ if ((domain === "healthcheck" || domain === "healthchecks" || (domain === "admin" && action === "healthchecks")) && action === "list") return output(query("SELECT id,feature,component,display_name,state,metadata_json,created_at,updated_at FROM core_healthchecks ORDER BY feature,component;", { ...options, target }));
16
+ if ((domain === "healthcheck" || domain === "healthchecks" || (domain === "admin" && action === "healthchecks")) && action === "get") { if (!identifier) throw new Error("A healthcheck id is required."); return output(query("SELECT id,feature,component,display_name,state,metadata_json,created_at,updated_at FROM core_healthchecks WHERE id=" + sql(identifier) + " LIMIT 1;", { ...options, target })); }
17
+ if ((domain === "healthcheck" || domain === "healthchecks" || (domain === "admin" && action === "healthchecks")) && ["set", "update"].includes(action)) { if (!identifier || !value) throw new Error("healthcheck:set requires an id and state (red, yellow, or green)."); if (!["red", "yellow", "green"].includes(value)) throw new Error("Healthcheck state must be red, yellow, or green."); query("UPDATE core_healthchecks SET state=" + sql(value) + ",updated_at=CURRENT_TIMESTAMP WHERE id=" + sql(identifier) + ";", { ...options, target }); return output({ id: identifier, state: value, environment: target, changed_by: "human:cli" }); }
18
+ if ((domain === "circuit-breaker" || domain === "circuit-breakers" || domain === "circuit" || (domain === "admin" && action === "circuit-breakers")) && action === "list") return output(query("SELECT id,feature,name,display_name,state,healthcheck_mode,allow_self_healing,metadata_json,created_at,updated_at FROM core_circuit_breakers ORDER BY feature,name;", { ...options, target }));
19
+ if ((domain === "circuit-breaker" || domain === "circuit-breakers" || domain === "circuit" || (domain === "admin" && action === "circuit-breakers")) && action === "get") {
20
+ if (!identifier) throw new Error("A circuit-breaker id is required.");
21
+ return output(query("SELECT id,feature,name,display_name,state,healthcheck_mode,allow_self_healing,metadata_json,created_at,updated_at FROM core_circuit_breakers WHERE id=" + sql(identifier) + " LIMIT 1;", { ...options, target }));
22
+ }
23
+ if ((domain === "circuit-breaker" || domain === "circuit-breakers" || domain === "circuit" || (domain === "admin" && action === "circuit-breakers")) && (action === "set" || action === "update")) {
24
+ if (!identifier || !value) throw new Error("circuit-breaker:set requires an id and state (off, tripped, or on).");
25
+ if (!["off", "tripped", "on"].includes(value)) throw new Error("Circuit-breaker state must be off, tripped, or on.");
26
+ if (target === "production" && !options.confirmProduction) throw new Error("Production circuit-breaker changes require --confirm-production.");
27
+ const rows = query("SELECT id,state FROM core_circuit_breakers WHERE id=" + sql(identifier) + " LIMIT 1;", { ...options, target });
28
+ if (!rows[0]) throw new Error("Circuit breaker not found: " + identifier);
29
+ query("UPDATE core_circuit_breakers SET state=" + sql(value) + ",updated_at=CURRENT_TIMESTAMP WHERE id=" + sql(identifier) + ";", { ...options, target });
30
+ return output({ id: identifier, state: value, environment: target, changed_by: "human:cli" });
31
+ }
32
+ throw new Error("Unknown operational command.");
33
+ }
34
+ function sql(value) { return "'" + String(value).replaceAll("'", "''") + "'"; }