@lunora/config 1.0.0-alpha.4 → 1.0.0-alpha.41

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.
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, renameSync, rmSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { DEV_VARS_FILE, DEV_VARS_EXAMPLE_FILE, parseDevVariableEntries, DEV_VARS_NEWLINE, splitDevVariableLine, unquoteDevVariable } from './DEV_VARS_EXAMPLE_FILE-dJPNTEnK.mjs';
5
- import { secretsForPackages } from './PACKAGE_SECRETS_REGISTRY-CySy5vR_.mjs';
4
+ import { DEV_VARS_FILE, DEV_VARS_EXAMPLE_FILE, splitDevVariableLine, parseDevVariableEntries, DEV_VARS_NEWLINE, unquoteDevVariable } from './DEV_VARS_EXAMPLE_FILE-dJPNTEnK.mjs';
5
+ import { CORE_SECRETS, PACKAGE_SECRETS_REGISTRY, secretsForPackages } from './PACKAGE_SECRETS_REGISTRY-B8t_SdoZ.mjs';
6
6
 
7
+ const requiredSecrets = (packageNames) => [...CORE_SECRETS, ...secretsForPackages(packageNames)];
7
8
  const SECRET_BYTES = 32;
8
9
  const SECRET_KEY = /(?:KEY|PASSWORD|SECRET|TOKEN)$/u;
9
10
  const PLACEHOLDER_MARKERS = [
@@ -45,6 +46,11 @@ const isPlaceholderValue = (value) => {
45
46
  const isPlaceholder = (rawValue) => isPlaceholderValue(unquoteDevVariable(rawValue.trim()));
46
47
  const defaultRandomHex = (bytes) => randomBytes(bytes).toString("hex");
47
48
  const generatedSecretFor = (key, rawValue, randomHex) => SECRET_KEY.test(key) && isPlaceholder(rawValue) ? randomHex(SECRET_BYTES) : void 0;
49
+ const PROVIDER_SECRET_KEYS = new Set(
50
+ [...CORE_SECRETS, ...Object.values(PACKAGE_SECRETS_REGISTRY).flat()].filter((entry) => SECRET_KEY.test(entry.key) && entry.placeholderValue.startsWith("<")).map((entry) => entry.key)
51
+ );
52
+ const isMintableSecretKey = (key) => SECRET_KEY.test(key) && !PROVIDER_SECRET_KEYS.has(key);
53
+ const generateSecretValue = (randomHex = defaultRandomHex) => randomHex(SECRET_BYTES);
48
54
  const planDevVariablesScaffold = (input) => {
49
55
  if (input.devVarsExists) {
50
56
  return { status: "exists" };
@@ -98,19 +104,48 @@ const atomicCreateDevVariables = (path, content) => {
98
104
  throw error;
99
105
  }
100
106
  };
101
- const appendDevVariables = (path, additions) => {
102
- const existing = readFileSync(path, "utf8");
103
- const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
104
- const content = `${existing}${separator}${additions.join("\n")}
105
- `;
106
- const temporaryPath = `${path}.tmp-${String(process.pid)}`;
107
+ const APPEND_MAX_ATTEMPTS = 5;
108
+ const fingerprintOf = (path) => {
107
109
  try {
108
- writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 384 });
109
- renameSync(temporaryPath, path);
110
- } catch (error) {
111
- rmSync(temporaryPath, { force: true });
112
- throw error;
110
+ const stats = statSync(path);
111
+ return { mtimeMs: stats.mtimeMs, size: stats.size };
112
+ } catch {
113
+ return void 0;
114
+ }
115
+ };
116
+ const sameFingerprint = (a, b) => {
117
+ if (a === void 0 || b === void 0) {
118
+ return false;
119
+ }
120
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
121
+ };
122
+ const appendDevVariables = (path, buildAdditions) => {
123
+ for (let attempt = 0; attempt < APPEND_MAX_ATTEMPTS; attempt += 1) {
124
+ const beforeFingerprint = fingerprintOf(path);
125
+ const existing = readFileSync(path, "utf8");
126
+ const additions = buildAdditions(existing);
127
+ if (additions.length === 0) {
128
+ return [];
129
+ }
130
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
131
+ const content = `${existing}${separator}${additions.join("\n")}
132
+ `;
133
+ const temporaryPath = `${path}.tmp-${String(process.pid)}-${randomBytes(6).toString("hex")}`;
134
+ try {
135
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 384 });
136
+ const beforeRenameFingerprint = fingerprintOf(path);
137
+ if (!sameFingerprint(beforeFingerprint, beforeRenameFingerprint)) {
138
+ rmSync(temporaryPath, { force: true });
139
+ continue;
140
+ }
141
+ renameSync(temporaryPath, path);
142
+ return additions;
143
+ } catch (error) {
144
+ rmSync(temporaryPath, { force: true });
145
+ throw error;
146
+ }
113
147
  }
148
+ throw new Error(`Failed to append to ${path} after ${String(APPEND_MAX_ATTEMPTS)} attempts — a concurrent writer kept winning the race.`);
114
149
  };
