@flue/sdk 0.3.1 → 0.3.3

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/README.md CHANGED
@@ -318,6 +318,17 @@ Defaults to port `3583` ("FLUE" on a phone keypad). Override with `--port`.
318
318
 
319
319
  `flue dev --target cloudflare` requires `wrangler` as a peer dependency in your project (`npm install --save-dev wrangler`).
320
320
 
321
+ #### Loading environment variables
322
+
323
+ Pass `--env <path>` to load a `.env`-format file. Works for both targets:
324
+
325
+ ```bash
326
+ flue dev --target node --env .env
327
+ flue dev --target cloudflare --env .env
328
+ ```
329
+
330
+ Repeatable; later files override earlier ones on key collision. Shell-set env vars win over file values. Edits to the file trigger a reload. Same flag works for `flue run`.
331
+
321
332
  ### Trigger From the CLI (`flue run`)
322
333
 
323
334
  Build and run any agent locally, perfect for running in CI or for one-shot scripted invocations. Production-shaped — builds the deployable artifact and starts it once.
package/dist/index.d.mts CHANGED
@@ -43,6 +43,24 @@ interface DevOptions {
43
43
  target: 'node' | 'cloudflare';
44
44
  /** Defaults to 3583 ("FLUE" on a phone keypad). */
45
45
  port?: number;
46
+ /**
47
+ * Absolute paths to env files (`.env`-format) to load before starting the
48
+ * dev server. Repeatable; later files override earlier ones on key
49
+ * collision (matching wrangler's `envFiles` semantics and standard
50
+ * dotenv composition patterns).
51
+ *
52
+ * - Node: parsed with `node:util.parseEnv` and merged into the child
53
+ * server process's env. Shell-set env vars win over file values.
54
+ * - Cloudflare: passed through to wrangler's `unstable_startWorker` as
55
+ * `envFiles`, which loads them as `secret_text` bindings.
56
+ *
57
+ * If empty/undefined, no env loading happens. Cloudflare's auto-discovery
58
+ * of `.dev.vars` is disabled in either case (we always pass an explicit
59
+ * `envFiles` array to wrangler so its default search is suppressed).
60
+ *
61
+ * Each path must exist; otherwise dev fails fast with a clear error.
62
+ */
63
+ envFiles?: string[];
46
64
  }
47
65
  /** Default port for `flue dev`. F=3, L=5, U=8, E=3 on a phone keypad. */
48
66
  declare const DEFAULT_DEV_PORT = 3583;
@@ -53,6 +71,26 @@ declare const DEFAULT_DEV_PORT = 3583;
53
71
  * — the user is editing code, after all, and we want to recover when they fix it.
54
72
  */
55
73
  declare function dev(options: DevOptions): Promise<void>;
74
+ /**
75
+ * Resolve and validate a list of env-file paths. Returns absolute paths.
76
+ *
77
+ * Throws a friendly `[flue]`-prefixed error if any path doesn't exist. The
78
+ * goal of `--env` is explicitness — silent skip on a typo would defeat
79
+ * the purpose.
80
+ */
81
+ declare function resolveEnvFiles(envFiles: string[] | undefined, cwd: string): string[];
82
+ /**
83
+ * Parse one or more `.env`-format files and return their merged contents.
84
+ * Later files override earlier files on key collision.
85
+ *
86
+ * Uses Node's built-in `util.parseEnv` (Node 20.6+; Flue requires Node 22+).
87
+ * No `dotenv` package needed.
88
+ *
89
+ * Parse-only — doesn't touch `process.env`. Caller composes with
90
+ * `process.env` as needed (typical pattern: spread file vars first, then
91
+ * `process.env`, so shell-set values win).
92
+ */
93
+ declare function parseEnvFiles(absolutePaths: string[]): Record<string, string>;
56
94
  //#endregion
57
95
  //#region src/agent.d.ts
58
96
  declare const BUILTIN_TOOL_NAMES: Set<string>;
@@ -75,4 +113,4 @@ interface CreateToolsOptions {
75
113
  }
