saasaloy 0.5.2 → 0.6.1

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.
Files changed (84) hide show
  1. package/README.md +7 -7
  2. package/dist/index.js +637 -522
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/schemas/registry-item.schema.json +31 -1
  6. package/templates/base/AGENTS.md +204 -89
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +38 -17
  9. package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
  10. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
  11. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
  12. package/templates/base/_gitignore +0 -3
  13. package/templates/base/_saasaloy-base.json +8 -1
  14. package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
  15. package/templates/base/apps/web/astro.config.mjs +36 -6
  16. package/templates/base/apps/web/package.json +2 -1
  17. package/templates/base/apps/web/src/env.ts +41 -0
  18. package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
  19. package/templates/base/apps/web/src/pages/index.astro +33 -24
  20. package/templates/base/apps/web/src/pages/privacy.astro +10 -2
  21. package/templates/base/apps/web/src/pages/terms.astro +10 -2
  22. package/templates/base/oxlint.config.mjs +125 -4
  23. package/templates/base/package.json +7 -3
  24. package/templates/base/packages/env/_env.example +54 -0
  25. package/templates/base/packages/env/package.json +35 -0
  26. package/templates/base/packages/env/scripts/env-setup.ts +87 -0
  27. package/templates/base/packages/env/src/check.test.ts +41 -0
  28. package/templates/base/packages/env/src/check.ts +50 -0
  29. package/templates/base/packages/env/src/define.test.ts +206 -0
  30. package/templates/base/packages/env/src/define.ts +315 -0
  31. package/templates/base/packages/env/src/deploy.test.ts +438 -0
  32. package/templates/base/packages/env/src/deploy.ts +518 -0
  33. package/templates/base/packages/env/src/dotenv.test.ts +179 -0
  34. package/templates/base/packages/env/src/dotenv.ts +173 -0
  35. package/templates/base/packages/env/src/env-file.ts +51 -0
  36. package/templates/base/packages/env/src/error.ts +38 -0
  37. package/templates/base/packages/env/src/example.test.ts +86 -0
  38. package/templates/base/packages/env/src/example.ts +90 -0
  39. package/templates/base/packages/env/src/index.ts +15 -0
  40. package/templates/base/packages/env/src/schema.ts +63 -0
  41. package/templates/base/packages/env/src/services.test.ts +83 -0
  42. package/templates/base/packages/env/src/services.ts +110 -0
  43. package/templates/base/packages/env/src/setup.test.ts +254 -0
  44. package/templates/base/packages/env/src/setup.ts +177 -0
  45. package/templates/base/packages/env/src/source.ts +20 -0
  46. package/templates/base/packages/env/src/sources.test.ts +137 -0
  47. package/templates/base/packages/env/src/sources.ts +93 -0
  48. package/templates/base/packages/env/src/zod.ts +8 -0
  49. package/templates/base/packages/env/tsconfig.json +13 -0
  50. package/templates/base/packages/tsconfig/base.json +7 -0
  51. package/templates/base/packages/ui/components.json +1 -1
  52. package/templates/base/packages/ui/package.json +4 -2
  53. package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
  54. package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
  55. package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
  56. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
  57. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
  58. package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
  59. package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
  60. package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
  61. package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
  62. package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
  63. package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
  64. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
  65. package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
  66. package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
  67. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
  68. package/templates/base/packages/ui/src/components/badge.tsx +1 -1
  69. package/templates/base/packages/ui/src/components/button.tsx +2 -2
  70. package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
  71. package/templates/base/packages/ui/src/containers/README.md +6 -5
  72. package/templates/base/packages/ui/src/content/landing.ts +590 -125
  73. package/templates/base/packages/ui/src/index.ts +6 -2
  74. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  75. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  76. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  77. package/templates/base/packages/ui/src/lib/theme.ts +140 -56
  78. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  79. package/templates/base/packages/ui/src/styles/web.css +359 -0
  80. package/templates/base/pnpm-workspace.yaml +0 -6
  81. package/templates/base/saasaloy.json +1 -0
  82. package/templates/base/stylelint.config.js +3 -3
  83. package/templates/base/turbo.json +13 -0
  84. package/templates/base/vitest.config.ts +21 -0
