@flue/sdk 0.3.1 → 0.3.2

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
  /**
@@ -153,6 +154,38 @@ function mergeFlueAdditions(userConfig, additions) {
153
154
  return merged;
154
155
  }
155
156
  /**
157
+ * Strip wrangler-normalizer defaults that cause spurious warnings when wrangler
158
+ * re-parses our generated dist/wrangler.jsonc.
159
+ *
160
+ * Background: `unstable_readConfig` returns a fully-normalized `Unstable_Config`
161
+ * with every section populated to a default — including `unsafe: {}`. Wrangler's
162
+ * own validator then emits a `"unsafe" fields are experimental` warning whenever
163
+ * the field is *present*, regardless of whether it's empty. So our merged file,
164
+ * which inherits the empty default, would trip the warning at every dev start
165
+ * and every deploy.
166
+ *
167
+ * We delete `unsafe` only when it's an empty object (the exact shape wrangler's
168
+ * normalizer produces). If a user has actually written `unsafe: {...}` in their
169
+ * own wrangler.jsonc, the value will be non-empty and we leave it alone — the
170
+ * warning in that case is wrangler's intended diagnostic, not noise.
171
+ *
172
+ * Other normalizer-defaulted-empty fields (`vars: {}`, `kv_namespaces: []`,
173
+ * `python_modules: { exclude: ['**\/*.pyc'] }`, etc.) are left in place. They're
174
+ * harmless: wrangler doesn't warn about them, dist/wrangler.jsonc is an
175
+ * internal build artifact, and stripping them only saves bytes. Only `unsafe`
176
+ * has a user-visible side effect we need to fix.
177
+ *
178
+ * If wrangler adds another field to its `experimental()` warning list in a
179
+ * future version (today there are only two: `unsafe` and `secrets`), this
180
+ * function is the place to extend.
181
+ *
182
+ * Mutates `merged` in place to match the shallow-clone pattern in
183
+ * `mergeFlueAdditions`.
184
+ */
185
+ function stripNoisyWranglerDefaults(merged) {
186
+ if ("unsafe" in merged && typeof merged.unsafe === "object" && merged.unsafe !== null && !Array.isArray(merged.unsafe) && Object.keys(merged.unsafe).length === 0) delete merged.unsafe;
187
+ }
188
+ /**
156
189
  * Return the list of `class_name`s declared in the user's wrangler
157
190
  * `durable_objects.bindings` that contain the literal substring `Sandbox`
158
191
  * (case-sensitive).
@@ -637,6 +670,7 @@ export default {
637
670
  for (const className of sandboxClassNames) console.log(`[flue] Detected Sandbox-named DO binding "${className}" — re-exporting from @cloudflare/sandbox.`);
638
671
  }
639
672
  const merged = mergeFlueAdditions(userConfig, additions);
673
+ stripNoisyWranglerDefaults(merged);
640
674
  if (typeof merged.$schema !== "string") merged.$schema = "https://workers.cloudflare.com/schema/wrangler.json";
641
675
  outputs["wrangler.jsonc"] = JSON.stringify(merged, null, 2);
642
676
  writeDeployRedirectIfMissing(ctx.outputDir);
@@ -1155,6 +1189,8 @@ async function dev(options) {
1155
1189
  const workspaceDir = path.resolve(options.workspaceDir);
1156
1190
  const outputDir = path.resolve(options.outputDir);
1157
1191
  const port = options.port ?? DEFAULT_DEV_PORT;
1192
+ const envFiles = resolveEnvFiles(options.envFiles, outputDir);
1193
+ for (const f of envFiles) console.error(`[flue] Loading env from: ${f}`);
1158
1194
  const buildOptions = {
1159
1195
  workspaceDir,
1160
1196
  outputDir,
@@ -1172,10 +1208,12 @@ async function dev(options) {
1172
1208
  console.error(`[flue] Built in ${Date.now() - initialStart}ms`);
1173
1209
  const reloader = options.target === "node" ? new NodeReloader({
1174
1210
  outputDir,
1175
- port
1211
+ port,
1212
+ envFiles
1176
1213
  }) : await createCloudflareReloader({
1177
1214
  outputDir,
1178
- port
1215
+ port,
1216
+ envFiles
1179
1217
  });
1180
1218
  await reloader.start();
1181
1219
  if (reloader.url) {
@@ -1188,14 +1226,17 @@ async function dev(options) {
1188
1226
  }
1189
1227
  console.error(`[flue] Press Ctrl+C to stop\n`);
1190
1228
  const rebuilder = createRebuilder(buildOptions, reloader);
1229
+ const envFileSet = new Set(envFiles);
1191
1230
  const watcher = createWatcher({
1192
1231
  workspaceDir,
1193
1232
  outputDir,
1194
1233
  target: options.target,
1234
+ envFiles,
1195
1235
  onChange: (relPath) => {
1196
1236
  if (!reloader.shouldRebuildOn(relPath)) return;
1237
+ const isEnvFile = envFileSet.has(relPath);
1197
1238
  console.error(`[flue] Change detected: ${relPath}`);
1198
- rebuilder.schedule();
1239
+ rebuilder.schedule(isEnvFile);
1199
1240
  }
1200
1241
  });
1201
1242
  let shuttingDown = false;
@@ -1224,31 +1265,40 @@ async function dev(options) {
1224
1265
  function createRebuilder(buildOptions, reloader) {
1225
1266
  let running = false;
1226
1267
  let queued = false;
1268
+ let queuedForce = false;
1269
+ let pendingForce = false;
1227
1270
  let debounceTimer = null;
1228
- const runOnce = async () => {
1271
+ const runOnce = async (force) => {
1229
1272
  running = true;
1230
1273
  const start = Date.now();
1231
1274
  console.error(`[flue] Rebuilding...`);
1232
1275
  try {
1233
1276
  const { changed } = await build(buildOptions);
1234
- await reloader.reload(changed);
1277
+ await reloader.reload(changed || force);
1235
1278
  console.error(`[flue] Reloaded in ${Date.now() - start}ms\n`);
1236
1279
  } catch (err) {
1237
1280
  console.error(`[flue] Rebuild failed: ${err instanceof Error ? err.message : String(err)}\n`);
1238
1281
  } finally {
1239
1282
  running = false;
1240
1283
  if (queued) {
1284
+ const nextForce = queuedForce;
1241
1285
  queued = false;
1242
- runOnce();
1286
+ queuedForce = false;
1287
+ runOnce(nextForce);
1243
1288
  }
1244
1289
  }
1245
1290
  };
1246
- return { schedule() {
1291
+ return { schedule(forceReload = false) {
1292
+ if (forceReload) pendingForce = true;
1247
1293
  if (debounceTimer) clearTimeout(debounceTimer);
1248
1294
  debounceTimer = setTimeout(() => {
1249
1295
  debounceTimer = null;
1250
- if (running) queued = true;
1251
- else runOnce();
1296
+ const force = pendingForce;
1297
+ pendingForce = false;
1298
+ if (running) {
1299
+ queued = true;
1300
+ if (force) queuedForce = true;
1301
+ } else runOnce(force);
1252
1302
  }, 150);
1253
1303
  } };
1254
1304
  }
@@ -1267,7 +1317,7 @@ function createRebuilder(buildOptions, reloader) {
1267
1317
  * - editor backup/swap suffixes
1268
1318
  */
