@devdogsuga/devtools 0.1.50 → 0.1.51-next.527

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.
@@ -0,0 +1,640 @@
1
+ //#region ../sdk/dist/env/targets.js
2
+ /**
3
+ * The env TARGETS: one row per thing a command can be pointed at.
4
+ *
5
+ * | `--target` | File | Bitwarden project | `DEPLOY_ENV`? |
6
+ * |---------------|-------------------|-------------------|---------------|
7
+ * | `development` | `.env` | none | yes |
8
+ * | `preflight` | `.env.preflight` | `preflight` | **no** |
9
+ * | `staging` | `.env.staging` | `staging` | yes |
10
+ * | `production` | `.env.production` | `production` | yes |
11
+ *
12
+ * ⚠️ ONE TABLE, ON PURPOSE. This used to be two enums that overlapped in the
13
+ * middle and diverged at both ends: a `DeployEnvironment`
14
+ * (`development | staging | production`, deciding WHICH FILE) in this package,
15
+ * and a `BwsEnvironment` (`preflight | staging | production`, deciding WHICH
16
+ * VAULT PROJECT) in devtools, behind a single `--env` flag that meant whichever
17
+ * of the two the subcommand happened to import. That was not a type error
18
+ * anywhere, because the words `staging` and `production` are in both:
19
+ *
20
+ * * `init --env staging` created `.env.staging`, mapping target → file;
21
+ * * `push --env staging` read the root `.env`, because its path helper
22
+ * defaulted there and only honoured an explicit `--file`.
23
+ *
24
+ * So generating `.env.staging`, filling it in, and pushing it uploaded the
25
+ * DEVELOPMENT file to the staging vault project and reported success. In the
26
+ * other direction `MissingEnvFileError` told people to run a pull that wrote
27
+ * staging values into the development `.env` and never created the file the
28
+ * error was about, so the error repeated.
29
+ *
30
+ * Neither subcommand was wrong about its own enum, so the fix is a smaller
31
+ * vocabulary rather than a better check: one row per target, every per-target
32
+ * fact read from that row, and the two old enums derived as SUBSETS of it
33
+ * (`DeployEnvironment`, `VaultTarget`) rather than maintained beside it. A
34
+ * future subcommand can pick a wrong target; it can no longer pick the wrong
35
+ * vocabulary.
36
+ *
37
+ * Two rows are asymmetric, and both asymmetries are deliberate:
38
+ *
39
+ * * **`development` has no vault project.** `.env` is one contributor's own
40
+ * file, and there is no shared "development" set of credentials to sync it
41
+ * against. `pull`/`push`/`audit` refuse it by name rather than falling
42
+ * through to some default project.
43
+ * * **`preflight` is not a deploy environment.** `.env.preflight` is a
44
+ * staging area for pushing credentials into the preflight vault project;
45
+ * nothing ever boots from it. See `resolveEnvironment()` below.
46
+ *
47
+ * `.env` IS development rather than a base the others extend, and that is the
48
+ * whole design. A base file plus overlays sounds tidier and fails in one
49
+ * specific way: a variable present in `.env` and forgotten in
50
+ * `.env.production` silently falls through to the development value while
51
+ * `DEPLOY_ENV` says production. Standalone files make that a validation error
52
+ * instead of a wrong answer.
53
+ *
54
+ * There is no `local` alongside `development`. To most people they are
55
+ * synonyms, and a distinction nobody can state is a distinction people get
56
+ * wrong.
57
+ */
58
+ /**
59
+ * Ordered least- to most-dangerous, and the order is load-bearing: it is what
60
+ * the interactive picker lists, so a reflexive Enter never lands on production.
61
+ */
62
+ const TARGETS = {
63
+ development: {
64
+ file: ".env",
65
+ project: null,
66
+ deployEnv: true,
67
+ guarded: false
68
+ },
69
+ preflight: {
70
+ file: ".env.preflight",
71
+ project: "preflight",
72
+ deployEnv: false,
73
+ guarded: false
74
+ },
75
+ staging: {
76
+ file: ".env.staging",
77
+ project: "staging",
78
+ deployEnv: true,
79
+ guarded: false
80
+ },
81
+ production: {
82
+ file: ".env.production",
83
+ project: "production",
84
+ deployEnv: true,
85
+ guarded: true
86
+ }
87
+ };
88
+ const ENV_TARGETS = Object.keys(TARGETS);
89
+ ENV_TARGETS.filter((target) => TARGETS[target].project !== null);
90
+ /**
91
+ * A non-empty tuple type because `z.enum()` requires one, and derived rather
92
+ * than written out because a hand-kept copy is a copy that can disagree with
93
+ * the table. `preflight` is absent here and that is the point, see
94
+ * `resolveEnvironment()`.
95
+ *
96
+ * The emptiness check is real rather than an assertion: an empty list would
97
+ * make `z.enum()` a type error at every app manifest, but only after somebody
98
+ * had already emptied the table, and "no environment is valid" is worth
99
+ * failing on at import.
100
+ */
101
+ function deployEnvironments() {
102
+ const [first, ...rest] = ENV_TARGETS.filter((target) => TARGETS[target].deployEnv);
103
+ if (first === void 0) throw new Error("The target table declares no deploy environments.");
104
+ return [first, ...rest];
105
+ }
106
+ const DEPLOY_ENVIRONMENTS = deployEnvironments();
107
+ function isDeployEnvironment(value) {
108
+ return DEPLOY_ENVIRONMENTS.includes(value);
109
+ }
110
+ /** The file a target reads and writes. The ONLY place that mapping is made. */
111
+ function fileFor(target) {
112
+ return TARGETS[target].file;
113
+ }
114
+ var UnknownEnvironmentError = class extends Error {
115
+ value;
116
+ constructor(value) {
117
+ super(`DEPLOY_ENV="${value}" is not one of ${DEPLOY_ENVIRONMENTS.join(", ")}.`);
118
+ this.value = value;
119
+ this.name = "UnknownEnvironmentError";
120
+ }
121
+ };
122
+ /**
123
+ * Resolves `DEPLOY_ENV` to an environment, refusing anything unrecognised.
124
+ *
125
+ * ⚠️ AN ALLOWLIST, NOT A PARSE, and the difference is not pedantry. Treating
126
+ * the value as a filename suffix means `DEPLOY_ENV=example` loads
127
+ * `.env.example`, a real committed file whose placeholder values pass most of
128
+ * the schema. The app boots, looks configured, and is pointed at nothing.
129
+ *
130
+ * The failure mode of an unrecognised value is worse than "not found" in the
131
+ * other direction too: the old `switchEnvironment()` treated anything that was
132
+ * not `"development"` as deployed, so a stray `DEPLOY_ENV=production-build`
133
+ * applied the strict schemas while every `=== "production"` gate stayed shut.
134
+ * Configured and wrong, in both directions at once.
135
+ *
136
+ * ⚠️ `DEPLOY_ENV=preflight` IS REFUSED, and that is not an oversight left over
137
+ * from the two-enum era. Do not "fix" the inconsistency by adding it. A
138
+ * `preflight` row exists in the table because `.env.preflight` is a staging
139
+ * area for pushing credentials into the preflight vault project: someone runs
140
+ * `env init --target preflight`, fills the file in, and pushes it. Nothing
141
+ * boots from it. The preflight credentials are read-only by construction (a
142
+ * Postgres role that sees only the migrations table), so an app started
143
+ * against them would fail in a scattered, feature-by-feature way rather than
144
+ * at startup. Refusing here is what keeps that failure at the door.
145
+ */
146
+ function resolveEnvironment(value = process.env.DEPLOY_ENV) {
147
+ if (value === void 0 || value === "") return "development";
148
+ if (!isDeployEnvironment(value)) throw new UnknownEnvironmentError(value);
149
+ return value;
150
+ }
151
+ //#endregion
152
+ //#region ../sdk/dist/env/derivation.js
153
+ /**
154
+ * `$NAME` and `${NAME}`, dotenvx's expansion syntax, restricted to the
155
+ * SHOUTING_CASE shape every declared key has.
156
+ *
157
+ * Anchored on that shape deliberately: a bare `$` in a password or a price is
158
+ * not a reference, and treating one as such would classify a placeholder as a
159
+ * derivation, the exact mistake this module exists to prevent.
160
+ */
161
+ const REFERENCE = /\$\{([A-Z][A-Z0-9_]*)\}|\$([A-Z][A-Z0-9_]*)/g;
162
+ /** The variable names a value expands from, in order, duplicates included. */
163
+ function envReferences(value) {
164
+ return [...value.matchAll(REFERENCE)].map((match) => match[1] ?? match[2]);
165
+ }
166
+ /**
167
+ * Expands `$NAME` / `${NAME}` references in `value`, order-independently.
168
+ *
169
+ * A name is resolved first against `raw` — a COMPLETE map of other
170
+ * not-yet-expanded values, typically every key an env file itself declares —
171
+ * recursing into it when the name is there, and only falling back to
172
+ * `resolved` (already-known, fully-expanded values — the rest of the loaded
173
+ * environment) when it is not. Because `raw` is a full map rather than a
174
+ * running accumulator, a name defined LATER in a file than the value
175
+ * referencing it still resolves: this is the fix for dotenvx's own
176
+ * expansion, which is a single left-to-right pass over the file and silently
177
+ * expands a forward reference to `""`, exactly as an undefined one would.
178
+ *
179
+ * A name found in NEITHER map falls back to `""`, matching dotenvx's own
180
+ * behaviour for a genuinely unresolved reference. That is deliberately left
181
+ * alone rather than "fixed": a key declared ahead of its own deployment
182
+ * (`STUDY_GROUP_FINDER_URL` and its `_CALLBACK`, see `write-env.ts`) depends
183
+ * on an unresolved reference staying silent, not becoming a thrown error.
184
+ *
185
+ * Throws when a chain of `raw` references loops back on itself, naming the
186
+ * whole chain — expanding a value that never terminates is a fault in the
187
+ * source file, not something to paper over with another guess. Mirrors
188
+ * `packages/devtools/src/deploy/write-env.ts`'s `expand()`, which resolves
189
+ * the identical formula syntax against a differently-sourced map (GitHub
190
+ * secrets/variables instead of a loaded file) for the same reason: CI
191
+ * composes the file order-independently already, and a local `with-env` load
192
+ * must not depend on which line happened to come first.
193
+ */
194
+ function expandReferences(key, value, raw, resolved, seen = []) {
195
+ return value.replace(REFERENCE, (_match, braced, bare) => {
196
+ const name = braced ?? bare;
197
+ if (seen.includes(name)) throw new Error(`${key} derives from ${name}, which derives back from it (chain: ${[...seen, name].join(" -> ")}).`);
198
+ const rawValue = raw.get(name);
199
+ if (rawValue !== void 0) return expandReferences(name, rawValue, raw, resolved, [...seen, name]);
200
+ return resolved[name] ?? "";
201
+ });
202
+ }
203
+ //#endregion
204
+ //#region ../cli-core/src/env-runtime/load.ts
205
+ /**
206
+ * Which env files `with-env` loads. The decision, separated from the doing.
207
+ *
208
+ * `selectEnvFiles()` is pure: environment, file existence and probe result in,
209
+ * ordered file list and warnings out. The CLI wires in the real `existsSync`
210
+ * and the real TCP probe; the tests inject stubs and never open a socket. Keep
211
+ * it that way. The four-row probe table below is the kind of logic that rots
212
+ * when it can only be exercised by spawning processes.
213
+ *
214
+ * ⚠️ This module lives in cli-core, outside `@devdogsuga/sdk/env`, on purpose.
215
+ * The root `"."` export is imported by `apps/platform`, which deploys to
216
+ * Cloudflare Workers; nothing here may ever be re-exported from `index.ts`, or
217
+ * an `import { selectEnvFiles } from "@devdogsuga/sdk/env"` in server code fails at
218
+ * the edge instead of at build. The same is true of `loadEnvironment` below:
219
+ * it dynamically imports `node:fs`/`node:path`/`node:url` and
220
+ * `@dotenvx/dotenvx`, none of which exist at the edge.
221
+ */
222
+ /**
223
+ * The local Supabase API port. Hardcoded to match `supabase/config.toml`,
224
+ * which itself hardcodes 54321 deliberately: `supabase seed` rejects `env()`
225
+ * interpolation in numeric fields, so the port cannot come from the
226
+ * environment on either side. The file is committed; the number is stable.
227
+ */
228
+ const LOCAL_STACK_PORT = 54321;
229
+ /** The local-stack connection overlay, written by `start-local-stack`. */
230
+ const GENERATED_FILE = ".env.generated";
231
+ function isDevDatabase(value) {
232
+ return value === "local" || value === "remote";
233
+ }
234
+ /**
235
+ * The environment variable that carries a session's `DevDatabase` answer to
236
+ * child processes, the same way `DEPLOY_ENV` carries the tier: the devtools
237
+ * launcher asks (or is told) once, exports it, and every nested `with-env`
238
+ * under that session honours the SAME answer instead of re-running the probe
239
+ * — without this, a session that chose `remote` would spawn children that
240
+ * silently re-choose `local` whenever the Docker stack happens to be up.
241
+ */
242
+ const DEV_DB_ENV = "DEV_DB";
243
+ /**
244
+ * The environment variable that carries the FULL list of key NAMES (comma-
245
+ * separated, values never included) the devtools launcher's own process
246
+ * inherited from the calling shell BEFORE it entered any tier at all — the
247
+ * ONE signal `loadEnvironment`'s default `applyEnvFiles` (below) uses to
248
+ * tell "the user genuinely exported this in their shell" apart from "this
249
+ * looks set only because an EARLIER `enterEnvironment` call in the same
250
+ * session already copied a file value — possibly a stale one, loaded before
251
+ * the local stack was up, or before a failed `db start` finished writing
252
+ * `.env.generated` — onto `process.env`, and every process since has just
253
+ * been inheriting that copy". Only the former is meant to outrank a freshly
254
+ * loaded file, mirroring `with-env`'s classic "a shell var beats the file";
255
+ * the latter is exactly the staleness bug this marker exists to close (see
256
+ * `packages/devtools/src/launch.ts`'s header for the full mechanism).
257
+ *
258
+ * Set exactly once, by the devtools launcher, at the very top of the FIRST
259
+ * process in a session — before it calls `enterEnvironment` at all — from a
260
+ * snapshot of `process.env`'s keys at that moment. Every child (an
261
+ * `enterEnvironment`-entered `next dev`, a nested `pnpm devtools` spawning
262
+ * another launcher, …) re-exports the SAME list unchanged rather than
263
+ * recomputing it: by the time a nested launcher runs, `process.env` no
264
+ * longer reflects "what the shell had", it already holds this session's
265
+ * entered values, and recomputing from it would misclassify every one of
266
+ * them as a genuine shell export.
267
+ *
268
+ * Absence means "outside a devtools session" — a bare `with-env` run with no
269
+ * launcher in front of it, or any caller that never went through
270
+ * `launch.ts` — and `applyEnvFiles` falls back to its long-standing,
271
+ * unqualified "anything already set in the base snapshot wins" rule. Only
272
+ * `override: false` callers ever consult it; an explicit `override: true`
273
+ * (a re-entrant load of a DIFFERENT tier than the process runs under, see
274
+ * `LoadEnvironmentOptions.override`) already overrides everything and has no
275
+ * use for a narrower exemption list.
276
+ */
277
+ const SHELL_KEYS_ENV = "DEVTOOLS_SHELL_KEYS";
278
+ /**
279
+ * An explicitly `local` development session, but the local Supabase stack is
280
+ * not actually reachable. Thrown instead of falling back to `.env` alone,
281
+ * because that fallback is how a hosted `DB_URL` ends up behind a command
282
+ * that asked for the Docker stack. The message carries its own
283
+ * troubleshooting; callers print it and stop (the devtools launcher exempts
284
+ * `db`/the menu so `db start` can fix the very state being reported).
285
+ */
286
+ var LocalStackOfflineError = class extends Error {
287
+ constructor(detail, advice = "Run `pnpm devtools db start` to bring the stack up (and confirm with `supabase status`), or choose the remote development database with `--tier development:remote`.") {
288
+ super(`the session asked for the local development database, but ${detail} ${advice}`);
289
+ this.name = "LocalStackOfflineError";
290
+ }
291
+ };
292
+ /**
293
+ * Wrangler's local Hyperdrive emulator does not read an application's DB_URL.
294
+ * It requires this binding-specific process variable instead. Keep DB_URL as
295
+ * the repository's one source of database credentials and derive Wrangler's
296
+ * alias only in the child-process environment.
297
+ */
298
+ const HYPERDRIVE_LOCAL_CONNECTION_ENV = "CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE";
299
+ function applyWranglerLocalDatabaseAlias(environment) {
300
+ if (environment["CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE"] === void 0 && environment.DB_URL !== void 0) environment[HYPERDRIVE_LOCAL_CONNECTION_ENV] = environment.DB_URL;
301
+ }
302
+ /**
303
+ * The variables a deployed build reads to learn its tier, all of which say the
304
+ * same thing `DEPLOY_ENV` already does: `CLOUDFLARE_ENV` picks the wrangler
305
+ * environment, `NEXT_PUBLIC_DEPLOY_ENV` is inlined into the client bundle.
306
+ * Naming the tier once, as `DEPLOY_ENV` (or `--tier`), is enough; the build
307
+ * scripts no longer restate it three times.
308
+ *
309
+ * An explicit, non-empty value always wins, so a build can still target a
310
+ * different wrangler environment than the tier it loaded. Development derives
311
+ * nothing: it has no wrangler environment, and the client bundle already
312
+ * defaults to it.
313
+ *
314
+ * Deliberately NOT part of `loadEnvironment`: that also runs in-process inside
315
+ * a devtools session, where a derived value written onto `process.env` would
316
+ * read as "explicitly set" the next time the tier changed. Only the `with-env`
317
+ * child, whose environment is built fresh per run, derives them.
318
+ */
319
+ function applyDeployTierAliases(environment, tier) {
320
+ if (tier === "development") return;
321
+ for (const key of ["CLOUDFLARE_ENV", "NEXT_PUBLIC_DEPLOY_ENV"]) if (environment[key] === void 0 || environment[key] === "") environment[key] = tier;
322
+ }
323
+ /**
324
+ * A minimal, single-line-only `.env` assignment reader, used ONLY to recover
325
+ * a key's UNEXPANDED value so a `$NAME` reference inside it can be resolved
326
+ * order-independently — see `expandReferences` in `derivation.ts`, and the
327
+ * `applyEnvFiles` default below, which is the actual bug fix. It is not a
328
+ * general-purpose parser: dotenvx's own parser (used for everything else —
329
+ * quoting edge cases, encryption, multiline private keys) stays the source of
330
+ * truth for every key this cannot confidently read, which it signals by
331
+ * leaving that key out of the returned map entirely rather than guessing.
332
+ *
333
+ * A key is included only when its whole value fits on one physical line with
334
+ * matched quoting:
335
+ *
336
+ * * `'...'` — single-quoted. dotenvx never expands these (`quote()` in
337
+ * `packages/devtools/src/deploy/write-env.ts` relies on exactly this), so
338
+ * `literal: true` tells the caller not to recurse into it looking for a
339
+ * reference — a literal `$` in a single-quoted password is not one.
340
+ * * `"..."` — double-quoted, expandable.
341
+ * * a bare unquoted value, read to the end of the line (minus a trailing
342
+ * ` #comment`), also expandable.
343
+ *
344
+ * An unterminated quote — the start of a multiline value like the GitHub
345
+ * App private key — is left out entirely, and so is an `encrypted:` blob or
346
+ * anything else that does not match one of the three shapes above.
347
+ */
348
+ function parseRawAssignments(text) {
349
+ const out = /* @__PURE__ */ new Map();
350
+ for (const line of text.split(/\r?\n/)) {
351
+ const trimmed = line.trim();
352
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
353
+ const assignment = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(trimmed);
354
+ if (!assignment) continue;
355
+ const key = assignment[1];
356
+ const rest = assignment[2];
357
+ const doubleQuoted = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(rest);
358
+ if (doubleQuoted) {
359
+ out.set(key, {
360
+ raw: doubleQuoted[1],
361
+ literal: false
362
+ });
363
+ continue;
364
+ }
365
+ const singleQuoted = /^'([^']*)'\s*(?:#.*)?$/.exec(rest);
366
+ if (singleQuoted) {
367
+ out.set(key, {
368
+ raw: singleQuoted[1],
369
+ literal: true
370
+ });
371
+ continue;
372
+ }
373
+ if (rest.startsWith("\"") || rest.startsWith("'")) continue;
374
+ const hash = rest.search(/\s#/);
375
+ out.set(key, {
376
+ raw: (hash === -1 ? rest : rest.slice(0, hash)).trim(),
377
+ literal: false
378
+ });
379
+ }
380
+ return out;
381
+ }
382
+ /**
383
+ * A selected environment's file is missing. The message names the file and the
384
+ * command that materialises it, because "ENOENT: .env.staging" tells a new
385
+ * contributor nothing about `env pull`.
386
+ *
387
+ * ⚠️ The named command has to actually produce the named file. It did not
388
+ * before: this said `secrets pull --env staging`, whose `--env` was the VAULT
389
+ * vocabulary. So it wrote staging's values into the development `.env`, the
390
+ * file that may hold the only copy of somebody's production credentials, and
391
+ * left `.env.staging` still missing, so the error repeated. `--target` now
392
+ * defaults the file from the same table this message reads.
393
+ */
394
+ var MissingEnvFileError = class extends Error {
395
+ environment;
396
+ file;
397
+ constructor(environment, file) {
398
+ super(environment === "development" ? `${file} does not exist. Run \`pnpm devtools setup\` to create it.` : `${file} does not exist. Run \`pnpm devtools env pull --target ${environment}\` to fetch it.`);
399
+ this.environment = environment;
400
+ this.file = file;
401
+ this.name = "MissingEnvFileError";
402
+ }
403
+ };
404
+ /**
405
+ * Resolves `DEPLOY_ENV` and decides which files to load.
406
+ *
407
+ * Throws `UnknownEnvironmentError` for anything outside the allowlist (never
408
+ * fall through to `.env.${DEPLOY_ENV}`: `DEPLOY_ENV=example` would load the
409
+ * committed placeholder file, largely pass validation, and boot an app pointed
410
+ * at nothing) and `MissingEnvFileError` when the selected file is absent.
411
+ *
412
+ * ⚠️ `.env.generated` IS DEVELOPMENT-ONLY, and the design doc's probe table
413
+ * does not say so because it never imagines otherwise. The overlay holds a
414
+ * local Docker container's connection block, and under `DEPLOY_ENV=staging` or
415
+ * `production` a running container must never shadow the deployed connection
416
+ * values: first-file-wins would silently point a staging build at localhost.
417
+ * So the whole table below is gated on `development`.
418
+ *
419
+ * An explicit `devDatabase` answer overrides the table before it is consulted:
420
+ *
421
+ * * `"local"` — the overlay is REQUIRED and required LIVE. A dead port or a
422
+ * missing file throws `LocalStackOfflineError` rather than falling back
423
+ * to `.env` alone, because `.env` may carry a hosted `DB_URL` and "I
424
+ * asked for the Docker stack" must never quietly become "a remote
425
+ * database answered".
426
+ * * `"remote"` — the overlay is skipped and the port is not even probed:
427
+ * the session has said which database it means, so a running container
428
+ * is a fact worth one warning line, not a vote. (Whether the overlay
429
+ * file exists is still checked, only to phrase that warning.)
430
+ *
431
+ * With no answer, the probe table (file is a hint; the port is the truth):
432
+ *
433
+ * | `.env.generated` | port 54321 | behaviour |
434
+ * |------------------|------------|---------------------------------------|
435
+ * | exists | listening | prepend it, a first-file-wins overlay |
436
+ * | exists | refused | ignore it, warn: stale |
437
+ * | missing | listening | warn: regenerate it |
438
+ * | missing | refused | hosted project, silent, also normal |
439
+ *
440
+ * No file-existence rule can distinguish the middle two rows; that is the
441
+ * entire reason the probe exists.
442
+ */
443
+ async function selectEnvFiles(ctx) {
444
+ const environment = resolveEnvironment(ctx.deployEnv);
445
+ const envFile = fileFor(environment);
446
+ if (!ctx.exists(envFile)) throw new MissingEnvFileError(environment, envFile);
447
+ const files = [envFile];
448
+ const warnings = [];
449
+ if (environment === "development" && ctx.devDatabase === "remote") {
450
+ if (ctx.exists(".env.generated")) warnings.push(`ignoring ${GENERATED_FILE} — this session targets the remote development database (DEV_DB=remote), not the local stack.`);
451
+ return {
452
+ environment,
453
+ files,
454
+ warnings
455
+ };
456
+ }
457
+ if (environment === "development" && ctx.devDatabase === "local") {
458
+ const generated = ctx.exists(GENERATED_FILE);
459
+ if (!await ctx.probeLocalStack()) throw new LocalStackOfflineError(`nothing is listening on 127.0.0.1:${LOCAL_STACK_PORT}` + (generated ? ` (${GENERATED_FILE} exists but is stale).` : ` (and ${GENERATED_FILE} is missing).`));
460
+ if (!generated) throw new LocalStackOfflineError(`the stack is listening on 127.0.0.1:${LOCAL_STACK_PORT} while ${GENERATED_FILE} — its connection block — is missing.`, `Run \`supabase status -o env > ${GENERATED_FILE}\` at the repo root to regenerate it.`);
461
+ files.unshift(GENERATED_FILE);
462
+ return {
463
+ environment,
464
+ files,
465
+ warnings
466
+ };
467
+ }
468
+ if (environment === "development") {
469
+ const generated = ctx.exists(GENERATED_FILE);
470
+ const listening = await ctx.probeLocalStack();
471
+ if (generated && listening) files.unshift(GENERATED_FILE);
472
+ else if (generated) warnings.push(`ignoring stale ${GENERATED_FILE} — nothing is listening on 127.0.0.1:${LOCAL_STACK_PORT}, so the local stack is not running.`);
473
+ else if (listening) warnings.push(`the local stack is listening on 127.0.0.1:${LOCAL_STACK_PORT} but ${GENERATED_FILE} is missing — run \`supabase status -o env > ${GENERATED_FILE}\` at the repo root to regenerate it.`);
474
+ }
475
+ return {
476
+ environment,
477
+ files,
478
+ warnings
479
+ };
480
+ }
481
+ /**
482
+ * The real probe: one TCP connect to 127.0.0.1:54321.
483
+ *
484
+ * On loopback a refused connection returns ECONNREFUSED immediately, so the
485
+ * hosted-project path costs well under a millisecond. The timeout is for the
486
+ * pathological case, a firewall that drops instead of refusing, and is kept
487
+ * tight so a weird network stack cannot stall every script in the repo by more
488
+ * than a quarter second.
489
+ *
490
+ * Accepted hole (recorded in the design doc): something *else* listening on
491
+ * 54321 fools this. Proving identity would mean an HTTP round-trip on every
492
+ * command, and that failure is loud and immediate rather than silent.
493
+ */
494
+ async function probeLocalStack(port = LOCAL_STACK_PORT, timeoutMs = 250) {
495
+ const { connect } = await import("node:net");
496
+ return new Promise((resolve) => {
497
+ const socket = connect({
498
+ host: "127.0.0.1",
499
+ port
500
+ });
501
+ const settle = (up) => {
502
+ socket.destroy();
503
+ resolve(up);
504
+ };
505
+ socket.setTimeout(timeoutMs, () => settle(false));
506
+ socket.once("connect", () => settle(true));
507
+ socket.once("error", () => settle(false));
508
+ });
509
+ }
510
+ /**
511
+ * Walks up from THIS module's directory for the workspace marker, mirroring
512
+ * `cli.ts`'s own `findRoot`. The duplication is deliberate rather than a
513
+ * shared export: `cli.ts` still needs its own root synchronously, before it
514
+ * can decide the Windows dotenvx-CLI `-f` paths below, and passing that same
515
+ * root through as `context.root` (as `with-env` now does) is what lets a
516
+ * caller skip this walk entirely rather than paying for it twice.
517
+ *
518
+ * Throws instead of `console.error` + `process.exit`, unlike `cli.ts`'s
519
+ * version: this is a library function other commands call in-process, and
520
+ * exiting the whole process out from under an unrelated caller would be a far
521
+ * worse failure than a rejected promise.
522
+ */
523
+ async function findRepoRoot() {
524
+ const { existsSync } = await import("node:fs");
525
+ const { dirname, join, resolve } = await import("node:path");
526
+ const { fileURLToPath } = await import("node:url");
527
+ let dir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
528
+ for (;;) {
529
+ if (existsSync(join(dir, "pnpm-workspace.yaml"))) return dir;
530
+ const parent = dirname(dir);
531
+ if (parent === dir) throw new Error("loadEnvironment: could not locate the monorepo root (no pnpm-workspace.yaml found above this package).");
532
+ dir = parent;
533
+ }
534
+ }
535
+ /**
536
+ * Loads a deploy tier's env files into a FRESH map, in-process — the same
537
+ * selection-then-load `with-env` does at startup (`selectEnvFiles` here plus
538
+ * dotenvx's Node API), pulled out so any command can load a tier's
539
+ * environment WITHOUT re-execing itself through the `with-env` bin.
540
+ *
541
+ * ⚠️ NODE-ONLY / EDGE-UNSAFE, same as the rest of this module: never
542
+ * re-export this from `index.ts`. It dynamically imports
543
+ * `node:fs`/`node:path`/`node:url` (only when `context.root` or
544
+ * `context.exists` is not supplied) and `@dotenvx/dotenvx` (only when files
545
+ * are actually applied), none of which resolve at the Cloudflare Workers edge
546
+ * `apps/platform` deploys to.
547
+ *
548
+ * `process.env` (or `context.baseEnv`) is read, never written: `env` on the
549
+ * result is a new object built from a snapshot of it, so loading one tier
550
+ * in-process can never leak into, or be clobbered by, a caller's own
551
+ * `process.env`.
552
+ *
553
+ * `MissingEnvFileError` and `UnknownEnvironmentError` from `selectEnvFiles`
554
+ * are NOT caught here — they propagate. Only `with-env` knows how to survive
555
+ * a missing file (report it, continue with none loaded); a library function
556
+ * deciding that on every caller's behalf would take away the choice.
557
+ *
558
+ * See `LoadEnvironmentOptions.override` for the precedence subtlety: the
559
+ * default (false) matches `with-env`'s "a shell var beats the file", which is
560
+ * only correct when the caller is loading the SAME tier the process already
561
+ * runs under. A caller loading a DIFFERENT tier — one that did not shape
562
+ * `process.env` — must pass `override: true`, or that tier's file loses to
563
+ * values that were never meant to apply to it.
564
+ */
565
+ async function loadEnvironment(deployEnv, options, context) {
566
+ const override = options?.override ?? false;
567
+ const root = context?.root ?? await findRepoRoot();
568
+ let exists = context?.exists;
569
+ if (!exists) {
570
+ const { existsSync } = await import("node:fs");
571
+ const { join } = await import("node:path");
572
+ exists = (file) => existsSync(join(root, file));
573
+ }
574
+ const base = context?.baseEnv ?? process.env;
575
+ let devDatabase = options?.devDatabase;
576
+ if (devDatabase === void 0) {
577
+ const inherited = base[DEV_DB_ENV];
578
+ if (inherited !== void 0 && inherited !== "") {
579
+ if (!isDevDatabase(inherited)) throw new Error(`${DEV_DB_ENV}="${inherited}" is not one of: local, remote.`);
580
+ devDatabase = inherited;
581
+ }
582
+ }
583
+ const selection = await selectEnvFiles({
584
+ deployEnv,
585
+ exists,
586
+ probeLocalStack: context?.probeLocalStack ?? (() => probeLocalStack()),
587
+ devDatabase
588
+ });
589
+ const env = {};
590
+ for (const [key, value] of Object.entries(base)) if (value !== void 0) env[key] = value;
591
+ if (selection.files.length > 0) {
592
+ const { join } = await import("node:path");
593
+ await (context?.applyEnvFiles ?? (async (paths, target, overrideExisting) => {
594
+ const { default: dx } = await import("@dotenvx/dotenvx");
595
+ const { readFileSync } = await import("node:fs");
596
+ const raw = /* @__PURE__ */ new Map();
597
+ for (const path of paths) {
598
+ let text;
599
+ try {
600
+ text = readFileSync(path, "utf8");
601
+ } catch {
602
+ continue;
603
+ }
604
+ for (const [key, entry] of parseRawAssignments(text)) if (!raw.has(key) && !entry.literal) raw.set(key, entry.raw);
605
+ }
606
+ const shellKeysMarker = target["DEVTOOLS_SHELL_KEYS"];
607
+ const shellKeys = !overrideExisting && shellKeysMarker !== void 0 ? new Set(shellKeysMarker.split(",").filter((k) => k !== "")) : void 0;
608
+ const shellWins = /* @__PURE__ */ new Set();
609
+ if (shellKeys) {
610
+ for (const key of raw.keys()) if (shellKeys.has(key) && target[key] !== void 0) shellWins.add(key);
611
+ } else if (!overrideExisting) {
612
+ for (const key of raw.keys()) if (target[key] !== void 0) shellWins.add(key);
613
+ }
614
+ const sessionOverride = shellKeys !== void 0;
615
+ const preserved = /* @__PURE__ */ new Map();
616
+ if (sessionOverride) for (const key of shellWins) preserved.set(key, target[key]);
617
+ dx.config({
618
+ path: overrideExisting || sessionOverride ? [...paths].reverse() : paths,
619
+ processEnv: target,
620
+ quiet: true,
621
+ overload: overrideExisting || sessionOverride
622
+ });
623
+ for (const [key, value] of preserved) target[key] = value;
624
+ for (const [key, value] of raw) {
625
+ if (shellWins.has(key)) continue;
626
+ if (envReferences(value).length === 0) continue;
627
+ target[key] = expandReferences(key, value, raw, target);
628
+ }
629
+ }))(selection.files.map((file) => join(root, file)), env, override);
630
+ }
631
+ applyWranglerLocalDatabaseAlias(env);
632
+ return {
633
+ environment: selection.environment,
634
+ files: selection.files,
635
+ env,
636
+ warnings: selection.warnings
637
+ };
638
+ }
639
+ //#endregion
640
+ export { fileFor as _, LocalStackOfflineError as a, applyDeployTierAliases as c, loadEnvironment as d, parseRawAssignments as f, UnknownEnvironmentError as g, DEPLOY_ENVIRONMENTS as h, LOCAL_STACK_PORT as i, applyWranglerLocalDatabaseAlias as l, selectEnvFiles as m, GENERATED_FILE as n, MissingEnvFileError as o, probeLocalStack as p, HYPERDRIVE_LOCAL_CONNECTION_ENV as r, SHELL_KEYS_ENV as s, DEV_DB_ENV as t, isDevDatabase as u, isDeployEnvironment as v };
@@ -0,0 +1,2 @@
1
+ import { a as LocalStackOfflineError, c as applyDeployTierAliases, d as loadEnvironment, f as parseRawAssignments, i as LOCAL_STACK_PORT, l as applyWranglerLocalDatabaseAlias, m as selectEnvFiles, n as GENERATED_FILE, o as MissingEnvFileError, p as probeLocalStack, r as HYPERDRIVE_LOCAL_CONNECTION_ENV, s as SHELL_KEYS_ENV, t as DEV_DB_ENV, u as isDevDatabase } from "./load-Cp8AMERM.js";
2
+ export { DEV_DB_ENV, GENERATED_FILE, HYPERDRIVE_LOCAL_CONNECTION_ENV, LOCAL_STACK_PORT, LocalStackOfflineError, MissingEnvFileError, SHELL_KEYS_ENV, applyDeployTierAliases, applyWranglerLocalDatabaseAlias, isDevDatabase, loadEnvironment, parseRawAssignments, probeLocalStack, selectEnvFiles };