115
150
  const ensureDevVariables = async (deps) => {
116
151
  const devVariablesPath = join(deps.cwd, DEV_VARS_FILE);
@@ -146,16 +181,24 @@ const ensureDevVariables = async (deps) => {
146
181
  deps.info(`Skipped — add ${list} to ${DEV_VARS_FILE} when you're ready.`);
147
182
  return { addedKeys: [], generatedKeys: [], status: "declined" };
148
183
  }
149
- appendDevVariables(devVariablesPath, augment.additions);
150
- deps.info(`Updated ${DEV_VARS_FILE} — added ${list}${generatedSuffix(augment.generatedKeys)}.`);
151
- return { addedKeys: augment.missingKeys, generatedKeys: augment.generatedKeys, status: "augmented" };
184
+ const written = appendDevVariables(
185
+ devVariablesPath,
186
+ (currentContent) => planDevVariablesAugment({ existingContent: currentContent, exampleContent, randomHex: deps.randomHex }).additions
187
+ );
188
+ const writtenKeys = written.map((line) => splitDevVariableLine(line)?.key).filter((key) => key !== void 0);
189
+ if (writtenKeys.length === 0) {
190
+ return { addedKeys: [], generatedKeys: [], status: "exists" };
191
+ }
192
+ const writtenGeneratedKeys = augment.generatedKeys.filter((key) => writtenKeys.includes(key));
193
+ deps.info(`Updated ${DEV_VARS_FILE} — added ${writtenKeys.join(", ")}${generatedSuffix(writtenGeneratedKeys)}.`);
194
+ return { addedKeys: writtenKeys, generatedKeys: writtenGeneratedKeys, status: "augmented" };
152
195
  };
153
196
  const secretEntryBlock = (entry) => {
154
197
  const lines = [`# ${entry.description}`, `# Docs: ${entry.docsUrl}`, `${entry.key}="${entry.placeholderValue}"`];
155
198
  return lines.join("\n");
156
199
  };
157
200
  const buildPackageSecretsBlock = (packageNames, existingKeys) => {
158
- const entries = secretsForPackages(packageNames).filter((entry) => !existingKeys.has(entry.key));
201
+ const entries = requiredSecrets(packageNames).filter((entry) => !existingKeys.has(entry.key));
159
202
  if (entries.length === 0) {
160
203
  return "";
161
204
  }
@@ -181,7 +224,57 @@ ${block}
181
224
  rmSync(temporaryPath, { force: true });
182
225
  throw error;
183
226
  }