1269
1319
  function createWatcher(options) {
1270
- const { workspaceDir, outputDir, target, onChange } = options;
1320
+ const { workspaceDir, outputDir, target, envFiles, onChange } = options;
1271
1321
  const watchers = [];
1272
1322
  const isIgnoredPath = (relPath) => {
1273
1323
  const parts = relPath.replace(/\\/g, "/").split("/");
@@ -1307,6 +1357,10 @@ function createWatcher(options) {
1307
1357
  watchers.push(w);
1308
1358
  } catch {}
1309
1359
  }
1360
+ for (const envPath of envFiles) try {
1361
+ const w = fs.watch(envPath, () => onChange(envPath));
1362
+ watchers.push(w);
1363
+ } catch {}
1310
1364
  return { close() {
1311
1365
  for (const w of watchers) try {
1312
1366
  w.close();
@@ -1318,10 +1372,12 @@ var NodeReloader = class {
1318
1372
  serverPath;
1319
1373
  outputDir;
1320
1374
  port;
1375
+ envFiles;
1321
1376
  url;
1322
1377
  constructor(opts) {
1323
1378
  this.outputDir = opts.outputDir;
1324
1379
  this.port = opts.port;
1380
+ this.envFiles = opts.envFiles;
1325
1381
  this.serverPath = path.join(this.outputDir, "dist", "server.mjs");
1326
1382
  this.url = `http://localhost:${this.port}`;
1327
1383
  }
@@ -1346,6 +1402,7 @@ var NodeReloader = class {
1346
1402
  } catch {}
1347
1403
  }
1348
1404
  async spawnAndWait() {
1405
+ const fromFiles = parseEnvFiles(this.envFiles);
1349
1406
  const child = spawn("node", [this.serverPath], {
1350
1407
  stdio: [
1351
1408
  "ignore",
@@ -1354,6 +1411,7 @@ var NodeReloader = class {
1354
1411
  ],
1355
1412
  cwd: this.outputDir,
1356
1413
  env: {
1414
+ ...fromFiles,
1357
1415
  ...process.env,
1358
1416
  PORT: String(this.port),
1359
1417
  FLUE_MODE: "local"
@@ -1436,11 +1494,13 @@ var CloudflareReloader = class {
1436
1494
  outputDir;
1437
1495
  port;
1438
1496
  configPath;
1497
+ envFiles;
1439
1498
  url;
1440
1499
  constructor(wrangler, opts) {
1441
1500
  this.wrangler = wrangler;
1442
1501
  this.outputDir = opts.outputDir;
1443
1502
  this.port = opts.port;
1503
+ this.envFiles = opts.envFiles;
1444
1504
  this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
1445
1505
  }
1446
1506
  async start() {
@@ -1467,6 +1527,7 @@ var CloudflareReloader = class {
1467
1527
  * baked into the entry).
1468
1528
  */
1469
1529
  shouldRebuildOn(relPath) {
1530
+ if (this.envFiles.includes(relPath)) return true;
1470
1531
  const normalized = relPath.replace(/\\/g, "/");
1471
1532
  if (normalized === "wrangler.jsonc" || normalized === "wrangler.json" || normalized === "wrangler.toml") return true;
1472
1533
  if (normalized.startsWith("agents/")) return true;
@@ -1491,6 +1552,7 @@ var CloudflareReloader = class {
1491
1552
  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
1553
  this.worker = await this.wrangler.unstable_startWorker({
1493
1554
  config: this.configPath,
1555
+ envFiles: this.envFiles,
1494
1556
  build: { nodejsCompatMode: "v2" },
1495
1557
  dev: {
1496
1558
  server: {
@@ -1518,6 +1580,40 @@ var CloudflareReloader = class {
1518
1580
  }
1519
1581
  }
1520
1582
  };
1583
+ /**
1584
+ * Resolve and validate a list of env-file paths. Returns absolute paths.
1585
+ *
1586
+ * Throws a friendly `[flue]`-prefixed error if any path doesn't exist. The
1587
+ * goal of `--env` is explicitness — silent skip on a typo would defeat
1588
+ * the purpose.
1589
+ */
1590
+ function resolveEnvFiles(envFiles, cwd) {
1591
+ if (!envFiles || envFiles.length === 0) return [];
1592
+ return envFiles.map((p) => {
1593
+ const abs = path.isAbsolute(p) ? p : path.resolve(cwd, p);
1594
+ if (!fs.existsSync(abs)) throw new Error(`[flue] --env points at a path that doesn't exist: ${p}`);
1595
+ return abs;
1596
+ });
1597
+ }
1598
+ /**
1599
+ * Parse one or more `.env`-format files and return their merged contents.
1600
+ * Later files override earlier files on key collision.
1601
+ *
1602
+ * Uses Node's built-in `util.parseEnv` (Node 20.6+; Flue requires Node 22+).
1603
+ * No `dotenv` package needed.
1604
+ *
1605
+ * Parse-only — doesn't touch `process.env`. Caller composes with
1606
+ * `process.env` as needed (typical pattern: spread file vars first, then
1607
+ * `process.env`, so shell-set values win).
1608
+ */
1609
+ function parseEnvFiles(absolutePaths) {
1610
+ const merged = {};
1611
+ for (const p of absolutePaths) {
1612
+ const parsed = parseEnv(fs.readFileSync(p, "utf-8"));
1613
+ Object.assign(merged, parsed);
1614
+ }
1615
+ return merged;
1616
+ }
1521
1617
  async function waitForHealth(baseUrl, timeoutMs) {
1522
1618
  const start = Date.now();
1523
1619
  while (Date.now() - start < timeoutMs) {
@@ -1565,4 +1661,4 @@ function pickExampleAgentName(outputDir, workspaceDir) {
1565
1661
  }
1566
1662
 
1567
1663
  //#endregion
1568
- export { BUILTIN_TOOL_NAMES, DEFAULT_DEV_PORT, build, createTools, dev, resolveWorkspaceFromCwd };
1664
+ 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.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "exports": {