@beechcms/cli 0.8.0 → 0.9.0
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/dist/index.js +1474 -203
- package/package.json +13 -9
- package/dist/.tsbuildinfo +0 -1
package/dist/index.js
CHANGED
|
@@ -17,8 +17,15 @@ var __export = (target, all) => {
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { writeFileSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
|
-
import { join, resolve } from "node:path";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
21
|
import { DatabaseSync } from "node:sqlite";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
function validateDbName(name) {
|
|
24
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
25
|
+
throw new Error(`Invalid D1 database name: ${name}`);
|
|
26
|
+
}
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
22
29
|
function buildArgs(options) {
|
|
23
30
|
const args = [];
|
|
24
31
|
if (options.configPath) args.push("--config", options.configPath);
|
|
@@ -50,6 +57,7 @@ function getLocalD1SqlitePath(startDir = process.cwd()) {
|
|
|
50
57
|
return null;
|
|
51
58
|
}
|
|
52
59
|
function executeD1File(sql, options) {
|
|
60
|
+
validateDbName(options.db);
|
|
53
61
|
if (options.local) {
|
|
54
62
|
const sqlitePath = getLocalD1SqlitePath();
|
|
55
63
|
if (sqlitePath) {
|
|
@@ -65,10 +73,10 @@ function executeD1File(sql, options) {
|
|
|
65
73
|
try {
|
|
66
74
|
writeFileSync(tmpFile, sql, "utf-8");
|
|
67
75
|
const args = ["d1", "execute", options.db, "--file", tmpFile, ...buildArgs(options)];
|
|
68
|
-
const result = spawnSync("
|
|
76
|
+
const result = spawnSync(process.execPath, [join(dirname(require2.resolve("wrangler/package.json")), "bin", "wrangler.js"), ...args], {
|
|
69
77
|
stdio: "inherit",
|
|
70
78
|
cwd: process.cwd(),
|
|
71
|
-
shell:
|
|
79
|
+
shell: false,
|
|
72
80
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false" }
|
|
73
81
|
});
|
|
74
82
|
return result.status === 0;
|
|
@@ -80,6 +88,7 @@ function executeD1File(sql, options) {
|
|
|
80
88
|
}
|
|
81
89
|
}
|
|
82
90
|
function queryD1(sql, options) {
|
|
91
|
+
validateDbName(options.db);
|
|
83
92
|
if (options.local) {
|
|
84
93
|
const sqlitePath = getLocalD1SqlitePath();
|
|
85
94
|
if (sqlitePath) {
|
|
@@ -95,10 +104,10 @@ function queryD1(sql, options) {
|
|
|
95
104
|
try {
|
|
96
105
|
writeFileSync(tmpFile, sql, "utf-8");
|
|
97
106
|
const args = ["d1", "execute", options.db, "--file", tmpFile, "--json", ...buildArgs(options)];
|
|
98
|
-
result = spawnSync("
|
|
107
|
+
result = spawnSync(process.execPath, [join(dirname(require2.resolve("wrangler/package.json")), "bin", "wrangler.js"), ...args], {
|
|
99
108
|
encoding: "utf-8",
|
|
100
109
|
cwd: process.cwd(),
|
|
101
|
-
shell:
|
|
110
|
+
shell: false,
|
|
102
111
|
env: { ...process.env, WRANGLER_SEND_METRICS: "false" }
|
|
103
112
|
});
|
|
104
113
|
} finally {
|
|
@@ -123,12 +132,12 @@ function findWranglerConfig() {
|
|
|
123
132
|
let dir = process.cwd();
|
|
124
133
|
while (true) {
|
|
125
134
|
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
|
|
126
|
-
const
|
|
127
|
-
if (existsSync(
|
|
135
|
+
const p4 = resolve(dir, name);
|
|
136
|
+
if (existsSync(p4)) return p4;
|
|
128
137
|
}
|
|
129
138
|
for (const name of ["wrangler.jsonc", "wrangler.json", "wrangler.toml"]) {
|
|
130
|
-
const
|
|
131
|
-
if (existsSync(
|
|
139
|
+
const p4 = resolve(dir, "apps", "api", name);
|
|
140
|
+
if (existsSync(p4)) return p4;
|
|
132
141
|
}
|
|
133
142
|
const parent = resolve(dir, "..");
|
|
134
143
|
if (parent === dir) break;
|
|
@@ -138,6 +147,7 @@ function findWranglerConfig() {
|
|
|
138
147
|
}
|
|
139
148
|
function resolveDbName(configPath) {
|
|
140
149
|
if (!configPath) return "beech-db";
|
|
150
|
+
let name = "beech-db";
|
|
141
151
|
try {
|
|
142
152
|
const raw = readFileSync(configPath, "utf-8");
|
|
143
153
|
if (configPath.endsWith(".toml")) {
|
|
@@ -145,21 +155,24 @@ function resolveDbName(configPath) {
|
|
|
145
155
|
if (d1SectionMatch) {
|
|
146
156
|
const section = d1SectionMatch[0];
|
|
147
157
|
const dbNameMatch = section.match(/database_name\s*=\s*["'](.+?)["']/);
|
|
148
|
-
if (dbNameMatch)
|
|
158
|
+
if (dbNameMatch) name = dbNameMatch[1];
|
|
149
159
|
}
|
|
150
|
-
|
|
160
|
+
} else {
|
|
161
|
+
const stripped = raw.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
162
|
+
const parsed = JSON.parse(stripped);
|
|
163
|
+
const bindings = parsed?.d1_databases ?? [];
|
|
164
|
+
name = bindings[0]?.database_name ?? "beech-db";
|
|
151
165
|
}
|
|
152
|
-
const stripped = raw.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
153
|
-
const parsed = JSON.parse(stripped);
|
|
154
|
-
const bindings = parsed?.d1_databases ?? [];
|
|
155
|
-
return bindings[0]?.database_name ?? "beech-db";
|
|
156
166
|
} catch {
|
|
157
167
|
return "beech-db";
|
|
158
168
|
}
|
|
169
|
+
return validateDbName(name);
|
|
159
170
|
}
|
|
171
|
+
var require2;
|
|
160
172
|
var init_wrangler = __esm({
|
|
161
173
|
"src/lib/wrangler.ts"() {
|
|
162
174
|
"use strict";
|
|
175
|
+
require2 = createRequire(import.meta.url);
|
|
163
176
|
}
|
|
164
177
|
});
|
|
165
178
|
|
|
@@ -169,10 +182,11 @@ __export(init_exports, {
|
|
|
169
182
|
init: () => init
|
|
170
183
|
});
|
|
171
184
|
import pc2 from "picocolors";
|
|
172
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
185
|
+
import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
173
186
|
import { createInterface } from "node:readline/promises";
|
|
174
187
|
import { resolve as resolve2, basename } from "node:path";
|
|
175
188
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
189
|
+
import { randomBytes } from "node:crypto";
|
|
176
190
|
function checkWranglerAuth() {
|
|
177
191
|
const result = spawnSync2("npx", ["wrangler", "whoami", "--json"], {
|
|
178
192
|
encoding: "utf-8",
|
|
@@ -287,6 +301,24 @@ function echoApiKeys(configPath) {
|
|
|
287
301
|
} catch {
|
|
288
302
|
}
|
|
289
303
|
}
|
|
304
|
+
function ensurePrivacyMasterKey(devVarsPath) {
|
|
305
|
+
const content = readFileSync2(devVarsPath, "utf8");
|
|
306
|
+
const lines = content.split(/\r?\n/);
|
|
307
|
+
const index = lines.findIndex((line) => /^PRIVACY_MASTER_KEY=/.test(line));
|
|
308
|
+
if (index !== -1 && lines[index].slice("PRIVACY_MASTER_KEY=".length).trim() !== "") return false;
|
|
309
|
+
const entry = `PRIVACY_MASTER_KEY=${randomBytes(32).toString("hex")}`;
|
|
310
|
+
if (index !== -1) {
|
|
311
|
+
lines[index] = entry;
|
|
312
|
+
writeFileSync2(devVarsPath, lines.join("\n"), "utf8");
|
|
313
|
+
} else {
|
|
314
|
+
const separator = content === "" || content.endsWith("\n") ? "" : "\n";
|
|
315
|
+
appendFileSync(devVarsPath, `${separator}
|
|
316
|
+
# Master key for protected fields (AES-GCM + blind index). Do NOT change once data is stored.
|
|
317
|
+
${entry}
|
|
318
|
+
`, "utf8");
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
290
322
|
function checkFiles(cwd, checkDevVars) {
|
|
291
323
|
let ok = true;
|
|
292
324
|
const workerExists = existsSync2(resolve2(cwd, "worker.ts")) || existsSync2(resolve2(cwd, "worker.js"));
|
|
@@ -309,6 +341,9 @@ function checkFiles(cwd, checkDevVars) {
|
|
|
309
341
|
console.log(pc2.dim(" \u25CB .dev.vars \u2014 not found (optional: only needed for production R2 credentials)"));
|
|
310
342
|
} else {
|
|
311
343
|
console.log(pc2.green(" \u2713 .dev.vars"));
|
|
344
|
+
if (ensurePrivacyMasterKey(resolve2(cwd, ".dev.vars"))) {
|
|
345
|
+
console.log(pc2.green(" \u2713 PRIVACY_MASTER_KEY \u2014 generated in .dev.vars"));
|
|
346
|
+
}
|
|
312
347
|
}
|
|
313
348
|
}
|
|
314
349
|
return ok;
|
|
@@ -1021,132 +1056,1291 @@ async function reset(args) {
|
|
|
1021
1056
|
}
|
|
1022
1057
|
|
|
1023
1058
|
// src/commands/generate-types.ts
|
|
1024
|
-
init_wrangler();
|
|
1025
1059
|
import { writeFileSync as writeFileSync3, mkdirSync } from "node:fs";
|
|
1026
|
-
import { dirname, resolve as resolve4 } from "node:path";
|
|
1060
|
+
import { dirname as dirname2, resolve as resolve4 } from "node:path";
|
|
1061
|
+
import pc12 from "picocolors";
|
|
1062
|
+
import { computeSchemaFingerprint, generateSeedTypes } from "@beechcms/core";
|
|
1063
|
+
|
|
1064
|
+
// src/lib/d1-context.ts
|
|
1065
|
+
init_wrangler();
|
|
1027
1066
|
import pc11 from "picocolors";
|
|
1028
|
-
import {
|
|
1029
|
-
|
|
1030
|
-
|
|
1067
|
+
import { introspectSeedDefinitions } from "@beechcms/core";
|
|
1068
|
+
|
|
1069
|
+
// src/lib/d1-executor.ts
|
|
1070
|
+
init_wrangler();
|
|
1071
|
+
function createWranglerExecutor(options) {
|
|
1072
|
+
return {
|
|
1073
|
+
all(sql) {
|
|
1074
|
+
return Promise.resolve(queryD1(sql, options));
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/lib/d1-context.ts
|
|
1080
|
+
var CliError = class extends Error {
|
|
1081
|
+
constructor(message, hint, cause) {
|
|
1082
|
+
super(message);
|
|
1083
|
+
this.hint = hint;
|
|
1084
|
+
this.cause = cause;
|
|
1085
|
+
this.name = "CliError";
|
|
1086
|
+
}
|
|
1087
|
+
hint;
|
|
1088
|
+
cause;
|
|
1089
|
+
};
|
|
1090
|
+
function createD1Context(args = {}) {
|
|
1091
|
+
const local = args.local !== false;
|
|
1031
1092
|
const configPath = findWranglerConfig();
|
|
1032
1093
|
const db = args.db ?? resolveDbName(configPath);
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1094
|
+
if (local && !getLocalD1SqlitePath()) {
|
|
1095
|
+
throw new CliError(
|
|
1096
|
+
"Local D1 database state not found.",
|
|
1097
|
+
"Start the local environment with `beech dev`, or initialize it with `beech init --db`."
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const options = { db, local, configPath };
|
|
1101
|
+
return { executor: createWranglerExecutor(options), options };
|
|
1102
|
+
}
|
|
1103
|
+
async function loadLiveSeeds(context) {
|
|
1104
|
+
let seeds;
|
|
1105
|
+
try {
|
|
1106
|
+
seeds = await introspectSeedDefinitions(context.executor);
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
const detail = describeCause(error);
|
|
1109
|
+
if (detail.includes("no such table: seeds")) {
|
|
1110
|
+
throw new CliError(
|
|
1111
|
+
"System table `seeds` not found in the database.",
|
|
1112
|
+
"Run `beech init --db` or `beech onboard` to create the system tables.",
|
|
1113
|
+
error
|
|
1038
1114
|
);
|
|
1039
|
-
process.exit(1);
|
|
1040
1115
|
}
|
|
1116
|
+
throw new CliError(
|
|
1117
|
+
`Failed to introspect D1 database (${context.options.db}): ${detail}`,
|
|
1118
|
+
void 0,
|
|
1119
|
+
error
|
|
1120
|
+
);
|
|
1041
1121
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1122
|
+
if (seeds.length === 0) {
|
|
1123
|
+
throw new CliError(
|
|
1124
|
+
`No active seeds found in D1 database (${context.options.db}).`,
|
|
1125
|
+
"Create a content type in the dashboard (/admin) or through POST /api/seeds."
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
return seeds;
|
|
1129
|
+
}
|
|
1130
|
+
function describeCause(error) {
|
|
1131
|
+
const parts = [];
|
|
1132
|
+
let current = error;
|
|
1133
|
+
for (let depth = 0; current instanceof Error && depth < 5; depth++) {
|
|
1134
|
+
parts.push(current.message);
|
|
1135
|
+
current = current.cause;
|
|
1136
|
+
}
|
|
1137
|
+
if (parts.length === 0) parts.push(String(error));
|
|
1138
|
+
return parts.join(" \u2014 ");
|
|
1139
|
+
}
|
|
1140
|
+
function exitWithError(error) {
|
|
1141
|
+
if (error instanceof CliError) {
|
|
1142
|
+
console.error(pc11.red(`
|
|
1143
|
+
\u2717 ${error.message}`) + (error.hint ? pc11.gray(`
|
|
1144
|
+
${error.hint}
|
|
1145
|
+
`) : "\n"));
|
|
1146
|
+
} else {
|
|
1147
|
+
console.error(pc11.red(`
|
|
1148
|
+
\u2717 ${error instanceof Error ? error.message : String(error)}
|
|
1149
|
+
`));
|
|
1150
|
+
}
|
|
1151
|
+
process.exit(1);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// src/commands/generate-types.ts
|
|
1155
|
+
var DEFAULT_TYPES_PATH = "beech.generated.ts";
|
|
1156
|
+
async function generateTypes(args = {}) {
|
|
1048
1157
|
try {
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1158
|
+
const context = createD1Context(args);
|
|
1159
|
+
const seeds = await loadLiveSeeds(context);
|
|
1160
|
+
const fingerprint = await computeSchemaFingerprint(seeds);
|
|
1161
|
+
const code = generateSeedTypes(seeds, { fingerprint });
|
|
1162
|
+
if (args.out === null) {
|
|
1163
|
+
process.stdout.write(code);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const target = args.out ?? DEFAULT_TYPES_PATH;
|
|
1167
|
+
const outPath = resolve4(process.cwd(), target);
|
|
1168
|
+
mkdirSync(dirname2(outPath), { recursive: true });
|
|
1169
|
+
writeFileSync3(outPath, code, "utf-8");
|
|
1170
|
+
console.log(
|
|
1171
|
+
pc12.green(`
|
|
1172
|
+
\u2713 Generated ${seeds.length} interface(s) \u2192 ${target}`) + pc12.gray(`
|
|
1173
|
+
schema fingerprint ${fingerprint}
|
|
1174
|
+
`)
|
|
1052
1175
|
);
|
|
1053
1176
|
} catch (error) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1177
|
+
exitWithError(error);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// src/commands/types-check.ts
|
|
1182
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "node:fs";
|
|
1183
|
+
import { resolve as resolve5 } from "node:path";
|
|
1184
|
+
import pc13 from "picocolors";
|
|
1185
|
+
import { computeSchemaFingerprint as computeSchemaFingerprint2, generateSeedTypes as generateSeedTypes2 } from "@beechcms/core";
|
|
1186
|
+
async function typesCheck(args = {}) {
|
|
1187
|
+
try {
|
|
1188
|
+
const context = createD1Context(args);
|
|
1189
|
+
const seeds = await loadLiveSeeds(context);
|
|
1190
|
+
const fingerprint = await computeSchemaFingerprint2(seeds);
|
|
1191
|
+
const fresh = generateSeedTypes2(seeds, { fingerprint });
|
|
1192
|
+
const target = args.out ?? DEFAULT_TYPES_PATH;
|
|
1193
|
+
const outPath = resolve5(process.cwd(), target);
|
|
1194
|
+
if (!existsSync4(outPath)) {
|
|
1195
|
+
console.log(
|
|
1196
|
+
pc13.yellow(`
|
|
1197
|
+
\u26A0 ${target} not found.`) + pc13.gray(`
|
|
1198
|
+
Run \`beech types generate\` to create it.
|
|
1199
|
+
`)
|
|
1058
1200
|
);
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1201
|
+
process.exit(1);
|
|
1202
|
+
}
|
|
1203
|
+
const committed = readFileSync5(outPath, "utf-8");
|
|
1204
|
+
if (normalizeEol(committed) !== normalizeEol(fresh)) {
|
|
1205
|
+
console.log(
|
|
1206
|
+
pc13.yellow(`
|
|
1207
|
+
\u26A0 ${target} is stale \u2014 it does not match the live D1 schema.`) + pc13.gray(`
|
|
1208
|
+
Run \`beech types generate\` and commit the result.
|
|
1064
1209
|
`)
|
|
1065
1210
|
);
|
|
1211
|
+
process.exit(1);
|
|
1066
1212
|
}
|
|
1067
|
-
|
|
1213
|
+
console.log(
|
|
1214
|
+
pc13.green(`
|
|
1215
|
+
\u2713 ${target} matches live D1.`) + pc13.gray(`
|
|
1216
|
+
schema fingerprint ${fingerprint}
|
|
1217
|
+
`)
|
|
1218
|
+
);
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
exitWithError(error);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
function normalizeEol(text3) {
|
|
1224
|
+
return text3.replace(/\r\n/g, "\n");
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/commands/schema-diff.ts
|
|
1228
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
1229
|
+
import { resolve as resolve7 } from "node:path";
|
|
1230
|
+
import pc16 from "picocolors";
|
|
1231
|
+
|
|
1232
|
+
// src/lib/manifest-loader.ts
|
|
1233
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
1234
|
+
import { resolve as resolve6 } from "node:path";
|
|
1235
|
+
import { pathToFileURL } from "node:url";
|
|
1236
|
+
import { MANIFEST_VERSION, validateManifest } from "@beechcms/core/schema";
|
|
1237
|
+
var DEFAULT_MANIFEST_PATH = "beech.schema.ts";
|
|
1238
|
+
var ManifestLoadError = class extends Error {
|
|
1239
|
+
constructor(message, issues = [], cause) {
|
|
1240
|
+
super(message);
|
|
1241
|
+
this.issues = issues;
|
|
1242
|
+
this.cause = cause;
|
|
1243
|
+
this.name = "ManifestLoadError";
|
|
1244
|
+
}
|
|
1245
|
+
issues;
|
|
1246
|
+
cause;
|
|
1247
|
+
};
|
|
1248
|
+
function interpretManifestModule(module, path) {
|
|
1249
|
+
const candidate = module?.default;
|
|
1250
|
+
if (candidate === void 0) {
|
|
1251
|
+
throw new ManifestLoadError(
|
|
1252
|
+
`${path} has no default export. A manifest ends with \`export default defineSchema({ seeds: [...] })\`.`
|
|
1253
|
+
);
|
|
1068
1254
|
}
|
|
1069
|
-
if (
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1255
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
1256
|
+
throw new ManifestLoadError(`${path} default-exports a ${typeof candidate}, not a manifest object.`);
|
|
1257
|
+
}
|
|
1258
|
+
const manifest = candidate;
|
|
1259
|
+
if (manifest.version !== MANIFEST_VERSION) {
|
|
1260
|
+
throw new ManifestLoadError(
|
|
1261
|
+
`${path} declares manifest version ${String(manifest.version)}; this build understands ${MANIFEST_VERSION}.`
|
|
1073
1262
|
);
|
|
1074
|
-
process.exit(1);
|
|
1075
1263
|
}
|
|
1076
|
-
|
|
1264
|
+
if (!Array.isArray(manifest.seeds)) {
|
|
1265
|
+
throw new ManifestLoadError(`${path} is missing a \`seeds\` array.`);
|
|
1266
|
+
}
|
|
1267
|
+
const complete = manifest;
|
|
1268
|
+
const issues = validateManifest(complete);
|
|
1269
|
+
const fatal = issues.filter((issue) => issue.fatal);
|
|
1270
|
+
if (fatal.length > 0) {
|
|
1271
|
+
throw new ManifestLoadError(
|
|
1272
|
+
`${path} is not a valid schema: ${fatal.flatMap((issue) => issue.messages).join("; ")}`,
|
|
1273
|
+
issues
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
return complete;
|
|
1277
|
+
}
|
|
1278
|
+
async function loadManifest(path = DEFAULT_MANIFEST_PATH) {
|
|
1279
|
+
const absolute = resolve6(process.cwd(), path);
|
|
1280
|
+
if (!existsSync5(absolute)) {
|
|
1281
|
+
throw new ManifestLoadError(
|
|
1282
|
+
`No manifest at ${path}. Produce one from the live database with \`beech schema export\`.`
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
let module;
|
|
1077
1286
|
try {
|
|
1078
|
-
|
|
1079
|
-
} catch (
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
`)
|
|
1287
|
+
module = await import(pathToFileURL(absolute).href);
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
const code = error.code;
|
|
1290
|
+
if (code === "ERR_UNKNOWN_FILE_EXTENSION" || code === "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING") {
|
|
1291
|
+
throw new ManifestLoadError(
|
|
1292
|
+
`This Node build cannot import ${path} directly (TypeScript type stripping unavailable).`,
|
|
1293
|
+
[],
|
|
1294
|
+
error
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
throw new ManifestLoadError(`${path} failed to load: ${error.message}`, [], error);
|
|
1298
|
+
}
|
|
1299
|
+
return interpretManifestModule(module, path);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/lib/manifest-compare.ts
|
|
1303
|
+
import pc14 from "picocolors";
|
|
1304
|
+
import { normalizeSeedDefinition } from "@beechcms/core";
|
|
1305
|
+
import { MANIFEST_VERSION as MANIFEST_VERSION2, seedsToManifest, toCanonicalJson } from "@beechcms/core/schema";
|
|
1306
|
+
function canonicalize(seed) {
|
|
1307
|
+
const normalized = normalizeSeedDefinition(seed);
|
|
1308
|
+
return toCanonicalJson({ version: MANIFEST_VERSION2, seeds: [normalized] });
|
|
1309
|
+
}
|
|
1310
|
+
function compareManifest(manifest, liveSeeds) {
|
|
1311
|
+
const authored = new Map(manifest.seeds.map((seed) => [seed.slug, canonicalize(seed)]));
|
|
1312
|
+
const deployed = new Map(seedsToManifest(liveSeeds).seeds.map((seed) => [seed.slug, canonicalize(seed)]));
|
|
1313
|
+
const slugs = [.../* @__PURE__ */ new Set([...authored.keys(), ...deployed.keys()])].sort((a, b) => a.localeCompare(b));
|
|
1314
|
+
const seeds = slugs.map((slug) => {
|
|
1315
|
+
const left = authored.get(slug);
|
|
1316
|
+
const right = deployed.get(slug);
|
|
1317
|
+
if (left === void 0) return { slug, status: "only_in_database" };
|
|
1318
|
+
if (right === void 0) return { slug, status: "only_in_manifest" };
|
|
1319
|
+
return { slug, status: left === right ? "in_sync" : "differs" };
|
|
1320
|
+
});
|
|
1321
|
+
return { seeds, inSync: seeds.every((seed) => seed.status === "in_sync") };
|
|
1322
|
+
}
|
|
1323
|
+
function renderManifestDrift(drift, manifestPath) {
|
|
1324
|
+
if (drift.inSync) {
|
|
1325
|
+
console.log(pc14.green(` \u2713 ${manifestPath} matches the deployed schema`));
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
for (const seed of drift.seeds) {
|
|
1329
|
+
switch (seed.status) {
|
|
1330
|
+
case "in_sync":
|
|
1331
|
+
console.log(pc14.green(` \u2713 ${seed.slug}`));
|
|
1332
|
+
break;
|
|
1333
|
+
case "only_in_manifest":
|
|
1334
|
+
console.log(pc14.yellow(` + ${seed.slug} \u2014 in the manifest, not deployed (apply would create it)`));
|
|
1335
|
+
break;
|
|
1336
|
+
case "only_in_database":
|
|
1337
|
+
console.log(pc14.yellow(` - ${seed.slug} \u2014 deployed, absent from the manifest (re-export to refresh)`));
|
|
1338
|
+
break;
|
|
1339
|
+
case "differs":
|
|
1340
|
+
console.log(pc14.red(` \u2260 ${seed.slug} \u2014 manifest and deployed definition disagree`));
|
|
1341
|
+
break;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/lib/schema-diff.ts
|
|
1347
|
+
import pc15 from "picocolors";
|
|
1348
|
+
import { getExpectedColumns, introspectTable } from "@beechcms/core";
|
|
1349
|
+
function isSeedClean(diff) {
|
|
1350
|
+
return diff.tableExists && diff.columns.every((c) => c.status === "ok");
|
|
1351
|
+
}
|
|
1352
|
+
function renderSeedDiff(diff) {
|
|
1353
|
+
const table = `content_${diff.slug}`;
|
|
1354
|
+
if (!diff.tableExists) {
|
|
1355
|
+
console.log(pc15.red(` \u2717 ${table} \u2014 table missing`));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const problems = diff.columns.filter((c) => c.status !== "ok");
|
|
1359
|
+
if (problems.length === 0) {
|
|
1360
|
+
console.log(pc15.green(` \u2713 ${table}`));
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
console.log(pc15.yellow(` \u26A0 ${table}`));
|
|
1364
|
+
for (const col of problems) {
|
|
1365
|
+
switch (col.status) {
|
|
1366
|
+
case "missing":
|
|
1367
|
+
console.log(pc15.red(` + missing column: ${col.name} ${col.expectedType}`));
|
|
1368
|
+
break;
|
|
1369
|
+
case "extra":
|
|
1370
|
+
console.log(pc15.dim(` ~ orphaned column: "${col.name}" (${col.actualType}) \u2014 in DB, not in schema definition`));
|
|
1371
|
+
break;
|
|
1372
|
+
case "type_mismatch":
|
|
1373
|
+
console.log(pc15.red(` \u2260 type mismatch: ${col.name} (expected ${col.expectedType}, got ${col.actualType})`));
|
|
1374
|
+
break;
|
|
1375
|
+
case "fk_missing":
|
|
1376
|
+
console.log(pc15.red(` \u292C missing FK: ${col.name} \u2192 content_${col.expectedTarget}(id)`));
|
|
1377
|
+
break;
|
|
1378
|
+
case "fk_mismatch":
|
|
1379
|
+
console.log(pc15.yellow(` \u292C FK mismatch: ${col.name} expected ${col.expected}, got ${col.actual}`));
|
|
1380
|
+
break;
|
|
1381
|
+
case "index_missing":
|
|
1382
|
+
console.log(pc15.yellow(` \u2298 missing index on ${col.name}`));
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
async function diffSeed(seed, executor) {
|
|
1388
|
+
const live = await introspectTable(executor, `content_${seed.slug}`);
|
|
1389
|
+
const expected = getExpectedColumns(seed);
|
|
1390
|
+
if (!live.exists) {
|
|
1391
|
+
return {
|
|
1392
|
+
slug: seed.slug,
|
|
1393
|
+
tableExists: false,
|
|
1394
|
+
columns: expected.map((c) => ({ name: c.name, status: "missing", expectedType: c.sqlType }))
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
const actualMap = new Map(live.columns.map((c) => [c.name, c]));
|
|
1398
|
+
const expectedSet = new Set(expected.map((c) => c.name));
|
|
1399
|
+
const columns = [];
|
|
1400
|
+
for (const col of expected) {
|
|
1401
|
+
const actual = actualMap.get(col.name);
|
|
1402
|
+
if (!actual) {
|
|
1403
|
+
columns.push({ name: col.name, status: "missing", expectedType: col.sqlType });
|
|
1404
|
+
} else if (actual.sqlType !== col.sqlType) {
|
|
1405
|
+
columns.push({ name: col.name, status: "type_mismatch", expectedType: col.sqlType, actualType: actual.sqlType });
|
|
1406
|
+
} else {
|
|
1407
|
+
columns.push({ name: col.name, status: "ok" });
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
for (const actual of live.columns) {
|
|
1411
|
+
if (!expectedSet.has(actual.name)) {
|
|
1412
|
+
columns.push({ name: actual.name, status: "extra", actualType: actual.sqlType });
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
const relationBranches = seed.branches.filter((b) => b.type === "relation" && b.targetSeed);
|
|
1416
|
+
if (relationBranches.length > 0) {
|
|
1417
|
+
const fkByColumn = new Map(live.foreignKeys.map((fk) => [fk.column, fk]));
|
|
1418
|
+
const indexNames = new Set(live.indexes.map((i) => i.name));
|
|
1419
|
+
for (const branch of relationBranches) {
|
|
1420
|
+
const expectedFkTable = `content_${branch.targetSeed}`;
|
|
1421
|
+
const expectedOnDelete = (branch.onDelete ?? "SET NULL").toUpperCase();
|
|
1422
|
+
const expectedIndexName = `idx_${seed.slug}_${branch.alias}`;
|
|
1423
|
+
const colDiff = columns.find((c) => c.name === branch.alias);
|
|
1424
|
+
if (!colDiff || colDiff.status === "missing") continue;
|
|
1425
|
+
const fk = fkByColumn.get(branch.alias);
|
|
1426
|
+
if (!fk) {
|
|
1427
|
+
colDiff.status = "fk_missing";
|
|
1428
|
+
colDiff.expectedTarget = branch.targetSeed;
|
|
1429
|
+
} else if (fk.targetTable !== expectedFkTable || fk.onDelete !== expectedOnDelete) {
|
|
1430
|
+
colDiff.status = "fk_mismatch";
|
|
1431
|
+
colDiff.expected = `\u2192 ${expectedFkTable}(id) ON DELETE ${expectedOnDelete}`;
|
|
1432
|
+
colDiff.actual = `\u2192 ${fk.targetTable}(id) ON DELETE ${fk.onDelete}`;
|
|
1433
|
+
colDiff.expectedTarget = branch.targetSeed;
|
|
1434
|
+
}
|
|
1435
|
+
if (!indexNames.has(expectedIndexName)) {
|
|
1436
|
+
if (colDiff.status === "ok") colDiff.status = "index_missing";
|
|
1437
|
+
else columns.push({ name: branch.alias, status: "index_missing" });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return { slug: seed.slug, tableExists: true, columns };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// src/commands/schema-diff.ts
|
|
1445
|
+
async function schemaDiff(args = {}) {
|
|
1446
|
+
try {
|
|
1447
|
+
const context = createD1Context(args);
|
|
1448
|
+
const liveSeeds = await loadLiveSeeds(context);
|
|
1449
|
+
const manifestPath = args.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
1450
|
+
let manifestDrifted = false;
|
|
1451
|
+
console.log(pc16.cyan(`
|
|
1452
|
+
Manifest vs deployed definitions (${context.options.db})
|
|
1453
|
+
`));
|
|
1454
|
+
if (existsSync6(resolve7(process.cwd(), manifestPath))) {
|
|
1455
|
+
const manifest = await loadManifest(manifestPath);
|
|
1456
|
+
const drift = compareManifest(manifest, liveSeeds);
|
|
1457
|
+
renderManifestDrift(drift, manifestPath);
|
|
1458
|
+
manifestDrifted = !drift.inSync;
|
|
1459
|
+
} else {
|
|
1460
|
+
console.log(pc16.dim(` \u2298 no ${manifestPath} \u2014 run \`beech schema export\` to create one`));
|
|
1461
|
+
}
|
|
1462
|
+
console.log(pc16.cyan("\n Deployed definitions vs physical tables\n"));
|
|
1463
|
+
let physicalDrifted = false;
|
|
1464
|
+
for (const seed of liveSeeds) {
|
|
1465
|
+
const diff = await diffSeed(seed, context.executor);
|
|
1466
|
+
renderSeedDiff(diff);
|
|
1467
|
+
if (!isSeedClean(diff)) physicalDrifted = true;
|
|
1468
|
+
}
|
|
1469
|
+
console.log("");
|
|
1470
|
+
if (manifestDrifted || physicalDrifted) {
|
|
1471
|
+
console.log(pc16.yellow(" \u26A0 Schema drift detected.\n"));
|
|
1472
|
+
process.exit(1);
|
|
1473
|
+
}
|
|
1474
|
+
console.log(pc16.green(" \u2713 No drift.\n"));
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
exitWithError(error);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/commands/schema-migrate.ts
|
|
1481
|
+
import pc17 from "picocolors";
|
|
1482
|
+
import {
|
|
1483
|
+
findTagOrphans,
|
|
1484
|
+
manualTagSteps,
|
|
1485
|
+
planTagMigration,
|
|
1486
|
+
tagMigrationStatements
|
|
1487
|
+
} from "@beechcms/core";
|
|
1488
|
+
init_wrangler();
|
|
1489
|
+
function renderTagMigrationPlan(plan) {
|
|
1490
|
+
for (const step of plan.steps) {
|
|
1491
|
+
if (step.action === "manual") {
|
|
1492
|
+
console.log(pc17.red(` \u2717 ${step.seed}: ${step.summary}`));
|
|
1493
|
+
} else {
|
|
1494
|
+
console.log(pc17.green(` \u2713 ${step.seed}: ${step.summary}`));
|
|
1495
|
+
}
|
|
1496
|
+
for (const warning of step.warnings) console.log(pc17.yellow(` \u26A0 ${warning}`));
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async function schemaMigrate(args = {}) {
|
|
1500
|
+
try {
|
|
1501
|
+
const context = createD1Context(args);
|
|
1502
|
+
const seeds = await loadLiveSeeds(context);
|
|
1503
|
+
const plan = planTagMigration(seeds);
|
|
1504
|
+
console.log(pc17.cyan(`
|
|
1505
|
+
Tags contract migration (${context.options.db})
|
|
1506
|
+
`));
|
|
1507
|
+
if (plan.steps.length === 0) {
|
|
1508
|
+
console.log(pc17.green(" \u2713 Every seed already uses the closed-vocabulary tags contract. Nothing to migrate.\n"));
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
renderTagMigrationPlan(plan);
|
|
1512
|
+
const orphans = await findTagOrphans(context.executor, plan, seeds);
|
|
1513
|
+
for (const orphan of orphans) {
|
|
1514
|
+
console.log(pc17.yellow(
|
|
1515
|
+
` \u26A0 ${orphan.seed}.${orphan.alias}: ${orphan.rows} row(s) hold '${orphan.value}', which is not an option. Left untouched.`
|
|
1516
|
+
));
|
|
1517
|
+
}
|
|
1518
|
+
const manual = manualTagSteps(plan);
|
|
1519
|
+
const statements = tagMigrationStatements(plan, seeds);
|
|
1520
|
+
if (!args.apply) {
|
|
1521
|
+
console.log(pc17.dim(`
|
|
1522
|
+
Dry run: nothing was changed. ${statements.length > 0 ? "Re-run with --apply to migrate." : ""}`));
|
|
1523
|
+
if (manual.length > 0) console.log(pc17.red(` ${manual.length} step(s) need a manual edit of the seed.`));
|
|
1524
|
+
console.log("");
|
|
1525
|
+
process.exit(1);
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (statements.length === 0) {
|
|
1529
|
+
console.log(pc17.red(`
|
|
1530
|
+
\u2717 Nothing can be migrated automatically: ${manual.length} step(s) need a manual edit.
|
|
1531
|
+
`));
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (!executeD1File(statements.join("\n"), context.options)) {
|
|
1536
|
+
throw new CliError(
|
|
1537
|
+
"The migration statements failed to execute.",
|
|
1538
|
+
"They are idempotent: fix the reported error and run `beech schema migrate --apply` again."
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
const remaining = planTagMigration(await loadLiveSeeds(context));
|
|
1542
|
+
console.log(pc17.green(`
|
|
1543
|
+
\u2713 Migrated ${plan.seeds.length} seed(s).`));
|
|
1544
|
+
if (remaining.steps.length > 0) {
|
|
1545
|
+
console.log(pc17.yellow(` \u26A0 ${remaining.steps.length} step(s) still need attention; run \`beech schema audit\`.`));
|
|
1546
|
+
console.log("");
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
console.log(pc17.dim(" Restart running Workers or wait for the registry cache to refresh, then run `beech types generate`.\n"));
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
exitWithError(error);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/commands/schema-audit.ts
|
|
1557
|
+
import pc18 from "picocolors";
|
|
1558
|
+
import { auditRelationStorage, planTagMigration as planTagMigration2 } from "@beechcms/core";
|
|
1559
|
+
async function schemaAudit(args = {}) {
|
|
1560
|
+
try {
|
|
1561
|
+
const context = createD1Context(args);
|
|
1562
|
+
const seeds = await loadLiveSeeds(context);
|
|
1563
|
+
const findings = await auditRelationStorage(context.executor, seeds);
|
|
1564
|
+
console.log(pc18.cyan(`
|
|
1565
|
+
Relation storage audit (${context.options.db})
|
|
1566
|
+
`));
|
|
1567
|
+
if (findings.length === 0) {
|
|
1568
|
+
console.log(pc18.green(" \u2713 Every relation branch matches its physical storage.\n"));
|
|
1569
|
+
}
|
|
1570
|
+
for (const finding of findings) {
|
|
1571
|
+
console.log(pc18.yellow(` \u26A0 ${finding.seed}.${finding.alias} \u2014 ${finding.issue}`));
|
|
1572
|
+
console.log(pc18.dim(` expected: ${finding.expected}`));
|
|
1573
|
+
console.log(pc18.dim(` actual: ${finding.actual}`));
|
|
1574
|
+
if (finding.strandedRows > 0) {
|
|
1575
|
+
console.log(pc18.red(` ${finding.strandedRows} stranded value(s) NOT read by the API; do not drop this storage`));
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (findings.length > 0) {
|
|
1579
|
+
console.log(pc18.yellow(`
|
|
1580
|
+
\u26A0 ${findings.length} relation storage mismatch(es). Nothing was changed.
|
|
1581
|
+
`));
|
|
1582
|
+
}
|
|
1583
|
+
const tagPlan = planTagMigration2(seeds);
|
|
1584
|
+
console.log(pc18.cyan(" Tags contract audit\n"));
|
|
1585
|
+
if (tagPlan.steps.length === 0) {
|
|
1586
|
+
console.log(pc18.green(" \u2713 Every seed uses the closed-vocabulary tags contract.\n"));
|
|
1587
|
+
} else {
|
|
1588
|
+
renderTagMigrationPlan(tagPlan);
|
|
1589
|
+
console.log(pc18.yellow("\n \u26A0 Legacy tag/choice definitions found. Run `beech schema migrate` to review and apply the fix.\n"));
|
|
1590
|
+
}
|
|
1591
|
+
if (findings.length > 0 || tagPlan.steps.length > 0) process.exit(1);
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
exitWithError(error);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// src/commands/schema-export.ts
|
|
1598
|
+
import { writeFileSync as writeFileSync4, mkdirSync as mkdirSync2 } from "node:fs";
|
|
1599
|
+
import { dirname as dirname3, resolve as resolve8 } from "node:path";
|
|
1600
|
+
import pc19 from "picocolors";
|
|
1601
|
+
import { emitManifestModule, seedsToManifest as seedsToManifest2 } from "@beechcms/core/schema";
|
|
1602
|
+
async function schemaExport(args = {}) {
|
|
1603
|
+
try {
|
|
1604
|
+
const context = createD1Context(args);
|
|
1605
|
+
const seeds = await loadLiveSeeds(context);
|
|
1606
|
+
const source = emitManifestModule(seedsToManifest2(seeds));
|
|
1607
|
+
if (args.out === null) {
|
|
1608
|
+
process.stdout.write(source);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const target = args.out ?? DEFAULT_MANIFEST_PATH;
|
|
1612
|
+
const outPath = resolve8(process.cwd(), target);
|
|
1613
|
+
mkdirSync2(dirname3(outPath), { recursive: true });
|
|
1614
|
+
writeFileSync4(outPath, source, "utf-8");
|
|
1615
|
+
console.log(pc19.green(`
|
|
1616
|
+
\u2713 Exported ${seeds.length} seed(s) \u2192 ${target}
|
|
1617
|
+
`));
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
exitWithError(error);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/commands/schema-plan.ts
|
|
1624
|
+
import pc20 from "picocolors";
|
|
1625
|
+
|
|
1626
|
+
// ../api-client/dist/index.js
|
|
1627
|
+
import { existsSync as existsSync22, readFileSync as readFileSync22 } from "node:fs";
|
|
1628
|
+
import { join as join2 } from "node:path";
|
|
1629
|
+
import { createServer } from "node:http";
|
|
1630
|
+
import { spawn } from "node:child_process";
|
|
1631
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
1632
|
+
import { deriveCodeChallenge } from "@beechcms/core";
|
|
1633
|
+
import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync6, renameSync, unlinkSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
1634
|
+
import { homedir } from "node:os";
|
|
1635
|
+
import { dirname as dirname4, join as join3 } from "node:path";
|
|
1636
|
+
var BeechClientError = class extends Error {
|
|
1637
|
+
constructor(message, status, problem) {
|
|
1638
|
+
super(message);
|
|
1639
|
+
this.status = status;
|
|
1640
|
+
this.problem = problem;
|
|
1641
|
+
this.name = "BeechClientError";
|
|
1642
|
+
}
|
|
1643
|
+
status;
|
|
1644
|
+
problem;
|
|
1645
|
+
};
|
|
1646
|
+
function base64Url(bytes) {
|
|
1647
|
+
return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1648
|
+
}
|
|
1649
|
+
async function createPkcePair() {
|
|
1650
|
+
const verifier = base64Url(randomBytes2(32));
|
|
1651
|
+
const challenge = await deriveCodeChallenge(verifier);
|
|
1652
|
+
return { verifier, challenge };
|
|
1653
|
+
}
|
|
1654
|
+
async function exchangeToken(apiUrl, body) {
|
|
1655
|
+
const response = await fetch(`${apiUrl}/oauth/token`, {
|
|
1656
|
+
method: "POST",
|
|
1657
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1658
|
+
body
|
|
1659
|
+
});
|
|
1660
|
+
if (response.status === 429) {
|
|
1661
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
1662
|
+
throw new BeechClientError(`OAuth token request rate-limited.${retryAfter ? ` Retry after ${retryAfter}s.` : ""}`);
|
|
1663
|
+
}
|
|
1664
|
+
if (!response.ok) {
|
|
1665
|
+
const problem = await response.json();
|
|
1666
|
+
throw new BeechClientError(`OAuth token request failed (${problem.error}): ${problem.error_description ?? ""}`);
|
|
1667
|
+
}
|
|
1668
|
+
const data = await response.json();
|
|
1669
|
+
return {
|
|
1670
|
+
accessToken: data.access_token,
|
|
1671
|
+
refreshToken: data.refresh_token,
|
|
1672
|
+
scope: data.scope,
|
|
1673
|
+
expiresAt: Date.now() + data.expires_in * 1e3
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
function renderClosePage() {
|
|
1677
|
+
return `<!doctype html>
|
|
1678
|
+
<html lang="en">
|
|
1679
|
+
<head>
|
|
1680
|
+
<meta charset="utf-8">
|
|
1681
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1682
|
+
<title>Authorization Successful - BeechCMS</title>
|
|
1683
|
+
<style>
|
|
1684
|
+
:root {
|
|
1685
|
+
--bg: #f8fafc;
|
|
1686
|
+
--card-bg: #ffffff;
|
|
1687
|
+
--card-border: #e2e8f0;
|
|
1688
|
+
--text-primary: #0f172a;
|
|
1689
|
+
--text-muted: #64748b;
|
|
1690
|
+
--success-bg: #dcfce7;
|
|
1691
|
+
--success-border: #bbf7d0;
|
|
1692
|
+
--success-text: #15803d;
|
|
1693
|
+
}
|
|
1694
|
+
@media (prefers-color-scheme: dark) {
|
|
1695
|
+
:root {
|
|
1696
|
+
--bg: #09090b;
|
|
1697
|
+
--card-bg: #121215;
|
|
1698
|
+
--card-border: #27272a;
|
|
1699
|
+
--text-primary: #f4f4f5;
|
|
1700
|
+
--text-muted: #a1a1aa;
|
|
1701
|
+
--success-bg: rgba(34, 197, 94, 0.15);
|
|
1702
|
+
--success-border: rgba(34, 197, 94, 0.3);
|
|
1703
|
+
--success-text: #4ade80;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1707
|
+
body {
|
|
1708
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1709
|
+
background-color: var(--bg);
|
|
1710
|
+
color: var(--text-primary);
|
|
1711
|
+
min-height: 100vh;
|
|
1712
|
+
display: flex;
|
|
1713
|
+
align-items: center;
|
|
1714
|
+
justify-content: center;
|
|
1715
|
+
padding: 1.5rem;
|
|
1716
|
+
-webkit-font-smoothing: antialiased;
|
|
1717
|
+
}
|
|
1718
|
+
.card {
|
|
1719
|
+
background-color: var(--card-bg);
|
|
1720
|
+
border: 1px solid var(--card-border);
|
|
1721
|
+
border-radius: 1rem;
|
|
1722
|
+
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
|
1723
|
+
max-width: 380px;
|
|
1724
|
+
width: 100%;
|
|
1725
|
+
padding: 2.5rem 2rem;
|
|
1726
|
+
text-align: center;
|
|
1727
|
+
display: flex;
|
|
1728
|
+
flex-direction: column;
|
|
1729
|
+
align-items: center;
|
|
1730
|
+
justify-content: center;
|
|
1731
|
+
}
|
|
1732
|
+
.icon-badge {
|
|
1733
|
+
width: 3.5rem;
|
|
1734
|
+
height: 3.5rem;
|
|
1735
|
+
border-radius: 50%;
|
|
1736
|
+
background-color: var(--success-bg);
|
|
1737
|
+
border: 1px solid var(--success-border);
|
|
1738
|
+
color: var(--success-text);
|
|
1739
|
+
display: flex;
|
|
1740
|
+
align-items: center;
|
|
1741
|
+
justify-content: center;
|
|
1742
|
+
margin-bottom: 1.25rem;
|
|
1743
|
+
}
|
|
1744
|
+
.icon-badge svg {
|
|
1745
|
+
width: 1.75rem;
|
|
1746
|
+
height: 1.75rem;
|
|
1747
|
+
}
|
|
1748
|
+
h1 {
|
|
1749
|
+
font-size: 1.25rem;
|
|
1750
|
+
font-weight: 600;
|
|
1751
|
+
letter-spacing: -0.02em;
|
|
1752
|
+
margin-bottom: 0.5rem;
|
|
1753
|
+
}
|
|
1754
|
+
p {
|
|
1755
|
+
color: var(--text-muted);
|
|
1756
|
+
font-size: 0.875rem;
|
|
1757
|
+
line-height: 1.5;
|
|
1758
|
+
margin: 0;
|
|
1759
|
+
}
|
|
1760
|
+
</style>
|
|
1761
|
+
</head>
|
|
1762
|
+
<body>
|
|
1763
|
+
<div class="card">
|
|
1764
|
+
<div class="icon-badge">
|
|
1765
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1766
|
+
<polyline points="20 6 9 17 4 12"></polyline>
|
|
1767
|
+
</svg>
|
|
1768
|
+
</div>
|
|
1769
|
+
<h1>Authorization Successful</h1>
|
|
1770
|
+
<p>You can safely close this tab and return to your terminal or editor.</p>
|
|
1771
|
+
</div>
|
|
1772
|
+
<script>try { window.close(); } catch (_) {}</script>
|
|
1773
|
+
</body>
|
|
1774
|
+
</html>`;
|
|
1775
|
+
}
|
|
1776
|
+
function waitForCallback(state, timeoutMs, callbackPath) {
|
|
1777
|
+
let resolvePort;
|
|
1778
|
+
const redirectUriPromise = new Promise((resolve15) => {
|
|
1779
|
+
resolvePort = resolve15;
|
|
1780
|
+
});
|
|
1781
|
+
const result = new Promise((resolve15, reject) => {
|
|
1782
|
+
const server = createServer((req, res) => {
|
|
1783
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1784
|
+
if (url.pathname !== callbackPath) {
|
|
1785
|
+
res.writeHead(404);
|
|
1786
|
+
res.end();
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
const callbackState = url.searchParams.get("state");
|
|
1790
|
+
const error = url.searchParams.get("error");
|
|
1791
|
+
const code = url.searchParams.get("code");
|
|
1792
|
+
if (callbackState !== state) {
|
|
1793
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1794
|
+
res.end("state mismatch");
|
|
1795
|
+
finish(() => reject(new BeechClientError("OAuth callback state mismatch. Aborting for safety.")));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
if (error) {
|
|
1799
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
1800
|
+
res.end("authorization error");
|
|
1801
|
+
finish(() => reject(new BeechClientError(url.searchParams.get("error_description") ?? error)));
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (code) {
|
|
1805
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
1806
|
+
res.end(renderClosePage());
|
|
1807
|
+
finish(() => resolve15({ code }));
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
res.writeHead(404);
|
|
1811
|
+
res.end();
|
|
1812
|
+
});
|
|
1813
|
+
let timer;
|
|
1814
|
+
let finished = false;
|
|
1815
|
+
function finish(action) {
|
|
1816
|
+
if (finished) return;
|
|
1817
|
+
finished = true;
|
|
1818
|
+
clearTimeout(timer);
|
|
1819
|
+
server.close(() => action());
|
|
1820
|
+
}
|
|
1821
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1822
|
+
const address = server.address();
|
|
1823
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
1824
|
+
resolvePort(`http://127.0.0.1:${port}${callbackPath}`);
|
|
1825
|
+
});
|
|
1826
|
+
timer = setTimeout(() => {
|
|
1827
|
+
finish(() => reject(new BeechClientError(`Authorization timed out after ${timeoutMs / 1e3}s. Re-run the tool and complete the consent in the browser.`)));
|
|
1828
|
+
}, timeoutMs);
|
|
1829
|
+
});
|
|
1830
|
+
return { redirectUriPromise, result };
|
|
1831
|
+
}
|
|
1832
|
+
function openBrowser(url) {
|
|
1833
|
+
const platform = process.platform;
|
|
1834
|
+
const child = platform === "darwin" ? spawn("open", [url], { detached: true, stdio: "ignore" }) : platform === "win32" ? spawn("cmd", ["/d", "/c", "start", '""', `"${url}"`], { detached: true, stdio: "ignore", windowsVerbatimArguments: true }) : spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
1835
|
+
child.on("error", () => {
|
|
1836
|
+
process.stderr.write(`Open this URL to continue: ${url}
|
|
1837
|
+
`);
|
|
1838
|
+
});
|
|
1839
|
+
process.stderr.write(`Open this URL to continue: ${url}
|
|
1840
|
+
`);
|
|
1841
|
+
child.unref();
|
|
1842
|
+
}
|
|
1843
|
+
async function authorize(config) {
|
|
1844
|
+
const { verifier, challenge } = await createPkcePair();
|
|
1845
|
+
const state = base64Url(randomBytes2(16));
|
|
1846
|
+
const callbackPath = config.callbackPath ?? "/oauth/callback";
|
|
1847
|
+
const { redirectUriPromise, result } = waitForCallback(state, config.timeoutMs, callbackPath);
|
|
1848
|
+
const redirectUri = await redirectUriPromise;
|
|
1849
|
+
const authorizeUrl = new URL(`${config.authUrl}/oauth/authorize`);
|
|
1850
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
1851
|
+
authorizeUrl.searchParams.set("client_id", config.clientId);
|
|
1852
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
1853
|
+
authorizeUrl.searchParams.set("scope", config.scope);
|
|
1854
|
+
authorizeUrl.searchParams.set("state", state);
|
|
1855
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
1856
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
1857
|
+
openBrowser(authorizeUrl.toString());
|
|
1858
|
+
const { code } = await result;
|
|
1859
|
+
const body = new URLSearchParams({
|
|
1860
|
+
grant_type: "authorization_code",
|
|
1861
|
+
code,
|
|
1862
|
+
redirect_uri: redirectUri,
|
|
1863
|
+
client_id: config.clientId,
|
|
1864
|
+
code_verifier: verifier
|
|
1865
|
+
});
|
|
1866
|
+
return exchangeToken(config.apiUrl, body);
|
|
1867
|
+
}
|
|
1868
|
+
async function refresh(config, refreshToken) {
|
|
1869
|
+
const body = new URLSearchParams({
|
|
1870
|
+
grant_type: "refresh_token",
|
|
1871
|
+
refresh_token: refreshToken,
|
|
1872
|
+
client_id: config.clientId
|
|
1873
|
+
});
|
|
1874
|
+
return exchangeToken(config.apiUrl, body);
|
|
1875
|
+
}
|
|
1876
|
+
function cachePath() {
|
|
1877
|
+
return process.env.BEECH_TOKEN_CACHE ?? join3(homedir(), ".beechcms", "mcp-tokens.json");
|
|
1878
|
+
}
|
|
1879
|
+
function cacheKey(apiUrl, clientId) {
|
|
1880
|
+
return `${apiUrl}|${clientId}`;
|
|
1881
|
+
}
|
|
1882
|
+
function isValidGrant(value) {
|
|
1883
|
+
if (!value || typeof value !== "object") return false;
|
|
1884
|
+
const grant = value;
|
|
1885
|
+
return typeof grant.accessToken === "string" && grant.accessToken.length > 0 && typeof grant.refreshToken === "string" && grant.refreshToken.length > 0 && typeof grant.scope === "string" && grant.scope.length > 0 && typeof grant.expiresAt === "number" && Number.isFinite(grant.expiresAt);
|
|
1886
|
+
}
|
|
1887
|
+
function readCacheFile() {
|
|
1888
|
+
const path = cachePath();
|
|
1889
|
+
if (!existsSync7(path)) return {};
|
|
1890
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
1891
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
1892
|
+
return parsed;
|
|
1893
|
+
}
|
|
1894
|
+
function readGrant(apiUrl, clientId) {
|
|
1895
|
+
try {
|
|
1896
|
+
const entry = readCacheFile()[cacheKey(apiUrl, clientId)];
|
|
1897
|
+
return isValidGrant(entry) ? entry : void 0;
|
|
1898
|
+
} catch {
|
|
1899
|
+
return void 0;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
function writeGrant(apiUrl, clientId, grant) {
|
|
1903
|
+
try {
|
|
1904
|
+
const path = cachePath();
|
|
1905
|
+
mkdirSync3(dirname4(path), { recursive: true, mode: 448 });
|
|
1906
|
+
let cache;
|
|
1907
|
+
try {
|
|
1908
|
+
cache = readCacheFile();
|
|
1909
|
+
} catch {
|
|
1910
|
+
cache = {};
|
|
1911
|
+
}
|
|
1912
|
+
cache[cacheKey(apiUrl, clientId)] = grant;
|
|
1913
|
+
const tmpPath = `${path}.tmp`;
|
|
1914
|
+
writeFileSync5(tmpPath, JSON.stringify(cache));
|
|
1915
|
+
chmodSync(tmpPath, 384);
|
|
1916
|
+
renameSync(tmpPath, path);
|
|
1917
|
+
} catch {
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
function clearGrant(apiUrl, clientId) {
|
|
1921
|
+
try {
|
|
1922
|
+
const path = cachePath();
|
|
1923
|
+
if (!existsSync7(path)) return;
|
|
1924
|
+
const cache = readCacheFile();
|
|
1925
|
+
delete cache[cacheKey(apiUrl, clientId)];
|
|
1926
|
+
if (Object.keys(cache).length === 0) {
|
|
1927
|
+
unlinkSync(path);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
const tmpPath = `${path}.tmp`;
|
|
1931
|
+
writeFileSync5(tmpPath, JSON.stringify(cache));
|
|
1932
|
+
chmodSync(tmpPath, 384);
|
|
1933
|
+
renameSync(tmpPath, path);
|
|
1934
|
+
} catch {
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
1938
|
+
var MAX_TRANSIENT_RETRIES = 2;
|
|
1939
|
+
var RETRY_BASE_DELAY_MS = 100;
|
|
1940
|
+
function readDevVarsApiUrl() {
|
|
1941
|
+
const devVarsPath = join2(process.cwd(), ".dev.vars");
|
|
1942
|
+
if (!existsSync22(devVarsPath)) return void 0;
|
|
1943
|
+
const match = readFileSync22(devVarsPath, "utf8").match(/^BEECH_API_URL=(.*)$/m);
|
|
1944
|
+
return match ? match[1].trim() : void 0;
|
|
1945
|
+
}
|
|
1946
|
+
function resolveApiConfig(overrides = {}) {
|
|
1947
|
+
const baseUrl = overrides.baseUrl ?? process.env.BEECH_API_URL ?? readDevVarsApiUrl() ?? "http://localhost:8789";
|
|
1948
|
+
return {
|
|
1949
|
+
baseUrl,
|
|
1950
|
+
oauth: {
|
|
1951
|
+
apiUrl: baseUrl,
|
|
1952
|
+
authUrl: process.env.BEECH_AUTH_URL ?? baseUrl,
|
|
1953
|
+
clientId: overrides.clientId ?? process.env.BEECH_OAUTH_CLIENT_ID ?? "beech-mcp",
|
|
1954
|
+
scope: overrides.scope ?? process.env.BEECH_OAUTH_SCOPE ?? "schema:read schema:write",
|
|
1955
|
+
timeoutMs: process.env.BEECH_OAUTH_TIMEOUT_MS ? Number(process.env.BEECH_OAUTH_TIMEOUT_MS) : 18e4,
|
|
1956
|
+
callbackPath: overrides.callbackPath
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
function isRetryableStatus(status) {
|
|
1961
|
+
return status === 502 || status === 503 || status === 504;
|
|
1962
|
+
}
|
|
1963
|
+
function sleep(ms) {
|
|
1964
|
+
return new Promise((resolve15) => setTimeout(resolve15, ms));
|
|
1965
|
+
}
|
|
1966
|
+
function createApiClient(config) {
|
|
1967
|
+
let grant;
|
|
1968
|
+
let inFlightAuth;
|
|
1969
|
+
async function getAccessToken(forceRefresh = false) {
|
|
1970
|
+
if (grant === void 0) grant = readGrant(config.baseUrl, config.oauth.clientId);
|
|
1971
|
+
if (grant && !forceRefresh && grant.expiresAt - Date.now() > EXPIRY_SKEW_MS) return grant.accessToken;
|
|
1972
|
+
if (inFlightAuth) return (await inFlightAuth).accessToken;
|
|
1973
|
+
const authPromise = (async () => {
|
|
1974
|
+
if (grant?.refreshToken) {
|
|
1975
|
+
try {
|
|
1976
|
+
const refreshed = await refresh(config.oauth, grant.refreshToken);
|
|
1977
|
+
writeGrant(config.baseUrl, config.oauth.clientId, refreshed);
|
|
1978
|
+
grant = refreshed;
|
|
1979
|
+
return refreshed;
|
|
1980
|
+
} catch {
|
|
1981
|
+
clearGrant(config.baseUrl, config.oauth.clientId);
|
|
1982
|
+
grant = void 0;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const authorized = await authorize(config.oauth);
|
|
1986
|
+
writeGrant(config.baseUrl, config.oauth.clientId, authorized);
|
|
1987
|
+
grant = authorized;
|
|
1988
|
+
return authorized;
|
|
1989
|
+
})();
|
|
1990
|
+
inFlightAuth = authPromise;
|
|
1991
|
+
try {
|
|
1992
|
+
return (await authPromise).accessToken;
|
|
1993
|
+
} finally {
|
|
1994
|
+
inFlightAuth = void 0;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
async function rawFetch(method, path, accessToken, body) {
|
|
1998
|
+
try {
|
|
1999
|
+
return await fetch(`${config.baseUrl}${path}`, {
|
|
2000
|
+
method,
|
|
2001
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
|
|
2002
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
2003
|
+
});
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
if (error instanceof TypeError || error?.code === "ECONNREFUSED") {
|
|
2006
|
+
throw new BeechClientError(`Cannot reach the BeechCMS API at ${config.baseUrl}. Start the local stack with: pnpm beech dev`);
|
|
2007
|
+
}
|
|
2008
|
+
throw error;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
async function request(method, path, body) {
|
|
2012
|
+
let accessToken = await getAccessToken();
|
|
2013
|
+
let response = await rawFetch(method, path, accessToken, body);
|
|
2014
|
+
if (response.status === 401) {
|
|
2015
|
+
accessToken = await getAccessToken(true);
|
|
2016
|
+
response = await rawFetch(method, path, accessToken, body);
|
|
2017
|
+
if (response.status === 401) {
|
|
2018
|
+
throw new BeechClientError(
|
|
2019
|
+
"Authorization failed. Run any Beech tool again to re-authorize in the browser, or revoke and re-grant the client from Settings \u2192 Connected apps.",
|
|
2020
|
+
401
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
for (let attempt = 0; isRetryableStatus(response.status) && attempt < MAX_TRANSIENT_RETRIES; attempt++) {
|
|
2025
|
+
await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt + Math.random() * RETRY_BASE_DELAY_MS);
|
|
2026
|
+
response = await rawFetch(method, path, accessToken, body);
|
|
2027
|
+
}
|
|
2028
|
+
if (response.status === 403) {
|
|
2029
|
+
let errorBody = {};
|
|
2030
|
+
try {
|
|
2031
|
+
errorBody = await response.json();
|
|
2032
|
+
} catch {
|
|
2033
|
+
}
|
|
2034
|
+
if (errorBody.error === "insufficient_scope") {
|
|
2035
|
+
const scope = errorBody.error_description?.match(/'([^']+)'/)?.[1] ?? "";
|
|
2036
|
+
throw new BeechClientError(
|
|
2037
|
+
`Token lacks the '${scope}' scope. Revoke 'BeechCMS MCP Server' under Settings \u2192 Connected apps and re-authorize.`,
|
|
2038
|
+
403
|
|
2039
|
+
);
|
|
2040
|
+
}
|
|
2041
|
+
throw new BeechClientError(
|
|
2042
|
+
JSON.stringify({ status: 403, title: errorBody.error ?? response.statusText, detail: errorBody.error_description ?? "" }),
|
|
2043
|
+
403
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
if (!response.ok) {
|
|
2047
|
+
let problem = {};
|
|
2048
|
+
try {
|
|
2049
|
+
problem = await response.json();
|
|
2050
|
+
} catch {
|
|
2051
|
+
}
|
|
2052
|
+
throw new BeechClientError(
|
|
2053
|
+
JSON.stringify({ status: problem.status ?? response.status, title: problem.title ?? response.statusText, detail: problem.detail ?? "" }),
|
|
2054
|
+
problem.status ?? response.status,
|
|
2055
|
+
problem
|
|
2056
|
+
);
|
|
2057
|
+
}
|
|
2058
|
+
return { data: await response.json(), headers: response.headers };
|
|
2059
|
+
}
|
|
2060
|
+
return { request, baseUrl: config.baseUrl };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// src/lib/control-plane.ts
|
|
2064
|
+
var CLI_OAUTH_CLIENT_ID = "beech-mcp-cli";
|
|
2065
|
+
var CLI_CALLBACK_PATH = "/callback";
|
|
2066
|
+
function toCliError(error, slug) {
|
|
2067
|
+
if (!(error instanceof BeechClientError)) {
|
|
2068
|
+
return new CliError(error instanceof Error ? error.message : String(error), void 0, error);
|
|
2069
|
+
}
|
|
2070
|
+
const detail = error.problem?.detail ?? error.message;
|
|
2071
|
+
if (error.status === 401 || error.status === 403) {
|
|
2072
|
+
return new CliError(`Not authorized to change schema (${slug}).`, error.message, error);
|
|
2073
|
+
}
|
|
2074
|
+
if (error.status === 409) {
|
|
2075
|
+
return new CliError(
|
|
2076
|
+
`The schema registry moved while '${slug}' was being applied. Nothing was written.`,
|
|
2077
|
+
"Re-run `beech schema plan` and apply again.",
|
|
2078
|
+
error
|
|
1084
2079
|
);
|
|
1085
|
-
process.exit(1);
|
|
1086
2080
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
2081
|
+
if (error.status === 422) {
|
|
2082
|
+
return new CliError(`Server refused the change to '${slug}': ${detail}`, void 0, error);
|
|
2083
|
+
}
|
|
2084
|
+
return new CliError(`Control plane request failed for '${slug}': ${detail}`, void 0, error);
|
|
2085
|
+
}
|
|
2086
|
+
function createControlPlane(options = {}) {
|
|
2087
|
+
const config = resolveApiConfig({
|
|
2088
|
+
baseUrl: options.apiUrl,
|
|
2089
|
+
clientId: CLI_OAUTH_CLIENT_ID,
|
|
2090
|
+
scope: "schema:read schema:write",
|
|
2091
|
+
callbackPath: CLI_CALLBACK_PATH
|
|
2092
|
+
});
|
|
2093
|
+
const client = createApiClient(config);
|
|
2094
|
+
return {
|
|
2095
|
+
baseUrl: config.baseUrl,
|
|
2096
|
+
async plan(slug, candidate) {
|
|
2097
|
+
try {
|
|
2098
|
+
const { data } = await client.request("POST", `/api/seeds/${slug}/mcp-plan`, { candidate });
|
|
2099
|
+
return data;
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
throw toCliError(error, slug);
|
|
2102
|
+
}
|
|
2103
|
+
},
|
|
2104
|
+
async apply({ slug, candidate, expectedVersion, planId }) {
|
|
2105
|
+
try {
|
|
2106
|
+
const { data } = await client.request("POST", `/api/seeds/${slug}/mcp-apply`, {
|
|
2107
|
+
candidate,
|
|
2108
|
+
expectedVersion,
|
|
2109
|
+
planId,
|
|
2110
|
+
source: "code"
|
|
2111
|
+
});
|
|
2112
|
+
return data;
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
throw toCliError(error, slug);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/lib/manifest-order.ts
|
|
2121
|
+
function manifestTargets(seed, slugs) {
|
|
2122
|
+
const targets = /* @__PURE__ */ new Set();
|
|
2123
|
+
for (const branch of seed.branches ?? []) {
|
|
2124
|
+
if (branch.type === "relation" && branch.targetSeed && branch.targetSeed !== seed.slug && slugs.has(branch.targetSeed)) {
|
|
2125
|
+
targets.add(branch.targetSeed);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
return [...targets].sort((a, b) => a.localeCompare(b));
|
|
2129
|
+
}
|
|
2130
|
+
function orderSeedsForApply(seeds) {
|
|
2131
|
+
const bySlug = new Map(seeds.map((seed) => [seed.slug, seed]));
|
|
2132
|
+
const slugs = new Set(bySlug.keys());
|
|
2133
|
+
const sorted = [...seeds].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
2134
|
+
const ordered = [];
|
|
2135
|
+
const state = /* @__PURE__ */ new Map();
|
|
2136
|
+
const cycles = [];
|
|
2137
|
+
function visit(seed, stack) {
|
|
2138
|
+
const mark = state.get(seed.slug);
|
|
2139
|
+
if (mark === "done") return;
|
|
2140
|
+
if (mark === "visiting") {
|
|
2141
|
+
cycles.push([...stack.slice(stack.indexOf(seed.slug)), seed.slug]);
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
state.set(seed.slug, "visiting");
|
|
2145
|
+
for (const target of manifestTargets(seed, slugs)) {
|
|
2146
|
+
const next = bySlug.get(target);
|
|
2147
|
+
if (next) visit(next, [...stack, seed.slug]);
|
|
2148
|
+
}
|
|
2149
|
+
state.set(seed.slug, "done");
|
|
2150
|
+
ordered.push(seed);
|
|
2151
|
+
}
|
|
2152
|
+
for (const seed of sorted) visit(seed, []);
|
|
2153
|
+
return { ordered, cycles };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/commands/schema-plan.ts
|
|
2157
|
+
async function schemaPlan(args = {}) {
|
|
2158
|
+
try {
|
|
2159
|
+
const manifestPath = args.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
2160
|
+
const manifest = await loadManifest(manifestPath);
|
|
2161
|
+
const { ordered, cycles } = orderSeedsForApply(manifest.seeds);
|
|
2162
|
+
if (cycles.length > 0) {
|
|
2163
|
+
throw new CliError(
|
|
2164
|
+
`Relation cycle in ${manifestPath}: ${cycles.map((cycle) => cycle.join(" \u2192 ")).join("; ")}.`,
|
|
2165
|
+
"Apply one of these seeds without its relation branch first, then add the branch and apply again."
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
const controlPlane = createControlPlane({ apiUrl: args.apiUrl });
|
|
2169
|
+
console.log(pc20.cyan(`
|
|
2170
|
+
Plan for ${manifestPath} against ${controlPlane.baseUrl}
|
|
2171
|
+
`));
|
|
2172
|
+
const plans = [];
|
|
2173
|
+
for (const seed of ordered) {
|
|
2174
|
+
const plan = await controlPlane.plan(seed.slug, seed);
|
|
2175
|
+
plans.push(plan);
|
|
2176
|
+
renderPlan(plan);
|
|
2177
|
+
}
|
|
2178
|
+
const blocked = plans.filter((plan) => !plan.applicable);
|
|
2179
|
+
console.log("");
|
|
2180
|
+
if (blocked.length > 0) {
|
|
2181
|
+
console.log(pc20.yellow(` \u26A0 ${blocked.length} seed(s) cannot be applied: ${blocked.map((p4) => p4.slug).join(", ")}
|
|
1094
2182
|
`));
|
|
2183
|
+
process.exit(1);
|
|
2184
|
+
}
|
|
2185
|
+
const changing = plans.filter((plan) => plan.definitionChanged);
|
|
2186
|
+
if (changing.length === 0) {
|
|
2187
|
+
console.log(pc20.green(" \u2713 Nothing to apply \u2014 the deployed schema already matches the manifest.\n"));
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
console.log(pc20.green(` \u2713 ${changing.length} seed(s) ready to apply. Run \`beech schema apply\`.
|
|
2191
|
+
`));
|
|
2192
|
+
} catch (error) {
|
|
2193
|
+
exitWithError(error);
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
function renderPlan(plan) {
|
|
2197
|
+
const label = plan.classification === "create" ? pc20.green("create") : plan.classification === "additive" ? pc20.cyan("additive") : pc20.red("destructive");
|
|
2198
|
+
const owner = plan.source === null ? "new" : plan.source === "code" ? "manifest-owned" : "dashboard-owned";
|
|
2199
|
+
console.log(` ${pc20.bold(plan.slug)} \u2014 ${label} (${owner})`);
|
|
2200
|
+
if (plan.source === "runtime") {
|
|
2201
|
+
console.log(pc20.dim(" note: stays dashboard-editable \u2014 applying does not transfer ownership"));
|
|
2202
|
+
}
|
|
2203
|
+
for (const issue of plan.issues) {
|
|
2204
|
+
for (const message of issue.messages) {
|
|
2205
|
+
console.log(issue.fatal ? pc20.red(` \u2717 ${message}`) : pc20.yellow(` ! ${message}`));
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
for (const reason of plan.blockedReasons) console.log(pc20.red(` \u2717 ${reason}`));
|
|
2209
|
+
if (plan.applicable && !plan.definitionChanged) {
|
|
2210
|
+
console.log(pc20.dim(" (no change)"));
|
|
1095
2211
|
} else {
|
|
1096
|
-
|
|
2212
|
+
for (const statement of plan.statements) console.log(pc20.dim(` ${statement}`));
|
|
2213
|
+
for (const alias of plan.changedBranches) console.log(pc20.yellow(` ~ ${alias} \u2014 definition changed`));
|
|
1097
2214
|
}
|
|
2215
|
+
if (plan.ftsRebuildNeeded) console.log(pc20.yellow(" ! full-text index will be rebuilt after apply"));
|
|
1098
2216
|
}
|
|
1099
2217
|
|
|
1100
|
-
// src/commands/schema-
|
|
1101
|
-
import
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
2218
|
+
// src/commands/schema-apply.ts
|
|
2219
|
+
import { randomUUID } from "node:crypto";
|
|
2220
|
+
import * as p from "@clack/prompts";
|
|
2221
|
+
import pc21 from "picocolors";
|
|
2222
|
+
async function schemaApply(args = {}) {
|
|
2223
|
+
try {
|
|
2224
|
+
const manifestPath = args.manifest ?? DEFAULT_MANIFEST_PATH;
|
|
2225
|
+
const manifest = await loadManifest(manifestPath);
|
|
2226
|
+
const { ordered, cycles } = orderSeedsForApply(manifest.seeds);
|
|
2227
|
+
if (cycles.length > 0) {
|
|
2228
|
+
throw new CliError(
|
|
2229
|
+
`Relation cycle in ${manifestPath}: ${cycles.map((cycle) => cycle.join(" \u2192 ")).join("; ")}.`,
|
|
2230
|
+
"Apply one of these seeds without its relation branch first, then add the branch and apply again."
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
const controlPlane = createControlPlane({ apiUrl: args.apiUrl });
|
|
2234
|
+
console.log(pc21.cyan(`
|
|
2235
|
+
Plan for ${manifestPath} against ${controlPlane.baseUrl}
|
|
2236
|
+
`));
|
|
2237
|
+
const previews = /* @__PURE__ */ new Map();
|
|
2238
|
+
for (const seed of ordered) {
|
|
2239
|
+
const plan = await controlPlane.plan(seed.slug, seed);
|
|
2240
|
+
previews.set(seed.slug, plan);
|
|
2241
|
+
renderPlan(plan);
|
|
2242
|
+
}
|
|
2243
|
+
const blocked = [...previews.values()].filter((plan) => !plan.applicable);
|
|
2244
|
+
if (blocked.length > 0) {
|
|
2245
|
+
throw new CliError(
|
|
2246
|
+
`${blocked.length} seed(s) cannot be applied: ${blocked.map((plan) => plan.slug).join(", ")}. Nothing was written.`,
|
|
2247
|
+
"Fix the manifest, or perform the flagged changes through the endpoints named above."
|
|
2248
|
+
);
|
|
2249
|
+
}
|
|
2250
|
+
const pending = ordered.filter((seed) => previews.get(seed.slug)?.definitionChanged === true);
|
|
2251
|
+
if (pending.length === 0) {
|
|
2252
|
+
console.log(pc21.green("\n \u2713 Nothing to apply \u2014 the deployed schema already matches the manifest.\n"));
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
await confirmApply(pending, args.yes === true);
|
|
2256
|
+
const planId = randomUUID();
|
|
2257
|
+
console.log("");
|
|
2258
|
+
for (const seed of pending) {
|
|
2259
|
+
const result = await applyOne(controlPlane, seed, previews.get(seed.slug), planId);
|
|
2260
|
+
console.log(pc21.green(` \u2713 ${seed.slug} \u2014 registry version ${result.newVersion}`));
|
|
2261
|
+
if (result.warning) console.log(pc21.yellow(` ! ${result.warning}`));
|
|
2262
|
+
}
|
|
2263
|
+
console.log(pc21.green(`
|
|
2264
|
+
\u2713 Applied ${pending.length} seed(s).`));
|
|
2265
|
+
console.log(pc21.dim(" Regenerate client types with `beech types generate`.\n"));
|
|
2266
|
+
} catch (error) {
|
|
2267
|
+
exitWithError(error);
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
async function applyOne(controlPlane, seed, preview, planId) {
|
|
2271
|
+
const fresh = await controlPlane.plan(seed.slug, seed);
|
|
2272
|
+
if (!fresh.applicable) {
|
|
2273
|
+
throw new CliError(
|
|
2274
|
+
`'${seed.slug}' became unappliable between plan and apply: ${fresh.blockedReasons.join("; ")}`,
|
|
2275
|
+
"The deployed schema changed under this run. Re-run `beech schema plan`."
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
2278
|
+
if (fresh.statements.join("\n") !== preview.statements.join("\n") || fresh.changedBranches.join("\n") !== preview.changedBranches.join("\n")) {
|
|
2279
|
+
throw new CliError(
|
|
2280
|
+
`The plan for '${seed.slug}' changed between review and apply. Nothing was written for it.`,
|
|
2281
|
+
"Someone else changed the schema during this run. Re-run `beech schema plan`."
|
|
2282
|
+
);
|
|
2283
|
+
}
|
|
2284
|
+
return controlPlane.apply({ slug: seed.slug, candidate: seed, expectedVersion: fresh.expectedVersion, planId });
|
|
2285
|
+
}
|
|
2286
|
+
async function confirmApply(pending, skip) {
|
|
2287
|
+
if (skip) return;
|
|
2288
|
+
if (!process.stdout.isTTY) {
|
|
2289
|
+
throw new CliError(
|
|
2290
|
+
"Refusing to apply schema changes without confirmation in a non-interactive shell.",
|
|
2291
|
+
"Re-run with --yes once the plan above has been reviewed."
|
|
2292
|
+
);
|
|
2293
|
+
}
|
|
2294
|
+
const answer = await p.confirm({
|
|
2295
|
+
message: `Apply ${pending.length} seed(s) to the deployed schema? (${pending.map((seed) => seed.slug).join(", ")})`,
|
|
2296
|
+
initialValue: false
|
|
2297
|
+
});
|
|
2298
|
+
if (p.isCancel(answer) || !answer) {
|
|
2299
|
+
throw new CliError("Aborted. Nothing was written.");
|
|
2300
|
+
}
|
|
1107
2301
|
}
|
|
1108
2302
|
|
|
1109
2303
|
// src/commands/db-migrate.ts
|
|
1110
|
-
import
|
|
2304
|
+
import pc22 from "picocolors";
|
|
1111
2305
|
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
1112
|
-
import { existsSync as
|
|
1113
|
-
import { resolve as
|
|
2306
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "node:fs";
|
|
2307
|
+
import { resolve as resolve9 } from "node:path";
|
|
1114
2308
|
async function dbMigrate(_args) {
|
|
1115
|
-
console.log(
|
|
2309
|
+
console.log(pc22.cyan("\n beech db:migrate \u2014 apply migrations\n"));
|
|
1116
2310
|
const cwd = process.cwd();
|
|
1117
|
-
const apiDir =
|
|
1118
|
-
if (
|
|
2311
|
+
const apiDir = resolve9(cwd, "apps", "api");
|
|
2312
|
+
if (existsSync8(resolve9(apiDir, "package.json"))) {
|
|
1119
2313
|
const result = spawnSync7("npm", ["run", "db:migrate:local"], {
|
|
1120
2314
|
stdio: "inherit",
|
|
1121
2315
|
cwd: apiDir,
|
|
1122
2316
|
shell: true
|
|
1123
2317
|
});
|
|
1124
2318
|
if (result.status !== 0) {
|
|
1125
|
-
console.log(
|
|
2319
|
+
console.log(pc22.red("\n \u2717 Failed to apply migrations."));
|
|
1126
2320
|
process.exit(1);
|
|
1127
2321
|
return;
|
|
1128
2322
|
}
|
|
1129
|
-
console.log(
|
|
2323
|
+
console.log(pc22.green("\n \u2713 Migrations applied successfully."));
|
|
1130
2324
|
return;
|
|
1131
2325
|
}
|
|
1132
|
-
if (
|
|
2326
|
+
if (existsSync8(resolve9(cwd, "scripts", "bootstrap-d1.mjs"))) {
|
|
1133
2327
|
const result = spawnSync7("node", ["scripts/bootstrap-d1.mjs"], {
|
|
1134
2328
|
stdio: "inherit",
|
|
1135
2329
|
cwd,
|
|
1136
2330
|
shell: true
|
|
1137
2331
|
});
|
|
1138
2332
|
if (result.status !== 0) {
|
|
1139
|
-
console.log(
|
|
2333
|
+
console.log(pc22.red("\n \u2717 Failed to apply migrations."));
|
|
1140
2334
|
process.exit(1);
|
|
1141
2335
|
return;
|
|
1142
2336
|
}
|
|
1143
|
-
console.log(
|
|
2337
|
+
console.log(pc22.green("\n \u2713 Migrations applied successfully."));
|
|
1144
2338
|
return;
|
|
1145
2339
|
}
|
|
1146
|
-
const rootPkgPath =
|
|
1147
|
-
if (
|
|
2340
|
+
const rootPkgPath = resolve9(cwd, "package.json");
|
|
2341
|
+
if (existsSync8(rootPkgPath)) {
|
|
1148
2342
|
try {
|
|
1149
|
-
const pkg = JSON.parse(
|
|
2343
|
+
const pkg = JSON.parse(readFileSync7(rootPkgPath, "utf-8"));
|
|
1150
2344
|
if (pkg.scripts?.["db:migrate:local"]) {
|
|
1151
2345
|
const result = spawnSync7("npm", ["run", "db:migrate:local"], {
|
|
1152
2346
|
stdio: "inherit",
|
|
@@ -1154,38 +2348,38 @@ async function dbMigrate(_args) {
|
|
|
1154
2348
|
shell: true
|
|
1155
2349
|
});
|
|
1156
2350
|
if (result.status !== 0) {
|
|
1157
|
-
console.log(
|
|
2351
|
+
console.log(pc22.red("\n \u2717 Failed to apply migrations."));
|
|
1158
2352
|
process.exit(1);
|
|
1159
2353
|
return;
|
|
1160
2354
|
}
|
|
1161
|
-
console.log(
|
|
2355
|
+
console.log(pc22.green("\n \u2713 Migrations applied successfully."));
|
|
1162
2356
|
return;
|
|
1163
2357
|
}
|
|
1164
2358
|
} catch {
|
|
1165
2359
|
}
|
|
1166
2360
|
}
|
|
1167
|
-
console.log(
|
|
2361
|
+
console.log(pc22.dim(" No migration script found \u2014 initialising database via beech init --db...\n"));
|
|
1168
2362
|
const { init: init2 } = await Promise.resolve().then(() => (init_init(), init_exports));
|
|
1169
2363
|
try {
|
|
1170
2364
|
await init2({ initDb: true, local: true, nonInteractive: true });
|
|
1171
2365
|
} catch {
|
|
1172
|
-
console.log(
|
|
1173
|
-
console.log(
|
|
2366
|
+
console.log(pc22.red("\n \u2717 Database initialisation failed."));
|
|
2367
|
+
console.log(pc22.dim(" Run: npm run db:migrate:local or npx beech init --db\n"));
|
|
1174
2368
|
process.exit(1);
|
|
1175
2369
|
}
|
|
1176
2370
|
}
|
|
1177
2371
|
|
|
1178
2372
|
// src/commands/dev.ts
|
|
1179
|
-
import
|
|
2373
|
+
import pc23 from "picocolors";
|
|
1180
2374
|
import { spawnSync as spawnSync8 } from "node:child_process";
|
|
1181
|
-
import { existsSync as
|
|
1182
|
-
import { resolve as
|
|
2375
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
2376
|
+
import { resolve as resolve10 } from "node:path";
|
|
1183
2377
|
async function dev(args) {
|
|
1184
|
-
console.log(
|
|
2378
|
+
console.log(pc23.cyan("\n beech dev \u2014 start development environment\n"));
|
|
1185
2379
|
const cwd = process.cwd();
|
|
1186
|
-
const devScript =
|
|
1187
|
-
if (!
|
|
1188
|
-
console.log(
|
|
2380
|
+
const devScript = resolve10(cwd, "scripts", "dev.mjs");
|
|
2381
|
+
if (!existsSync9(devScript)) {
|
|
2382
|
+
console.log(pc23.red(" \u2717 Could not find development script (scripts/dev.mjs)."));
|
|
1189
2383
|
process.exit(1);
|
|
1190
2384
|
return;
|
|
1191
2385
|
}
|
|
@@ -1206,7 +2400,7 @@ async function dev(args) {
|
|
|
1206
2400
|
}
|
|
1207
2401
|
|
|
1208
2402
|
// src/commands/dev-stop.ts
|
|
1209
|
-
import
|
|
2403
|
+
import pc24 from "picocolors";
|
|
1210
2404
|
import { spawnSync as spawnSync9 } from "node:child_process";
|
|
1211
2405
|
function isDockerInstalled2() {
|
|
1212
2406
|
try {
|
|
@@ -1225,44 +2419,44 @@ function isDockerRunning2() {
|
|
|
1225
2419
|
}
|
|
1226
2420
|
}
|
|
1227
2421
|
async function devStop() {
|
|
1228
|
-
console.log(
|
|
2422
|
+
console.log(pc24.cyan("\n beech dev:stop \u2014 stop Docker environment\n"));
|
|
1229
2423
|
if (!isDockerInstalled2()) {
|
|
1230
|
-
console.log(
|
|
2424
|
+
console.log(pc24.red(" \u2717 Docker is not installed or not found in your PATH."));
|
|
1231
2425
|
process.exit(1);
|
|
1232
2426
|
return;
|
|
1233
2427
|
}
|
|
1234
2428
|
if (!isDockerRunning2()) {
|
|
1235
|
-
console.log(
|
|
2429
|
+
console.log(pc24.yellow(" \u26A0 Docker is installed, but the Docker daemon is NOT running."));
|
|
1236
2430
|
process.exit(1);
|
|
1237
2431
|
return;
|
|
1238
2432
|
}
|
|
1239
|
-
console.log(
|
|
2433
|
+
console.log(pc24.dim(" Stopping Docker containers\u2026\n"));
|
|
1240
2434
|
const result = spawnSync9("docker", ["compose", "-f", "docker/docker-compose.yml", "stop"], {
|
|
1241
2435
|
stdio: "inherit",
|
|
1242
2436
|
cwd: process.cwd(),
|
|
1243
2437
|
shell: true
|
|
1244
2438
|
});
|
|
1245
2439
|
if (result.status === 0) {
|
|
1246
|
-
console.log(
|
|
2440
|
+
console.log(pc24.green("\n \u2713 Docker containers stopped."));
|
|
1247
2441
|
} else {
|
|
1248
|
-
console.log(
|
|
2442
|
+
console.log(pc24.red("\n \u2717 Failed to stop Docker containers."));
|
|
1249
2443
|
process.exit(1);
|
|
1250
2444
|
return;
|
|
1251
2445
|
}
|
|
1252
2446
|
}
|
|
1253
2447
|
|
|
1254
2448
|
// src/commands/dev-tunnel.ts
|
|
1255
|
-
import
|
|
2449
|
+
import pc25 from "picocolors";
|
|
1256
2450
|
import { spawnSync as spawnSync10 } from "node:child_process";
|
|
1257
2451
|
async function devTunnel() {
|
|
1258
|
-
console.log(
|
|
2452
|
+
console.log(pc25.cyan("\n beech dev:tunnel \u2014 get Cloudflare Tunnel URL\n"));
|
|
1259
2453
|
const result = spawnSync10("docker", ["compose", "-f", "docker/docker-compose.yml", "logs", "tunnel"], {
|
|
1260
2454
|
encoding: "utf-8",
|
|
1261
2455
|
cwd: process.cwd(),
|
|
1262
2456
|
shell: true
|
|
1263
2457
|
});
|
|
1264
2458
|
if (result.status !== 0) {
|
|
1265
|
-
console.log(
|
|
2459
|
+
console.log(pc25.red(" \u2717 Failed to retrieve tunnel logs."));
|
|
1266
2460
|
process.exit(1);
|
|
1267
2461
|
return;
|
|
1268
2462
|
}
|
|
@@ -1270,38 +2464,38 @@ async function devTunnel() {
|
|
|
1270
2464
|
const match = logs2.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g);
|
|
1271
2465
|
if (match && match.length > 0) {
|
|
1272
2466
|
const url = match[match.length - 1];
|
|
1273
|
-
console.log(
|
|
2467
|
+
console.log(pc25.green(` \u2713 Active Cloudflare Tunnel URL: ${pc25.bold(url)}`));
|
|
1274
2468
|
} else {
|
|
1275
|
-
console.log(
|
|
1276
|
-
console.log(
|
|
2469
|
+
console.log(pc25.yellow(" \u26A0 No active Cloudflare Tunnel URL found in logs."));
|
|
2470
|
+
console.log(pc25.dim(" Make sure the dev server is running with Docker (`pnpm beech dev`)."));
|
|
1277
2471
|
}
|
|
1278
2472
|
}
|
|
1279
2473
|
|
|
1280
2474
|
// src/commands/mailpit-clear.ts
|
|
1281
|
-
import
|
|
2475
|
+
import pc26 from "picocolors";
|
|
1282
2476
|
async function mailpitClear() {
|
|
1283
|
-
console.log(
|
|
2477
|
+
console.log(pc26.cyan("\n beech mailpit:clear \u2014 clear test emails\n"));
|
|
1284
2478
|
try {
|
|
1285
2479
|
const res = await fetch("http://localhost:8025/api/v1/messages", {
|
|
1286
2480
|
method: "DELETE"
|
|
1287
2481
|
});
|
|
1288
2482
|
if (res.ok) {
|
|
1289
|
-
console.log(
|
|
2483
|
+
console.log(pc26.green(" \u2713 Mailpit inbox cleared successfully."));
|
|
1290
2484
|
} else {
|
|
1291
|
-
console.log(
|
|
2485
|
+
console.log(pc26.red(` \u2717 Failed to clear Mailpit inbox: ${res.statusText}`));
|
|
1292
2486
|
process.exit(1);
|
|
1293
2487
|
return;
|
|
1294
2488
|
}
|
|
1295
2489
|
} catch (err) {
|
|
1296
|
-
console.log(
|
|
1297
|
-
console.log(
|
|
2490
|
+
console.log(pc26.red(` \u2717 Error connecting to Mailpit: ${err.message}`));
|
|
2491
|
+
console.log(pc26.dim(" Make sure Mailpit is running (default port 8025)."));
|
|
1298
2492
|
process.exit(1);
|
|
1299
2493
|
return;
|
|
1300
2494
|
}
|
|
1301
2495
|
}
|
|
1302
2496
|
|
|
1303
2497
|
// src/commands/logs.ts
|
|
1304
|
-
import
|
|
2498
|
+
import pc27 from "picocolors";
|
|
1305
2499
|
import { spawnSync as spawnSync11 } from "node:child_process";
|
|
1306
2500
|
var SERVICE_MAP = {
|
|
1307
2501
|
mailpit: "mailpit",
|
|
@@ -1314,18 +2508,18 @@ var SERVICE_MAP = {
|
|
|
1314
2508
|
async function logs(args) {
|
|
1315
2509
|
const inputService = args.service?.toLowerCase();
|
|
1316
2510
|
if (!inputService || !SERVICE_MAP[inputService]) {
|
|
1317
|
-
console.log(
|
|
1318
|
-
console.log(
|
|
1319
|
-
console.log(` - ${
|
|
1320
|
-
console.log(` - ${
|
|
1321
|
-
console.log(` - ${
|
|
1322
|
-
console.log(` - ${
|
|
2511
|
+
console.log(pc27.red("\n \u2717 Error: Please specify a valid service name."));
|
|
2512
|
+
console.log(pc27.dim("\n Accepted services:"));
|
|
2513
|
+
console.log(` - ${pc27.cyan("mailpit")}`);
|
|
2514
|
+
console.log(` - ${pc27.cyan("db")} / ${pc27.cyan("sqlite")}`);
|
|
2515
|
+
console.log(` - ${pc27.cyan("tunnel")}`);
|
|
2516
|
+
console.log(` - ${pc27.cyan("storage")} / ${pc27.cyan("minio")}
|
|
1323
2517
|
`);
|
|
1324
2518
|
process.exit(1);
|
|
1325
2519
|
return;
|
|
1326
2520
|
}
|
|
1327
2521
|
const service = SERVICE_MAP[inputService];
|
|
1328
|
-
console.log(
|
|
2522
|
+
console.log(pc27.cyan(`
|
|
1329
2523
|
beech logs ${inputService} \u2014 streaming logs for ${service}\u2026
|
|
1330
2524
|
`));
|
|
1331
2525
|
const result = spawnSync11("docker", ["compose", "-f", "docker/docker-compose.yml", "logs", "-f", service], {
|
|
@@ -1340,33 +2534,97 @@ async function logs(args) {
|
|
|
1340
2534
|
}
|
|
1341
2535
|
|
|
1342
2536
|
// src/commands/test.ts
|
|
1343
|
-
import
|
|
2537
|
+
import pc28 from "picocolors";
|
|
1344
2538
|
import { spawnSync as spawnSync12 } from "node:child_process";
|
|
1345
|
-
import { existsSync as
|
|
1346
|
-
import { resolve as
|
|
2539
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
2540
|
+
import { resolve as resolve12 } from "node:path";
|
|
2541
|
+
import { pathToFileURL as pathToFileURL3 } from "node:url";
|
|
2542
|
+
|
|
2543
|
+
// src/lib/cpu-slot.ts
|
|
2544
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
2545
|
+
import { resolve as resolve11 } from "node:path";
|
|
2546
|
+
import { pathToFileURL as pathToFileURL2 } from "node:url";
|
|
2547
|
+
async function loadCpuSlot(cwd) {
|
|
2548
|
+
const modulePath = resolve11(cwd, "scripts", "lib", "cpu-slot.mjs");
|
|
2549
|
+
if (!existsSync10(modulePath)) return null;
|
|
2550
|
+
return await import(pathToFileURL2(modulePath).href);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/commands/test.ts
|
|
2554
|
+
var RUNNABLE_TIERS = ["unit", "flow", "integration", "e2e", "scale"];
|
|
2555
|
+
async function loadTestResources(cwd) {
|
|
2556
|
+
const modulePath = resolve12(cwd, "scripts", "lib", "test-resources.mjs");
|
|
2557
|
+
if (!existsSync11(modulePath)) return { env: {}, turboArgs: [] };
|
|
2558
|
+
try {
|
|
2559
|
+
const mod = await import(pathToFileURL3(modulePath).href);
|
|
2560
|
+
const resources = mod.resolveTestResources();
|
|
2561
|
+
console.log(pc28.dim(` ${mod.describeTestResources(resources)}
|
|
2562
|
+
`));
|
|
2563
|
+
if (!resources) return { env: {}, turboArgs: [] };
|
|
2564
|
+
mod.lowerProcessPriority();
|
|
2565
|
+
return { env: mod.testResourceEnv(resources), turboArgs: [`--concurrency=${resources.turboConcurrency}`] };
|
|
2566
|
+
} catch {
|
|
2567
|
+
return { env: {}, turboArgs: [] };
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
1347
2570
|
async function test(args) {
|
|
1348
|
-
console.log(
|
|
2571
|
+
console.log(pc28.cyan("\n beech test \u2014 run test suite\n"));
|
|
1349
2572
|
const cwd = process.cwd();
|
|
2573
|
+
const tiers = (args.tier ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
|
2574
|
+
const invalid = tiers.filter((t) => !RUNNABLE_TIERS.includes(t));
|
|
2575
|
+
if (invalid.length > 0) {
|
|
2576
|
+
console.log(pc28.red(` \u2717 Unknown tier(s): ${invalid.join(", ")}. Valid tiers: ${RUNNABLE_TIERS.join(", ")}.`));
|
|
2577
|
+
process.exit(1);
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
if (args.diff && (tiers.includes("e2e") || tiers.includes("scale"))) {
|
|
2581
|
+
console.log(pc28.red(" \u2717 The e2e and scale tiers are never selected by --diff."));
|
|
2582
|
+
process.exit(1);
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
1350
2585
|
let command = "turbo";
|
|
1351
2586
|
let commandArgs = ["run", "test"];
|
|
1352
2587
|
if (args.diff) {
|
|
1353
|
-
const diffScript =
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1356
|
-
commandArgs = ["scripts/test-coverage-diff.mjs"];
|
|
1357
|
-
} else {
|
|
1358
|
-
console.log(pc19.red(" \u2717 Coverage diff script not found (scripts/test-coverage-diff.mjs)."));
|
|
2588
|
+
const diffScript = resolve12(cwd, "scripts", "test-coverage-diff.mjs");
|
|
2589
|
+
if (!existsSync11(diffScript)) {
|
|
2590
|
+
console.log(pc28.red(" \u2717 Coverage diff script not found (scripts/test-coverage-diff.mjs)."));
|
|
1359
2591
|
process.exit(1);
|
|
1360
2592
|
return;
|
|
1361
2593
|
}
|
|
2594
|
+
command = "node";
|
|
2595
|
+
commandArgs = ["scripts/test-coverage-diff.mjs"];
|
|
2596
|
+
if (tiers.length > 0) commandArgs.push("--tier", tiers.join(","));
|
|
2597
|
+
} else if (tiers.length > 0) {
|
|
2598
|
+
commandArgs = ["run", ...tiers.map((t) => `test:${t}`)];
|
|
1362
2599
|
} else if (args.coverage) {
|
|
1363
2600
|
commandArgs = ["run", "test:coverage"];
|
|
1364
2601
|
}
|
|
2602
|
+
let releaseSlot = () => {
|
|
2603
|
+
};
|
|
2604
|
+
const slotEnv = {};
|
|
2605
|
+
const cpuSlot = command === "turbo" ? await loadCpuSlot(cwd) : null;
|
|
2606
|
+
if (cpuSlot) {
|
|
2607
|
+
const exclusive = tiers.length === 0 || tiers.includes("flow");
|
|
2608
|
+
try {
|
|
2609
|
+
releaseSlot = await cpuSlot.acquireCpuSlot({ exclusive, label: `beech test: turbo ${commandArgs.join(" ")}` });
|
|
2610
|
+
} catch (err) {
|
|
2611
|
+
if (err.code !== "SLOT_TIMEOUT") throw err;
|
|
2612
|
+
console.log(pc28.red(` \u2717 ${err.message} The tests did NOT run.`));
|
|
2613
|
+
process.exit(cpuSlot.EXIT_SLOT_TIMEOUT);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
cpuSlot.releaseOnExit(releaseSlot);
|
|
2617
|
+
slotEnv[cpuSlot.HELD_ENV] = "1";
|
|
2618
|
+
}
|
|
2619
|
+
const resources = await loadTestResources(cwd);
|
|
2620
|
+
if (command === "turbo") commandArgs.push(...resources.turboArgs);
|
|
1365
2621
|
const result = spawnSync12(command, commandArgs, {
|
|
1366
2622
|
stdio: "inherit",
|
|
1367
2623
|
cwd,
|
|
1368
|
-
shell: true
|
|
2624
|
+
shell: true,
|
|
2625
|
+
env: { ...process.env, ...resources.env, ...slotEnv }
|
|
1369
2626
|
});
|
|
2627
|
+
releaseSlot();
|
|
1370
2628
|
if (result.status !== 0) {
|
|
1371
2629
|
process.exit(result.status ?? 1);
|
|
1372
2630
|
return;
|
|
@@ -1374,10 +2632,17 @@ async function test(args) {
|
|
|
1374
2632
|
}
|
|
1375
2633
|
|
|
1376
2634
|
// src/commands/lint.ts
|
|
1377
|
-
import
|
|
2635
|
+
import pc29 from "picocolors";
|
|
1378
2636
|
import { spawnSync as spawnSync13 } from "node:child_process";
|
|
1379
2637
|
async function lint() {
|
|
1380
|
-
console.log(
|
|
2638
|
+
console.log(pc29.cyan("\n beech lint \u2014 check code style\n"));
|
|
2639
|
+
const placement = spawnSync13("node", ["scripts/check-test-placement.mjs"], {
|
|
2640
|
+
stdio: "inherit",
|
|
2641
|
+
cwd: process.cwd()
|
|
2642
|
+
});
|
|
2643
|
+
if (placement.status !== 0) {
|
|
2644
|
+
process.exit(placement.status ?? 1);
|
|
2645
|
+
}
|
|
1381
2646
|
const result = spawnSync13("turbo", ["run", "lint"], {
|
|
1382
2647
|
stdio: "inherit",
|
|
1383
2648
|
cwd: process.cwd(),
|
|
@@ -1389,10 +2654,10 @@ async function lint() {
|
|
|
1389
2654
|
}
|
|
1390
2655
|
|
|
1391
2656
|
// src/commands/doctor.ts
|
|
1392
|
-
import
|
|
2657
|
+
import pc30 from "picocolors";
|
|
1393
2658
|
import { spawnSync as spawnSync14 } from "node:child_process";
|
|
1394
2659
|
async function doctor() {
|
|
1395
|
-
console.log(
|
|
2660
|
+
console.log(pc30.cyan("\n beech doctor \u2014 React diagnostics\n"));
|
|
1396
2661
|
const cmd = process.env.npm_config_user_agent?.includes("pnpm") ? "pnpm" : "npx";
|
|
1397
2662
|
const args = cmd === "pnpm" ? ["dlx", "react-doctor@latest"] : ["--yes", "react-doctor@latest"];
|
|
1398
2663
|
const result = spawnSync14(cmd, args, {
|
|
@@ -1406,10 +2671,10 @@ async function doctor() {
|
|
|
1406
2671
|
}
|
|
1407
2672
|
|
|
1408
2673
|
// src/commands/forms.ts
|
|
1409
|
-
import * as
|
|
1410
|
-
import
|
|
1411
|
-
import { mkdirSync as
|
|
1412
|
-
import { dirname as
|
|
2674
|
+
import * as p2 from "@clack/prompts";
|
|
2675
|
+
import pc31 from "picocolors";
|
|
2676
|
+
import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync6 } from "node:fs";
|
|
2677
|
+
import { dirname as dirname5, resolve as resolve13 } from "node:path";
|
|
1413
2678
|
function getReactTemplate(seedSlug, mode) {
|
|
1414
2679
|
if (mode === "headless") {
|
|
1415
2680
|
return `import React, { useState, useEffect } from 'react'
|
|
@@ -2042,9 +3307,9 @@ export { BeechFormElement }
|
|
|
2042
3307
|
async function forms(options = {}) {
|
|
2043
3308
|
let { framework, seed, mode, out, yes, json } = options;
|
|
2044
3309
|
if (!yes && !json) {
|
|
2045
|
-
|
|
3310
|
+
p2.intro(pc31.bgCyan(pc31.black(" \u{1F332} BeechCMS Form Generator ")));
|
|
2046
3311
|
if (!framework) {
|
|
2047
|
-
const selected = await
|
|
3312
|
+
const selected = await p2.select({
|
|
2048
3313
|
message: "Which framework are you using?",
|
|
2049
3314
|
options: [
|
|
2050
3315
|
{ value: "react", label: "React", hint: "Next.js / Vite / Remix (.tsx)" },
|
|
@@ -2054,14 +3319,14 @@ async function forms(options = {}) {
|
|
|
2054
3319
|
],
|
|
2055
3320
|
initialValue: "react"
|
|
2056
3321
|
});
|
|
2057
|
-
if (
|
|
2058
|
-
|
|
3322
|
+
if (p2.isCancel(selected)) {
|
|
3323
|
+
p2.cancel("Operation cancelled.");
|
|
2059
3324
|
process.exit(0);
|
|
2060
3325
|
}
|
|
2061
3326
|
framework = selected;
|
|
2062
3327
|
}
|
|
2063
3328
|
if (!seed) {
|
|
2064
|
-
const seedInput = await
|
|
3329
|
+
const seedInput = await p2.text({
|
|
2065
3330
|
message: "Which Seed do you want to bind this form to?",
|
|
2066
3331
|
placeholder: "clienti",
|
|
2067
3332
|
defaultValue: "clienti",
|
|
@@ -2069,14 +3334,14 @@ async function forms(options = {}) {
|
|
|
2069
3334
|
if (!value.trim()) return "Seed slug cannot be empty";
|
|
2070
3335
|
}
|
|
2071
3336
|
});
|
|
2072
|
-
if (
|
|
2073
|
-
|
|
3337
|
+
if (p2.isCancel(seedInput)) {
|
|
3338
|
+
p2.cancel("Operation cancelled.");
|
|
2074
3339
|
process.exit(0);
|
|
2075
3340
|
}
|
|
2076
3341
|
seed = seedInput.trim();
|
|
2077
3342
|
}
|
|
2078
3343
|
if (!mode && framework !== "vanilla") {
|
|
2079
|
-
const modeSelected = await
|
|
3344
|
+
const modeSelected = await p2.select({
|
|
2080
3345
|
message: "Choose styling preset:",
|
|
2081
3346
|
options: [
|
|
2082
3347
|
{ value: "styled", label: "Tailwind CSS", hint: "Full responsive ready-to-use component" },
|
|
@@ -2084,8 +3349,8 @@ async function forms(options = {}) {
|
|
|
2084
3349
|
],
|
|
2085
3350
|
initialValue: "styled"
|
|
2086
3351
|
});
|
|
2087
|
-
if (
|
|
2088
|
-
|
|
3352
|
+
if (p2.isCancel(modeSelected)) {
|
|
3353
|
+
p2.cancel("Operation cancelled.");
|
|
2089
3354
|
process.exit(0);
|
|
2090
3355
|
}
|
|
2091
3356
|
mode = modeSelected;
|
|
@@ -2096,7 +3361,7 @@ async function forms(options = {}) {
|
|
|
2096
3361
|
mode = mode || "styled";
|
|
2097
3362
|
const ext = framework === "react" ? "tsx" : framework === "vue" ? "vue" : framework === "svelte" ? "svelte" : "js";
|
|
2098
3363
|
const filename = `BeechForm.${ext}`;
|
|
2099
|
-
const targetPath = out ?
|
|
3364
|
+
const targetPath = out ? resolve13(process.cwd(), out) : resolve13(process.cwd(), "src", "components", filename);
|
|
2100
3365
|
let content = "";
|
|
2101
3366
|
if (framework === "react") {
|
|
2102
3367
|
content = getReactTemplate(seed, mode);
|
|
@@ -2107,8 +3372,8 @@ async function forms(options = {}) {
|
|
|
2107
3372
|
} else {
|
|
2108
3373
|
content = getVanillaTemplate(seed);
|
|
2109
3374
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
3375
|
+
mkdirSync4(dirname5(targetPath), { recursive: true });
|
|
3376
|
+
writeFileSync6(targetPath, content, "utf-8");
|
|
2112
3377
|
if (json) {
|
|
2113
3378
|
console.log(
|
|
2114
3379
|
JSON.stringify(
|
|
@@ -2127,24 +3392,24 @@ async function forms(options = {}) {
|
|
|
2127
3392
|
return;
|
|
2128
3393
|
}
|
|
2129
3394
|
if (!yes) {
|
|
2130
|
-
|
|
3395
|
+
p2.outro(pc31.green(`\u2714 Form component created at ${out ? out : `src/components/${filename}`}`));
|
|
2131
3396
|
console.log(`
|
|
2132
|
-
${
|
|
3397
|
+
${pc31.bold("Next steps:")}`);
|
|
2133
3398
|
if (framework === "react") {
|
|
2134
|
-
console.log(
|
|
2135
|
-
console.log(
|
|
3399
|
+
console.log(pc31.cyan(` import { BeechForm } from './components/BeechForm'`));
|
|
3400
|
+
console.log(pc31.cyan(` <BeechForm seed="${seed}" />
|
|
2136
3401
|
`));
|
|
2137
3402
|
} else if (framework === "vue") {
|
|
2138
|
-
console.log(
|
|
2139
|
-
console.log(
|
|
3403
|
+
console.log(pc31.cyan(` import BeechForm from './components/BeechForm.vue'`));
|
|
3404
|
+
console.log(pc31.cyan(` <BeechForm seed="${seed}" />
|
|
2140
3405
|
`));
|
|
2141
3406
|
} else if (framework === "svelte") {
|
|
2142
|
-
console.log(
|
|
2143
|
-
console.log(
|
|
3407
|
+
console.log(pc31.cyan(` import BeechForm from './components/BeechForm.svelte'`));
|
|
3408
|
+
console.log(pc31.cyan(` <BeechForm seed="${seed}" />
|
|
2144
3409
|
`));
|
|
2145
3410
|
} else {
|
|
2146
|
-
console.log(
|
|
2147
|
-
console.log(
|
|
3411
|
+
console.log(pc31.cyan(` <script type="module" src="./src/components/BeechForm.js"></script>`));
|
|
3412
|
+
console.log(pc31.cyan(` <beech-form seed="${seed}"></beech-form>
|
|
2148
3413
|
`));
|
|
2149
3414
|
}
|
|
2150
3415
|
} else {
|
|
@@ -2154,19 +3419,19 @@ async function forms(options = {}) {
|
|
|
2154
3419
|
|
|
2155
3420
|
// src/commands/setup-cloudflare.ts
|
|
2156
3421
|
init_wrangler();
|
|
2157
|
-
import
|
|
2158
|
-
import * as
|
|
3422
|
+
import pc32 from "picocolors";
|
|
3423
|
+
import * as p3 from "@clack/prompts";
|
|
2159
3424
|
import { spawnSync as spawnSync15 } from "node:child_process";
|
|
2160
|
-
import { readFileSync as
|
|
2161
|
-
import { resolve as
|
|
3425
|
+
import { readFileSync as readFileSync8, writeFileSync as writeFileSync7, existsSync as existsSync13 } from "node:fs";
|
|
3426
|
+
import { resolve as resolve14 } from "node:path";
|
|
2162
3427
|
function parseWranglerConfig(configPath) {
|
|
2163
|
-
const raw =
|
|
3428
|
+
const raw = readFileSync8(configPath, "utf-8");
|
|
2164
3429
|
const stripped = raw.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
2165
3430
|
const parsed = JSON.parse(stripped);
|
|
2166
3431
|
return { name: parsed.name || "my-beech-project", raw, parsed };
|
|
2167
3432
|
}
|
|
2168
3433
|
async function setupCloudflare(options = {}) {
|
|
2169
|
-
|
|
3434
|
+
p3.intro(pc32.cyan("\u26A1 BeechCMS \u2014 Automated Cloudflare Provisioning"));
|
|
2170
3435
|
const configPath = findWranglerConfig();
|
|
2171
3436
|
let projectName = options.projectName;
|
|
2172
3437
|
if (!projectName && configPath) {
|
|
@@ -2180,7 +3445,7 @@ async function setupCloudflare(options = {}) {
|
|
|
2180
3445
|
if (options.nonInteractive) {
|
|
2181
3446
|
projectName = "my-beech-project";
|
|
2182
3447
|
} else {
|
|
2183
|
-
const input = await
|
|
3448
|
+
const input = await p3.text({
|
|
2184
3449
|
message: "Project name",
|
|
2185
3450
|
placeholder: "my-website",
|
|
2186
3451
|
validate: (v) => {
|
|
@@ -2188,8 +3453,8 @@ async function setupCloudflare(options = {}) {
|
|
|
2188
3453
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(v.trim())) return "Lowercase letters, numbers and hyphens only";
|
|
2189
3454
|
}
|
|
2190
3455
|
});
|
|
2191
|
-
if (
|
|
2192
|
-
|
|
3456
|
+
if (p3.isCancel(input)) {
|
|
3457
|
+
p3.cancel("Setup cancelled");
|
|
2193
3458
|
return;
|
|
2194
3459
|
}
|
|
2195
3460
|
projectName = input.trim();
|
|
@@ -2197,8 +3462,8 @@ async function setupCloudflare(options = {}) {
|
|
|
2197
3462
|
}
|
|
2198
3463
|
const d1Name = `${projectName}-db`;
|
|
2199
3464
|
const r2Bucket = `${projectName}-media`;
|
|
2200
|
-
const s =
|
|
2201
|
-
s.start(`Creating Cloudflare D1 database: ${
|
|
3465
|
+
const s = p3.spinner();
|
|
3466
|
+
s.start(`Creating Cloudflare D1 database: ${pc32.bold(d1Name)}\u2026`);
|
|
2202
3467
|
const d1Result = spawnSync15("npx", ["wrangler", "d1", "create", d1Name], {
|
|
2203
3468
|
encoding: "utf-8",
|
|
2204
3469
|
shell: true,
|
|
@@ -2209,13 +3474,13 @@ async function setupCloudflare(options = {}) {
|
|
|
2209
3474
|
const idMatch = d1Output.match(/database_id\s*=\s*["']?([a-f0-9-]{36})["']?/i) || d1Output.match(/"database_id":\s*"([a-f0-9-]{36})"/i) || d1Output.match(/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/i);
|
|
2210
3475
|
if (idMatch) {
|
|
2211
3476
|
databaseId = idMatch[1];
|
|
2212
|
-
s.stop(
|
|
3477
|
+
s.stop(pc32.green(`\u2713 D1 database ready: ${pc32.bold(d1Name)} (${databaseId})`));
|
|
2213
3478
|
} else if (d1Output.includes("already exists") || d1Result.status === 0) {
|
|
2214
|
-
s.stop(
|
|
3479
|
+
s.stop(pc32.yellow(`\u2139 D1 database ${pc32.bold(d1Name)} already exists or created.`));
|
|
2215
3480
|
} else {
|
|
2216
|
-
s.stop(
|
|
3481
|
+
s.stop(pc32.yellow(`\u26A0 Note on D1 create: ${d1Output.trim().slice(0, 150)}`));
|
|
2217
3482
|
}
|
|
2218
|
-
s.start(`Creating Cloudflare R2 bucket: ${
|
|
3483
|
+
s.start(`Creating Cloudflare R2 bucket: ${pc32.bold(r2Bucket)}\u2026`);
|
|
2219
3484
|
const r2Result = spawnSync15("npx", ["wrangler", "r2", "bucket", "create", r2Bucket], {
|
|
2220
3485
|
encoding: "utf-8",
|
|
2221
3486
|
shell: true,
|
|
@@ -2223,26 +3488,26 @@ async function setupCloudflare(options = {}) {
|
|
|
2223
3488
|
});
|
|
2224
3489
|
const r2Output = (r2Result.stdout || "") + (r2Result.stderr || "");
|
|
2225
3490
|
if (r2Result.status === 0 || r2Output.includes("already exists")) {
|
|
2226
|
-
s.stop(
|
|
3491
|
+
s.stop(pc32.green(`\u2713 R2 bucket ready: ${pc32.bold(r2Bucket)}`));
|
|
2227
3492
|
} else {
|
|
2228
|
-
s.stop(
|
|
3493
|
+
s.stop(pc32.yellow(`\u26A0 Note on R2 create: ${r2Output.trim().slice(0, 150)}`));
|
|
2229
3494
|
}
|
|
2230
|
-
if (configPath &&
|
|
3495
|
+
if (configPath && existsSync13(configPath)) {
|
|
2231
3496
|
try {
|
|
2232
|
-
let content =
|
|
3497
|
+
let content = readFileSync8(configPath, "utf-8");
|
|
2233
3498
|
content = content.replace(/"database_name":\s*"[^"]*"/, `"database_name": "${d1Name}"`);
|
|
2234
3499
|
if (databaseId) {
|
|
2235
3500
|
content = content.replace(/"database_id":\s*"[^"]*"/, `"database_id": "${databaseId}"`);
|
|
2236
3501
|
}
|
|
2237
3502
|
content = content.replace(/"bucket_name":\s*"[^"]*"/, `"bucket_name": "${r2Bucket}"`);
|
|
2238
|
-
|
|
2239
|
-
|
|
3503
|
+
writeFileSync7(configPath, content, "utf-8");
|
|
3504
|
+
p3.log.success(pc32.green(`Updated ${pc32.bold(configPath)} with database_id and bucket_name.`));
|
|
2240
3505
|
} catch (err) {
|
|
2241
|
-
|
|
3506
|
+
p3.log.warn(`Could not update wrangler config automatically: ${err.message}`);
|
|
2242
3507
|
}
|
|
2243
3508
|
}
|
|
2244
3509
|
if (!options.nonInteractive) {
|
|
2245
|
-
|
|
3510
|
+
p3.note(
|
|
2246
3511
|
[
|
|
2247
3512
|
"Direct uploads via Presigned URLs (SigV4) stream directly from browser to R2 with zero Worker CPU/RAM.",
|
|
2248
3513
|
"To configure Presigned uploads:",
|
|
@@ -2252,33 +3517,33 @@ async function setupCloudflare(options = {}) {
|
|
|
2252
3517
|
].join("\n"),
|
|
2253
3518
|
"R2 S3 Credentials Setup"
|
|
2254
3519
|
);
|
|
2255
|
-
const configureSecrets = await
|
|
3520
|
+
const configureSecrets = await p3.confirm({
|
|
2256
3521
|
message: "Do you have your R2 API Token ready to configure now?",
|
|
2257
3522
|
initialValue: true
|
|
2258
3523
|
});
|
|
2259
|
-
if (!
|
|
2260
|
-
const accountId = await
|
|
3524
|
+
if (!p3.isCancel(configureSecrets) && configureSecrets) {
|
|
3525
|
+
const accountId = await p3.text({
|
|
2261
3526
|
message: "Cloudflare Account ID (from dash.cloudflare.com)",
|
|
2262
3527
|
placeholder: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
2263
3528
|
validate: (v) => {
|
|
2264
3529
|
if (!v.trim()) return "Required";
|
|
2265
3530
|
}
|
|
2266
3531
|
});
|
|
2267
|
-
const accessKeyId = !
|
|
3532
|
+
const accessKeyId = !p3.isCancel(accountId) ? await p3.text({
|
|
2268
3533
|
message: "R2 Access Key ID",
|
|
2269
3534
|
validate: (v) => {
|
|
2270
3535
|
if (!v.trim()) return "Required";
|
|
2271
3536
|
}
|
|
2272
3537
|
}) : null;
|
|
2273
|
-
const secretAccessKey = !
|
|
3538
|
+
const secretAccessKey = !p3.isCancel(accessKeyId) && accessKeyId ? await p3.password({
|
|
2274
3539
|
message: "R2 Secret Access Key",
|
|
2275
3540
|
validate: (v) => {
|
|
2276
3541
|
if (!v.trim()) return "Required";
|
|
2277
3542
|
}
|
|
2278
3543
|
}) : null;
|
|
2279
|
-
if (!
|
|
3544
|
+
if (!p3.isCancel(accountId) && !p3.isCancel(accessKeyId) && !p3.isCancel(secretAccessKey) && accountId && accessKeyId && secretAccessKey) {
|
|
2280
3545
|
const endpoint = `https://${accountId.trim()}.r2.cloudflarestorage.com`;
|
|
2281
|
-
const devVarsPath =
|
|
3546
|
+
const devVarsPath = resolve14(process.cwd(), ".dev.vars");
|
|
2282
3547
|
const devVarsContent = [
|
|
2283
3548
|
"# Cloudflare R2 S3 credentials (for direct Presigned URL uploads)",
|
|
2284
3549
|
`R2_ACCESS_KEY_ID=${accessKeyId.trim()}`,
|
|
@@ -2286,8 +3551,8 @@ async function setupCloudflare(options = {}) {
|
|
|
2286
3551
|
`R2_ENDPOINT=${endpoint}`,
|
|
2287
3552
|
`R2_BUCKET_NAME=${r2Bucket}`
|
|
2288
3553
|
].join("\n") + "\n";
|
|
2289
|
-
|
|
2290
|
-
|
|
3554
|
+
writeFileSync7(devVarsPath, devVarsContent, "utf-8");
|
|
3555
|
+
p3.log.success(pc32.green(`Created ${pc32.bold(".dev.vars")} with local development credentials.`));
|
|
2291
3556
|
s.start("Setting production secrets on Cloudflare Worker\u2026");
|
|
2292
3557
|
spawnSync15("npx", ["wrangler", "secret", "put", "R2_ACCESS_KEY_ID"], {
|
|
2293
3558
|
input: accessKeyId.trim(),
|
|
@@ -2309,11 +3574,11 @@ async function setupCloudflare(options = {}) {
|
|
|
2309
3574
|
encoding: "utf-8",
|
|
2310
3575
|
shell: true
|
|
2311
3576
|
});
|
|
2312
|
-
s.stop(
|
|
3577
|
+
s.stop(pc32.green("\u2713 Production secrets configured on Cloudflare Worker."));
|
|
2313
3578
|
}
|
|
2314
3579
|
}
|
|
2315
3580
|
}
|
|
2316
|
-
|
|
3581
|
+
p3.outro(pc32.green("\u2728 Cloudflare infrastructure setup complete! You are ready to run `npx beech deploy`."));
|
|
2317
3582
|
}
|
|
2318
3583
|
export {
|
|
2319
3584
|
dbMigrate,
|
|
@@ -2332,11 +3597,17 @@ export {
|
|
|
2332
3597
|
mailpitClear,
|
|
2333
3598
|
onboard,
|
|
2334
3599
|
reset,
|
|
3600
|
+
schemaApply,
|
|
3601
|
+
schemaAudit,
|
|
2335
3602
|
schemaDiff,
|
|
3603
|
+
schemaExport,
|
|
3604
|
+
schemaMigrate,
|
|
3605
|
+
schemaPlan,
|
|
2336
3606
|
seedCreate,
|
|
2337
3607
|
seedLoad,
|
|
2338
3608
|
setupCloudflare,
|
|
2339
3609
|
test,
|
|
3610
|
+
typesCheck,
|
|
2340
3611
|
update,
|
|
2341
3612
|
validate,
|
|
2342
3613
|
validateSeeds
|