184
- return secretsForPackages(packageNames).filter((entry) => !existingKeys.has(entry.key)).map((entry) => entry.key);
227
+ return requiredSecrets(packageNames).filter((entry) => !existingKeys.has(entry.key)).map((entry) => entry.key);
228
+ };
229
+ const planDevSecretsFill = (input) => {
230
+ const randomHex = input.randomHex ?? defaultRandomHex;
231
+ const filledKeys = [];
232
+ const lines = input.existingContent.split(DEV_VARS_NEWLINE).map((line) => {
233
+ const parsed = splitDevVariableLine(line);
234
+ const secret = parsed ? generatedSecretFor(parsed.key, parsed.value, randomHex) : void 0;
235
+ if (!parsed || secret === void 0) {
236
+ return line;
237
+ }
238
+ filledKeys.push(parsed.key);
239
+ return `${parsed.key}="${secret}"`;
240
+ });
241
+ const present = new Set(parseDevVariableEntries(input.existingContent).map((entry) => entry.key));
242
+ const addedKeys = [];
243
+ const additions = [];
244
+ for (const entry of CORE_SECRETS) {
245
+ if (present.has(entry.key)) {
246
+ continue;
247
+ }
248
+ addedKeys.push(entry.key);
249
+ additions.push(`# ${entry.description}`, `${entry.key}="${randomHex(SECRET_BYTES)}"`);
250
+ }
251
+ const body = lines.join("\n");
252
+ if (additions.length === 0) {
253
+ return { addedKeys, content: body, filledKeys };
254
+ }
255
+ const separator = body === "" || body.endsWith("\n") ? "" : "\n";
256
+ return { addedKeys, content: `${body}${separator}${additions.join("\n")}
257
+ `, filledKeys };
258
+ };
259
+ const fillDevSecrets = (deps) => {
260
+ const devVariablesPath = join(deps.cwd, DEV_VARS_FILE);
261
+ const exists = existsSync(devVariablesPath);
262
+ const existingContent = exists ? readFileSync(devVariablesPath, "utf8") : "";
263
+ const plan = planDevSecretsFill({ existingContent, randomHex: deps.randomHex });
264
+ if (plan.filledKeys.length === 0 && plan.addedKeys.length === 0) {
265
+ return { addedKeys: [], filledKeys: [], status: "unchanged" };
266
+ }
267
+ const temporaryPath = `${devVariablesPath}.tmp-${String(process.pid)}`;
268
+ try {
269
+ writeFileSync(temporaryPath, plan.content, { encoding: "utf8", mode: 384 });
270
+ renameSync(temporaryPath, devVariablesPath);
271
+ } catch (error) {
272
+ rmSync(temporaryPath, { force: true });
273
+ throw error;
274
+ }
275
+ const generated = [...plan.filledKeys, ...plan.addedKeys];
276
+ deps.info?.(`Generated ${String(generated.length)} dev secret(s) in ${DEV_VARS_FILE}: ${generated.join(", ")}`);
277
+ return { addedKeys: plan.addedKeys, filledKeys: plan.filledKeys, status: exists ? "filled" : "created" };
185
278
  };
186
279
 
187
- export { buildPackageSecretsBlock, ensureDevVariables, ensureDevVariablesExample as ensureDevVarsExample, isPlaceholderValue, planDevVariablesAugment, planDevVariablesScaffold };
280
+ export { buildPackageSecretsBlock, ensureDevVariables, ensureDevVariablesExample as ensureDevVarsExample, fillDevSecrets, generateSecretValue, isMintableSecretKey, isPlaceholderValue, planDevSecretsFill, planDevVariablesAugment, planDevVariablesScaffold, requiredSecrets };
@@ -10,7 +10,7 @@ const discoverSchemaInfo = (projectRoot, schemaDirectory) => {
10
10
  }
11
11
  try {
12
12
  const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: false });
13
- const schema = discoverSchema(project, schemaPath);
13
+ const schema = discoverSchema(project, schemaPath, projectRoot);
14
14
  return {
15
15
  info: {
16
16
  hasGlobalTable: schema.tables.some((table) => table.shardMode === "global"),
@@ -1,10 +1,38 @@
1
1
  import { existsSync, statSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { init, parse } from 'es-module-lexer';
3
3
  import { discoverContainerInfo } from './discoverContainerInfo-BXFs6Wav.mjs';
4
- import { discoverSchemaInfo } from './discoverSchemaInfo-DWtypqpP.mjs';
4
+ import { FLAGS_FILENAME, discoverFlags, QUEUES_FILENAME, discoverQueues } from '@lunora/codegen';
5
+ import { Project } from 'ts-morph';
6
+ import { join } from 'node:path';
7
+ import { discoverSchemaInfo } from './discoverSchemaInfo-BB-CKlTK.mjs';
5
8
  import { discoverWorkflowInfo } from './discoverWorkflowInfo-CedvR0mn.mjs';
6
9
  import { WRANGLER_FILES, readWranglerJsonc } from './WRANGLER_FILES-DwSuC-Kn.mjs';
7
- import { join } from 'node:path';
10
+
11
+ const discoverFlagsInfo = (projectRoot, schemaDirectory) => {
12
+ const flagsPath = join(projectRoot, schemaDirectory, FLAGS_FILENAME);
13
+ if (!existsSync(flagsPath)) {
14
+ return {};
15
+ }
16
+ try {
17
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: false });
18
+ return { flags: discoverFlags(project, join(projectRoot, schemaDirectory)) };
19
+ } catch (error) {
20
+ return { error: error instanceof Error ? error.message : String(error) };
21
+ }
22
+ };
23
+
24
+ const discoverQueueInfo = (projectRoot, schemaDirectory) => {
25
+ const queuesPath = join(projectRoot, schemaDirectory, QUEUES_FILENAME);
26
+ if (!existsSync(queuesPath)) {
27
+ return { queues: [] };
28
+ }
29
+ try {
30
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: false });
31
+ return { queues: discoverQueues(project, join(projectRoot, schemaDirectory)) };
32
+ } catch (error) {
33
+ return { error: error instanceof Error ? error.message : String(error), queues: [] };
34
+ }
35
+ };
8
36
 