76
114
  declare function createTools(env: SessionEnv, options?: CreateToolsOptions): AgentTool<any>[];
77
115
  //#endregion
78
- export { type AgentConfig, type AgentInfo, type AgentInit, BUILTIN_TOOL_NAMES, type BashFactory, type BashLike, type BuildContext, type BuildOptions, type BuildPlugin, type Command, type CommandDef, DEFAULT_DEV_PORT, type DevOptions, type FileStat, type FlueAgent, type FlueContext, type FlueEvent, type FlueEventCallback, type FlueSession, type FlueSessions, type PromptOptions, type PromptResponse, type Role, type SandboxFactory, type SessionData, type SessionEnv, type SessionOptions, type SessionStore, type ShellOptions, type ShellResult, type Skill, type SkillOptions, type TaskOptions, type ToolDef, type ToolParameters, build, createTools, dev, resolveWorkspaceFromCwd };
116
+ export { type AgentConfig, type AgentInfo, type AgentInit, BUILTIN_TOOL_NAMES, type BashFactory, type BashLike, type BuildContext, type BuildOptions, type BuildPlugin, type Command, type CommandDef, DEFAULT_DEV_PORT, type DevOptions, type FileStat, type FlueAgent, type FlueContext, type FlueEvent, type FlueEventCallback, type FlueSession, type FlueSessions, type PromptOptions, type PromptResponse, type Role, type SandboxFactory, type SessionData, type SessionEnv, type SessionOptions, type SessionStore, type ShellOptions, type ShellResult, type Skill, type SkillOptions, type TaskOptions, type ToolDef, type ToolParameters, build, createTools, dev, parseEnvFiles, resolveEnvFiles, resolveWorkspaceFromCwd };
package/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { packageUpSync } from "package-up";
6
6
  import { spawn } from "node:child_process";
7
+ import { parseEnv } from "node:util";
7
8
 
8
9
  //#region src/cloudflare-wrangler-merge.ts
