@devdogsuga/devtools 0.1.50 → 0.1.51-next.526
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 +81 -7
- package/bin/with-env.mjs +17 -0
- package/dist/app-registry-BoAnenqC.js +38 -0
- package/dist/{cli-t3gWyy3w.js → cli-BEFBVH8e.js} +2286 -718
- package/dist/launch.js +19 -10
- package/dist/load-Cp8AMERM.js +640 -0
- package/dist/load-Czhayrsk.js +2 -0
- package/dist/{menu-DYQ-Dd-O.js → menu-CJC7OElU.js} +473 -178
- package/dist/{failure-log-DOt6E3hS.js → root-CsocfxnK.js} +104 -5
- package/dist/session-CmDxHEMn.js +2 -0
- package/dist/session-rbONwFvY.js +431 -0
- package/dist/{stack-C_50FafG.js → stack-7Lbym6Wq.js} +2 -3
- package/dist/stack-7qA2UP9g.js +2 -0
- package/dist/supabase-project-BoJKMCxL.js +766 -0
- package/dist/{telemetry-C07-ioHm.js → telemetry-BKFDtzXI.js} +8 -9
- package/dist/telemetry.js +1 -1
- package/dist/with-env.js +370 -0
- package/dist/worker-env-CAFmquhW.js +36 -0
- package/dist/wrangler-env-BrlNe4pm.js +34 -0
- package/env.ts +1 -1
- package/package.json +11 -20
- package/dist/local-env-BuHU5Ckd.js +0 -357
- package/dist/local-env-MqH18s-N.js +0 -2
- package/dist/peers-DvUH5VxX.js +0 -354
- package/dist/root-CjPWNkBu.js +0 -99
- package/dist/stack-o1Kml3V0.js +0 -2
- package/dist/supabase-project-C93xRFkN.js +0 -436
|
@@ -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 };
|