@@ -0,0 +1,206 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createEnv, definePreset, EnvValidationError } from "./define.ts";
4
+ import type { StandardSchema } from "./schema.ts";
5
+
6
+ // Hand-written Standard Schemas, so these tests prove the core is validator-blind: no zod
7
+ // is imported anywhere below, and `createEnv` never learns which vendor wrote them.
8
+
9
+ function required(label = "a string"): StandardSchema<unknown, string> {
10
+ return {
11
+ "~standard": {
12
+ version: 1,
13
+ vendor: "test",
14
+ validate: (value) =>
15
+ typeof value === "string" && value !== ""
16
+ ? { value }
17
+ : { issues: [{ message: `expected ${label}` }] },
18
+ },
19
+ };
20
+ }
21
+
22
+ function optional(fallback: string): StandardSchema<unknown, string> {
23
+ return {
24
+ "~standard": {
25
+ version: 1,
26
+ vendor: "test",
27
+ validate: (value) => ({
28
+ value: typeof value === "string" ? value : fallback,
29
+ }),
30
+ },
31
+ };
32
+ }
33
+
34
+ /** The error a call threw, so a test can read its fields. */
35
+ function caught(fn: () => unknown): EnvValidationError {
36
+ try {
37
+ fn();
38
+ } catch (error) {
39
+ assert.ok(error instanceof EnvValidationError);
40
+ return error;
41
+ }
42
+ throw new Error("expected a throw");
43
+ }
44
+
45
+ describe("createEnv", () => {
46
+ it("returns the parsed values", () => {
47
+ const env = createEnv({ server: { A: required() } });
48
+ assert.equal(env({ A: "one" }).A, "one");
49
+ });
50
+
51
+ it("names every failing key in one throw", () => {
52
+ const env = createEnv({
53
+ extends: [
54
+ definePreset({ module: "@repo/kv", server: { KV: required() } }),
55
+ ],
56
+ server: { A: required(), B: required() },
57
+ });
58
+ const error = caught(() => env({}));
59
+ assert.deepEqual(
60
+ error.issues.map((issue) => issue.key),
61
+ ["KV", "A", "B"]
62
+ );
63
+ assert.equal(error.issues[0]?.module, "@repo/kv");
64
+ assert.match(error.message, /3 environment keys are missing or invalid/);
65
+ });
66
+
67
+ it("reports the declaring module's own wording", () => {
68
+ const schema = required();
69
+ (schema as { description?: string }).description = "where mail comes from";
70
+ const env = createEnv({
71
+ extends: [
72
+ definePreset({ module: "@repo/email", server: { FROM: schema } }),
73
+ ],
74
+ });
75
+ const error = caught(() => env({}));
76
+ assert.equal(error.issues[0]?.description, "where mail comes from");
77
+ assert.match(error.message, /where mail comes from/);
78
+ });
79
+
80
+ it("treats an empty string as unset by default", () => {
81
+ const env = createEnv({ server: { A: required() } });
82
+ const error = caught(() => env({ A: "" }));
83
+ assert.equal(error.issues[0]?.reason, "unset");
84
+ });
85
+
86
+ it("keeps an empty string when emptyStringAsUndefined is off", () => {
87
+ const env = createEnv({
88
+ server: { A: optional("fallback") },
89
+ emptyStringAsUndefined: false,
90
+ });
91
+ assert.equal(env({ A: "" }).A, "");
92
+ });
93
+
94
+ it("memoizes per source object", () => {
95
+ let calls = 0;
96
+ const counted: StandardSchema<unknown, string> = {
97
+ "~standard": {
98
+ version: 1,
99
+ vendor: "test",
100
+ validate: (value) => {
101
+ calls += 1;
102
+ return { value: String(value) };
103
+ },
104
+ },
105
+ };
106
+ const env = createEnv({ server: { A: counted } });
107
+ const source = { A: "one" };
108
+ env(source);
109
+ env(source);
110
+ assert.equal(calls, 1);
111
+ env({ A: "one" });
112
+ assert.equal(calls, 2);
113
+ });
114
+
115
+ it("skips validation when the build-time flag is set", () => {
116
+ const env = createEnv({ server: { A: required() }, skipValidation: true });
117
+ assert.equal(env({}).A, undefined);
118
+ });
119
+
120
+ it("calls onValidationError instead of throwing its own error", () => {
121
+ const seen: string[] = [];
122
+ const env = createEnv({
123
+ server: { A: required() },
124
+ onValidationError: (issues) => {
125
+ seen.push(...issues.map((issue) => issue.key));
126
+ throw new Error("mine");
127
+ },
128
+ });
129
+ assert.throws(() => env({}), /mine/);
130
+ assert.deepEqual(seen, ["A"]);
131
+ });
132
+
133
+ it("refuses a client key without the prefix", () => {
134
+ assert.throws(
135
+ () =>
136
+ createEnv({
137
+ // @ts-expect-error a client key must start with PUBLIC_, and the type says so
138
+ client: { SECRET: required() },
139
+ }),
140
+ /does not start with PUBLIC_/
141
+ );
142
+ });
143
+
144
+ it("accepts a client key that carries the prefix", () => {
145
+ const env = createEnv({
146
+ client: { PUBLIC_A: required() },
147
+ runtimeEnvStrict: { PUBLIC_A: "one" },
148
+ isServer: false,
149
+ });
150
+ assert.equal(env().PUBLIC_A, "one");
151
+ });
152
+
153
+ it("throws when client code reads a server key", () => {
154
+ const env = createEnv({
155
+ extends: [
156
+ definePreset({ module: "@repo/auth", server: { SECRET: required() } }),
157
+ ],
158
+ client: { PUBLIC_A: required() },
159
+ runtimeEnvStrict: { PUBLIC_A: "one", SECRET: "leaked" },
160
+ isServer: false,
161
+ });
162
+ const values = env();
163
+ assert.equal(values.PUBLIC_A, "one");
164
+ assert.throws(
165
+ () => values.SECRET,
166
+ /SECRET is a server environment key and this code runs on the client/
167
+ );
168
+ });
169
+
170
+ it("does not validate server keys on the client", () => {
171
+ const env = createEnv({
172
+ server: { SECRET: required() },
173
+ client: { PUBLIC_A: required() },
174
+ runtimeEnvStrict: { PUBLIC_A: "one" },
175
+ isServer: false,
176
+ });
177
+ assert.equal(env().PUBLIC_A, "one");
178
+ });
179
+
180
+ it("reads a shared key on both sides", () => {
181
+ const shared = { MODE: required() };
182
+ assert.equal(createEnv({ shared })({ MODE: "dev" }).MODE, "dev");
183
+ assert.equal(
184
+ createEnv({
185
+ shared,
186
+ isServer: false,
187
+ runtimeEnvStrict: { MODE: "dev" },
188
+ })().MODE,
189
+ "dev"
190
+ );
191
+ });
192
+
193
+ it("refuses an async validator", () => {
194
+ const async: StandardSchema<unknown, string> = {
195
+ "~standard": {
196
+ version: 1,
197
+ vendor: "test",
198
+ validate: async () => await Promise.resolve({ value: "one" }),
199
+ },
200
+ };
201
+ assert.throws(
202
+ () => createEnv({ server: { A: async } })({ A: "one" }),
203
+ /cannot await one/
204
+ );
205
+ });
206
+ });
@@ -0,0 +1,315 @@
1
+ import { describeSchema, validateSync } from "./schema.ts";
2
+ import type { InferOutput, StandardSchema } from "./schema.ts";
3
+
4
+ // `createEnv` — the gate every capability sits behind.
5
+ //
6
+ // A capability package exports a preset saying which keys it needs and what they look
7
+ // like. A service workspace composes the presets it installed with `extends` and gets one
8
+ // typed object back. Three rules make that worth doing:
9
+ //
10
+ // 1. Validation aggregates. One throw names every failing key, the module that declared
11
+ // it and that module's own wording, so a deploy with three unset keys takes one round
12
+ // trip to fix instead of three.
13
+ // 2. A `client` key must carry the client prefix, and the type system says so. A preset
14
+ // that puts a secret in `client` does not compile.
15
+ // 3. A `server` key read while `isServer` is false throws through a Proxy, so a bundle
16
+ // that never should have carried the value fails loudly when it reaches for it.
17
+ //
18
+ // Nothing here reads `process.env`. The caller passes the object: a Worker's `env` on the
19
+ // server, an inlined `runtimeEnvStrict` map in a frontend build.
20
+
21
+ /** The prefix a client key must carry. A key without it is a server key by definition. */
22
+ export const DEFAULT_CLIENT_PREFIX = "PUBLIC_";
23
+
24
+ export type SchemaRecord = Record<string, StandardSchema>;
25
+
26
+ /** The message a mis-prefixed client key reports instead of a type. */
27
+ export type PrefixError<
28
+ Key extends string,
29
+ Prefix extends string,
30
+ > = `${Key} is declared as a client key but does not start with ${Prefix}. Move it to server, or rename it.`;
31
+
32
+ /** Every key of `Shape` must start with `Prefix`; the ones that do not become a message. */
33
+ export type ClientShape<Shape extends SchemaRecord, Prefix extends string> = {
34
+ [Key in keyof Shape]: Key extends `${Prefix}${string}`
35
+ ? Shape[Key]
36
+ : Key extends string
37
+ ? PrefixError<Key, Prefix>
38
+ : never;
39
+ };
40
+
41
+ export interface EnvPreset<
42
+ Server extends SchemaRecord = SchemaRecord,
43
+ Client extends SchemaRecord = SchemaRecord,
44
+ Shared extends SchemaRecord = SchemaRecord,
45
+ > {
46
+ /** The package that declared these keys, named in the aggregate error. */
47
+ module: string;
48
+ /** Presets this one folds in — how a provider module adds its own keys. */
49
+ extends: EnvPreset[];
50
+ server: Server;
51
+ client: Client;
52
+ shared: Shared;
53
+ }
54
+
55
+ /**
56
+ * Declare one capability's keys. `server` is read only where `isServer` is true, `client`
57
+ * is inlined into a public bundle and must carry the prefix, and `shared` is read in both.
58
+ */
59
+ export function definePreset<
60
+ const Server extends SchemaRecord,
61
+ const Client extends SchemaRecord,
62
+ const Shared extends SchemaRecord,
63
+ >(preset: {
64
+ module: string;
65
+ /**
66
+ * Keep this an array literal. A provider module appends its own preset here with a
67
+ * `plugin-array` patch, beside the one that appends its provider.
68
+ */
69
+ extends?: EnvPreset[];
70
+ server?: Server;
71
+ client?: ClientShape<Client, typeof DEFAULT_CLIENT_PREFIX>;
72
+ shared?: Shared;
73
+ }): EnvPreset<Server, Client, Shared> {
74
+ return {
75
+ module: preset.module,
76
+ extends: preset.extends ?? [],
77
+ server: (preset.server ?? {}) as Server,
78
+ client: (preset.client ?? {}) as unknown as Client,
79
+ shared: (preset.shared ?? {}) as Shared,
80
+ };
81
+ }
82
+
83
+ export interface EnvIssue {
84
+ key: string;
85
+ /** The package that declared the key. */
86
+ module: string;
87
+ /** Why it failed: unset, or the validator's own message. */
88
+ reason: string;
89
+ /** The declaring module's own wording for what the key is for. */
90
+ description: string | undefined;
91
+ }
92
+
93
+ /** One throw naming every failing key at once. */
94
+ export class EnvValidationError extends Error {
95
+ override name = "EnvValidationError";
96
+ readonly issues: EnvIssue[];
97
+
98
+ constructor(issues: EnvIssue[]) {
99
+ super(formatIssues(issues));
100
+ this.issues = issues;
101
+ }
102
+ }
103
+
104
+ function formatIssues(issues: EnvIssue[]): string {
105
+ // ASCII only. An Astro prerender failure travels back as an HTTP header, and a non-ASCII
106
+ // byte there gets an encoding warning printed over the message a reader needs.
107
+ const lines = issues.map((issue) => {
108
+ const what =
109
+ issue.description === undefined ? "" : ` - ${issue.description}`;
110
+ return ` ${issue.key} (${issue.module}): ${issue.reason}${what}`;
111
+ });
112
+ return `${issues.length} environment ${
113
+ issues.length === 1 ? "key is" : "keys are"
114
+ } missing or invalid:\n${lines.join("\n")}`;
115
+ }
116
+
117
+ export interface CreateEnvOptions<
118
+ Server extends SchemaRecord,
119
+ Client extends SchemaRecord,
120
+ Shared extends SchemaRecord,
121
+ > {
122
+ /**
123
+ * The capability presets this service installed. Keep this an array literal: the
124
+ * codemod behind the `plugin-array` patch kind has nothing to push into otherwise, and
125
+ * `saasaloy add` fails silently.
126
+ */
127
+ extends?: EnvPreset[];
128
+ server?: Server;
129
+ client?: ClientShape<Client, typeof DEFAULT_CLIENT_PREFIX>;
130
+ shared?: Shared;
131
+ clientPrefix?: string;
132
+ /**
133
+ * The inlined values a frontend build supplies, one literal
134
+ * `import.meta.env.PUBLIC_X` per client and shared key. Vite inlines a literal member
135
+ * expression and nothing else, so this map is hand-written and cannot be derived.
136
+ */
137
+ runtimeEnvStrict?: Record<string, unknown>;
138
+ /** True on a server. A build-time constant, because a Worker is a server too. */
139
+ isServer?: boolean;
140
+ /** Treat `KEY=` as unset. True by default: the CLI writes `KEY=` as a placeholder. */
141
+ emptyStringAsUndefined?: boolean;
142
+ /** A build-time flag only. Never read this from a runtime key. */
143
+ skipValidation?: boolean;
144
+ onValidationError?: (issues: EnvIssue[]) => never;
145
+ onInvalidAccess?: (key: string) => never;
146
+ }
147
+
148
+ export type EnvOutput<
149
+ Server extends SchemaRecord,
150
+ Client extends SchemaRecord,
151
+ Shared extends SchemaRecord,
152
+ > = { [Key in keyof Server]: InferOutput<Server[Key]> } & {
153
+ [Key in keyof Client]: Client[Key] extends StandardSchema
154
+ ? InferOutput<Client[Key]>
155
+ : never;
156
+ } & { [Key in keyof Shared]: InferOutput<Shared[Key]> } & Record<
157
+ string,
158
+ unknown
159
+ >;
160
+
161
+ /** What `createEnv` returns: call it with the runtime env to get the typed values. */
162
+ export type EnvAccessor<Values> = (source?: Record<string, unknown>) => Values;
163
+
164
+ /**
165
+ * Compose the presets and return the accessor a service's code calls.
166
+ *
167
+ * The accessor validates on its first call for a given source object and memoizes the
168
+ * result in a `WeakMap`. A Worker cannot see its `env` at module scope, so the first
169
+ * request is the earliest honest point to check; every later request in the same isolate
170
+ * pays a map lookup.
171
+ */
172
+ export function createEnv<
173
+ const Server extends SchemaRecord = Record<never, never>,
174
+ const Client extends SchemaRecord = Record<never, never>,
175
+ const Shared extends SchemaRecord = Record<never, never>,
176
+ >(
177
+ options: CreateEnvOptions<Server, Client, Shared>
178
+ ): EnvAccessor<EnvOutput<Server, Client, Shared>> {
179
+ const prefix = options.clientPrefix ?? DEFAULT_CLIENT_PREFIX;
180
+ const isServer = options.isServer ?? true;
181
+ const emptyIsUnset = options.emptyStringAsUndefined ?? true;
182
+
183
+ const own: EnvPreset = {
184
+ module: "this workspace",
185
+ extends: [],
186
+ server: (options.server ?? {}) as SchemaRecord,
187
+ client: (options.client ?? {}) as unknown as SchemaRecord,
188
+ shared: (options.shared ?? {}) as SchemaRecord,
189
+ };
190
+ const presets = [...(options.extends ?? []), own];
191
+
192
+ const server = collect(presets, "server");
193
+ const client = collect(presets, "client");
194
+ const shared = collect(presets, "shared");
195
+
196
+ for (const key of client.keys()) {
197
+ if (!key.startsWith(prefix)) {
198
+ throw new Error(
199
+ `${key} is declared as a client key but does not start with ${prefix}. Move it to server, or rename it.`
200
+ );
201
+ }
202
+ }
203
+
204
+ // A server key is unreadable on the client. Listing them by name keeps the Proxy's
205
+ // refusal specific: an unknown key is a plain `undefined`, not a throw.
206
+ const serverOnly = new Set(server.keys());
207
+ const cache = new WeakMap<object, EnvOutput<Server, Client, Shared>>();
208
+
209
+ return (source) => {
210
+ const raw: Record<string, unknown> =
211
+ source ?? options.runtimeEnvStrict ?? {};
212
+ const cached = cache.get(raw);
213
+ if (cached) {
214
+ return cached;
215
+ }
216
+ const values =
217
+ options.skipValidation === true
218
+ ? { ...raw }
219
+ : validate({
220
+ raw,
221
+ emptyIsUnset,
222
+ fields: [...(isServer ? server : new Map()), ...client, ...shared],
223
+ onValidationError: options.onValidationError,
224
+ });
225
+
226
+ const guarded = guard(
227
+ values,
228
+ serverOnly,
229
+ isServer,
230
+ options.onInvalidAccess
231
+ ) as EnvOutput<Server, Client, Shared>;
232
+ cache.set(raw, guarded);
233
+ return guarded;
234
+ };
235
+ }
236
+
237
+ function collect(
238
+ presets: EnvPreset[],
239
+ part: "server" | "client" | "shared"
240
+ ): Map<string, { schema: StandardSchema; module: string }> {
241
+ const fields = new Map<string, { schema: StandardSchema; module: string }>();
242
+ for (const preset of flatten(presets)) {
243
+ for (const [key, schema] of Object.entries(preset[part])) {
244
+ fields.set(key, { schema, module: preset.module });
245
+ }
246
+ }
247
+ return fields;
248
+ }
249
+
250
+ /** Every preset a composition reaches, each one's own `extends` first. */
251
+ export function flatten(presets: EnvPreset[]): EnvPreset[] {
252
+ return presets.flatMap((preset) => [...flatten(preset.extends), preset]);
253
+ }
254
+
255
+ function validate(options: {
256
+ raw: Record<string, unknown>;
257
+ emptyIsUnset: boolean;
258
+ fields: [string, { schema: StandardSchema; module: string }][];
259
+ onValidationError?: (issues: EnvIssue[]) => never;
260
+ }): Record<string, unknown> {
261
+ const { raw, emptyIsUnset, fields } = options;
262
+ const parsed: Record<string, unknown> = { ...raw };
263
+ const issues: EnvIssue[] = [];
264
+
265
+ for (const [key, field] of fields) {
266
+ const given = raw[key];
267
+ const input = emptyIsUnset && given === "" ? undefined : given;
268
+ const result = validateSync(field.schema, input);
269
+ if (result.issues) {
270
+ issues.push({
271
+ key,
272
+ module: field.module,
273
+ reason:
274
+ input === undefined
275
+ ? "unset"
276
+ : result.issues.map((issue) => issue.message).join("; "),
277
+ description: describeSchema(field.schema),
278
+ });
279
+ continue;
280
+ }
281
+ parsed[key] = result.value;
282
+ }
283
+
284
+ if (issues.length > 0) {
285
+ if (options.onValidationError) {
286
+ options.onValidationError(issues);
287
+ }
288
+ throw new EnvValidationError(issues);
289
+ }
290
+ return parsed;
291
+ }
292
+
293
+ function guard(
294
+ values: Record<string, unknown>,
295
+ serverOnly: Set<string>,
296
+ isServer: boolean,
297
+ onInvalidAccess?: (key: string) => never
298
+ ): Record<string, unknown> {
299
+ if (isServer) {
300
+ return values;
301
+ }
302
+ return new Proxy(values, {
303
+ get(target, property, receiver) {
304
+ if (typeof property === "string" && serverOnly.has(property)) {
305
+ if (onInvalidAccess) {
306
+ onInvalidAccess(property);
307
+ }
308
+ throw new Error(
309
+ `${property} is a server environment key and this code runs on the client. It is not in this bundle, and it must not be.`
310
+ );
311
+ }
312
+ return Reflect.get(target, property, receiver);
313
+ },
314
+ });
315
+ }