9
10
  /**
@@ -126,6 +127,83 @@ function validateUserWranglerConfig(config) {
126
127
  }
127
128
  }
128
129
  /**
130
+ * Compute Flue's migration contributions for a build.
131
+ *
132
+ * Algorithm:
133
+ *
134
+ * 1. Walk every existing migration entry in the user's config and union the
135
+ * SQLite-backed classes it declares — across `new_sqlite_classes` and
136
+ * the `to` side of `renamed_classes` / `transferred_classes`. The
137
+ * resulting set is "already-declared": every SQLite-backed class
138
+ * Cloudflare's runtime currently knows about for this Worker.
139
+ * `deleted_classes` and the `from` side of renames are subtracted, since
140
+ * they've been explicitly removed.
141
+ *
142
+ * KV-backed classes (`new_classes`) are deliberately NOT added to the
143
+ * "declared" set. Flue agents always need a SQLite-backed class for
144
+ * session storage; if a user happens to have a KV-backed DO with the
145
+ * same name as a Flue agent, we still need to emit our SQLite migration.
146
+ * The deploy itself will then surface a clear "class already defined"
147
+ * error from Cloudflare rather than silently shipping a broken worker
148
+ * where the agent has no working session store.
149
+ * 2. For each class in `currentClasses` that isn't already-declared, emit a
150
+ * deterministic per-class migration: one tag, one class. Per-class tags
151
+ * are essential because Cloudflare migration tags are immutable once
152
+ * deployed — packing all classes under a single shared tag (the original
153
+ * bug in issue #15) means new classes added on a redeploy are silently
154
+ * ignored. With per-class tags, every redeploy is a no-op except for
155
+ * the truly net-new classes.
156
+ *
157
+ * Renames and deletes are not auto-detected. If an agent file disappears,
158
+ * Flue silently emits no migration for it — Cloudflare's runtime keeps the
159
+ * orphaned class data alive but unbound, and the user can clean up (or
160
+ * rename to recover) by adding a manual `renamed_classes` / `deleted_classes`
161
+ * migration to their own wrangler.jsonc. Auto-emitting `deleted_classes`
162
+ * would destroy stored DO data on every accidental file removal, which is
163
+ * never the right default.
164
+ *
165
+ * Returned in alphabetical order so a regenerated `dist/wrangler.jsonc` is
166
+ * byte-identical across machines and CI runs.
167
+ *
168
+ * Pure function: takes the current class list + the user's existing
169
+ * migrations array (typically `userConfig.migrations` straight from
170
+ * wrangler's reader) and returns the migrations to append. Doesn't read or
171
+ * write any files.
172
+ */
173
+ function computeFlueMigrations(currentClasses, userMigrations) {
174
+ const migrationsArray = Array.isArray(userMigrations) ? userMigrations : [];
175
+ const declared = /* @__PURE__ */ new Set();
176
+ for (const raw of migrationsArray) {
177
+ if (typeof raw !== "object" || raw === null) continue;
178
+ const m = raw;
179
+ const collectClassList = (key) => {
180
+ const v = m[key];
181
+ return Array.isArray(v) ? v.filter((c) => typeof c === "string") : [];
182
+ };
183
+ for (const c of collectClassList("new_sqlite_classes")) declared.add(c);
184
+ for (const c of collectClassList("deleted_classes")) declared.delete(c);
185
+ const renamed = Array.isArray(m.renamed_classes) ? m.renamed_classes : [];
186
+ for (const r of renamed) {
187
+ if (typeof r !== "object" || r === null) continue;
188
+ const obj = r;
189
+ if (typeof obj.from === "string") declared.delete(obj.from);
190
+ if (typeof obj.to === "string") declared.add(obj.to);
191
+ }
192
+ const transferred = Array.isArray(m.transferred_classes) ? m.transferred_classes : [];
193
+ for (const t of transferred) {
194
+ if (typeof t !== "object" || t === null) continue;
195
+ const obj = t;
196
+ if (typeof obj.to === "string") declared.add(obj.to);
197
+ }
198
+ }
199
+ const additions = [];
200
+ for (const c of [...currentClasses].sort()) if (!declared.has(c)) additions.push({
201
+ tag: `flue-class-${c}`,
202
+ new_sqlite_classes: [c]
203
+ });
204
+ return additions;
205
+ }
206
+ /**
129
207
  * Produce the merged wrangler config: start from the user's, layer Flue's
130
208
  * contributions on top. Pure function — caller handles reading and writing.
131
209
  */
@@ -148,11 +226,46 @@ function mergeFlueAdditions(userConfig, additions) {
148
226
  const existingMigrations = Array.isArray(merged.migrations) ? merged.migrations : [];
149
227
  const existingMigrationTags = new Set(existingMigrations.filter((m) => typeof m === "object" && m !== null).map((m) => m.tag).filter((t) => typeof t === "string"));
150
228
  const migrationsOut = [...existingMigrations];
151
- if (!existingMigrationTags.has(additions.migration.tag)) migrationsOut.push(additions.migration);
229
+ for (const migration of additions.migrations) if (!existingMigrationTags.has(migration.tag)) {
230
+ migrationsOut.push(migration);
231
+ existingMigrationTags.add(migration.tag);
232
+ }
152
233
  merged.migrations = migrationsOut;
153
234
  return merged;
154
235
  }
