@lunora/config 0.0.0 → 1.0.0-alpha.10
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/LICENSE.md +105 -0
- package/README.md +115 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/index.d.mts +1140 -0
- package/dist/index.d.ts +1140 -0
- package/dist/index.mjs +22 -0
- package/dist/packem_shared/ACCENT-DW1XJn8i.mjs +40 -0
- package/dist/packem_shared/AGENT_RULES_DIR-lcgC08aE.mjs +40 -0
- package/dist/packem_shared/DEV_VARS_EXAMPLE_FILE-dJPNTEnK.mjs +37 -0
- package/dist/packem_shared/LINKED_PROJECT_DIR-CXwXzV_C.mjs +52 -0
- package/dist/packem_shared/LUNORA_CONFIG_FILE-CtcIcB5-.mjs +34 -0
- package/dist/packem_shared/LUNORA_EVENT_SOURCE-D2fDeGB6.mjs +86 -0
- package/dist/packem_shared/LunoraReporter-Ci-bDCK9.mjs +70 -0
- package/dist/packem_shared/PACKAGE_SECRETS_REGISTRY-B8t_SdoZ.mjs +70 -0
- package/dist/packem_shared/POLICY_SCAFFOLD_ENDPOINT-CiC2IGKx.mjs +103 -0
- package/dist/packem_shared/REMOTE_ELIGIBLE_KEYS-BC7_e9Bz.mjs +105 -0
- package/dist/packem_shared/REQUIRED_COMPATIBILITY_DATE-DRSNSOOp.mjs +476 -0
- package/dist/packem_shared/SCHEMA_EDIT_ENDPOINT-Df-Wrix-.mjs +99 -0
- package/dist/packem_shared/SEED_ENDPOINT-DVCjaGO-.mjs +61 -0
- package/dist/packem_shared/WRANGLER_FILES-DwSuC-Kn.mjs +25 -0
- package/dist/packem_shared/applyAdditiveEdit-C-snTFEV.mjs +228 -0
- package/dist/packem_shared/buildPackageSecretsBlock-DNzNRu7T.mjs +188 -0
- package/dist/packem_shared/classifyPolicyEdit-BHeAqF8P.mjs +99 -0
- package/dist/packem_shared/createConfirm-fvpdgJ9s.mjs +100 -0
- package/dist/packem_shared/detectFramework-Br-BcPBq.mjs +41 -0
- package/dist/packem_shared/discoverContainerInfo-BXFs6Wav.mjs +19 -0
- package/dist/packem_shared/discoverSchemaInfo-BB-CKlTK.mjs +25 -0
- package/dist/packem_shared/discoverWorkflowInfo-CedvR0mn.mjs +19 -0
- package/dist/packem_shared/inferLunoraBindings-DIku9mTN.mjs +302 -0
- package/dist/packem_shared/loadStudioAssets-Csk5RS4E.mjs +28 -0
- package/dist/packem_shared/parseDevVariable-CJiq2IwE.mjs +30 -0
- package/dist/packem_shared/parseSchema-DSeyktvG.mjs +107 -0
- package/dist/packem_shared/policy-scaffold.d-DCmwn7zQ.d.mts +74 -0
- package/dist/packem_shared/policy-scaffold.d-DCmwn7zQ.d.ts +74 -0
- package/dist/packem_shared/reconcileWranglerBindings-DTHmqTbL.mjs +277 -0
- package/dist/packem_shared/renderStudioHtml-449Ysn75.mjs +37 -0
- package/dist/packem_shared/serveJsonHandler-B4OLTGLS.mjs +86 -0
- package/dist/studio-host/index.d.mts +227 -0
- package/dist/studio-host/index.d.ts +227 -0
- package/dist/studio-host/index.mjs +7 -0
- package/package.json +58 -17
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { discoverSchemaInfo } from './discoverSchemaInfo-BB-CKlTK.mjs';
|
|
4
|
+
import { findWranglerFile, readWranglerJsonc } from './WRANGLER_FILES-DwSuC-Kn.mjs';
|
|
5
|
+
|
|
6
|
+
const REQUIRED_COMPATIBILITY_DATE = "2026-04-07";
|
|
7
|
+
const REQUIRED_FLAG = "web_socket_auto_reply_to_close";
|
|
8
|
+
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
const validateVectorizeBindings = (wrangler, vectorIndexNames, errors) => {
|
|
10
|
+
if (vectorIndexNames.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const vectorizeBindings = wrangler.vectorize ?? [];
|
|
14
|
+
const declaredIndexNames = new Set(vectorizeBindings.filter(Boolean).map((binding) => binding?.index_name));
|
|
15
|
+
for (const indexName of vectorIndexNames) {
|
|
16
|
+
if (!declaredIndexNames.has(indexName)) {
|
|
17
|
+
errors.push(`schema declares vector index "${indexName}"; wrangler "vectorize" must include a binding with index_name "${indexName}"`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
const NAMED_INSTANCE_TYPES = /* @__PURE__ */ new Set(["basic", "dev", "lite", "standard", "standard-1", "standard-2", "standard-3", "standard-4"]);
|
|
22
|
+
const CUSTOM_INSTANCE_LIMITS = { disk_mb: 2e4, memory_mib: 12288, vcpu: 4 };
|
|
23
|
+
const validateInstanceType = (entry, label, errors) => {
|
|
24
|
+
const instanceType = entry.instance_type;
|
|
25
|
+
if (instanceType === void 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (typeof instanceType === "string") {
|
|
29
|
+
if (!NAMED_INSTANCE_TYPES.has(instanceType)) {
|
|
30
|
+
errors.push(
|
|
31
|
+
`${label} has unknown instance_type "${instanceType}" — expected lite, basic, standard-1..4, or a custom { vcpu, memory_mib, disk_mb } object`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const [field, limit] of Object.entries(CUSTOM_INSTANCE_LIMITS)) {
|
|
37
|
+
const value = instanceType[field];
|
|
38
|
+
if (value !== void 0 && (typeof value !== "number" || value <= 0 || value > limit)) {
|
|
39
|
+
errors.push(`${label} custom instance_type ${field} must be a positive number ≤ ${String(limit)} (got ${String(value)})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const { disk_mb: diskMb, memory_mib: memoryMib, vcpu } = instanceType;
|
|
43
|
+
if (typeof vcpu === "number" && typeof memoryMib === "number" && memoryMib < vcpu * 3072) {
|
|
44
|
+
errors.push(`${label} custom instance_type needs ≥ 3 GiB (3072 MiB) memory per vCPU (got ${String(memoryMib)} MiB for ${String(vcpu)} vCPU)`);
|
|
45
|
+
}
|
|
46
|
+
if (typeof memoryMib === "number" && typeof diskMb === "number") {
|
|
47
|
+
const maxDiskMb = Math.floor(memoryMib / 1024 * 2e3);
|
|
48
|
+
if (diskMb > maxDiskMb) {
|
|
49
|
+
errors.push(
|
|
50
|
+
`${label} custom instance_type allows ≤ 2 GB disk per GiB memory (≤ ${String(maxDiskMb)} MB for ${String(memoryMib)} MiB memory; got ${String(diskMb)} MB)`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const validateContainerEntry = (entry, label, checks) => {
|
|
56
|
+
const { boundClasses, errors, nonSqliteClasses, sqliteClasses, warnings } = checks;
|
|
57
|
+
if (!entry || typeof entry !== "object" || typeof entry.class_name !== "string" || entry.class_name.length === 0) {
|
|
58
|
+
errors.push(`${label} must have a non-empty "class_name" naming its container-enabled Durable Object class`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof entry.image !== "string" || entry.image.length === 0) {
|
|
62
|
+
errors.push(`${label} ("${entry.class_name}") must have an "image" — a Dockerfile path or a registry reference`);
|
|
63
|
+
}
|
|
64
|
+
if (!boundClasses.has(entry.class_name)) {
|
|
65
|
+
errors.push(
|
|
66
|
+
`${label} class "${entry.class_name}" has no matching durable_objects binding — run \`lunora dev\` to auto-reconcile wrangler.jsonc, or add { "name": "...", "class_name": "${entry.class_name}" }`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!sqliteClasses.has(entry.class_name)) {
|
|
70
|
+
errors.push(
|
|
71
|
+
nonSqliteClasses.has(entry.class_name) ? `${label} class "${entry.class_name}" is registered via "new_classes" but containers require SQLite-backed DOs — move it to "new_sqlite_classes"` : `${label} class "${entry.class_name}" is missing from migrations — add a migration entry with "new_sqlite_classes": ["${entry.class_name}"]`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
validateInstanceType(entry, `${label} ("${entry.class_name}")`, errors);
|
|
75
|
+
if (entry.max_instances === void 0) {
|
|
76
|
+
warnings.push(`${label} ("${entry.class_name}") declares no max_instances — set a cap so a traffic spike can't fan out unbounded container spend`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const validateContainers = (wrangler, errors, warnings) => {
|
|
80
|
+
if (wrangler.containers === void 0) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(wrangler.containers)) {
|
|
84
|
+
errors.push("containers must be an array of { class_name, image, ... } entries");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const entries = wrangler.containers;
|
|
88
|
+
if (entries.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const boundClasses = new Set((wrangler.durable_objects?.bindings ?? []).map((binding) => binding.class_name));
|
|
92
|
+
const migrations = wrangler.migrations ?? [];
|
|
93
|
+
const sqliteClasses = new Set(migrations.flatMap((migration) => [...migration?.new_sqlite_classes ?? []]));
|
|
94
|
+
const nonSqliteClasses = new Set(migrations.flatMap((migration) => [...migration?.new_classes ?? []]));
|
|
95
|
+
for (const [index, entry] of entries.entries()) {
|
|
96
|
+
validateContainerEntry(entry, `containers[${String(index)}]`, { boundClasses, errors, nonSqliteClasses, sqliteClasses, warnings });
|
|
97
|
+
}
|
|
98
|
+
if (wrangler.observability?.enabled !== true) {
|
|
99
|
+
warnings.push(
|
|
100
|
+
'containers are configured but observability is not enabled — container logs will not be captured (add { "observability": { "enabled": true } })'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const validateWorkflows = (wrangler, errors) => {
|
|
105
|
+
if (wrangler.workflows === void 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(wrangler.workflows)) {
|
|
109
|
+
errors.push("workflows must be an array of { name, binding, class_name } entries");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const entries = wrangler.workflows;
|
|
113
|
+
for (const [index, entry] of entries.entries()) {
|
|
114
|
+
const label = `workflows[${String(index)}]`;
|
|
115
|
+
if (!entry || typeof entry !== "object") {
|
|
116
|
+
errors.push(`${label} must be a { name, binding, class_name } object`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (typeof entry.binding !== "string" || entry.binding.length === 0) {
|
|
120
|
+
errors.push(`${label} must have a non-empty "binding" naming the Workflow binding (e.g. WORKFLOW_ORDER_PIPELINE)`);
|
|
121
|
+
}
|
|
122
|
+
if (typeof entry.class_name !== "string" || entry.class_name.length === 0) {
|
|
123
|
+
errors.push(`${label} must have a non-empty "class_name" naming the exported WorkflowEntrypoint class`);
|
|
124
|
+
}
|
|
125
|
+
if (typeof entry.name !== "string" || entry.name.length === 0) {
|
|
126
|
+
errors.push(`${label} must have a non-empty "name" naming the deployed workflow`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
|
|
131
|
+
const asBindingEntries = (value) => value;
|
|
132
|
+
const HINT_BINDING_RULES = [
|
|
133
|
+
{
|
|
134
|
+
arrayMessage: "kv_namespaces must be an array of { binding, id } entries",
|
|
135
|
+
bindingMessage: (label) => `${label} must have a non-empty "binding" naming the KV namespace binding`,
|
|
136
|
+
hintField: "id",
|
|
137
|
+
hintMessage: (label, binding) => `${label} ("${binding}") has no "id" — run \`wrangler kv namespace create\` and set the namespace id, or the binding can't resolve`,
|
|
138
|
+
key: "kv_namespaces"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
arrayMessage: "hyperdrive must be an array of { binding, id } entries",
|
|
142
|
+
bindingMessage: (label) => `${label} must have a non-empty "binding" naming the Hyperdrive binding`,
|
|
143
|
+
hintField: "id",
|
|
144
|
+
hintMessage: (label, binding) => `${label} ("${binding}") has no "id" — run \`wrangler hyperdrive create\` and set the id, or the binding can't connect`,
|
|
145
|
+
key: "hyperdrive"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
arrayMessage: "pipelines must be an array of { binding, pipeline } entries",
|
|
149
|
+
bindingMessage: (label) => `${label} must have a non-empty "binding" naming the Pipelines binding`,
|
|
150
|
+
hintField: "pipeline",
|
|
151
|
+
hintMessage: (label, binding) => `${label} ("${binding}") has no "pipeline" — run \`wrangler pipelines create <name>\` and set the pipeline name, or the binding can't resolve`,
|
|
152
|
+
key: "pipelines"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
arrayMessage: "analytics_engine_datasets must be an array of { binding, dataset } entries",
|
|
156
|
+
bindingMessage: (label) => `${label} must have a non-empty "binding" naming the Analytics Engine binding`,
|
|
157
|
+
hintField: "dataset",
|
|
158
|
+
hintMessage: (label, binding) => `${label} ("${binding}") has no "dataset" — it defaults to the binding name; set it explicitly to avoid drift`,
|
|
159
|
+
key: "analytics_engine_datasets"
|
|
160
|
+
}
|
|
161
|
+
];
|
|
162
|
+
const validateHintBinding = (wrangler, rule, errors, warnings) => {
|
|
163
|
+
const value = wrangler[rule.key];
|
|
164
|
+
if (value === void 0) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!Array.isArray(value)) {
|
|
168
|
+
errors.push(rule.arrayMessage);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const [index, entry] of asBindingEntries(value).entries()) {
|
|
172
|
+
const label = `${rule.key}[${String(index)}]`;
|
|
173
|
+
if (!entry || typeof entry !== "object" || !isNonEmptyString(entry.binding)) {
|
|
174
|
+
errors.push(rule.bindingMessage(label));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!isNonEmptyString(entry[rule.hintField])) {
|
|
178
|
+
warnings.push(rule.hintMessage(label, entry.binding));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const SELF_DESCRIBING_BINDING_RULES = [
|
|
183
|
+
{ key: "browser", message: 'browser must be an object with a non-empty "binding" (e.g. { "binding": "BROWSER" })' },
|
|
184
|
+
{ key: "images", message: 'images must be an object with a non-empty "binding" (e.g. { "binding": "IMAGES" })' }
|
|
185
|
+
];
|
|
186
|
+
const validateSelfDescribingBinding = (wrangler, rule, errors) => {
|
|
187
|
+
const value = wrangler[rule.key];
|
|
188
|
+
if (value === void 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value !== "object" || Array.isArray(value) || !isNonEmptyString(value.binding)) {
|
|
192
|
+
errors.push(rule.message);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const REQUIRED_FIELD_BINDING_RULES = [
|
|
196
|
+
{
|
|
197
|
+
arrayMessage: "services must be an array of { binding, service, entrypoint? } entries",
|
|
198
|
+
fields: [
|
|
199
|
+
{ field: "binding", message: (label) => `${label} must have a non-empty "binding" naming the service binding` },
|
|
200
|
+
{ field: "service", message: (label) => `${label} must have a non-empty "service" naming the target Worker` }
|
|
201
|
+
],
|
|
202
|
+
key: "services",
|
|
203
|
+
objectMessage: (label) => `${label} must be a { binding, service, entrypoint? } object`
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
arrayMessage: "dispatch_namespaces must be an array of { binding, namespace } entries",
|
|
207
|
+
fields: [
|
|
208
|
+
{ field: "binding", message: (label) => `${label} must have a non-empty "binding"` },
|
|
209
|
+
{ field: "namespace", message: (label) => `${label} must have a non-empty "namespace" naming the dispatch namespace` }
|
|
210
|
+
],
|
|
211
|
+
key: "dispatch_namespaces",
|
|
212
|
+
objectMessage: (label) => `${label} must be a { binding, namespace } object`
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
arrayMessage: "mtls_certificates must be an array of { binding, certificate_id } entries",
|
|
216
|
+
fields: [
|
|
217
|
+
{ field: "binding", message: (label) => `${label} must have a non-empty "binding"` },
|
|
218
|
+
{
|
|
219
|
+
field: "certificate_id",
|
|
220
|
+
message: (label) => `${label} must have a non-empty "certificate_id" (upload via \`wrangler mtls-certificate upload\`)`
|
|
221
|
+
}
|
|
222
|
+
],
|
|
223
|
+
key: "mtls_certificates",
|
|
224
|
+
objectMessage: (label) => `${label} must be a { binding, certificate_id } object`
|
|
225
|
+
}
|
|
226
|
+
];
|
|
227
|
+
const validateRequiredFieldsBinding = (wrangler, rule, errors) => {
|
|
228
|
+
const value = wrangler[rule.key];
|
|
229
|
+
if (value === void 0) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
errors.push(rule.arrayMessage);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
for (const [index, entry] of asBindingEntries(value).entries()) {
|
|
237
|
+
const label = `${rule.key}[${String(index)}]`;
|
|
238
|
+
if (!entry || typeof entry !== "object") {
|
|
239
|
+
errors.push(rule.objectMessage(label));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const field of rule.fields) {
|
|
243
|
+
if (!isNonEmptyString(entry[field.field])) {
|
|
244
|
+
errors.push(field.message(label));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const validateSendEmail = (wrangler, errors, warnings) => {
|
|
250
|
+
const sendEmail = wrangler.send_email;
|
|
251
|
+
if (sendEmail === void 0) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(sendEmail)) {
|
|
255
|
+
errors.push("send_email must be an array of { name, destination_address? } entries");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const entries = sendEmail;
|
|
259
|
+
for (const [index, entry] of entries.entries()) {
|
|
260
|
+
if (!entry || typeof entry !== "object" || typeof entry.name !== "string" || entry.name.length === 0) {
|
|
261
|
+
warnings.push(`send_email[${String(index)}] has no non-empty "name" naming the send-email binding — set one before deploying`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const validateLogpush = (wrangler, errors) => {
|
|
266
|
+
if (wrangler.logpush !== void 0 && typeof wrangler.logpush !== "boolean") {
|
|
267
|
+
errors.push('logpush must be a boolean (set "logpush": true to enable Cloudflare Logpush)');
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
const validatePlacement = (wrangler, errors) => {
|
|
271
|
+
const { placement } = wrangler;
|
|
272
|
+
if (placement === void 0) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (typeof placement !== "object" || Array.isArray(placement)) {
|
|
276
|
+
errors.push('placement must be an object (e.g. { "mode": "smart" })');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (placement.mode !== void 0 && placement.mode !== "smart") {
|
|
280
|
+
errors.push('placement.mode must be "smart" (the only supported Smart Placement mode)');
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const validateObservability = (wrangler, errors) => {
|
|
284
|
+
const { observability } = wrangler;
|
|
285
|
+
if (observability === void 0) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (typeof observability !== "object" || Array.isArray(observability)) {
|
|
289
|
+
errors.push('observability must be an object (e.g. { "enabled": true, "head_sampling_rate": 1 })');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const checkSamplingRate = (rate, path) => {
|
|
293
|
+
if (rate !== void 0 && (typeof rate !== "number" || Number.isNaN(rate) || rate < 0 || rate > 1)) {
|
|
294
|
+
errors.push(`${path} must be a number in [0, 1] (the fraction of requests sampled)`);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
checkSamplingRate(observability.head_sampling_rate, "observability.head_sampling_rate");
|
|
298
|
+
if (observability.logs !== void 0) {
|
|
299
|
+
if (typeof observability.logs !== "object" || Array.isArray(observability.logs)) {
|
|
300
|
+
errors.push("observability.logs must be an object");
|
|
301
|
+
} else {
|
|
302
|
+
checkSamplingRate(observability.logs.head_sampling_rate, "observability.logs.head_sampling_rate");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const validateAssets = (wrangler, errors) => {
|
|
307
|
+
const { assets } = wrangler;
|
|
308
|
+
if (assets === void 0) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (typeof assets !== "object" || Array.isArray(assets)) {
|
|
312
|
+
errors.push('assets must be an object (e.g. { "directory": "./dist/client", "binding": "ASSETS" })');
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (typeof assets.directory !== "string" || assets.directory.length === 0) {
|
|
316
|
+
errors.push('assets must declare a non-empty "directory" pointing at the built client output (e.g. "./dist/client")');
|
|
317
|
+
}
|
|
318
|
+
if (assets.binding !== void 0 && (typeof assets.binding !== "string" || assets.binding.length === 0)) {
|
|
319
|
+
errors.push('assets.binding must be a non-empty string (e.g. "ASSETS")');
|
|
320
|
+
}
|
|
321
|
+
if (assets.html_handling !== void 0 && typeof assets.html_handling !== "string") {
|
|
322
|
+
errors.push("assets.html_handling must be a string");
|
|
323
|
+
}
|
|
324
|
+
if (assets.not_found_handling !== void 0 && typeof assets.not_found_handling !== "string") {
|
|
325
|
+
errors.push("assets.not_found_handling must be a string");
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const validateTailConsumers = (wrangler, errors) => {
|
|
329
|
+
const consumers = wrangler.tail_consumers;
|
|
330
|
+
if (consumers === void 0) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!Array.isArray(consumers)) {
|
|
334
|
+
errors.push("tail_consumers must be an array of { service, environment? } entries");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const entries = consumers;
|
|
338
|
+
for (const [index, consumer] of entries.entries()) {
|
|
339
|
+
if (!consumer || typeof consumer !== "object" || typeof consumer.service !== "string" || consumer.service.length === 0) {
|
|
340
|
+
errors.push(`tail_consumers[${String(index)}] must have a non-empty "service" naming the consumer Worker`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const withTailConsumer = (wrangler, consumer) => {
|
|
345
|
+
const existing = wrangler.tail_consumers ?? [];
|
|
346
|
+
const alreadyWired = existing.some((entry) => Boolean(entry) && entry?.service === consumer.service && entry?.environment === consumer.environment);
|
|
347
|
+
if (alreadyWired) {
|
|
348
|
+
return wrangler;
|
|
349
|
+
}
|
|
350
|
+
return { ...wrangler, tail_consumers: [...existing, consumer] };
|
|
351
|
+
};
|
|
352
|
+
const TRUTHY_ENV_VALUES = /* @__PURE__ */ new Set(["1", "enabled", "on", "true", "yes"]);
|
|
353
|
+
const validateCorsVariables = (wrangler, errors) => {
|
|
354
|
+
const { vars } = wrangler;
|
|
355
|
+
if (!vars || typeof vars !== "object") {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const allowedOrigins = vars["LUNORA_ALLOWED_ORIGINS"];
|
|
359
|
+
const allowCredentials = vars["LUNORA_CORS_ALLOW_CREDENTIALS"];
|
|
360
|
+
const hasWildcard = typeof allowedOrigins === "string" && allowedOrigins.split(",").some((entry) => entry.trim() === "*");
|
|
361
|
+
const credentialsOn = typeof allowCredentials === "string" && TRUTHY_ENV_VALUES.has(allowCredentials.trim().toLowerCase());
|
|
362
|
+
if (hasWildcard && credentialsOn) {
|
|
363
|
+
errors.push(
|
|
364
|
+
'vars.LUNORA_ALLOWED_ORIGINS includes a "*" wildcard while vars.LUNORA_CORS_ALLOW_CREDENTIALS is on — browsers reject this combination and it defeats the allowlist; name explicit origins or drop credentials'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const validateWranglerConfig = (wrangler, schema) => {
|
|
369
|
+
const errors = [];
|
|
370
|
+
const warnings = [];
|
|
371
|
+
if (!wrangler || typeof wrangler !== "object") {
|
|
372
|
+
errors.push("wrangler config is not a valid object");
|
|
373
|
+
return { errors, valid: false, warnings };
|
|
374
|
+
}
|
|
375
|
+
const durableObjectBindings = wrangler.durable_objects?.bindings ?? [];
|
|
376
|
+
const shardBinding = durableObjectBindings.find((binding) => binding.name === "SHARD" && binding.class_name === "ShardDO");
|
|
377
|
+
if (!shardBinding) {
|
|
378
|
+
errors.push(
|
|
379
|
+
'durable_objects.bindings must include { "name": "SHARD", "class_name": "ShardDO" } — run `lunora dev` to auto-reconcile wrangler.jsonc, or add the binding manually'
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
const compatibilityDate = wrangler.compatibility_date ?? "";
|
|
383
|
+
if (compatibilityDate && !ISO_DATE_PATTERN.test(compatibilityDate)) {
|
|
384
|
+
errors.push(`compatibility_date must be in YYYY-MM-DD format (got "${compatibilityDate}")`);
|
|
385
|
+
} else if (compatibilityDate < REQUIRED_COMPATIBILITY_DATE) {
|
|
386
|
+
errors.push(`compatibility_date must be >= "${REQUIRED_COMPATIBILITY_DATE}" (got "${compatibilityDate || "<missing>"}")`);
|
|
387
|
+
}
|
|
388
|
+
if (schema?.hasGlobalTable) {
|
|
389
|
+
const d1Bindings = wrangler.d1_databases ?? [];
|
|
390
|
+
const databaseBinding = d1Bindings.find((binding) => binding.binding === "DB");
|
|
391
|
+
if (!databaseBinding) {
|
|
392
|
+
errors.push(
|
|
393
|
+
'schema declares .global() tables; d1_databases must include a binding named "DB" — run `lunora dev` to auto-reconcile wrangler.jsonc, or add the binding manually'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
validateVectorizeBindings(wrangler, schema?.vectorIndexNames ?? [], errors);
|
|
398
|
+
validateTailConsumers(wrangler, errors);
|
|
399
|
+
validateContainers(wrangler, errors, warnings);
|
|
400
|
+
validateWorkflows(wrangler, errors);
|
|
401
|
+
for (const rule of HINT_BINDING_RULES) {
|
|
402
|
+
validateHintBinding(wrangler, rule, errors, warnings);
|
|
403
|
+
}
|
|
404
|
+
for (const rule of REQUIRED_FIELD_BINDING_RULES) {
|
|
405
|
+
validateRequiredFieldsBinding(wrangler, rule, errors);
|
|
406
|
+
}
|
|
407
|
+
for (const rule of SELF_DESCRIBING_BINDING_RULES) {
|
|
408
|
+
validateSelfDescribingBinding(wrangler, rule, errors);
|
|
409
|
+
}
|
|
410
|
+
validateSendEmail(wrangler, errors, warnings);
|
|
411
|
+
validateLogpush(wrangler, errors);
|
|
412
|
+
validatePlacement(wrangler, errors);
|
|
413
|
+
validateObservability(wrangler, errors);
|
|
414
|
+
validateAssets(wrangler, errors);
|
|
415
|
+
validateCorsVariables(wrangler, errors);
|
|
416
|
+
return { errors, valid: errors.length === 0, warnings };
|
|
417
|
+
};
|
|
418
|
+
const validateWrangler = validateWranglerConfig;
|
|
419
|
+
const collectContainerImageErrors = (containers, configDirectory, wranglerPath) => {
|
|
420
|
+
const errors = [];
|
|
421
|
+
for (const entry of containers) {
|
|
422
|
+
const image = entry?.image;
|
|
423
|
+
if (typeof image !== "string" || !(image.startsWith("./") || image.startsWith("../") || image.startsWith("/") || image.includes("Dockerfile"))) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (!existsSync(image.startsWith("/") ? image : join(configDirectory, image))) {
|
|
427
|
+
errors.push(
|
|
428
|
+
`containers image "${image}" does not exist (resolved relative to ${wranglerPath}); create the Dockerfile or point image at a registry reference`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return errors;
|
|
433
|
+
};
|
|
434
|
+
const validateWranglerProject = (options) => {
|
|
435
|
+
const schemaDirectory = options.schemaDir ?? "lunora";
|
|
436
|
+
const wranglerPath = findWranglerFile(options.projectRoot);
|
|
437
|
+
if (!wranglerPath) {
|
|
438
|
+
const message = `wrangler.jsonc not found in ${options.projectRoot}; create one declaring at least the SHARD durable object binding.`;
|
|
439
|
+
return {
|
|
440
|
+
problems: [message],
|
|
441
|
+
report: { errors: [message], valid: false, warnings: [] },
|
|
442
|
+
wranglerPath: void 0
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const { parsed: wrangler } = readWranglerJsonc(wranglerPath);
|
|
446
|
+
if (wrangler === void 0) {
|
|
447
|
+
const message = `failed to parse ${wranglerPath} as JSONC.`;
|
|
448
|
+
return {
|
|
449
|
+
problems: [message],
|
|
450
|
+
report: { errors: [message], valid: false, warnings: [] },
|
|
451
|
+
wranglerPath
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
const { error: schemaError, info: schemaInfo } = discoverSchemaInfo(options.projectRoot, schemaDirectory);
|
|
455
|
+
const report = validateWranglerConfig(wrangler, schemaInfo);
|
|
456
|
+
if (schemaError !== void 0) {
|
|
457
|
+
report.warnings.push(`schema parse failed in ${schemaDirectory}/schema.ts: ${schemaError}`);
|
|
458
|
+
}
|
|
459
|
+
const configDirectory = dirname(wranglerPath);
|
|
460
|
+
report.errors.push(...collectContainerImageErrors(wrangler.containers ?? [], configDirectory, wranglerPath));
|
|
461
|
+
const assetsDirectory = wrangler.assets?.directory;
|
|
462
|
+
if (typeof assetsDirectory === "string" && assetsDirectory.length > 0) {
|
|
463
|
+
const resolved = assetsDirectory.startsWith("/") ? assetsDirectory : join(configDirectory, assetsDirectory);
|
|
464
|
+
if (!existsSync(resolved)) {
|
|
465
|
+
report.warnings.push(`assets.directory "${assetsDirectory}" does not exist yet — it is created by the client build; run the build before deploy`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
report.valid = report.errors.length === 0;
|
|
469
|
+
return {
|
|
470
|
+
problems: report.errors,
|
|
471
|
+
report,
|
|
472
|
+
wranglerPath
|
|
473
|
+
};
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
export { REQUIRED_COMPATIBILITY_DATE, REQUIRED_FLAG, validateWrangler, validateWranglerConfig, validateWranglerProject, withTailConsumer };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { runCodegen, CodegenDiagnosticError } from '@lunora/codegen';
|
|
3
|
+
import { classifyEdit, applyAdditiveEdit } from './applyAdditiveEdit-C-snTFEV.mjs';
|
|
4
|
+
import { parseSchema } from './parseSchema-DSeyktvG.mjs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SCHEMA_EDIT_ENDPOINT = "/__lunora/schema-edit";
|
|
8
|
+
const statusForFailure = (reason) => {
|
|
9
|
+
if (reason === "destructive") {
|
|
10
|
+
return 409;
|
|
11
|
+
}
|
|
12
|
+
if (reason === "duplicate-table" || reason === "duplicate-column" || reason === "duplicate-index") {
|
|
13
|
+
return 409;
|
|
14
|
+
}
|
|
15
|
+
if (reason === "unknown-table") {
|
|
16
|
+
return 404;
|
|
17
|
+
}
|
|
18
|
+
if (reason === "invalid-identifier" || reason === "invalid-validator") {
|
|
19
|
+
return 400;
|
|
20
|
+
}
|
|
21
|
+
return 422;
|
|
22
|
+
};
|
|
23
|
+
const parseFailureResponse = (result) => {
|
|
24
|
+
return {
|
|
25
|
+
body: { error: result.reason, ok: false },
|
|
26
|
+
status: 422
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const readSchema = (schemaPath) => {
|
|
30
|
+
if (!existsSync(schemaPath)) {
|
|
31
|
+
return { response: { body: { error: "no-schema-file", ok: false }, status: 404 } };
|
|
32
|
+
}
|
|
33
|
+
const parsed = parseSchema(readFileSync(schemaPath, "utf8"));
|
|
34
|
+
if (!parsed.ok) {
|
|
35
|
+
return { response: parseFailureResponse(parsed) };
|
|
36
|
+
}
|
|
37
|
+
return { tables: parsed.tables };
|
|
38
|
+
};
|
|
39
|
+
const writeSchemaAtomic = (schemaPath, text) => {
|
|
40
|
+
const temporaryPath = `${schemaPath}.lunora-tmp`;
|
|
41
|
+
writeFileSync(temporaryPath, text, "utf8");
|
|
42
|
+
renameSync(temporaryPath, schemaPath);
|
|
43
|
+
};
|
|
44
|
+
const needsMigrationResponse = (edit) => {
|
|
45
|
+
return {
|
|
46
|
+
body: {
|
|
47
|
+
edit,
|
|
48
|
+
message: "This edit changes stored data and must go through a migration. Review the migration before applying.",
|
|
49
|
+
needsMigration: true,
|
|
50
|
+
ok: false
|
|
51
|
+
},
|
|
52
|
+
status: 409
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const handlePost = (request, schemaPath) => {
|
|
56
|
+
const edit = request.body;
|
|
57
|
+
if (edit === void 0 || typeof edit !== "object" || typeof edit.kind !== "string") {
|
|
58
|
+
return { body: { error: "invalid-edit", ok: false }, status: 400 };
|
|
59
|
+
}
|
|
60
|
+
if (classifyEdit(edit) === "destructive") {
|
|
61
|
+
return needsMigrationResponse(edit);
|
|
62
|
+
}
|
|
63
|
+
if (!existsSync(schemaPath)) {
|
|
64
|
+
return { body: { error: "no-schema-file", ok: false }, status: 404 };
|
|
65
|
+
}
|
|
66
|
+
const applied = applyAdditiveEdit(readFileSync(schemaPath, "utf8"), edit);
|
|
67
|
+
if (!applied.ok) {
|
|
68
|
+
return { body: { error: applied.reason, ok: false }, status: statusForFailure(applied.reason) };
|
|
69
|
+
}
|
|
70
|
+
writeSchemaAtomic(schemaPath, applied.text);
|
|
71
|
+
let diagnostics = [];
|
|
72
|
+
try {
|
|
73
|
+
runCodegen({ lunoraDirectory: request.schemaDirectory ?? "lunora", projectRoot: request.projectRoot });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof CodegenDiagnosticError) {
|
|
76
|
+
diagnostics = [error.message];
|
|
77
|
+
} else {
|
|
78
|
+
return { body: { error: error instanceof Error ? error.message : String(error), ok: false }, status: 500 };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const parsed = parseSchema(applied.text);
|
|
82
|
+
return {
|
|
83
|
+
body: { diagnostics, ok: true, tables: parsed.ok ? parsed.tables : [] },
|
|
84
|
+
status: 200
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const handleSchemaEditRequest = (request) => {
|
|
88
|
+
const schemaPath = join(request.projectRoot, request.schemaDirectory ?? "lunora", "schema.ts");
|
|
89
|
+
if (request.method === "GET") {
|
|
90
|
+
const read = readSchema(schemaPath);
|
|
91
|
+
return "response" in read ? read.response : { body: { ok: true, tables: read.tables }, status: 200 };
|
|
92
|
+
}
|
|
93
|
+
if (request.method === "POST") {
|
|
94
|
+
return handlePost(request, schemaPath);
|
|
95
|
+
}
|
|
96
|
+
return { body: { error: "method-not-allowed", ok: false }, status: 405 };
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export { SCHEMA_EDIT_ENDPOINT, handleSchemaEditRequest };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { discoverSchema, schemaFromIr } from '@lunora/codegen';
|
|
3
|
+
import { seedPlan } from '@lunora/seed';
|
|
4
|
+
import { Project } from 'ts-morph';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
const SEED_ENDPOINT = "/__lunora/seed";
|
|
8
|
+
const MAX_SEED_ROWS = 1e3;
|
|
9
|
+
const jsonReplacer = (_key, value) => {
|
|
10
|
+
if (typeof value === "bigint") {
|
|
11
|
+
return Number(value);
|
|
12
|
+
}
|
|
13
|
+
if (value instanceof ArrayBuffer) {
|
|
14
|
+
return [...new Uint8Array(value)];
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const clampCount = (count) => {
|
|
19
|
+
if (typeof count !== "number" || !Number.isFinite(count)) {
|
|
20
|
+
return 10;
|
|
21
|
+
}
|
|
22
|
+
return Math.min(Math.max(1, Math.floor(count)), MAX_SEED_ROWS);
|
|
23
|
+
};
|
|
24
|
+
const handleSeedRequest = (request) => {
|
|
25
|
+
if (request.method !== "POST") {
|
|
26
|
+
return { body: { error: "method-not-allowed", ok: false }, status: 405 };
|
|
27
|
+
}
|
|
28
|
+
const { body } = request;
|
|
29
|
+
if (body === void 0 || typeof body !== "object") {
|
|
30
|
+
return { body: { error: "invalid-request", ok: false }, status: 400 };
|
|
31
|
+
}
|
|
32
|
+
const { count, existingIds, seed, table } = body;
|
|
33
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
34
|
+
return { body: { error: "missing-table", ok: false }, status: 400 };
|
|
35
|
+
}
|
|
36
|
+
const lunoraDirectory = request.schemaDirectory ?? "lunora";
|
|
37
|
+
const schemaPath = join(request.projectRoot, lunoraDirectory, "schema.ts");
|
|
38
|
+
if (!existsSync(schemaPath)) {
|
|
39
|
+
return { body: { error: "schema-not-found", ok: false }, status: 404 };
|
|
40
|
+
}
|
|
41
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
42
|
+
const ir = discoverSchema(project, schemaPath);
|
|
43
|
+
if (!ir.tables.some((candidate) => candidate.name === table)) {
|
|
44
|
+
return { body: { error: "unknown-table", ok: false, table }, status: 404 };
|
|
45
|
+
}
|
|
46
|
+
const schema = schemaFromIr(ir);
|
|
47
|
+
const plan = seedPlan(schema, {
|
|
48
|
+
// Pass every sampled parent table as `existingIds` so the generator links
|
|
49
|
+
// to live rows and never fabricates parents — matching the studio's
|
|
50
|
+
// "existing rows are not affected, FKs point at what's there" semantics.
|
|
51
|
+
existingIds: existingIds ?? {},
|
|
52
|
+
defaultCount: clampCount(count),
|
|
53
|
+
only: [table],
|
|
54
|
+
seed: seed ?? 0
|
|
55
|
+
});
|
|
56
|
+
const rows = plan.find((entry) => entry.table === table)?.rows ?? [];
|
|
57
|
+
const jsonSafeRows = JSON.parse(JSON.stringify(rows, jsonReplacer));
|
|
58
|
+
return { body: { ok: true, rows: jsonSafeRows }, status: 200 };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { SEED_ENDPOINT, handleSeedRequest };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse } from 'jsonc-parser';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const WRANGLER_FILES = ["wrangler.jsonc", "wrangler.json"];
|
|
6
|
+
const findWranglerFile = (projectRoot) => {
|
|
7
|
+
for (const candidate of WRANGLER_FILES) {
|
|
8
|
+
const fullPath = join(projectRoot, candidate);
|
|
9
|
+
if (existsSync(fullPath)) {
|
|
10
|
+
return fullPath;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return void 0;
|
|
14
|
+
};
|
|
15
|
+
const readWranglerJsonc = (wranglerPath) => {
|
|
16
|
+
const text = readFileSync(wranglerPath, "utf8");
|
|
17
|
+
const parseErrors = [];
|
|
18
|
+
const value = parse(text, parseErrors, { allowTrailingComma: true });
|
|
19
|
+
if (parseErrors.length > 0 || value === null || typeof value !== "object") {
|
|
20
|
+
return { parsed: void 0, text };
|
|
21
|
+
}
|
|
22
|
+
return { parsed: value, text };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { WRANGLER_FILES, findWranglerFile, readWranglerJsonc };
|