9
37
  const SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
10
38
  const IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", ".lunora-cache", ".wrangler", "_generated", "dist", "node_modules"]);
@@ -23,18 +51,23 @@ const TYPE_ONLY_EXPORT_PATTERNS = {
23
51
  };
24
52
  const ENV_DB_PATTERN = /\benv\s*\.\s*DB\b/;
25
53
  const ENV_AI_PATTERN = /\benv\s*\.\s*AI\b/;
54
+ const CTX_PIPELINES_PATTERN = /\bctx\s*\.\s*pipelines\b/;
26
55
  const TYPE_ONLY_IMPORT_PATTERN = /^\s*import\s+type\b/;
27
56
  const CAPABILITY_SOURCES = {
28
57
  usesAi: { pattern: /\bfrom\s+["']@lunora\/ai["']/, source: "@lunora/ai" },
29
- usesAnalytics: { pattern: /\bfrom\s+["']@lunora\/analytics["']/, source: "@lunora/analytics" },
58
+ usesAnalytics: { pattern: /\bfrom\s+["']@lunora\/bindings\/analytics["']/, source: "@lunora/bindings/analytics" },
30
59
  usesAuth: { pattern: /\bfrom\s+["']@lunora\/auth["']/, source: "@lunora/auth" },
31
60
  usesBrowser: { pattern: /\bfrom\s+["']@lunora\/browser["']/, source: "@lunora/browser" },
32
61
  usesHyperdrive: { pattern: /\bfrom\s+["']@lunora\/hyperdrive["']/, source: "@lunora/hyperdrive" },
33
- usesImages: { pattern: /\bfrom\s+["']@lunora\/images["']/, source: "@lunora/images" },
34
- usesKv: { pattern: /\bfrom\s+["']@lunora\/kv["']/, source: "@lunora/kv" },
62
+ usesImages: { pattern: /\bfrom\s+["']@lunora\/bindings\/images["']/, source: "@lunora/bindings/images" },
63
+ usesKv: { pattern: /\bfrom\s+["']@lunora\/bindings\/kv["']/, source: "@lunora/bindings/kv" },
35
64
  usesMail: { pattern: /\bfrom\s+["']@lunora\/mail["']/, source: "@lunora/mail" },
36
65
  usesPayment: { pattern: /\bfrom\s+["']@lunora\/payment["']/, source: "@lunora/payment" },
37
- usesPipelines: { pattern: /\bfrom\s+["']@lunora\/pipelines["']/, source: "@lunora/pipelines" },
66
+ // Keyed off the `ctx.pipelines` access (not an import) — see CTX_PIPELINES_PATTERN.
67
+ // Pipelines is codegen-wired onto ActionCtx, so apps reach it via `ctx.pipelines`
68
+ // rather than importing `@lunora/bindings/pipelines`; `source` names that subpath
69
+ // for the hint message.
70
+ usesPipelines: { pattern: CTX_PIPELINES_PATTERN, source: "@lunora/bindings/pipelines" },
38
71
  usesScheduler: { pattern: /\bfrom\s+["']@lunora\/scheduler["']/, source: "@lunora/scheduler" },
39
72
  usesStorage: { pattern: /\bfrom\s+["']@lunora\/storage["']/, source: "@lunora/storage" }
40
73
  };
@@ -90,7 +123,12 @@ const capabilitiesFromSource = (code) => {
90
123
  } catch {
91
124
  capabilities = regexCapabilities(code);
92
125
  }
93
- return mergeCapabilities(capabilities, { ...NO_CAPABILITIES, needsD1: ENV_DB_PATTERN.test(code), usesAi: ENV_AI_PATTERN.test(code) });
126
+ return mergeCapabilities(capabilities, {
127
+ ...NO_CAPABILITIES,
128
+ needsD1: ENV_DB_PATTERN.test(code),
129
+ usesAi: ENV_AI_PATTERN.test(code),
130
+ usesPipelines: CTX_PIPELINES_PATTERN.test(code)
131
+ });
94
132
  };
95
133
  const collectSourceFiles = (directory, accumulator) => {
96
134
  let entries;
@@ -237,14 +275,14 @@ const describeCapabilitySignals = (capabilities, exported) => {
237
275
  // Self-describing bindings: the binding name is the whole config (no remote
238
276
  // id to mint), so reconcile auto-writes them like the DO/D1 bindings.
239
277
  [capabilities.usesBrowser, "browser (@lunora/browser imported) — self-describing { binding: BROWSER }"],
240
- [capabilities.usesImages, "images (@lunora/images imported) — self-describing { binding: IMAGES }"],
241
- [capabilities.usesAnalytics, "analytics_engine_datasets (@lunora/analytics imported) — self-describing { binding: ANALYTICS, dataset }"],
278
+ [capabilities.usesImages, "images (@lunora/bindings/images imported) — self-describing { binding: IMAGES }"],
279
+ [capabilities.usesAnalytics, "analytics_engine_datasets (@lunora/bindings/analytics imported) — self-describing { binding: ANALYTICS, dataset }"],
242
280
  // Hint bindings: each needs a remote resource Lunora can't fabricate (a KV
243
281
  // namespace id, a Hyperdrive id, a Pipelines pipeline name), so they surface
244
282
  // as hints — never an auto-write — exactly like R2's user-defined bucket name.
245
283
  [
246
284
  capabilities.usesKv,
247
- "hint: @lunora/kv is imported; add a kv_namespaces binding ({ binding, id }) and pass env.<BINDING> to createKv() — the namespace id can't be auto-provisioned"
285
+ "hint: @lunora/bindings/kv is imported; add a kv_namespaces binding ({ binding, id }) and pass env.<BINDING> to createKv() — the namespace id can't be auto-provisioned"
248
286
  ],
249
287
  [
250
288
  capabilities.usesHyperdrive,
@@ -252,7 +290,7 @@ const describeCapabilitySignals = (capabilities, exported) => {
252
290
  ],
253
291
  [
254
292
  capabilities.usesPipelines,
255
- "hint: @lunora/pipelines is imported; run 'wrangler pipelines create <name>' and add a 'pipelines' binding ({ binding, pipeline }) — the pipeline resource can't be auto-provisioned"
293
+ "hint: ctx.pipelines is used; run 'wrangler pipelines create <name>' and add a 'pipelines' binding ({ binding, pipeline }) — the pipeline resource can't be auto-provisioned"
256
294
  ]
257
295
  ];
258
296
  return rules.filter(([active]) => active).map(([, signal]) => signal);
@@ -276,15 +314,27 @@ const inferLunoraBindings = async (options) => {
276
314
  const needsD1 = capabilities.needsD1 || schemaNeedsD1(options.projectRoot, schemaDirectory);
277
315
  const containers = detectContainerExports(entryPath, discoverContainerInfo(options.projectRoot, schemaDirectory).containers);
278
316
  const workflows = detectWorkflowExports(entryPath, discoverWorkflowInfo(options.projectRoot, schemaDirectory).workflows);
317
+ const queues = [...discoverQueueInfo(options.projectRoot, schemaDirectory).queues];
318
+ const { flags } = discoverFlagsInfo(options.projectRoot, schemaDirectory);
319
+ const flagshipBinding = flags?.provider === "flagship" && flags.mode === "binding" ? flags.bindingName : void 0;
279
320
  const capabilityFlags = {};
280
321
  for (const flag of CAPABILITY_FLAGS) {
281
322
  capabilityFlags[flag] = capabilities[flag];
282
323
  }
324
+ const signals = describeSignals(durableObjects, needsD1, capabilities, containers, workflows);
325
+ if (flagshipBinding !== void 0) {
326
+ signals.push(
327
+ `hint: lunora/flags.ts uses Flagship in binding mode; add a flagship binding ({ binding: "${flagshipBinding}", app_id }) — the app_id can't be auto-provisioned`
328
+ );
329
+ }
283
330
  return {
284
331
  containers,
285
332
  durableObjects,
333
+ flagshipBinding,
286
334
  needsD1,
287
- signals: describeSignals(durableObjects, needsD1, capabilities, containers, workflows),
335
+ queues,
336
+ signals,
337
+ usesFlags: flags !== void 0,
288
338
  workflows,
289
339
  ...capabilityFlags
290
340
  };
@@ -20,10 +20,11 @@ const collectExportGaps = (inferred) => {
20
20
  return gaps;
21
21
  };
22
22
  const collectHintBindingWarnings = (inferred, parsed) => {
23
+ const flagshipBindingMissing = inferred.flagshipBinding !== void 0 && !(parsed?.flagship ?? []).some((entry) => entry.binding === inferred.flagshipBinding);
23
24
  const rules = [
24
25
  [
25
26
  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
+ "@lunora/bindings/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
  ],
28
29
  [
29
30
  inferred.usesHyperdrive && (parsed?.hyperdrive?.length ?? 0) === 0,
@@ -31,7 +32,11 @@ const collectHintBindingWarnings = (inferred, parsed) => {
31
32
  ],
32
33
  [
33
34
  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
+ "ctx.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."
36
+ ],
37
+ [
38
+ flagshipBindingMissing,
39
+ `lunora/flags.ts uses Flagship in binding mode but no flagship binding "${inferred.flagshipBinding ?? ""}" exists; add a flagship entry ({ binding: "${inferred.flagshipBinding ?? ""}", app_id }) — the app_id can't be auto-provisioned.`
35
40
  ]
36
41
  ];
37
42
  return rules.filter(([active]) => active).map(([, warning]) => warning);
@@ -222,6 +227,57 @@ const reconcileWorkflows = (text, parsed, workflows) => {
222
227
  const nextText = applyModify(text, ["workflows"], [...existing, ...missing.map((workflow) => workflowEntryFor(workflow))]);
223
228
  return { added: missing.map((workflow) => `workflows/${workflow.className}`), text: nextText };
224
229
  };
230
+ const reconcileQueues = (text, parsed, queues) => {
231
+ const existing = parsed.queues ?? {};
232
+ const existingProducers = existing.producers ?? [];
233
+ const existingConsumers = existing.consumers ?? [];
234
+ const haveProducer = new Set(existingProducers.map((entry) => entry.binding));
235
+ const haveConsumer = new Set(existingConsumers.map((entry) => entry.queue));
236
+ const missingProducers = queues.filter((queue) => !haveProducer.has(queue.bindingName));
237
+ const missingConsumers = queues.filter((queue) => !haveConsumer.has(queue.name));
238
+ if (missingProducers.length === 0 && missingConsumers.length === 0) {
239
+ return { added: [], text };
240
+ }
241
+ const nextProducers = [
242
+ ...existingProducers,
243
+ ...missingProducers.map((queue) => {
244
+ return { binding: queue.bindingName, queue: queue.name };
245
+ })
246
+ ];
247
+ const nextConsumers = [
248
+ ...existingConsumers,
249
+ ...missingConsumers.map((queue) => {
250
+ const consumer = { queue: queue.name };
251
+ if (queue.mode === "pull") {
252
+ consumer.type = "http_pull";
253
+ }
254
+ if (queue.tuning.maxBatchSize !== void 0) {
255
+ consumer.max_batch_size = queue.tuning.maxBatchSize;
256
+ }
257
+ if (queue.tuning.maxBatchTimeout !== void 0) {
258
+ consumer.max_batch_timeout = queue.tuning.maxBatchTimeout;
259
+ }
260
+ if (queue.tuning.maxRetries !== void 0) {
261
+ consumer.max_retries = queue.tuning.maxRetries;
262
+ }
263
+ if (queue.tuning.deadLetterQueue !== void 0) {
264
+ consumer.dead_letter_queue = queue.tuning.deadLetterQueue;
265
+ }
266
+ if (queue.tuning.retryDelay !== void 0) {
267
+ consumer.retry_delay = queue.tuning.retryDelay;
268
+ }
269
+ return consumer;
270
+ })
271
+ ];
272
+ const nextText = applyModify(text, ["queues"], { consumers: nextConsumers, producers: nextProducers });
273
+ return {
274
+ added: [
275
+ ...missingProducers.map((queue) => `queues.producers/${queue.bindingName}`),
276
+ ...missingConsumers.map((queue) => `queues.consumers/${queue.name}`)
277
+ ],
278
+ text: nextText
279
+ };
280
+ };
225
281
  const reconcileWranglerBindings = (projectRoot, inferred) => {
226
282
  const wranglerPath = findWranglerFile(projectRoot);
227
283
  const exportGaps = collectExportGaps(inferred);
@@ -250,7 +306,8 @@ const reconcileWranglerBindings = (projectRoot, inferred) => {
250
306
  { enabled: inferred.usesAnalytics, run: (text2) => reconcileAnalytics(text2, parsed) },
251
307
  { enabled: true, run: (text2) => reconcileObservability(text2, parsed) },
252
308
  { enabled: exportedContainers.length > 0, run: (text2) => reconcileContainers(text2, parsed, exportedContainers) },
253
- { enabled: exportedWorkflows.length > 0, run: (text2) => reconcileWorkflows(text2, parsed, exportedWorkflows) }
309
+ { enabled: exportedWorkflows.length > 0, run: (text2) => reconcileWorkflows(text2, parsed, exportedWorkflows) },
310
+ { enabled: inferred.queues.length > 0, run: (text2) => reconcileQueues(text2, parsed, inferred.queues) }
254
311
  ];
255
312
  let text = original;
256
313
  const added = [];
@@ -0,0 +1,157 @@
1
+ import { Writable } from 'node:stream';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+
4
+ const DEV_CONTAINER_IMAGE_PREFIX = "cloudflare-dev/";
5
+ const DEFAULT_POLL_INTERVAL_MS = 1500;
6
+ const createDefaultDocker = async () => {
7
+ const { default: Dockerode } = await import('dockerode');
8
+ return new Dockerode();
9
+ };
10
+ const classFromImage = (image) => {
11
+ if (!image.startsWith(DEV_CONTAINER_IMAGE_PREFIX)) {
12
+ return void 0;
13
+ }
14
+ const [segment] = image.slice(DEV_CONTAINER_IMAGE_PREFIX.length).split(":");
15
+ return segment !== void 0 && segment.length > 0 ? segment : void 0;
16
+ };
17
+ const lineBufferWritable = (emit) => {
18
+ const decoder = new StringDecoder("utf8");
19
+ let buffer = "";
20
+ const flushLine = (line) => {
21
+ emit(line.endsWith("\r") ? line.slice(0, -1) : line);
22
+ };
23
+ return new Writable({
24
+ final(callback) {
25
+ buffer += decoder.end();
26
+ if (buffer.length > 0) {
27
+ flushLine(buffer);
28
+ buffer = "";
29
+ }
30
+ callback();
31
+ },
32
+ write(chunk, _encoding, callback) {
33
+ buffer += decoder.write(chunk);
34
+ const lines = buffer.split("\n");
35
+ buffer = lines.pop() ?? "";
36
+ for (const line of lines) {
37
+ flushLine(line);
38
+ }
39
+ callback();
40
+ }
41
+ });
42
+ };
43
+ const streamContainerLogs = (options) => {
44
+ const classToExport = new Map(options.containers.map((source) => [source.className.toLowerCase(), source.exportName]));
45
+ if (classToExport.size === 0) {
46
+ return { close: () => {
47
+ } };
48
+ }
49
+ const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
50
+ const attached = /* @__PURE__ */ new Map();
51
+ let closed = false;
52
+ let unavailableNotified = false;
53
+ let timer;
54
+ let dockerPromise;
55
+ const getDocker = async () => {
56
+ dockerPromise ??= options.docker ? Promise.resolve(options.docker) : createDefaultDocker();
57
+ return dockerPromise;
58
+ };
59
+ const attach = async (docker, id, exportName) => {
60
+ try {
61
+ const stream = await docker.getContainer(id).logs({ follow: true, stderr: true, stdout: true, tail: "all", timestamps: false });
62
+ if (closed) {
63
+ stream.destroy();
64
+ return;
65
+ }
66
+ const stdout = lineBufferWritable((text) => {
67
+ options.onLine({ level: "info", name: exportName, text });
68
+ });
69
+ const stderr = lineBufferWritable((text) => {
70
+ options.onLine({ level: "error", name: exportName, text });
71
+ });
72
+ docker.modem.demuxStream(stream, stdout, stderr);
73
+ stream.on("end", () => {
74
+ stdout.end();
75
+ stderr.end();
76
+ attached.delete(id);
77
+ });
78
+ stream.on("error", () => {
79
+ attached.delete(id);
80
+ });
81
+ attached.set(id, stream);
82
+ } catch {
83
+ }
84
+ };
85
+ const listRunning = async () => {
86
+ try {
87
+ const docker = await getDocker();
88
+ const running = await docker.listContainers({ filters: { status: ["running"] } });
89
+ unavailableNotified = false;
90
+ return running;
91
+ } catch (error) {
92
+ if (!unavailableNotified) {
93
+ unavailableNotified = true;
94
+ options.onUnavailable?.(error instanceof Error ? error.message : String(error));
95
+ }
96
+ return void 0;
97
+ }
98
+ };
99
+ const reconcile = async (docker, running) => {
100
+ const live = /* @__PURE__ */ new Set();
101
+ for (const summary of running) {
102
+ const className = classFromImage(summary.Image);
103
+ const exportName = className === void 0 ? void 0 : classToExport.get(className);
104
+ if (exportName === void 0) {
105
+ continue;
106
+ }
107
+ live.add(summary.Id);
108
+ if (!attached.has(summary.Id)) {
109
+ await attach(docker, summary.Id, exportName);
110
+ }
111
+ }
112
+ for (const [id, stream] of attached) {
113
+ if (!live.has(id)) {
114
+ stream.destroy();
115
+ attached.delete(id);
116
+ }
117
+ }
118
+ };
119
+ const poll = async () => {
120
+ if (closed) {
121
+ return;
122
+ }
123
+ const running = await listRunning();
124
+ if (running === void 0) {
125
+ return;
126
+ }
127
+ await reconcile(await getDocker(), running);
128
+ };
129
+ let polling = false;
130
+ const onTick = () => {
131
+ if (polling || closed) {
132
+ return;
133
+ }
134
+ polling = true;
135
+ poll().finally(() => {
136
+ polling = false;
137
+ }).catch(() => void 0);
138
+ };
139
+ timer = setInterval(onTick, interval);
140
+ timer.unref();
141
+ onTick();
142
+ return {
143
+ close: () => {
144
+ closed = true;
145
+ if (timer) {
146
+ clearInterval(timer);
147
+ timer = void 0;
148
+ }
149
+ for (const [id, stream] of attached) {
150
+ stream.destroy();
151
+ attached.delete(id);
152
+ }
153
+ }
154
+ };
155
+ };
156
+
157
+ export { streamContainerLogs };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/config",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.41",
4
4
  "description": "Internal shared CLI + Vite config layer for Lunora: wrangler.jsonc validation, binding inference, and .dev.vars scaffolding",
5
5
  "keywords": [
6
6
  "bindings",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/config"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "__assets__",
30
30
  "README.md",
31
31
  "LICENSE.md"
@@ -50,15 +50,17 @@
50
50
  "access": "public"
51
51
  },
52
52
  "dependencies": {
53
- "@lunora/codegen": "1.0.0-alpha.3",
54
- "@lunora/container": "1.0.0-alpha.1",
55
- "@lunora/seed": "1.0.0-alpha.1",
53
+ "@lunora/codegen": "1.0.0-alpha.26",
54
+ "@lunora/container": "1.0.0-alpha.5",
55
+ "@lunora/seed": "1.0.0-alpha.12",
56
+ "@visulima/colorize": "2.0.0-alpha.14",
57
+ "dockerode": "^5.0.1",
56
58
  "es-module-lexer": "^2.1.0",
57
59
  "jsonc-parser": "^3.3.1",
58
60
  "ts-morph": "^28.0.0"
59
61
  },
60
62
  "peerDependencies": {
61
- "@lunora/studio": "1.0.0-alpha.3"
63
+ "@lunora/studio": "1.0.0-alpha.27"
62
64
  },
63
65
  "peerDependenciesMeta": {
64
66
  "@lunora/studio": {