155
236
  /**
237
+ * Strip wrangler-normalizer defaults that cause spurious warnings when wrangler
238
+ * re-parses our generated dist/wrangler.jsonc.
239
+ *
240
+ * Background: `unstable_readConfig` returns a fully-normalized `Unstable_Config`
241
+ * with every section populated to a default — including `unsafe: {}`. Wrangler's
242
+ * own validator then emits a `"unsafe" fields are experimental` warning whenever
243
+ * the field is *present*, regardless of whether it's empty. So our merged file,
244
+ * which inherits the empty default, would trip the warning at every dev start
245
+ * and every deploy.
246
+ *
247
+ * We delete `unsafe` only when it's an empty object (the exact shape wrangler's
248
+ * normalizer produces). If a user has actually written `unsafe: {...}` in their
249
+ * own wrangler.jsonc, the value will be non-empty and we leave it alone — the
250
+ * warning in that case is wrangler's intended diagnostic, not noise.
251
+ *
252
+ * Other normalizer-defaulted-empty fields (`vars: {}`, `kv_namespaces: []`,
253
+ * `python_modules: { exclude: ['**\/*.pyc'] }`, etc.) are left in place. They're
254
+ * harmless: wrangler doesn't warn about them, dist/wrangler.jsonc is an
255
+ * internal build artifact, and stripping them only saves bytes. Only `unsafe`
256
+ * has a user-visible side effect we need to fix.
257
+ *
258
+ * If wrangler adds another field to its `experimental()` warning list in a
259
+ * future version (today there are only two: `unsafe` and `secrets`), this
260
+ * function is the place to extend.
261
+ *
262
+ * Mutates `merged` in place to match the shallow-clone pattern in
263
+ * `mergeFlueAdditions`.
264
+ */
265
+ function stripNoisyWranglerDefaults(merged) {
266
+ if ("unsafe" in merged && typeof merged.unsafe === "object" && merged.unsafe !== null && !Array.isArray(merged.unsafe) && Object.keys(merged.unsafe).length === 0) delete merged.unsafe;
267
+ }
268
+ /**
156
269
  * Return the list of `class_name`s declared in the user's wrangler
157
270
  * `durable_objects.bindings` that contain the literal substring `Sandbox`
158
271
  * (case-sensitive).
@@ -283,6 +396,9 @@ var CloudflarePlugin = class {
283
396
  }`;
284
397
  }).join("\n\n");
285
398
  const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
399
+ const sandboxClassNames = detectSandboxBindings(userConfig);
400
+ const sandboxReExports = sandboxClassNames.map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
401
+ const sandboxClassImport = sandboxClassNames.length > 0 ? `import { Sandbox as __FlueCfSandbox } from '@cloudflare/sandbox';` : "";
286
402
  return `
287
403
  // Auto-generated by @flue/sdk build (cloudflare)
288
404
  import { Agent, routeAgentRequest } from 'agents';
@@ -294,7 +410,7 @@ import {
294
410
  resolveModel,
295
411
  } from '@flue/sdk/internal';
296
412
  import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
297
-
413
+ ${sandboxClassImport ? "\n" + sandboxClassImport : ""}
298
414
  ${agentImports}
299
415
 
300
416
  // ─── Config ─────────────────────────────────────────────────────────────────
@@ -338,19 +454,19 @@ async function createLocalEnv() {
338
454
 
339
455
  /**
340
456
  * Detect and wrap external sandbox instances (e.g. from @cloudflare/sandbox's
341
- * getSandbox()). Returns SessionEnv if the object quacks like a container
342
- * sandbox, null otherwise.
457
+ * getSandbox()). Returns SessionEnv if the value is a @cloudflare/sandbox
458
+ * RPC stub, null otherwise.
459
+ *
460
+ * NOTE: We must use \`instanceof\` here, not structural duck-typing. The value
461
+ * returned by \`getSandbox()\` is a workerd RPC Proxy that returns \`true\` for
462
+ * any \`in\` check and \`'function'\` for \`typeof <anything>\`, so structural
463
+ * checks (positive or negative) are unreliable against it. \`instanceof\` walks
464
+ * the prototype chain via the runtime, which the proxy can't fake.
343
465
  */
344
466
  function resolveSandbox(sandbox) {
345
- if (
346
- sandbox && typeof sandbox === 'object' &&
347
- typeof sandbox.exec === 'function' &&
348
- typeof sandbox.readFile === 'function' &&
349
- typeof sandbox.destroy === 'function' &&
350
- !('getCwd' in sandbox) && !('fs' in sandbox)
351
- ) {
467
+ ${sandboxClassNames.length > 0 ? `if (sandbox instanceof __FlueCfSandbox) {
352
468
  return cfSandboxToSessionEnv(sandbox);
353
- }
469
+ }` : "/* no @cloudflare/sandbox bindings declared in wrangler config */"}
354
470
  return null;
355
471
  }
356
472
 
@@ -580,7 +696,7 @@ ${agentClasses}
580
696
  // \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
581
697
  // bundle's top level. The binding + container image configuration is owned
582
698
  // by the user's wrangler.jsonc.
583
- ${detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n")}
699
+ ${sandboxReExports}
584
700
 
585
701
  // ─── Worker Fetch Handler ───────────────────────────────────────────────────
586
702
 
@@ -619,24 +735,23 @@ export default {
619
735
  name: agentClassName(a.name)
620
736
  }));
