@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/CLI.md +121 -0
- package/CONTRACT.md +2 -2
- package/README.md +49 -0
- package/bin/cf-genai.js +11 -0
- package/changelog.md +10 -0
- package/package.json +14 -4
- package/src/admin/routes.js +1 -1
- package/src/auth/index.js +1 -0
- package/src/auth/oauth-single.js +204 -0
- package/src/auth/subscriptions.js +19 -2
- package/src/auth/users.js +16 -7
- package/src/cli/cli.js +133 -0
- package/src/cli/d1.js +165 -0
- package/src/cli/operations.js +34 -0
- package/src/cli/project.js +560 -0
- package/src/cli/status.js +46 -0
- package/src/cli/tenants.js +16 -0
- package/src/cli/users.js +34 -0
- package/src/cli/version.js +54 -0
- package/src/core/security.js +22 -5
- package/src/runtime/worker.js +16 -3
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { migrateD1, shellCommand } from "./d1.js";
|
|
6
|
+
import { compareVersions } from "./version.js";
|
|
7
|
+
|
|
8
|
+
const packagePath = "package.json";
|
|
9
|
+
const basePackageName = "@agilesyndrome/cf-genai-base";
|
|
10
|
+
const upgradePackages = Object.freeze({
|
|
11
|
+
base: basePackageName,
|
|
12
|
+
auth: "@agilesyndrome/cf-genai-auth",
|
|
13
|
+
llm: "@agilesyndrome/cf-genai-llm",
|
|
14
|
+
messaging: "@agilesyndrome/cf-genai-messaging",
|
|
15
|
+
});
|
|
16
|
+
const npmRegistry = "https://registry.npmjs.org";
|
|
17
|
+
const releaseStatusCommandTimeout = 10_000;
|
|
18
|
+
const DATA_ACCESS_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
|
|
19
|
+
const DATA_ACCESS_DIRECT = /\b(?:env|environment)\s*\.\s*DB\s*\.\s*prepare\s*\(|\bDB\s*\.\s*prepare\s*\(/;
|
|
20
|
+
|
|
21
|
+
function run(command, args, { inherit = true, failOnError = true, timeout, env } = {}) {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
26
|
+
...(env === undefined ? {} : { env }),
|
|
27
|
+
});
|
|
28
|
+
if (result.error && failOnError) throw result.error;
|
|
29
|
+
if (result.status !== 0 && failOnError) process.exitCode = result.status || 1;
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function packageJson() {
|
|
34
|
+
if (!existsSync(packagePath)) throw new Error("package.json was not found in the current directory.");
|
|
35
|
+
return JSON.parse(readFileSync(packagePath, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function packageMigrations(packageName, cwd = process.cwd()) {
|
|
39
|
+
const directory = join(cwd, "node_modules", ...packageName.split("/"), "migrations");
|
|
40
|
+
if (!existsSync(directory)) return [];
|
|
41
|
+
return readdirSync(directory).filter((name) => /^\d+_.+\.sql$/.test(name)).sort().map((name) => ({ name, path: join(directory, name), sql: readFileSync(join(directory, name), "utf8") }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function migrationHash(sql) { return createHash("sha256").update(sql).digest("hex").slice(0, 12); }
|
|
45
|
+
|
|
46
|
+
export function vendorPackageMigrations({ packageName = basePackageName, version, cwd = process.cwd() } = {}) {
|
|
47
|
+
const destination = join(cwd, "migrations");
|
|
48
|
+
if (!existsSync(destination)) mkdirSync(destination, { recursive: true });
|
|
49
|
+
const sources = packageMigrations(packageName, cwd);
|
|
50
|
+
if (!sources.length) return { packageName, version, added: [], skipped: [] };
|
|
51
|
+
const existing = readdirSync(destination).filter((name) => /^\d+_.+\.sql$/.test(name));
|
|
52
|
+
const used = new Set(existing);
|
|
53
|
+
const sourceText = existing.map((name) => readFileSync(join(destination, name), "utf8")).join("\n");
|
|
54
|
+
let next = Math.max(0, ...existing.map((name) => Number(name.match(/^\d+/)?.[0] || 0))) + 1;
|
|
55
|
+
const added = [];
|
|
56
|
+
const skipped = [];
|
|
57
|
+
for (const source of sources) {
|
|
58
|
+
const marker = `cf-genai-source: ${packageName}/${source.name}`;
|
|
59
|
+
if (sourceText.includes(marker)) { skipped.push(source.name); continue; }
|
|
60
|
+
let target;
|
|
61
|
+
do { target = `${String(next++).padStart(4, "0")}_${packageName.split("/").at(-1).replaceAll("-", "_")}_${source.name.replace(/^\d+_/, "")}`; } while (used.has(target));
|
|
62
|
+
const content = `-- ${marker}@${version || "installed"} sha256:${migrationHash(source.sql)}\n${source.sql.trim()}\n`;
|
|
63
|
+
writeFileSync(join(destination, target), content);
|
|
64
|
+
used.add(target);
|
|
65
|
+
added.push(target);
|
|
66
|
+
}
|
|
67
|
+
return { packageName, version, added, skipped };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function upgradePackageName(value) {
|
|
71
|
+
const target = upgradePackages[String(value || "")];
|
|
72
|
+
if (!target) throw new Error(`Unknown cf-genai package: ${value || "missing"}. Use base, auth, llm, or messaging.`);
|
|
73
|
+
return target;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function upgradePackage(args = []) {
|
|
77
|
+
const target = upgradePackageName(args[0]);
|
|
78
|
+
const requested = args[1] || "latest";
|
|
79
|
+
if (args.length > 2) throw new Error("Upgrade accepts one package and version: cf-genai upgrade <base|auth|llm|messaging> <latest|VERSION>.");
|
|
80
|
+
const result = run("npm", ["install", `${target}@${requested}`], { failOnError: false });
|
|
81
|
+
if (result.status !== 0) throw new Error("npm install failed; no package migrations were copied.");
|
|
82
|
+
const installed = packageJson().dependencies?.[target] || packageJson().devDependencies?.[target] || requested;
|
|
83
|
+
const migrationResult = vendorPackageMigrations({ packageName: target, version: installed });
|
|
84
|
+
console.log(`Upgraded ${target} to ${installed}.`);
|
|
85
|
+
console.log(`Package migrations added: ${migrationResult.added.length}; already vendored: ${migrationResult.skipped.length}.`);
|
|
86
|
+
for (const name of migrationResult.added) console.log(` + migrations/${name}`);
|
|
87
|
+
return migrationResult;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function packageDependency(packageMetadata, name) {
|
|
91
|
+
return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
|
|
92
|
+
.map((section) => packageMetadata[section]?.[name])
|
|
93
|
+
.find((value) => value !== undefined) || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function baseDependencyVersion(packageMetadata = {}) {
|
|
97
|
+
if (packageMetadata.name === basePackageName) return packageMetadata.version || null;
|
|
98
|
+
const spec = packageDependency(packageMetadata, basePackageName);
|
|
99
|
+
if (!spec) return null;
|
|
100
|
+
const match = String(spec).match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
101
|
+
return match ? match[0] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isCfGenaiProject(cwd) {
|
|
105
|
+
if (!existsSync(join(cwd, packagePath))) return false;
|
|
106
|
+
const pkg = JSON.parse(readFileSync(join(cwd, packagePath), "utf8"));
|
|
107
|
+
const name = pkg.name || "";
|
|
108
|
+
if (name === "@agilesyndrome/cf-genai-base") return false;
|
|
109
|
+
const dependencies = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}) };
|
|
110
|
+
return name.startsWith("@agilesyndrome/cf-genai-") || name.startsWith("cf-genai-") || Boolean(dependencies["@agilesyndrome/cf-genai-base"]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sourceFiles(directory, files = []) {
|
|
114
|
+
if (!existsSync(directory)) return files;
|
|
115
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
116
|
+
if (["node_modules", ".git", ".wrangler", "dist", "coverage"].includes(entry.name)) continue;
|
|
117
|
+
const path = join(directory, entry.name);
|
|
118
|
+
if (entry.isDirectory()) sourceFiles(path, files);
|
|
119
|
+
else if (DATA_ACCESS_EXTENSIONS.has(path.slice(path.lastIndexOf(".")))) files.push(path);
|
|
120
|
+
}
|
|
121
|
+
return files;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function dataAccessLint({ cwd = process.cwd() } = {}) {
|
|
125
|
+
if (!isCfGenaiProject(cwd)) return { skipped: true, violations: [] };
|
|
126
|
+
const roots = ["src", "app", "apps", "functions", "workers", "server"].map((root) => join(cwd, root)).filter((root) => existsSync(root));
|
|
127
|
+
const files = (roots.length ? roots : [cwd]).flatMap((root) => sourceFiles(root));
|
|
128
|
+
const violations = files
|
|
129
|
+
.filter((path) => !path.split(/[\\/]/).includes("tests"))
|
|
130
|
+
.flatMap((path) => String(readFileSync(path, "utf8")).split(/\r?\n/).flatMap((line, index) => DATA_ACCESS_DIRECT.test(line) ? [{ path, line: index + 1, text: line.trim() }] : []));
|
|
131
|
+
if (violations.length) {
|
|
132
|
+
const details = violations.map((item) => ` ${item.path}:${item.line}: ${item.text}`).join("\n");
|
|
133
|
+
throw new Error(`Direct D1 access is not allowed in cf-genai domain source. Use state.data scoped readers instead.\n${details}`);
|
|
134
|
+
}
|
|
135
|
+
return { skipped: false, violations };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function packageName() {
|
|
139
|
+
return packageJson().name || "package";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function version() {
|
|
143
|
+
return packageJson().version;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertClean() {
|
|
147
|
+
const result = run("git", ["status", "--porcelain"], { inherit: false });
|
|
148
|
+
if (result.status !== 0) throw new Error("Unable to inspect git status.");
|
|
149
|
+
if (result.stdout.trim()) throw new Error("Working tree must be clean before a release.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function output(command, args) {
|
|
153
|
+
const result = run(command, args, { inherit: false, failOnError: false });
|
|
154
|
+
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed.`);
|
|
155
|
+
return result.stdout.trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function assertMainIsSynchronized() {
|
|
159
|
+
const branch = output("git", ["branch", "--show-current"]);
|
|
160
|
+
if (branch !== "main") throw new Error(`Releases must run from main (currently on ${branch || "detached HEAD"}).`);
|
|
161
|
+
const fetched = run("git", ["fetch", "origin", "main", "--tags"]);
|
|
162
|
+
if (fetched.status !== 0) throw new Error("Unable to fetch origin/main; release stopped before changing anything.");
|
|
163
|
+
const local = output("git", ["rev-parse", "main"]);
|
|
164
|
+
const remote = output("git", ["rev-parse", "origin/main"]);
|
|
165
|
+
if (local !== remote) throw new Error("Local main does not match origin/main; push or reconcile it before releasing.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertFlag(args, flag, message) {
|
|
169
|
+
if (!args.includes(flag)) throw new Error(`${message} Re-run with ${flag}.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function npmVersion(name, currentVersion) {
|
|
173
|
+
const result = run("npm", ["view", `${name}@${currentVersion}`, "version"], { inherit: false, failOnError: false });
|
|
174
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function npmVersionState(name, requestedVersion) {
|
|
178
|
+
const result = run("npm", ["view", `${name}@${requestedVersion}`, "version", "--json", "--registry", npmRegistry], { inherit: false, failOnError: false });
|
|
179
|
+
if (result.status === 0) {
|
|
180
|
+
let published = result.stdout.trim();
|
|
181
|
+
try { published = JSON.parse(published); } catch { /* npm may return an unquoted version */ }
|
|
182
|
+
return { exists: published === requestedVersion, version: published || null };
|
|
183
|
+
}
|
|
184
|
+
if (/E404|not found/i.test(`${result.stdout}\n${result.stderr}`)) return { exists: false, version: null };
|
|
185
|
+
throw new Error(`Unable to verify whether ${name}@${requestedVersion} is published.`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function npmPackageExists(name) {
|
|
189
|
+
const result = run("npm", ["view", name, "name", "--json", "--registry", npmRegistry], { inherit: false, failOnError: false });
|
|
190
|
+
if (result.status === 0) return Boolean(result.stdout.trim());
|
|
191
|
+
if (/E404|not found/i.test(`${result.stdout}\n${result.stderr}`)) return false;
|
|
192
|
+
throw new Error(`Unable to verify whether ${name} exists on npm.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function normalizeReleaseVersion(value) {
|
|
196
|
+
const match = String(value || "").trim().match(/^(\d+)\.(\d+)$/);
|
|
197
|
+
if (!match) throw new Error("--version must be a major.minor version such as 4.1; patch is assigned as .0.");
|
|
198
|
+
return `${Number(match[1])}.${Number(match[2])}.0`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function requestedReleaseAction(currentVersion, requestedVersion) {
|
|
202
|
+
const comparison = compareVersions(requestedVersion, currentVersion);
|
|
203
|
+
if (comparison < 0) throw new Error(`Requested version ${requestedVersion} must not be older than the current version ${currentVersion}.`);
|
|
204
|
+
return { version: requestedVersion, bump: comparison > 0 };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function requestedReleaseVersion(args = []) {
|
|
208
|
+
const index = args.findIndex((arg) => arg === "--version" || arg.startsWith("--version="));
|
|
209
|
+
if (index < 0) return null;
|
|
210
|
+
const raw = args[index].includes("=") ? args[index].split("=", 2)[1] : args[index + 1];
|
|
211
|
+
if (raw === undefined || raw.startsWith("--")) throw new Error("--version requires a major.minor value.");
|
|
212
|
+
return normalizeReleaseVersion(raw);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function packageScope(name) {
|
|
216
|
+
return name.startsWith("@") ? name.split("/", 1)[0] : "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function isNpmAuthenticationFailure(output = "") {
|
|
220
|
+
return /\b(?:E401|ENEEDAUTH)\b|401\s+Unauthorized|auth(?:entication|entication token)?[^\n]*(?:required|invalid|expired)|not logged in|must be logged in/i.test(output);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function ensureNpmLogin(name) {
|
|
224
|
+
console.log("Checking npm authentication for " + name + "...");
|
|
225
|
+
const whoami = run("npm", ["whoami", "--registry", npmRegistry], { inherit: false, failOnError: false });
|
|
226
|
+
if (whoami.status === 0 && whoami.stdout.trim()) {
|
|
227
|
+
console.log("npm authentication OK (" + whoami.stdout.trim() + ").");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const failure = whoami.stdout + "\n" + whoami.stderr;
|
|
232
|
+
if (!isNpmAuthenticationFailure(failure)) {
|
|
233
|
+
throw new Error("Unable to verify npm authentication. Check " + npmRegistry + " and try again.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const scope = packageScope(name);
|
|
237
|
+
console.log("npm authentication is missing or invalid; starting npm login...");
|
|
238
|
+
const loginArgs = ["login", "--registry", npmRegistry];
|
|
239
|
+
if (scope) loginArgs.push("--scope", scope);
|
|
240
|
+
const login = run("npm", loginArgs, { failOnError: false });
|
|
241
|
+
if (login.status !== 0) throw new Error("npm login failed or was cancelled. Re-run the release after logging in.");
|
|
242
|
+
|
|
243
|
+
const verified = run("npm", ["whoami", "--registry", npmRegistry], { inherit: false, failOnError: false });
|
|
244
|
+
if (verified.status !== 0 || !verified.stdout.trim()) {
|
|
245
|
+
throw new Error("npm login completed, but npm authentication could not be verified. Run npm whoami and try again.");
|
|
246
|
+
}
|
|
247
|
+
console.log("npm authentication OK (" + verified.stdout.trim() + ").");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function tagExists(tag) {
|
|
251
|
+
const local = run("git", ["tag", "--list", tag], { inherit: false, failOnError: false });
|
|
252
|
+
if (local.status === 0 && local.stdout.trim()) return true;
|
|
253
|
+
const remote = run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`], { inherit: false, failOnError: false });
|
|
254
|
+
return remote.status === 0 && Boolean(remote.stdout.trim());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function remoteRepository() {
|
|
258
|
+
const remote = output("git", ["remote", "get-url", "origin"]);
|
|
259
|
+
const match = remote.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
260
|
+
if (!match) throw new Error("origin does not point to a GitHub repository.");
|
|
261
|
+
return `${match[1]}/${match[2]}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function addNpmTrust(name) {
|
|
265
|
+
const workflow = ".github/workflows/publish.yml";
|
|
266
|
+
if (!existsSync(workflow)) throw new Error(`Cannot configure npm trust: ${workflow} was not found.`);
|
|
267
|
+
const npmVersionResult = run("npm", ["--version"], { inherit: false, failOnError: false });
|
|
268
|
+
const npmVersionOutput = npmVersionResult.stdout?.trim() || "";
|
|
269
|
+
if (npmVersionResult.status !== 0 || compareVersions(npmVersionOutput, "11.15.0") < 0) {
|
|
270
|
+
throw new Error(`npm trust requires npm 11.15.0 or newer (found ${npmVersionOutput || "unknown"}).`);
|
|
271
|
+
}
|
|
272
|
+
const trust = run("npm", [
|
|
273
|
+
"trust", "github", name,
|
|
274
|
+
"--file", "publish.yml",
|
|
275
|
+
"--repo", remoteRepository(),
|
|
276
|
+
"--allow-publish",
|
|
277
|
+
"--yes",
|
|
278
|
+
"--registry", npmRegistry,
|
|
279
|
+
], { failOnError: false });
|
|
280
|
+
if (trust.status !== 0) {
|
|
281
|
+
throw new Error("Unable to configure npm Trusted Publishing. Confirm that the package exists, your npm account has 2FA enabled, and you have package write access.");
|
|
282
|
+
}
|
|
283
|
+
return trust;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function releaseWaitMinutes(args = []) {
|
|
287
|
+
const index = args.findIndex((arg) => arg === "--wait" || arg.startsWith("--wait="));
|
|
288
|
+
if (index < 0) return 5;
|
|
289
|
+
const raw = args[index].includes("=") ? args[index].split("=", 2)[1] : args[index + 1];
|
|
290
|
+
if (raw === undefined || raw.startsWith("--")) throw new Error("--wait requires a number of minutes.");
|
|
291
|
+
const minutes = Number(raw);
|
|
292
|
+
if (!Number.isFinite(minutes) || minutes < 0) throw new Error("--wait must be a non-negative number of minutes.");
|
|
293
|
+
return minutes;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function releaseStatusShouldContinue(checks, now, deadline) {
|
|
297
|
+
if (now >= deadline) return false;
|
|
298
|
+
if (checks.github_actions.state === "failed") return false;
|
|
299
|
+
if (checks.github_actions.state === "unavailable") return !["published", "unavailable"].includes(checks.npm.state);
|
|
300
|
+
return !(checks.github_actions.state === "passed" && checks.npm.state === "published");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function releaseStatusDot(state, ready = false) {
|
|
304
|
+
if (ready || ["clean", "yes", "present and correct", "passed", "published", "ready"].includes(state)) return "🟢";
|
|
305
|
+
if (["dirty", "no", "missing or incorrect", "failed", "unavailable", "not ready"].includes(state)) return "🔴";
|
|
306
|
+
return "🟡";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function releaseTagState(tag) {
|
|
310
|
+
const local = run("git", ["rev-parse", `refs/tags/${tag}`], { inherit: false, failOnError: false });
|
|
311
|
+
// Do not allow Git to wait for credentials in non-interactive images or CI.
|
|
312
|
+
// A remote/network failure is a status result, not a reason to block the CLI.
|
|
313
|
+
const remote = run("git", ["ls-remote", "origin", `refs/tags/${tag}`], {
|
|
314
|
+
inherit: false,
|
|
315
|
+
failOnError: false,
|
|
316
|
+
timeout: releaseStatusCommandTimeout,
|
|
317
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
318
|
+
});
|
|
319
|
+
const localSha = local.status === 0 ? local.stdout.trim() : "";
|
|
320
|
+
const remoteSha = remote.status === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
|
|
321
|
+
return { present: Boolean(localSha && remoteSha), local_sha: localSha || null, remote_sha: remoteSha || null, matches: Boolean(localSha && remoteSha && localSha === remoteSha) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function gitReleaseState() {
|
|
325
|
+
const clean = run("git", ["status", "--porcelain"], { inherit: false, failOnError: false });
|
|
326
|
+
const branch = run("git", ["branch", "--show-current"], { inherit: false, failOnError: false });
|
|
327
|
+
const head = run("git", ["rev-parse", "HEAD"], { inherit: false, failOnError: false });
|
|
328
|
+
const upstream = run("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { inherit: false, failOnError: false });
|
|
329
|
+
const upstreamHead = upstream.status === 0 ? run("git", ["rev-parse", upstream.stdout.trim()], { inherit: false, failOnError: false }) : null;
|
|
330
|
+
const headSha = head.status === 0 ? head.stdout.trim() : "";
|
|
331
|
+
const remoteSha = upstreamHead?.status === 0 ? upstreamHead.stdout.trim() : "";
|
|
332
|
+
return { clean: clean.status === 0 && !clean.stdout.trim(), branch: branch.status === 0 ? branch.stdout.trim() : null, pushed: Boolean(headSha && remoteSha && headSha === remoteSha), head_sha: headSha || null, remote_sha: remoteSha || null };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function githubActionsState(repository, commit, timeout) {
|
|
336
|
+
const result = run("gh", ["run", "list", "--repo", repository, "--workflow", "publish.yml", "--commit", commit, "--limit", "100", "--json", "status,conclusion,databaseId,displayTitle,workflowName,url,headSha"], { inherit: false, failOnError: false, timeout });
|
|
337
|
+
if (result.status !== 0) return { state: "unavailable", runs: [], error: (result.stderr || result.stdout || "GitHub Actions could not be queried.").trim() };
|
|
338
|
+
let runs;
|
|
339
|
+
try { runs = JSON.parse(result.stdout || "[]"); } catch { return { state: "unavailable", runs: [], error: "GitHub Actions returned invalid JSON." }; }
|
|
340
|
+
if (!runs.length) return { state: "pending", runs: [] };
|
|
341
|
+
const failed = runs.filter((item) => item.status === "completed" && item.conclusion !== "success");
|
|
342
|
+
const running = runs.filter((item) => item.status !== "completed");
|
|
343
|
+
return { state: failed.length ? "failed" : running.length ? "running" : "passed", runs };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function npmReleaseState(name, currentVersion, timeout) {
|
|
347
|
+
const result = run("npm", ["view", `${name}@${currentVersion}`, "version", "--json", "--registry", npmRegistry], { inherit: false, failOnError: false, timeout });
|
|
348
|
+
if (result.status === 0) {
|
|
349
|
+
let published = result.stdout.trim();
|
|
350
|
+
try { published = JSON.parse(published); } catch { /* npm may return an unquoted version */ }
|
|
351
|
+
return { state: published === currentVersion ? "published" : "not_published", version: published || null };
|
|
352
|
+
}
|
|
353
|
+
return { state: /E404|not found/i.test(`${result.stdout}\n${result.stderr}`) ? "not_published" : "unavailable", version: null, error: (result.stderr || result.stdout || "npm could not be queried.").trim() };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function baseReleaseState(packageMetadata, timeout) {
|
|
357
|
+
const configured = baseDependencyVersion(packageMetadata);
|
|
358
|
+
const spec = packageMetadata.name === basePackageName ? packageMetadata.version : packageDependency(packageMetadata, basePackageName);
|
|
359
|
+
if (!spec) return { state: "not_declared", version: null, latest: null };
|
|
360
|
+
if (!configured) return { state: "unavailable", version: null, latest: null, spec, error: `Could not determine a version from ${basePackageName}: ${spec}` };
|
|
361
|
+
const result = run("npm", ["view", basePackageName, "version", "--json", "--registry", npmRegistry], { inherit: false, failOnError: false, timeout });
|
|
362
|
+
if (result.status !== 0) return { state: "unavailable", version: configured, latest: null, spec, error: (result.stderr || result.stdout || "npm could not be queried.").trim() };
|
|
363
|
+
let latest = result.stdout.trim();
|
|
364
|
+
try { latest = JSON.parse(latest); } catch { /* npm may return an unquoted version */ }
|
|
365
|
+
if (!latest) return { state: "unavailable", version: configured, latest: null, spec };
|
|
366
|
+
const outdated = compareVersions(latest, configured) > 0;
|
|
367
|
+
return { state: outdated ? "outdated" : "current", version: configured, latest, spec, ...(outdated ? { upgrade: "cf-genai upgrade base latest" } : {}) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function devCommand({ hasScript, args = [] }) {
|
|
371
|
+
const forwarded = args[0] === "--" ? args.slice(1) : args;
|
|
372
|
+
const command = hasScript ? ["npm", "run", "dev", ...forwarded] : ["npx", "wrangler", "dev", ...forwarded];
|
|
373
|
+
return ["op", "run", "--env-file=.env.dev", "--", ...command];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function lastProdRefresh({ cwd = process.cwd(), env = process.env } = {}) {
|
|
377
|
+
if (env.CF_GENAI_LAST_PROD_REFRESH) return env.CF_GENAI_LAST_PROD_REFRESH;
|
|
378
|
+
try {
|
|
379
|
+
return readFileSync(join(cwd, ".cf-genai-last-prod-refresh"), "utf8").trim() || null;
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function printDevDataStatus(options = {}) {
|
|
386
|
+
const refreshed = lastProdRefresh(options);
|
|
387
|
+
console.log("Last production data refresh: " + (refreshed || "unknown"));
|
|
388
|
+
console.log("To refresh data from prod run: cf-genai d1 refresh local");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function applyDevMigrations(env = process.env) {
|
|
392
|
+
if (!existsSync("migrations")) return;
|
|
393
|
+
console.log("Applying local D1 migrations...");
|
|
394
|
+
migrateD1({
|
|
395
|
+
target: "local",
|
|
396
|
+
database: env.CF_GENAI_DATABASE || "DB",
|
|
397
|
+
options: {
|
|
398
|
+
config: env.CF_GENAI_WRANGLER_CONFIG || "wrangler.jsonc",
|
|
399
|
+
wranglerCommand: ["op", "run", "--env-file=.env.dev", "--", ...shellCommand(env.CF_GENAI_WRANGLER || "npx wrangler")],
|
|
400
|
+
env,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function runProjectCommand(command, args = []) {
|
|
406
|
+
if (command === "check") { dataAccessLint(); return run("npm", ["run", "check"]); }
|
|
407
|
+
if (command === "lint") { if (args.length && args[0] !== "data-access") throw new Error("Unknown lint target. Use data-access."); return dataAccessLint(); }
|
|
408
|
+
if (command === "ci:lint") return dataAccessLint();
|
|
409
|
+
if (command === "test") return run("npm", ["test"]);
|
|
410
|
+
if (command === "build" || command === "ci") return run("npm", ["run", "build"]);
|
|
411
|
+
if (command === "dev") {
|
|
412
|
+
printDevDataStatus();
|
|
413
|
+
applyDevMigrations();
|
|
414
|
+
const scripts = packageJson().scripts || {};
|
|
415
|
+
const [program, ...programArgs] = devCommand({ hasScript: Boolean(scripts.dev), args });
|
|
416
|
+
return run(program, programArgs);
|
|
417
|
+
}
|
|
418
|
+
if (command === "upgrade") return upgradePackage(args);
|
|
419
|
+
if (command === "release" && args.includes("--first")) return publishFirst(args);
|
|
420
|
+
if (command === "release" && args.includes("--add-trust")) return addTrust(args);
|
|
421
|
+
if (command === "release") return release(args);
|
|
422
|
+
if (command === "release-status") return releaseStatus(args);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function publishFirst(args) {
|
|
427
|
+
if (args.some((arg) => arg === "--version" || arg.startsWith("--version="))) throw new Error("--version is only supported by the normal release command, not --first.");
|
|
428
|
+
assertFlag(args, "--confirm", "Initial npm publishing is irreversible and requires explicit human confirmation.");
|
|
429
|
+
assertClean();
|
|
430
|
+
if (!args.includes("--bypass-lint")) runProjectCommand("ci:lint");
|
|
431
|
+
assertMainIsSynchronized();
|
|
432
|
+
const name = packageName();
|
|
433
|
+
const currentVersion = version();
|
|
434
|
+
ensureNpmLogin(name);
|
|
435
|
+
if (npmVersion(name, currentVersion)) throw new Error(name + "@" + currentVersion + " is already published.");
|
|
436
|
+
console.log("Publishing " + name + "@" + currentVersion + " to npm...");
|
|
437
|
+
const published = run("npm", ["publish", "--access", "public", "--provenance=false"]);
|
|
438
|
+
if (published.status !== 0) return published;
|
|
439
|
+
console.log("Configuring npm Trusted Publishing for " + name + "...");
|
|
440
|
+
return addNpmTrust(name);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function addTrust(args) {
|
|
444
|
+
if (args.includes("--first")) throw new Error("Use either --first or --add-trust; --first already configures Trusted Publishing after the initial publish.");
|
|
445
|
+
assertFlag(args, "--confirm", "Changing npm Trusted Publishing requires explicit human confirmation.");
|
|
446
|
+
assertClean();
|
|
447
|
+
const name = packageName();
|
|
448
|
+
if (!npmPackageExists(name)) {
|
|
449
|
+
throw new Error(`${name} must be published before Trusted Publishing can be configured. Run cf-genai release --first --confirm.`);
|
|
450
|
+
}
|
|
451
|
+
console.log("Configuring npm Trusted Publishing for " + name + "...");
|
|
452
|
+
return addNpmTrust(name);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function release(args) {
|
|
456
|
+
const dryRun = args.includes("--dry-run");
|
|
457
|
+
const bypassLint = args.includes("--bypass-lint");
|
|
458
|
+
const requestedVersion = requestedReleaseVersion(args);
|
|
459
|
+
if (requestedVersion && args.includes("--type")) throw new Error("Use either --version or --type, not both.");
|
|
460
|
+
if (!dryRun) assertFlag(args, "--confirm", "A release requires explicit human confirmation because it creates a commit, tag, and npm publish trigger.");
|
|
461
|
+
assertClean();
|
|
462
|
+
if (!bypassLint) runProjectCommand("ci:lint");
|
|
463
|
+
assertMainIsSynchronized();
|
|
464
|
+
const name = packageName();
|
|
465
|
+
let currentVersion = version();
|
|
466
|
+
if (requestedVersion) {
|
|
467
|
+
const action = requestedReleaseAction(currentVersion, requestedVersion);
|
|
468
|
+
const published = npmVersionState(name, requestedVersion);
|
|
469
|
+
if (published.exists || tagExists(`v${requestedVersion}`)) throw new Error(`${name}@${requestedVersion} already exists; release versions cannot be reused.`);
|
|
470
|
+
if (dryRun) {
|
|
471
|
+
console.log(`Dry run: ${name}@${currentVersion} would release as ${action.version}. Main is synchronized and no changes were made.`);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (action.bump) {
|
|
475
|
+
const bumped = run("npm", ["version", requestedVersion, "--no-git-tag-version"]);
|
|
476
|
+
if (bumped.status !== 0) return bumped;
|
|
477
|
+
currentVersion = version();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const typeIndex = args.indexOf("--type");
|
|
481
|
+
const type = typeIndex >= 0 ? args[typeIndex + 1] : "patch";
|
|
482
|
+
if (!["patch", "minor", "major"].includes(type)) throw new Error("--type must be patch, minor, or major.");
|
|
483
|
+
const currentPublished = requestedVersion ? false : npmVersionState(name, currentVersion).exists;
|
|
484
|
+
const currentTagExists = requestedVersion ? false : tagExists(`v${currentVersion}`);
|
|
485
|
+
if (!requestedVersion && currentTagExists && !currentPublished) {
|
|
486
|
+
throw new Error(`${name}@${currentVersion} has a release tag but is not published. Repair the failed publish or run release-status before creating another release.`);
|
|
487
|
+
}
|
|
488
|
+
if (!requestedVersion && !currentPublished && !currentTagExists) {
|
|
489
|
+
throw new Error(`${name} is not published yet. Run cf-genai release --first --confirm to bootstrap the npm package before creating a release tag.`);
|
|
490
|
+
}
|
|
491
|
+
const consumed = requestedVersion ? false : Boolean(currentPublished || currentTagExists);
|
|
492
|
+
if (dryRun) {
|
|
493
|
+
console.log(`Dry run: ${name}@${currentVersion}${consumed ? ` would bump ${type}` : " is ready to release"}. Main is synchronized and no changes were made.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (consumed) {
|
|
497
|
+
const bumped = run("npm", ["version", type, "--no-git-tag-version"]);
|
|
498
|
+
if (bumped.status !== 0) return bumped;
|
|
499
|
+
currentVersion = version();
|
|
500
|
+
}
|
|
501
|
+
const staged = run("git", ["add", "package.json", "package-lock.json"]);
|
|
502
|
+
if (staged.status !== 0) return staged;
|
|
503
|
+
const stagedChanges = spawnSync("git", ["diff", "--cached", "--quiet"], { stdio: "ignore" });
|
|
504
|
+
if (stagedChanges.status !== 0) {
|
|
505
|
+
const committed = run("git", ["commit", "-m", `Release ${name} v${currentVersion}`]);
|
|
506
|
+
if (committed.status !== 0) return committed;
|
|
507
|
+
}
|
|
508
|
+
const pushedMain = run("git", ["push", "origin", "main"]);
|
|
509
|
+
if (pushedMain.status !== 0) return pushedMain;
|
|
510
|
+
const localHead = output("git", ["rev-parse", "HEAD"]);
|
|
511
|
+
const remoteHead = output("git", ["ls-remote", "origin", "refs/heads/main"]).split(/\s+/)[0];
|
|
512
|
+
if (localHead !== remoteHead) throw new Error("origin/main could not be verified at the release commit; tag was not created.");
|
|
513
|
+
const tagged = run("git", ["tag", `v${currentVersion}`]);
|
|
514
|
+
if (tagged.status !== 0) return tagged;
|
|
515
|
+
return run("git", ["push", "origin", `refs/tags/v${currentVersion}`]);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function releaseStatus(args = []) {
|
|
519
|
+
const name = packageName();
|
|
520
|
+
const currentVersion = version();
|
|
521
|
+
const waitMinutes = releaseWaitMinutes(args);
|
|
522
|
+
const json = args.includes("--json");
|
|
523
|
+
const deadline = Date.now() + waitMinutes * 60_000;
|
|
524
|
+
const tag = `v${currentVersion}`;
|
|
525
|
+
const checks = { git: gitReleaseState(), tag: releaseTagState(tag), base: baseReleaseState(packageJson(), releaseStatusCommandTimeout), github_actions: { state: "pending", runs: [] }, npm: { state: "not_published", version: null } };
|
|
526
|
+
const repository = remoteRepository();
|
|
527
|
+
do {
|
|
528
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
529
|
+
checks.github_actions = githubActionsState(repository, checks.tag.remote_sha || checks.tag.local_sha || "", Math.min(10_000, remaining));
|
|
530
|
+
checks.npm = npmReleaseState(name, currentVersion, Math.min(10_000, Math.max(1, deadline - Date.now())));
|
|
531
|
+
if (!releaseStatusShouldContinue(checks, Date.now(), deadline)) break;
|
|
532
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, Math.max(1, deadline - Date.now()))));
|
|
533
|
+
} while (Date.now() < deadline);
|
|
534
|
+
const result = { package: name, version: currentVersion, tag, repository, wait_minutes: waitMinutes, checks, ok: checks.git.clean && checks.git.pushed && checks.tag.matches && checks.github_actions.state === "passed" && checks.npm.state === "published" };
|
|
535
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
536
|
+
else {
|
|
537
|
+
console.log(`${name}@${currentVersion}`);
|
|
538
|
+
const clean = checks.git.clean ? "clean" : "dirty";
|
|
539
|
+
const pushed = checks.git.pushed ? "yes" : "no";
|
|
540
|
+
const tagState = checks.tag.matches ? "present and correct" : "missing or incorrect";
|
|
541
|
+
const releaseState = result.ok ? "ready" : "not ready";
|
|
542
|
+
console.log(`workspace: ${releaseStatusDot(clean)} ${clean}; pushed: ${releaseStatusDot(pushed)} ${pushed}`);
|
|
543
|
+
console.log(`tag ${tag}: ${releaseStatusDot(tagState)} ${tagState}`);
|
|
544
|
+
const actionsError = checks.github_actions.error ? ` — ${checks.github_actions.error.replace(/\s+/g, " ")}` : "";
|
|
545
|
+
const failedPublish = checks.github_actions.runs?.find((run) => run.status === "completed" && run.conclusion !== "success");
|
|
546
|
+
const failedPublishError = failedPublish ? ` — ${failedPublish.conclusion}${failedPublish.url ? ` (${failedPublish.url})` : ""}` : "";
|
|
547
|
+
const npmError = checks.npm.error ? ` — ${checks.npm.error.replace(/\s+/g, " ")}` : "";
|
|
548
|
+
const baseError = checks.base.error ? ` — ${checks.base.error.replace(/\s+/g, " ")}` : "";
|
|
549
|
+
console.log(`GitHub Actions: ${releaseStatusDot(checks.github_actions.state)} ${checks.github_actions.state}${actionsError}${failedPublishError}`);
|
|
550
|
+
console.log(`npm: ${releaseStatusDot(checks.npm.state)} ${checks.npm.state}${npmError}`);
|
|
551
|
+
if (checks.base.state === "outdated") {
|
|
552
|
+
console.log(`cf-genai-base: ${releaseStatusDot(checks.base.state)} outdated (${checks.base.version}; latest ${checks.base.latest})`);
|
|
553
|
+
console.log(` Upgrade: ${checks.base.upgrade}`);
|
|
554
|
+
} else if (checks.base.state !== "not_declared") {
|
|
555
|
+
console.log(`cf-genai-base: ${releaseStatusDot(checks.base.state)} ${checks.base.state}${baseError}`);
|
|
556
|
+
}
|
|
557
|
+
console.log(`release status: ${releaseStatusDot(releaseState)} ${releaseState}`);
|
|
558
|
+
}
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { executeJson, parseJsonRows } from "./d1.js";
|
|
3
|
+
|
|
4
|
+
function readConfig(path) {
|
|
5
|
+
try {
|
|
6
|
+
const text = readFileSync(path, "utf8").replace(/^\s*\/\/.*$/gm, "").replace(/,\s*([}])/g, "$1");
|
|
7
|
+
return JSON.parse(text);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
throw new Error("Unable to read Wrangler config " + path + ": " + error.message);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function siteDetails(options) {
|
|
14
|
+
const config = readConfig(options.config);
|
|
15
|
+
const routes = Array.isArray(config.routes) ? config.routes : [];
|
|
16
|
+
const route = routes.find((item) => item?.custom_domain && item.pattern) || routes.find((item) => item?.pattern);
|
|
17
|
+
const url = process.env.CF_GENAI_PRODUCTION_URL || config.vars?.PUBLIC_ORIGIN || (route?.pattern ? (String(route.pattern).startsWith("http") ? route.pattern : "https://" + route.pattern) : null);
|
|
18
|
+
return { name: config.name || "Unnamed site", url, config: options.config };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function query(options, target, sql) {
|
|
22
|
+
return parseJsonRows(executeJson(options.wranglerCommand, options.database, target, sql, { ...options, target }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function siteStatus(options) {
|
|
26
|
+
const target = options.target === "local" ? "production" : (options.target === "prod" ? "production" : options.target);
|
|
27
|
+
const checks = query(options, target, "SELECT id,display_name,feature,component,state FROM core_healthchecks ORDER BY feature,component;");
|
|
28
|
+
const breakers = query(options, target, "SELECT id,display_name,feature,name,state FROM core_circuit_breakers ORDER BY feature,name;");
|
|
29
|
+
const overview = {
|
|
30
|
+
site: siteDetails(options),
|
|
31
|
+
environment: target,
|
|
32
|
+
database: options.database,
|
|
33
|
+
healthchecks: { total: checks.length, green: checks.filter((item) => item.state === "green").length, yellow: checks.filter((item) => item.state === "yellow").length, red: checks.filter((item) => item.state === "red").length, attention: checks.filter((item) => item.state !== "green") },
|
|
34
|
+
circuit_breakers: { total: breakers.length, green: breakers.filter((item) => item.state === "on").length, off: breakers.filter((item) => item.state === "off").length, tripped: breakers.filter((item) => item.state === "tripped").length, attention: breakers.filter((item) => item.state !== "on") }
|
|
35
|
+
};
|
|
36
|
+
if (options.json) { console.log(JSON.stringify(overview, null, 2)); return overview; }
|
|
37
|
+
console.log("Site: " + overview.site.name);
|
|
38
|
+
console.log("URL: " + (overview.site.url || "unknown"));
|
|
39
|
+
console.log("Environment: " + overview.environment);
|
|
40
|
+
console.log("Health checks: " + overview.healthchecks.green + " green, " + overview.healthchecks.yellow + " yellow, " + overview.healthchecks.red + " red");
|
|
41
|
+
console.log("Circuit breakers: " + overview.circuit_breakers.green + " green, " + overview.circuit_breakers.off + " off, " + overview.circuit_breakers.tripped + " tripped");
|
|
42
|
+
const attention = [...overview.healthchecks.attention.map((item) => ({ type: "health check", item })), ...overview.circuit_breakers.attention.map((item) => ({ type: "circuit breaker", item }))];
|
|
43
|
+
if (attention.length) { console.log("Attention:"); for (const entry of attention) console.log("- " + entry.type + ": " + (entry.item.display_name || entry.item.id) + " (" + entry.item.state + ")"); } else console.log("All systems green.");
|
|
44
|
+
return overview;
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { executeJson, parseJsonRows } from "./d1.js";
|
|
2
|
+
|
|
3
|
+
function sql(value) { return "'" + String(value).replaceAll("'", "''") + "'"; }
|
|
4
|
+
function query(database, target, command, options) { return parseJsonRows(executeJson(options.wranglerCommand, database, target, command, options)); }
|
|
5
|
+
function output(value) { console.log(JSON.stringify(value, null, 2)); }
|
|
6
|
+
|
|
7
|
+
export function runTenantCommand({ action, identifier, name, options }) {
|
|
8
|
+
const target = options.target === "prod" ? "production" : options.target || "local";
|
|
9
|
+
if (!["local", "staging", "production"].includes(target)) throw new Error("Tenant target must be local, staging, or production.");
|
|
10
|
+
if (action === "list") return output(query(options.database, target, "SELECT t.id,t.name,t.created_at,t.updated_at,COUNT(ut.user_id) AS user_count FROM auth_tenants t LEFT JOIN auth_user_tenants ut ON ut.tenant_id=t.id GROUP BY t.id ORDER BY t.name COLLATE NOCASE;", options));
|
|
11
|
+
if (!identifier) throw new Error("A tenant id is required.");
|
|
12
|
+
if (action === "get") return output(query(options.database, target, "SELECT id,name,created_at,updated_at FROM auth_tenants WHERE id=" + sql(identifier) + " LIMIT 1;", options));
|
|
13
|
+
if (action === "create") { if (!name) throw new Error("tenant:create requires --name."); query(options.database, target, "INSERT INTO auth_tenants (id,name) VALUES (" + sql(identifier) + "," + sql(name) + ");", options); return output({ id: identifier, name, environment: target, changed_by: "human:cli" }); }
|
|
14
|
+
if (["update", "rename"].includes(action)) { if (!name) throw new Error("tenant:update requires --name."); query(options.database, target, "UPDATE auth_tenants SET name=" + sql(name) + ",updated_at=CURRENT_TIMESTAMP WHERE id=" + sql(identifier) + ";", options); return output({ id: identifier, name, environment: target, changed_by: "human:cli" }); }
|
|
15
|
+
throw new Error("Unknown tenant action.");
|
|
16
|
+
}
|