@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,277 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { containerBuildTag } from '@lunora/container';
|
|
3
|
+
import { modify, applyEdits } from 'jsonc-parser';
|
|
4
|
+
import { findWranglerFile, readWranglerJsonc } from './WRANGLER_FILES-DwSuC-Kn.mjs';
|
|
5
|
+
|
|
6
|
+
const FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 4 } };
|
|
7
|
+
const D1_PLACEHOLDER_ID = "<replace-with-d1-create-id>";
|
|
8
|
+
const collectExportGaps = (inferred) => {
|
|
9
|
+
const gaps = [];
|
|
10
|
+
for (const container of inferred.containers) {
|
|
11
|
+
if (!container.exported) {
|
|
12
|
+
gaps.push({ className: container.className, exportName: container.exportName, kind: "container", module: "containers" });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
for (const workflow of inferred.workflows) {
|
|
16
|
+
if (!workflow.exported) {
|
|
17
|
+
gaps.push({ className: workflow.className, exportName: workflow.exportName, kind: "workflow", module: "workflows" });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return gaps;
|
|
21
|
+
};
|
|
22
|
+
const collectHintBindingWarnings = (inferred, parsed) => {
|
|
23
|
+
const rules = [
|
|
24
|
+
[
|
|
25
|
+
inferred.usesKv && (parsed?.kv_namespaces?.length ?? 0) === 0,
|
|
26
|
+
"@lunora/kv is used but no kv_namespaces binding exists; add a kv_namespaces entry ({ binding, id }) and pass env.<BINDING> to createKv() — the namespace id can't be auto-provisioned."
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
inferred.usesHyperdrive && (parsed?.hyperdrive?.length ?? 0) === 0,
|
|
30
|
+
"@lunora/hyperdrive is used but no hyperdrive binding exists; run 'wrangler hyperdrive create' and add a 'hyperdrive' binding ({ binding, id }) — the id can't be auto-provisioned."
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
inferred.usesPipelines && (parsed?.pipelines?.length ?? 0) === 0,
|
|
34
|
+
"@lunora/pipelines is used but no pipelines binding exists; run 'wrangler pipelines create <name>' and add a 'pipelines' binding ({ binding, pipeline }) — the pipeline resource can't be auto-provisioned."
|
|
35
|
+
]
|
|
36
|
+
];
|
|
37
|
+
return rules.filter(([active]) => active).map(([, warning]) => warning);
|
|
38
|
+
};
|
|
39
|
+
const collectWarnings = (inferred, parsed) => {
|
|
40
|
+
const exported = new Set(inferred.durableObjects.map((object) => object.className));
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const hasR2Bucket = (parsed?.r2_buckets?.length ?? 0) > 0;
|
|
43
|
+
const hasSessionStore = (parsed?.d1_databases?.some((binding) => binding.binding === "DB") ?? false) || inferred.needsD1;
|
|
44
|
+
if (inferred.usesStorage && !hasR2Bucket) {
|
|
45
|
+
warnings.push(
|
|
46
|
+
"@lunora/storage is used but R2 bucket bindings have user-defined names; add an r2_buckets entry and pass env.<BINDING> to createStorage()."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (inferred.usesAuth && !exported.has("SessionDO") && !hasSessionStore) {
|
|
50
|
+
warnings.push(
|
|
51
|
+
"@lunora/auth is used but the worker entry exports no SessionDO; sessions are D1-backed, or export SessionDO to enable DO-backed sessions."
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (inferred.usesScheduler && !exported.has("SchedulerDO")) {
|
|
55
|
+
warnings.push("@lunora/scheduler is used but the worker entry exports no SchedulerDO; export it so the SCHEDULER binding can be provisioned.");
|
|
56
|
+
}
|
|
57
|
+
for (const container of inferred.containers) {
|
|
58
|
+
if (!container.exported) {
|
|
59
|
+
warnings.push(
|
|
60
|
+
`container "${container.exportName}" is declared but ${container.className} is not exported by the worker entry; add \`export * from "./lunora/_generated/containers"\` so its binding can be provisioned.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const workflow of inferred.workflows) {
|
|
65
|
+
if (!workflow.exported) {
|
|
66
|
+
warnings.push(
|
|
67
|
+
`workflow "${workflow.exportName}" is declared but ${workflow.className} is not exported by the worker entry; add \`export * from "./lunora/_generated/workflows"\` so its binding can be provisioned.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (inferred.containers.length > 0 && parsed?.observability?.enabled === false) {
|
|
72
|
+
warnings.push("containers are declared but observability is explicitly disabled in wrangler.jsonc — container logs will not be captured.");
|
|
73
|
+
}
|
|
74
|
+
if (inferred.usesPayment) {
|
|
75
|
+
warnings.push(
|
|
76
|
+
"@lunora/payment is used; set the provider secrets in .dev.vars — STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET (Stripe) or POLAR_ACCESS_TOKEN + POLAR_WEBHOOK_SECRET (Polar)."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
warnings.push(...collectHintBindingWarnings(inferred, parsed));
|
|
80
|
+
return warnings;
|
|
81
|
+
};
|
|
82
|
+
const applyModify = (text, path, value) => {
|
|
83
|
+
const edits = modify(text, [...path], value, FORMATTING);
|
|
84
|
+
return edits.length > 0 ? applyEdits(text, edits) : text;
|
|
85
|
+
};
|
|
86
|
+
const nextMigrationTag = (migrations) => {
|
|
87
|
+
const used = new Set(migrations.map((migration) => migration.tag));
|
|
88
|
+
let index = 1;
|
|
89
|
+
while (used.has(`v${String(index)}`)) {
|
|
90
|
+
index += 1;
|
|
91
|
+
}
|
|
92
|
+
return `v${String(index)}`;
|
|
93
|
+
};
|
|
94
|
+
const reconcileDurableObjects = (text, parsed, required) => {
|
|
95
|
+
const existingBindings = parsed.durable_objects?.bindings ?? [];
|
|
96
|
+
const existingNames = new Set(existingBindings.map((binding) => binding.name));
|
|
97
|
+
const missing = required.filter((object) => !existingNames.has(object.binding));
|
|
98
|
+
let nextText = text;
|
|
99
|
+
const added = [];
|
|
100
|
+
if (missing.length > 0) {
|
|
101
|
+
const nextBindings = [
|
|
102
|
+
...existingBindings,
|
|
103
|
+
...missing.map((object) => {
|
|
104
|
+
return { class_name: object.className, name: object.binding };
|
|
105
|
+
})
|
|
106
|
+
];
|
|
107
|
+
nextText = applyModify(nextText, ["durable_objects", "bindings"], nextBindings);
|
|
108
|
+
added.push(...missing.map((object) => `${object.binding}/${object.className}`));
|
|
109
|
+
}
|
|
110
|
+
const migrations = parsed.migrations ?? [];
|
|
111
|
+
const registered = new Set(migrations.flatMap((migration) => [...migration.new_sqlite_classes ?? [], ...migration.new_classes ?? []]));
|
|
112
|
+
const missingClasses = required.map((object) => object.className).filter((className) => !registered.has(className));
|
|
113
|
+
if (missingClasses.length > 0) {
|
|
114
|
+
const nextMigrations = [...migrations, { new_sqlite_classes: missingClasses, tag: nextMigrationTag(migrations) }];
|
|
115
|
+
nextText = applyModify(nextText, ["migrations"], nextMigrations);
|
|
116
|
+
}
|
|
117
|
+
return { added, text: nextText };
|
|
118
|
+
};
|
|
119
|
+
const reconcileD1 = (text, parsed) => {
|
|
120
|
+
const d1Bindings = parsed.d1_databases ?? [];
|
|
121
|
+
if (d1Bindings.some((binding) => binding.binding === "DB")) {
|
|
122
|
+
return { added: [], text };
|
|
123
|
+
}
|
|
124
|
+
const databaseName = typeof parsed.name === "string" && parsed.name.length > 0 ? parsed.name : "lunora";
|
|
125
|
+
const nextD1 = [...d1Bindings, { binding: "DB", database_id: D1_PLACEHOLDER_ID, database_name: databaseName }];
|
|
126
|
+
return { added: ["DB (D1)"], text: applyModify(text, ["d1_databases"], nextD1) };
|
|
127
|
+
};
|
|
128
|
+
const reconcileSelfDescribing = (text, parsed, key, binding, label) => {
|
|
129
|
+
const current = parsed[key]?.binding;
|
|
130
|
+
if (typeof current === "string" && current.length > 0) {
|
|
131
|
+
return { added: [], text };
|
|
132
|
+
}
|
|
133
|
+
return { added: [label], text: applyModify(text, [key], { binding }) };
|
|
134
|
+
};
|
|
135
|
+
const reconcileAnalytics = (text, parsed) => {
|
|
136
|
+
if ((parsed.analytics_engine_datasets?.length ?? 0) > 0) {
|
|
137
|
+
return { added: [], text };
|
|
138
|
+
}
|
|
139
|
+
const nextDatasets = [{ binding: "ANALYTICS", dataset: "ANALYTICS" }];
|
|
140
|
+
return { added: ["ANALYTICS (Analytics Engine)"], text: applyModify(text, ["analytics_engine_datasets"], nextDatasets) };
|
|
141
|
+
};
|
|
142
|
+
const wranglerInstanceType = (instanceType) => {
|
|
143
|
+
if (typeof instanceType === "string") {
|
|
144
|
+
return instanceType;
|
|
145
|
+
}
|
|
146
|
+
const custom = {};
|
|
147
|
+
if (instanceType.diskMb !== void 0) {
|
|
148
|
+
custom.disk_mb = instanceType.diskMb;
|
|
149
|
+
}
|
|
150
|
+
if (instanceType.memoryMib !== void 0) {
|
|
151
|
+
custom.memory_mib = instanceType.memoryMib;
|
|
152
|
+
}
|
|
153
|
+
if (instanceType.vcpu !== void 0) {
|
|
154
|
+
custom.vcpu = instanceType.vcpu;
|
|
155
|
+
}
|
|
156
|
+
return custom;
|
|
157
|
+
};
|
|
158
|
+
const imageRefFor = (container) => {
|
|
159
|
+
if (container.image.kind === "dockerfile") {
|
|
160
|
+
return container.image.dockerfilePath;
|
|
161
|
+
}
|
|
162
|
+
if (container.image.kind === "registry") {
|
|
163
|
+
return container.image.reference;
|
|
164
|
+
}
|
|
165
|
+
return containerBuildTag(container.exportName);
|
|
166
|
+
};
|
|
167
|
+
const containerEntryFor = (container) => {
|
|
168
|
+
const entry = {
|
|
169
|
+
class_name: container.className,
|
|
170
|
+
image: imageRefFor(container)
|
|
171
|
+
};
|
|
172
|
+
if (container.image.kind === "dockerfile") {
|
|
173
|
+
entry.image_build_context = container.image.buildContext;
|
|
174
|
+
}
|
|
175
|
+
if (container.buildArgs !== void 0 && container.image.kind !== "registry") {
|
|
176
|
+
entry.image_vars = container.buildArgs;
|
|
177
|
+
}
|
|
178
|
+
if (container.instanceType !== void 0) {
|
|
179
|
+
entry.instance_type = wranglerInstanceType(container.instanceType);
|
|
180
|
+
}
|
|
181
|
+
if (container.maxInstances !== void 0) {
|
|
182
|
+
entry.max_instances = container.maxInstances;
|
|
183
|
+
}
|
|
184
|
+
if (container.name !== void 0) {
|
|
185
|
+
entry.name = container.name;
|
|
186
|
+
}
|
|
187
|
+
if (container.rollout?.stepPercentage !== void 0) {
|
|
188
|
+
entry.rollout_step_percentage = container.rollout.stepPercentage;
|
|
189
|
+
}
|
|
190
|
+
if (container.rollout?.gracePeriodSeconds !== void 0) {
|
|
191
|
+
entry.rollout_active_grace_period = container.rollout.gracePeriodSeconds;
|
|
192
|
+
}
|
|
193
|
+
return entry;
|
|
194
|
+
};
|
|
195
|
+
const reconcileContainers = (text, parsed, containers) => {
|
|
196
|
+
const existing = parsed.containers ?? [];
|
|
197
|
+
const existingClasses = new Set(existing.map((entry) => entry.class_name));
|
|
198
|
+
const missing = containers.filter((container) => !existingClasses.has(container.className));
|
|
199
|
+
if (missing.length === 0) {
|
|
200
|
+
return { added: [], text };
|
|
201
|
+
}
|
|
202
|
+
const nextText = applyModify(text, ["containers"], [...existing, ...missing.map((container) => containerEntryFor(container))]);
|
|
203
|
+
return { added: missing.map((container) => `containers/${container.className}`), text: nextText };
|
|
204
|
+
};
|
|
205
|
+
const reconcileObservability = (text, parsed) => {
|
|
206
|
+
if (parsed.observability !== void 0) {
|
|
207
|
+
return { added: [], text };
|
|
208
|
+
}
|
|
209
|
+
const nextText = applyModify(text, ["observability"], { enabled: true, head_sampling_rate: 1 });
|
|
210
|
+
return { added: ["observability"], text: nextText };
|
|
211
|
+
};
|
|
212
|
+
const workflowEntryFor = (workflow) => {
|
|
213
|
+
return { binding: workflow.bindingName, class_name: workflow.className, name: workflow.name };
|
|
214
|
+
};
|
|
215
|
+
const reconcileWorkflows = (text, parsed, workflows) => {
|
|
216
|
+
const existing = parsed.workflows ?? [];
|
|
217
|
+
const existingClasses = new Set(existing.map((entry) => entry.class_name));
|
|
218
|
+
const missing = workflows.filter((workflow) => !existingClasses.has(workflow.className));
|
|
219
|
+
if (missing.length === 0) {
|
|
220
|
+
return { added: [], text };
|
|
221
|
+
}
|
|
222
|
+
const nextText = applyModify(text, ["workflows"], [...existing, ...missing.map((workflow) => workflowEntryFor(workflow))]);
|
|
223
|
+
return { added: missing.map((workflow) => `workflows/${workflow.className}`), text: nextText };
|
|
224
|
+
};
|
|
225
|
+
const reconcileWranglerBindings = (projectRoot, inferred) => {
|
|
226
|
+
const wranglerPath = findWranglerFile(projectRoot);
|
|
227
|
+
const exportGaps = collectExportGaps(inferred);
|
|
228
|
+
if (!wranglerPath) {
|
|
229
|
+
return { added: [], changed: false, exportGaps, reason: "wrangler.jsonc not found", warnings: collectWarnings(inferred) };
|
|
230
|
+
}
|
|
231
|
+
const { parsed, text: original } = readWranglerJsonc(wranglerPath);
|
|
232
|
+
if (parsed === void 0) {
|
|
233
|
+
return { added: [], changed: false, exportGaps, reason: `failed to parse ${wranglerPath} as JSONC`, warnings: collectWarnings(inferred), wranglerPath };
|
|
234
|
+
}
|
|
235
|
+
const warnings = collectWarnings(inferred, parsed);
|
|
236
|
+
const exportedContainers = inferred.containers.filter((container) => container.exported);
|
|
237
|
+
const requiredDurableObjects = [
|
|
238
|
+
...inferred.durableObjects,
|
|
239
|
+
...exportedContainers.map((container) => {
|
|
240
|
+
return { binding: container.bindingName, className: container.className };
|
|
241
|
+
})
|
|
242
|
+
];
|
|
243
|
+
const exportedWorkflows = inferred.workflows.filter((workflow) => workflow.exported);
|
|
244
|
+
const pipeline = [
|
|
245
|
+
{ enabled: true, run: (text2) => reconcileDurableObjects(text2, parsed, requiredDurableObjects) },
|
|
246
|
+
{ enabled: inferred.needsD1, run: (text2) => reconcileD1(text2, parsed) },
|
|
247
|
+
{ enabled: inferred.usesAi, run: (text2) => reconcileSelfDescribing(text2, parsed, "ai", "AI", "AI (Workers AI)") },
|
|
248
|
+
{ enabled: inferred.usesBrowser, run: (text2) => reconcileSelfDescribing(text2, parsed, "browser", "BROWSER", "BROWSER (Browser Rendering)") },
|
|
249
|
+
{ enabled: inferred.usesImages, run: (text2) => reconcileSelfDescribing(text2, parsed, "images", "IMAGES", "IMAGES (Cloudflare Images)") },
|
|
250
|
+
{ enabled: inferred.usesAnalytics, run: (text2) => reconcileAnalytics(text2, parsed) },
|
|
251
|
+
{ enabled: true, run: (text2) => reconcileObservability(text2, parsed) },
|
|
252
|
+
{ enabled: exportedContainers.length > 0, run: (text2) => reconcileContainers(text2, parsed, exportedContainers) },
|
|
253
|
+
{ enabled: exportedWorkflows.length > 0, run: (text2) => reconcileWorkflows(text2, parsed, exportedWorkflows) }
|
|
254
|
+
];
|
|
255
|
+
let text = original;
|
|
256
|
+
const added = [];
|
|
257
|
+
for (const step of pipeline) {
|
|
258
|
+
if (!step.enabled) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const result = step.run(text);
|
|
262
|
+
text = result.text;
|
|
263
|
+
added.push(...result.added);
|
|
264
|
+
}
|
|
265
|
+
if (added.includes("DB (D1)")) {
|
|
266
|
+
warnings.push(
|
|
267
|
+
`wrote a DB binding with a placeholder database_id ("${D1_PLACEHOLDER_ID}") — run \`wrangler d1 create <name>\` and replace it before deploying.`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (text === original) {
|
|
271
|
+
return { added: [], changed: false, exportGaps, reason: "bindings already in sync", warnings, wranglerPath };
|
|
272
|
+
}
|
|
273
|
+
writeFileSync(wranglerPath, text, "utf8");
|
|
274
|
+
return { added, changed: true, exportGaps, warnings, wranglerPath };
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export { reconcileWranglerBindings };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const forInlineScript = (value) => JSON.stringify(value).replaceAll("<", String.raw`\u003c`);
|
|
2
|
+
const forAttribute = (value) => value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
|
|
3
|
+
const renderStudioHtml = (config) => {
|
|
4
|
+
const settings = [`window.__LUNORA_BASE_PATH__=${forInlineScript(config.basePath)};`];
|
|
5
|
+
if (config.adminToken !== void 0 && config.adminToken !== "") {
|
|
6
|
+
settings.push(`window.__LUNORA_ADMIN_TOKEN__=${forInlineScript(config.adminToken)};`);
|
|
7
|
+
}
|
|
8
|
+
if (config.dataEditable === true) {
|
|
9
|
+
settings.push("window.__LUNORA_DATA_EDITABLE__=true;");
|
|
10
|
+
}
|
|
11
|
+
if (config.runAsIdentity === true) {
|
|
12
|
+
settings.push("window.__LUNORA_RUN_AS_IDENTITY__=true;");
|
|
13
|
+
}
|
|
14
|
+
if (config.schemaEditable === true) {
|
|
15
|
+
settings.push("window.__LUNORA_SCHEMA_EDITABLE__=true;");
|
|
16
|
+
}
|
|
17
|
+
if (config.rulesInstalled === false) {
|
|
18
|
+
settings.push("window.__LUNORA_RULES_INSTALLED__=false;");
|
|
19
|
+
}
|
|
20
|
+
return `<!doctype html>
|
|
21
|
+
<html lang="en">
|
|
22
|
+
<head>
|
|
23
|
+
<meta charset="UTF-8" />
|
|
24
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
25
|
+
<title>Lunora Studio</title>
|
|
26
|
+
<script>${settings.join("")}<\/script>
|
|
27
|
+
<link rel="stylesheet" href="${forAttribute(config.styleHref)}" />
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<div id="root"></div>
|
|
31
|
+
<script type="module" src="${forAttribute(config.scriptSrc)}"><\/script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
34
|
+
`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export { renderStudioHtml as default };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const MAX_BODY_BYTES = 1e6;
|
|
2
|
+
const readBody = async (request) => await new Promise((resolve, reject) => {
|
|
3
|
+
const chunks = [];
|
|
4
|
+
let size = 0;
|
|
5
|
+
request.on("data", (chunk) => {
|
|
6
|
+
size += chunk.length;
|
|
7
|
+
if (size > MAX_BODY_BYTES) {
|
|
8
|
+
reject(new Error("request body too large"));
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
chunks.push(chunk);
|
|
12
|
+
});
|
|
13
|
+
request.on("end", () => {
|
|
14
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
15
|
+
});
|
|
16
|
+
request.on("error", reject);
|
|
17
|
+
});
|
|
18
|
+
const respondJson = (response, status, body) => {
|
|
19
|
+
response.statusCode = status;
|
|
20
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
21
|
+
response.end(JSON.stringify(body));
|
|
22
|
+
};
|
|
23
|
+
const headerValue = (raw) => {
|
|
24
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
25
|
+
return typeof value === "string" ? value.trim().toLowerCase() : void 0;
|
|
26
|
+
};
|
|
27
|
+
const SAME_SITE_FETCH_VALUES = /* @__PURE__ */ new Set(["none", "same-origin", "same-site"]);
|
|
28
|
+
const originRejectionReason = (request) => {
|
|
29
|
+
const secFetchSite = headerValue(request.headers["sec-fetch-site"]);
|
|
30
|
+
if (secFetchSite !== void 0) {
|
|
31
|
+
return SAME_SITE_FETCH_VALUES.has(secFetchSite) ? void 0 : "cross-origin request rejected";
|
|
32
|
+
}
|
|
33
|
+
const origin = headerValue(request.headers.origin);
|
|
34
|
+
if (origin === void 0 || origin === "null") {
|
|
35
|
+
return void 0;
|
|
36
|
+
}
|
|
37
|
+
let originHost;
|
|
38
|
+
try {
|
|
39
|
+
originHost = new URL(origin).host.toLowerCase();
|
|
40
|
+
} catch {
|
|
41
|
+
return "invalid origin header";
|
|
42
|
+
}
|
|
43
|
+
return originHost === headerValue(request.headers.host) ? void 0 : "cross-origin request rejected";
|
|
44
|
+
};
|
|
45
|
+
const csrfRejectionReason = (request) => {
|
|
46
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
47
|
+
const isStateChanging = method !== "GET" && method !== "HEAD";
|
|
48
|
+
const originReason = originRejectionReason(request);
|
|
49
|
+
if (originReason !== void 0) {
|
|
50
|
+
return originReason;
|
|
51
|
+
}
|
|
52
|
+
if (isStateChanging) {
|
|
53
|
+
const contentType = headerValue(request.headers["content-type"]);
|
|
54
|
+
if (!contentType?.startsWith("application/json")) {
|
|
55
|
+
return "content-type must be application/json";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return void 0;
|
|
59
|
+
};
|
|
60
|
+
const serveJsonHandler = (request, response, handle, projectRoot) => {
|
|
61
|
+
const run = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const rejection = csrfRejectionReason(request);
|
|
64
|
+
if (rejection !== void 0) {
|
|
65
|
+
respondJson(response, 403, { error: rejection, ok: false });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const raw = request.method === "GET" ? "" : await readBody(request);
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = raw === "" ? void 0 : JSON.parse(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
respondJson(response, 400, { error: "invalid-json", ok: false });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const result = handle({ body: parsed, method: request.method ?? "POST", projectRoot });
|
|
77
|
+
respondJson(response, result.status, result.body);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
respondJson(response, 500, { error: error instanceof Error ? error.message : String(error), ok: false });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
run().catch(() => {
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export { serveJsonHandler };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { D as DestructivePolicyEdit, S as ScaffoldPolicyEdit, W as WireRlsEdit } from "../packem_shared/policy-scaffold.d-DCmwn7zQ.mjs";
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
/** Parse a single `KEY=value` (optionally quoted) out of a `.dev.vars` body. */
|
|
4
|
+
declare const parseDevVariable: (contents: string, key: string) => string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the worker's admin token so the studio can auto-authenticate in
|
|
7
|
+
* dev. Prefers the `LUNORA_ADMIN_TOKEN` env var, then the project's `.dev.vars`
|
|
8
|
+
* — the same file `@cloudflare/vite-plugin` / `wrangler dev` feed the worker, so
|
|
9
|
+
* the token the studio sends matches the one the worker's admin gate
|
|
10
|
+
* verifies. Returns `undefined` when neither is set (the studio then prompts).
|
|
11
|
+
*/
|
|
12
|
+
declare const resolveAdminToken: (root: string) => string | undefined;
|
|
13
|
+
/** Config injected into the studio document before the bundle loads. */
|
|
14
|
+
interface StudioHtmlConfig {
|
|
15
|
+
/** Admin token sent with admin requests; omitted (and not injected) when unset. */
|
|
16
|
+
readonly adminToken?: string;
|
|
17
|
+
/** Router basepath the studio mounts under (e.g. `/__lunora`, or `/` for a root server). */
|
|
18
|
+
readonly basePath: string;
|
|
19
|
+
/**
|
|
20
|
+
* Make the data browser editable (insert/edit/delete rows). Injected as
|
|
21
|
+
* `window.__LUNORA_DATA_EDITABLE__` for the bundle to read. The loopback-only
|
|
22
|
+
* dev hosts (the Vite `/__lunora` route, the CLI studio server) set this so a
|
|
23
|
+
* developer can edit; a static deploy leaves it off (read-only) by default.
|
|
24
|
+
*/
|
|
25
|
+
readonly dataEditable?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the project's Lunora agent skills ("rules") are installed. Injected
|
|
28
|
+
* as `window.__LUNORA_RULES_INSTALLED__` **only when `false`**, so the studio
|
|
29
|
+
* shows a "rules not installed" banner on the loopback dev hosts (which detect
|
|
30
|
+
* it) and stays quiet on a static deploy (which leaves it unset).
|
|
31
|
+
*/
|
|
32
|
+
readonly rulesInstalled?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Enable the function runner's "Run as identity" tool (execute a function as a
|
|
35
|
+
* chosen user to test auth/RLS). Injected as `window.__LUNORA_RUN_AS_IDENTITY__`.
|
|
36
|
+
* Like {@link StudioHtmlConfig.dataEditable}, only the loopback-only dev hosts set
|
|
37
|
+
* this — forging an identity is a developer-only affordance; a static deploy
|
|
38
|
+
* leaves it off so the control never renders.
|
|
39
|
+
*/
|
|
40
|
+
readonly runAsIdentity?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Enable the visual schema editor (add table / column / index, written back to
|
|
43
|
+
* `lunora/schema.ts` + codegen). Injected as `window.__LUNORA_SCHEMA_EDITABLE__`.
|
|
44
|
+
* Like {@link StudioHtmlConfig.dataEditable}, only the loopback-only dev hosts
|
|
45
|
+
* set this — editing source + running codegen needs the project's filesystem and
|
|
46
|
+
* toolchain, so a static deploy leaves it off and the diagram stays read-only.
|
|
47
|
+
*/
|
|
48
|
+
readonly schemaEditable?: boolean;
|
|
49
|
+
/** URL the studio bundle is served from (absolute, host-relative). */
|
|
50
|
+
readonly scriptSrc: string;
|
|
51
|
+
/** URL the compiled stylesheet is served from (absolute, host-relative). */
|
|
52
|
+
readonly styleHref: string;
|
|
53
|
+
}
|
|
54
|
+
/** Minimal logger surface both Vite's `Logger` and the CLI's logger satisfy. */
|
|
55
|
+
interface WarnLogger {
|
|
56
|
+
warnOnce?: (message: string) => void;
|
|
57
|
+
}
|
|
58
|
+
/** Prebuilt studio asset bytes, resolved from `@lunora/studio`'s dist. */
|
|
59
|
+
interface StudioAssets {
|
|
60
|
+
readonly script: Buffer;
|
|
61
|
+
readonly styles: Buffer;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the prebuilt static studio files shipped by `@lunora/studio`
|
|
65
|
+
* (`dist/standalone/studio.js` + `dist/styles.css`). Returns `undefined` —
|
|
66
|
+
* with a one-time warning — when the optional package isn't installed or hasn't
|
|
67
|
+
* been built, so a missing studio never breaks the dev server.
|
|
68
|
+
*
|
|
69
|
+
* `resolveFrom` controls where `@lunora/studio` is resolved from. It defaults
|
|
70
|
+
* to this module's own location, which is correct once this code is inlined into
|
|
71
|
+
* a host package (`@lunora/vite` / `@lunora/cli`) that has `@lunora/studio`
|
|
72
|
+
* installed — node walks up from the host's `dist` to find it.
|
|
73
|
+
*/
|
|
74
|
+
declare const loadStudioAssets: (logger?: WarnLogger, resolveFrom?: string) => StudioAssets | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* A freshness stamp for the studio assets: the latest mtime (ms) of the resolved
|
|
77
|
+
* `studio.js` / `styles.css`, or `undefined` when they can't be resolved. Hosts
|
|
78
|
+
* cache {@link loadStudioAssets} for the dev session but compare this stamp per
|
|
79
|
+
* request, so a `@lunora/studio` rebuild mid-session is picked up live — no dev
|
|
80
|
+
* server restart needed.
|
|
81
|
+
*/
|
|
82
|
+
declare const studioAssetsStamp: (resolveFrom?: string) => number | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Endpoint path both dev hosts mount the handler at. A sibling of the schema
|
|
85
|
+
* editor's `/__lunora/schema-edit`; the double underscore keeps it clear of the
|
|
86
|
+
* CLI's `/_lunora/*` worker proxy (single underscore).
|
|
87
|
+
*/
|
|
88
|
+
declare const POLICY_SCAFFOLD_ENDPOINT = "/__lunora/policy-scaffold";
|
|
89
|
+
/** A `wireRls` request additionally carries the procedure's source-file path. */
|
|
90
|
+
interface WirePolicyEdit extends WireRlsEdit {
|
|
91
|
+
/** Lunora-relative module path of the procedure file (no extension), e.g. `messages/list`. */
|
|
92
|
+
readonly filePath: string;
|
|
93
|
+
}
|
|
94
|
+
/** Body the host transport adapts from a `POST` — one scaffolder request. */
|
|
95
|
+
type PolicyScaffoldBody = DestructivePolicyEdit | ScaffoldPolicyEdit | WirePolicyEdit;
|
|
96
|
+
/** A request adapted from the host transport. */
|
|
97
|
+
interface PolicyScaffoldRequest {
|
|
98
|
+
/** Parsed JSON body of the `POST`. */
|
|
99
|
+
readonly body?: unknown;
|
|
100
|
+
/** HTTP method — only `POST` is handled. */
|
|
101
|
+
readonly method: string;
|
|
102
|
+
/** Project root containing the `lunora/` directory. */
|
|
103
|
+
readonly projectRoot: string;
|
|
104
|
+
/** Override the lunora subdirectory name. Defaults to `"lunora"`. */
|
|
105
|
+
readonly schemaDirectory?: string;
|
|
106
|
+
}
|
|
107
|
+
/** A response the host transport serialises back as JSON with `status`. */
|
|
108
|
+
interface PolicyScaffoldResponse {
|
|
109
|
+
readonly body: unknown;
|
|
110
|
+
readonly status: number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Handle a policy-scaffold request. Pure over its inputs apart from the file
|
|
114
|
+
* I/O + codegen it performs on an applied edit; safe to unit-test against a
|
|
115
|
+
* temp project directory.
|
|
116
|
+
*/
|
|
117
|
+
declare const handlePolicyScaffoldRequest: (request: PolicyScaffoldRequest) => PolicyScaffoldResponse;
|
|
118
|
+
/**
|
|
119
|
+
* Render the single-page document that boots the studio. Emitted verbatim —
|
|
120
|
+
* never through a bundler/transform — so the studio stays a static tool,
|
|
121
|
+
* decoupled from the host project's build. A small inline script publishes the
|
|
122
|
+
* per-server config on `globalThis` before the bundle loads: the mount basepath
|
|
123
|
+
* (so the router stays under its mount), the admin token when present (so the
|
|
124
|
+
* studio auto-authenticates instead of prompting), and an editable flag when the
|
|
125
|
+
* host is a loopback dev server (so the data browser allows edits).
|
|
126
|
+
*/
|
|
127
|
+
declare const renderStudioHtml: (config: StudioHtmlConfig) => string;
|
|
128
|
+
/**
|
|
129
|
+
* Endpoint path both dev hosts mount the handler at. Distinct from the CLI's
|
|
130
|
+
* `/_lunora/*` worker proxy (single underscore), so a schema edit is never
|
|
131
|
+
* forwarded to the worker.
|
|
132
|
+
*/
|
|
133
|
+
declare const SCHEMA_EDIT_ENDPOINT = "/__lunora/schema-edit";
|
|
134
|
+
/** A request adapted from the host transport. */
|
|
135
|
+
interface SchemaEditRequest {
|
|
136
|
+
/** Parsed JSON body for a `POST`; ignored for `GET`. */
|
|
137
|
+
readonly body?: unknown;
|
|
138
|
+
/** HTTP method (`GET` / `POST`). */
|
|
139
|
+
readonly method: string;
|
|
140
|
+
/** Project root containing the `lunora/` directory. */
|
|
141
|
+
readonly projectRoot: string;
|
|
142
|
+
/** Override the lunora subdirectory name. Defaults to `"lunora"`. */
|
|
143
|
+
readonly schemaDirectory?: string;
|
|
144
|
+
}
|
|
145
|
+
/** A response the host transport serialises back as JSON with `status`. */
|
|
146
|
+
interface SchemaEditResponse {
|
|
147
|
+
readonly body: unknown;
|
|
148
|
+
readonly status: number;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Handle a schema-edit request. Pure over its inputs apart from the file I/O +
|
|
152
|
+
* codegen it performs on a `POST` additive edit; safe to unit-test against a
|
|
153
|
+
* temp project directory.
|
|
154
|
+
*/
|
|
155
|
+
declare const handleSchemaEditRequest: (request: SchemaEditRequest) => SchemaEditResponse;
|
|
156
|
+
/**
|
|
157
|
+
* Endpoint path both dev hosts mount the handler at. A sibling of the schema
|
|
158
|
+
* editor's `/__lunora/schema-edit`; the double underscore keeps it clear of the
|
|
159
|
+
* CLI's `/_lunora/*` worker proxy (single underscore).
|
|
160
|
+
*/
|
|
161
|
+
declare const SEED_ENDPOINT = "/__lunora/seed";
|
|
162
|
+
/** Body the host transport adapts from a `POST` — one generate-rows request. */
|
|
163
|
+
interface SeedRequestBody {
|
|
164
|
+
/** How many rows to generate (clamped to `[1, MAX_SEED_ROWS]`). */
|
|
165
|
+
readonly count?: number;
|
|
166
|
+
/**
|
|
167
|
+
* Ids of rows that already exist in the live DB, keyed by referenced table.
|
|
168
|
+
* Foreign keys resolve against these, and every referenced parent table
|
|
169
|
+
* present here is treated as covered — so the generator links to existing
|
|
170
|
+
* rows instead of fabricating new parent rows. The studio samples these from
|
|
171
|
+
* the target table's FK columns before calling.
|
|
172
|
+
*/
|
|
173
|
+
readonly existingIds?: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
174
|
+
/** Deterministic mapping selector — same value yields identical rows. */
|
|
175
|
+
readonly seed?: number;
|
|
176
|
+
/** The table to generate rows for. */
|
|
177
|
+
readonly table?: string;
|
|
178
|
+
}
|
|
179
|
+
/** A request adapted from the host transport. */
|
|
180
|
+
interface SeedRequest {
|
|
181
|
+
/** Parsed JSON body of the `POST`. */
|
|
182
|
+
readonly body?: unknown;
|
|
183
|
+
/** HTTP method — only `POST` is handled. */
|
|
184
|
+
readonly method: string;
|
|
185
|
+
/** Project root containing the `lunora/` directory. */
|
|
186
|
+
readonly projectRoot: string;
|
|
187
|
+
/** Override the lunora subdirectory name. Defaults to `"lunora"`. */
|
|
188
|
+
readonly schemaDirectory?: string;
|
|
189
|
+
}
|
|
190
|
+
/** A response the host transport serialises back as JSON with `status`. */
|
|
191
|
+
interface SeedResponse {
|
|
192
|
+
readonly body: unknown;
|
|
193
|
+
readonly status: number;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Handle a generate-rows request: statically lift the schema, generate `count`
|
|
197
|
+
* rows for `table` (linking foreign keys to the supplied existing ids rather
|
|
198
|
+
* than fabricating parents), and return them JSON-safe for the client to insert.
|
|
199
|
+
*/
|
|
200
|
+
declare const handleSeedRequest: (request: SeedRequest) => SeedResponse;
|
|
201
|
+
/** A request adapted from a host transport, passed to a local-dev handler. */
|
|
202
|
+
interface LocalEndpointRequest {
|
|
203
|
+
/** Parsed JSON body of the request (`undefined` for `GET` / an empty body). */
|
|
204
|
+
readonly body?: unknown;
|
|
205
|
+
/** HTTP method. */
|
|
206
|
+
readonly method: string;
|
|
207
|
+
/** Project root containing the `lunora/` directory. */
|
|
208
|
+
readonly projectRoot: string;
|
|
209
|
+
/** Override the lunora subdirectory name. Defaults to `"lunora"`. */
|
|
210
|
+
readonly schemaDirectory?: string;
|
|
211
|
+
}
|
|
212
|
+
/** A response a local-dev handler returns, serialised back as JSON with its status. */
|
|
213
|
+
interface LocalEndpointResponse {
|
|
214
|
+
readonly body: unknown;
|
|
215
|
+
readonly status: number;
|
|
216
|
+
}
|
|
217
|
+
/** A transport-agnostic local-dev handler (schema edit, policy scaffold). */
|
|
218
|
+
type LocalEndpointHandler = (request: LocalEndpointRequest) => LocalEndpointResponse;
|
|
219
|
+
/**
|
|
220
|
+
* Adapt a `node:http` request/response pair to a transport-agnostic local-dev
|
|
221
|
+
* handler. `GET` carries no body (the schema editor uses it to read the parsed
|
|
222
|
+
* schema); every other method has its body read + JSON-parsed first. The
|
|
223
|
+
* handler's `{ status, body }` is serialised back as JSON. A malformed body is a
|
|
224
|
+
* `400`; an unexpected throw (e.g. the body-size guard) is a `500`.
|
|
225
|
+
*/
|
|
226
|
+
declare const serveJsonHandler: (request: IncomingMessage, response: ServerResponse, handle: LocalEndpointHandler, projectRoot: string) => void;
|
|
227
|
+
export { type LocalEndpointHandler, type LocalEndpointRequest, type LocalEndpointResponse, POLICY_SCAFFOLD_ENDPOINT, type PolicyScaffoldBody, type PolicyScaffoldRequest, type PolicyScaffoldResponse, SCHEMA_EDIT_ENDPOINT, SEED_ENDPOINT, type SchemaEditRequest, type SchemaEditResponse, type SeedRequest, type SeedRequestBody, type SeedResponse, type StudioAssets, type StudioHtmlConfig, type WarnLogger, type WirePolicyEdit, handlePolicyScaffoldRequest, handleSchemaEditRequest, handleSeedRequest, loadStudioAssets, parseDevVariable, renderStudioHtml, resolveAdminToken, serveJsonHandler, studioAssetsStamp };
|