621
737
  const flueSqliteClasses = flueBindings.map((b) => b.class_name);
738
+ const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
739
+ if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
740
+ validateUserWranglerConfig(userConfig);
741
+ const flueMigrations = computeFlueMigrations(flueSqliteClasses, userConfig.migrations);
622
742
  const additions = {
623
743
  defaultName: path.basename(ctx.outputDir) || "flue-agents",
624
744
  main: "_entry.ts",
625
745
  doBindings: flueBindings,
626
- migration: {
627
- tag: "flue-v1",
628
- new_sqlite_classes: flueSqliteClasses
629
- }
746
+ migrations: flueMigrations
630
747
  };
631
- const { config: userConfig, path: userConfigPath } = await this.getUserConfig(ctx.outputDir);
632
- if (userConfigPath) console.log(`[flue] Merging with user wrangler config: ${userConfigPath}`);
633
- validateUserWranglerConfig(userConfig);
634
748
  const sandboxClassNames = detectSandboxBindings(userConfig);
635
749
  if (sandboxClassNames.length > 0) {
636
750
  assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
637
751
  for (const className of sandboxClassNames) console.log(`[flue] Detected Sandbox-named DO binding "${className}" — re-exporting from @cloudflare/sandbox.`);
638
752
  }
639
753
  const merged = mergeFlueAdditions(userConfig, additions);
754
+ stripNoisyWranglerDefaults(merged);
640
755
  if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
641
756
  outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
642
757
  writeDeployRedirectIfMissing(ctx.outputDir);
@@ -1155,6 +1270,8 @@ async function dev(options) {
1155
1270
  const workspaceDir = path.resolve(options.workspaceDir);
1156
1271
  const outputDir = path.resolve(options.outputDir);
1157
1272
  const port = options.port ?? DEFAULT_DEV_PORT;
1273
+ const envFiles = resolveEnvFiles(options.envFiles, outputDir);
1274
+ for (const f of envFiles) console.error(`[flue] Loading env from: ${f}`);
1158
1275
  const buildOptions = {
1159
1276
  workspaceDir,
1160
1277
  outputDir,
@@ -1172,10 +1289,12 @@ async function dev(options) {
1172
1289
  console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
1173
1290
  const reloader = options.target === "node" ? new NodeReloader({
1174
1291
  outputDir,
1175
- port
1292
+ port,
1293
+ envFiles
1176
1294
  }) : await createCloudflareReloader({
1177
1295
  outputDir,
1178
- port
1296
+ port,
1297
+ envFiles
1179
1298
  });
1180
1299
  await reloader.start();
1181
1300
  if (reloader.url) {
@@ -1188,14 +1307,17 @@ async function dev(options) {
1188
1307
  }
1189
1308
  console.error(`[flue] Press Ctrl+C to stop\n`);
1190
1309
  const rebuilder = createRebuilder(buildOptions, reloader);
1310
+ const envFileSet = new Set(envFiles);
1191
1311
  const watcher = createWatcher({
1192
1312
  workspaceDir,
1193
1313
  outputDir,
1194
1314
  target: options.target,
1315
+ envFiles,
1195
1316
  onChange: (relPath) => {
1196
1317
  if (!reloader.shouldRebuildOn(relPath)) return;
1318
+ const isEnvFile = envFileSet.has(relPath);
1197
1319
  console.error(`[flue] Change detected: ${relPath}`);
1198
- rebuilder.schedule();
1320
+ rebuilder.schedule(isEnvFile);
1199
1321
  }
1200
1322
  });
1201
1323
  let shuttingDown = false;
@@ -1224,31 +1346,40 @@ async function dev(options) {
1224
1346
  function createRebuilder(buildOptions, reloader) {
1225
1347
  let running = false;
1226
1348
  let queued = false;
1349
+ let queuedForce = false;
1350
+ let pendingForce = false;
1227
1351
  let debounceTimer = null;
1228
- const runOnce = async () => {
1352
+ const runOnce = async (force) => {
1229
1353
  running = true;
1230
1354
  const start = Date.now();
1231
1355
  console.error(`[flue] Rebuilding...`);
1232
1356
  try {
1233
1357
  const { changed } = await build(buildOptions);
1234
- await reloader.reload(changed);
1358
+ await reloader.reload(changed || force);
1235
1359
  console.error(`[flue] Reloaded in ${Date.now() - start}ms\n`);
1236
1360
  } catch (err) {
1237
1361
  console.error(`[flue] Rebuild failed: ${err instanceof Error ? err.message : String(err)}\n`);
1238
1362
  } finally {
1239
1363
  running = false;
1240
1364
  if (queued) {
1365
+ const nextForce = queuedForce;
1241
1366
  queued = false;
1242
- runOnce();
1367
+ queuedForce = false;
1368
+ runOnce(nextForce);
1243
1369
  }
1244
1370
  }
1245
1371
  };
1246
- return { schedule() {
1372
+ return { schedule(forceReload = false) {
1373
+ if (forceReload) pendingForce = true;
1247
1374
  if (debounceTimer) clearTimeout(debounceTimer);
1248
1375
  debounceTimer = setTimeout(() => {
1249
1376
  debounceTimer = null;
1250
- if (running) queued = true;
1251
- else runOnce();
1377
+ const force = pendingForce;
1378
+ pendingForce = false;
1379
+ if (running) {
1380
+ queued = true;
1381
+ if (force) queuedForce = true;
1382
+ } else runOnce(force);
1252
1383
  }, 150);
1253
1384
  } };
1254
1385
  }
@@ -1267,7 +1398,7 @@ function createRebuilder(buildOptions, reloader) {
1267
1398
  * - editor backup/swap suffixes
1268
1399
  */
1269
1400
  function createWatcher(options) {
1270
- const { workspaceDir, outputDir, target, onChange } = options;
1401
+ const { workspaceDir, outputDir, target, envFiles, onChange } = options;
1271
1402
  const watchers = [];
1272
1403
  const isIgnoredPath = (relPath) => {
1273
1404
  const parts = relPath.replace(/\\/g, "/").split("/");
@@ -1307,6 +1438,10 @@ function createWatcher(options) {
1307
1438
  watchers.push(w);
1308
1439
  } catch {}
1309
1440
  }
1441
+ for (const envPath of envFiles) try {
1442
+ const w = fs.watch(envPath, () => onChange(envPath));
1443
+ watchers.push(w);
1444
+ } catch {}
1310
1445
  return { close() {
1311
1446
  for (const w of watchers) try {
1312
1447
  w.close();
@@ -1318,10 +1453,12 @@ var NodeReloader = class {
1318
1453
  serverPath;
1319
1454
  outputDir;
1320
1455
  port;
1456
+ envFiles;
1321
1457
  url;
1322
1458
  constructor(opts) {
1323
1459
  this.outputDir = opts.outputDir;
1324
1460
  this.port = opts.port;
1461
+ this.envFiles = opts.envFiles;
1325
1462
  this.serverPath = path.join(this.outputDir, "dist", "server.mjs");
1326
1463
  this.url = `http://localhost:${this.port}`;
1327
1464
  }
@@ -1346,6 +1483,7 @@ var NodeReloader = class {
1346
1483
  } catch {}
1347
1484
  }
1348
1485
  async spawnAndWait() {
1486
+ const fromFiles = parseEnvFiles(this.envFiles);
1349
1487
  const child = spawn("node", [this.serverPath], {
1350
1488
  stdio: [
1351
1489
  "ignore",
@@ -1354,6 +1492,7 @@ var NodeReloader = class {
1354
1492
  ],
1355
1493
  cwd: this.outputDir,
1356
1494
  env: {
1495
+ ...fromFiles,
1357
1496
  ...process.env,
1358
1497
  PORT: String(this.port),
1359
1498
  FLUE_MODE: "local"
@@ -1436,11 +1575,13 @@ var CloudflareReloader = class {
1436
1575
  outputDir;
1437
1576
  port;
1438
1577
  configPath;
1578
+ envFiles;
1439
1579
  url;
1440
1580
  constructor(wrangler, opts) {
1441
1581
  this.wrangler = wrangler;
1442
1582
  this.outputDir = opts.outputDir;
1443
1583
  this.port = opts.port;
1584
+ this.envFiles = opts.envFiles;
1444
1585
  this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1445
1586
  }
1446
1587
  async start() {
@@ -1467,6 +1608,7 @@ var CloudflareReloader = class {
1467
1608
  * baked into the entry).
1468
1609
  */
1469
1610
  shouldRebuildOn(relPath) {
1611
+ if (this.envFiles.includes(relPath)) return true;
1470
1612
  const normalized = relPath.replace(/\\/g, "/");
1471
1613
  if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
1472
1614
  if (normalized.startsWith("agents/")) return true;
@@ -1491,6 +1633,7 @@ var CloudflareReloader = class {
1491
1633
  if (!fs.existsSync(this.configPath)) throw new Error(`[flue] Expected ${this.configPath} after build, but it doesn't exist. Did the Cloudflare build succeed?`);
1492
1634
  this.worker = await this.wrangler.unstable_startWorker({
1493
1635
  config: this.configPath,
1636
+ envFiles: this.envFiles,
1494
1637
  build: { nodejsCompatMode: "v2" },
1495
1638
  dev: {
1496
1639
  server: {
@@ -1518,6 +1661,40 @@ var CloudflareReloader = class {
1518
1661
  }
1519
1662
  }
1520
1663
  };
1664
+ /**
1665
+ * Resolve and validate a list of env-file paths. Returns absolute paths.
1666
+ *
1667
+ * Throws a friendly `[flue]`-prefixed error if any path doesn't exist. The
1668
+ * goal of `--env` is explicitness — silent skip on a typo would defeat
1669
+ * the purpose.
1670
+ */
1671
+ function resolveEnvFiles(envFiles, cwd) {
1672
+ if (!envFiles || envFiles.length === 0) return [];
1673
+ return envFiles.map((p) => {
1674
+ const abs = path.isAbsolute(p) ? p : path.resolve(cwd, p);
1675
+ if (!fs.existsSync(abs)) throw new Error(`[flue] --env points at a path that doesn't exist: ${p}`);
1676
+ return abs;
1677
+ });
1678
+ }
1679
+ /**
1680
+ * Parse one or more `.env`-format files and return their merged contents.
1681
+ * Later files override earlier files on key collision.
1682
+ *
1683
+ * Uses Node's built-in `util.parseEnv` (Node 20.6+; Flue requires Node 22+).
1684
+ * No `dotenv` package needed.
1685
+ *
1686
+ * Parse-only — doesn't touch `process.env`. Caller composes with
1687
+ * `process.env` as needed (typical pattern: spread file vars first, then
1688
+ * `process.env`, so shell-set values win).
1689
+ */
1690
+ function parseEnvFiles(absolutePaths) {
1691
+ const merged = {};
1692
+ for (const p of absolutePaths) {
1693
+ const parsed = parseEnv(fs.readFileSync(p, "utf-8"));
1694
+ Object.assign(merged, parsed);
1695
+ }
1696
+ return merged;
1697
+ }
1521
1698
  async function waitForHealth(baseUrl, timeoutMs) {
1522
1699
  const start = Date.now();
1523
1700
  while (Date.now() - start < timeoutMs) {
@@ -1565,4 +1742,4 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1565
1742
  }
1566
1743
 
1567
1744
  //#endregion
1568
- export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, resolveWorkspaceFromCwd };
1745
+ export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, parseEnvFiles, resolveEnvFiles, resolveWorkspaceFromCwd };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flue/sdk",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {