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
@@ -2,6 +2,7 @@ import { defineConfig } from "oxlint";
2
2
  import astro from "ultracite/oxlint/astro";
3
3
  import core from "ultracite/oxlint/core";
4
4
  import react from "ultracite/oxlint/react";
5
+ import vitest from "ultracite/oxlint/vitest";
5
6
 
6
7
  // Run it as `oxlint -c oxlint.config.mjs` — the flag is not optional. oxlint only
7
8
  // auto-discovers `.oxlintrc.json`, and a JSON config cannot `extends` Ultracite's
@@ -9,8 +10,8 @@ import react from "ultracite/oxlint/react";
9
10
  // modules. The JS config format is experimental and needs Node.js, which this
10
11
  // project already requires (>= 24).
11
12
  //
12
- // Add `ultracite/oxlint/tanstack` when an admin SPA lands, and
13
- // `ultracite/oxlint/vitest` when the first test file does. The full preset list is
13
+ // Add `ultracite/oxlint/tanstack` when an admin SPA lands. `vitest` is on because the
14
+ // root `vitest.config.ts` runs every workspace's tests. The full preset list is
14
15
  // angular, astro, core, jest, js-plugins, nestjs, next, qwik, react, remix, solid,
15
16
  // svelte, tanstack, vitest, vue.
16
17
 
@@ -39,7 +40,7 @@ function mergePresets(...presets) {
39
40
  return merged;
40
41
  }
41
42
 
42
- const ultracite = mergePresets(core, astro, react);
43
+ const ultracite = mergePresets(core, astro, react, vitest);
43
44
 
44
45
  // Rules turned off for the whole project, each with its reason. Ultracite enables
45
46
  // ~470, including whole families oxlint itself files under `style`, `pedantic` and
@@ -69,7 +70,14 @@ const suppressed = {
69
70
  // Sequential awaits are deliberate where ordering matters, and `async` is kept on
70
71
  // some functions (e.g. an email provider's `send`) to satisfy an interface even
71
72
  // when that implementation has nothing to await.
73
+ //
74
+ // The two `prefer-await-to-*` rules fire on APIs that are callbacks by contract:
75
+ // Hono's `.onError((err, c) => …)` in `apps/api`, and the `.catch()` that
76
+ // `database-postgres`'s `withDb` hangs on a promise it hands to `waitUntil` rather
77
+ // than awaiting. Neither has an `await` form.
72
78
  "no-await-in-loop": "off",
79
+ "promise/prefer-await-to-callbacks": "off",
80
+ "promise/prefer-await-to-then": "off",
73
81
  "require-await": "off",
74
82
 
75
83
  // --- Regular expressions -------------------------------------------------
@@ -82,10 +90,25 @@ const suppressed = {
82
90
  // --- Key ordering and small style ----------------------------------------
83
91
  // Props and config objects are grouped by meaning (`id`, then `title`, then the
84
92
  // data). Alphabetising them loses that ordering for no benefit. `no-plusplus` and
85
- // `method-signature-style` are pure preference.
93
+ // `method-signature-style` are pure preference. `unicorn/import-style` wants a
94
+ // default import for `node:path` and friends, against the named imports
95
+ // (`import { resolve } from "node:path"`) every module file uses.
86
96
  "no-plusplus": "off",
87
97
  "sort-keys": "off",
88
98
  "typescript/method-signature-style": "off",
99
+ "unicorn/import-style": "off",
100
+
101
+ // --- Thresholds and placement --------------------------------------------
102
+ // Numeric limits and shape preferences with no defect behind them. `complexity`
103
+ // fires on a provider's error mapper, which is one flat branch per vendor code.
104
+ // `no-nested-ternary` fires on a three-way sort icon in JSX. `no-dynamic-delete`
105
+ // fires on dropping one key from a record keyed by a runtime id, which is what a
106
+ // permission draft is. `consistent-function-scoping` wants a helper hoisted out of
107
+ // the factory it serves, away from the one caller that reads it.
108
+ complexity: "off",
109
+ "no-nested-ternary": "off",
110
+ "typescript/no-dynamic-delete": "off",
111
+ "unicorn/consistent-function-scoping": "off",
89
112
 
90
113
  // --- Accessibility shape, not accessibility ------------------------------
91
114
  // Fires on `<div role="group">` and friends: the roles are correct, and swapping
@@ -104,6 +127,9 @@ export default defineConfig({
104
127
  // deployed Worker or a client bundle is a leak, not a log line. Add an override
105
128
  // below for any file where console output is genuinely the feature.
106
129
  "no-console": "error",
130
+ // A type-only import is erased before the code runs, so a cycle that closes only
131
+ // through one cannot leave a module half-initialised. The runtime cycles stay errors.
132
+ "import/no-cycle": ["error", { ignoreTypes: true }],
107
133
  },
108
134
 
109
135
  overrides: [
@@ -136,6 +162,40 @@ export default defineConfig({
136
162
  rules: { "unicorn/filename-case": "off" },
137
163
  },
138
164
 
165
+ // The admin app has its own primitive set in packages/ui/src/components/admin/, which
166
+ // `saasaloy add admin` writes. Its names repeat the base ones (`button`, `card`), so an
167
+ // editor's autocomplete offers both and the wrong one renders with no error, in the
168
+ // web app's styling. Inside the admin app and the admin folders of @repo/ui, a base
169
+ // primitive or a base block with an admin twin is a lint error. A project without the
170
+ // admin app has no file under these globs, so the rule never fires there.
171
+ {
172
+ files: [
173
+ "apps/admin/**",
174
+ "packages/ui/src/blocks/admin/**",
175
+ "packages/ui/src/components/admin/**",
176
+ "packages/ui/src/containers/admin/**",
177
+ ],
178
+ rules: {
179
+ "no-restricted-imports": [
180
+ "error",
181
+ {
182
+ patterns: [
183
+ {
184
+ group: [
185
+ "@repo/ui/components/*",
186
+ "!@repo/ui/components/admin/*",
187
+ "@repo/ui/blocks/error-state",
188
+ "@repo/ui/blocks/theme-toggle",
189
+ ],
190
+ message:
191
+ "Admin code uses the admin set: import from @repo/ui/components/admin/* and @repo/ui/blocks/admin/*.",
192
+ },
193
+ ],
194
+ },
195
+ ],
196
+ },
197
+ },
198
+
139
199
  // The design system is vendored from shadcn and re-synced with
140
200
  // `pnpm --filter @repo/ui exec shadcn add …`. `Label` is a primitive: the control
141
201
  // it labels is supplied by the call site, which this rule cannot see.
@@ -151,5 +211,66 @@ export default defineConfig({
151
211
  files: ["packages/*/src/providers/console.ts", "packages/logger*/**"],
152
212
  rules: { "no-console": "off" },
153
213
  },
214
+
215
+ // Two more places where `console` is the right call, both from the queue module.
216
+ // A queue provider's batch consumer and cron tick run outside a request, so there is
217
+ // no `c.get("log")` to reach for, and `@repo/queue` has no runtime dependencies to
218
+ // import `@repo/logger` with. Workers Logs indexes `console` directly. The example
219
+ // job's whole body is one log line, which is what makes it a readable example.
220
+ {
221
+ files: [
222
+ "packages/queue/src/providers/**",
223
+ "packages/queue/src/jobs/example.ts",
224
+ ],
225
+ rules: { "no-console": "off" },
226
+ },
227
+
228
+ // The queue barrel registers its providers, and each provider calls back into the
229
+ // barrel for `createQueue`, `dispatch` and `dueSchedules`. The cycle is deliberate:
230
+ // a provider reads those hoisted functions inside a function body, never while the
231
+ // module loads. `packages/queue/src/providers/memory.ts` explains it in full.
232
+ {
233
+ files: ["packages/queue/src/index.ts", "packages/queue/src/providers/**"],
234
+ rules: { "import/no-cycle": "off" },
235
+ },
236
+
237
+ // `packages/env` runs on `node:test`, not vitest. Its tests predate the base's Vitest
238
+ // setup, and the root `vitest.config.ts` leaves the package out. Three
239
+ // vitest rules point these files at a runner they cannot use, and
240
+ // `no-floating-promises` fires on every `describe`, whose promise the runner itself
241
+ // awaits. Prefixing twenty call sites with `void` would say nothing the runner does
242
+ // not already guarantee.
243
+ //
244
+ // `env:setup` and `env-deploy` are node scripts, so `process` and `console` are theirs
245
+ // to use. Nothing a Worker imports reaches them (ADR 0041).
246
+ {
247
+ files: ["packages/env/**/*.test.ts"],
248
+ plugins: ["vitest"],
249
+ rules: {
250
+ "typescript/no-floating-promises": "off",
251
+ "vitest/no-import-node-test": "off",
252
+ "vitest/prefer-importing-vitest-globals": "off",
253
+ "vitest/prefer-each": "off",
254
+ },
255
+ },
256
+ // `createEnv` is a generic mapped-type API: it takes `server`, `client` and `shared`
257
+ // as distinct type parameters, erases them to one `SchemaRecord` to iterate the keys,
258
+ // and puts the specific type back on the way out. Every assertion this file makes is
259
+ // that erase-and-restore, which the type system cannot express and the rules below
260
+ // cannot see through. The Proxy adds two more — `Reflect.get` on a mapped type is
261
+ // `any` by definition. Scoped to this one file; every rule stays on everywhere else.
262
+ {
263
+ files: ["packages/env/src/define.ts", "packages/env/src/define.test.ts"],
264
+ rules: {
265
+ "typescript/no-unsafe-type-assertion": "off",
266
+ "typescript/no-unnecessary-type-assertion": "off",
267
+ "typescript/no-unsafe-assignment": "off",
268
+ "typescript/no-unsafe-return": "off",
269
+ },
270
+ },
271
+ {
272
+ files: ["packages/env/scripts/**", "packages/env/src/deploy.ts"],
273
+ rules: { "no-console": "off", "unicorn/no-process-exit": "off" },
274
+ },
154
275
  ],
155
276
  });
@@ -12,12 +12,15 @@
12
12
  "prepare": "husky",
13
13
  "build": "turbo run build",
14
14
  "clean": "turbo run clean && rimraf -g node_modules .turbo \"{apps,packages}/*/node_modules\" \"{apps,packages}/*/.turbo\"",
15
+ "deploy:production": "node packages/env/src/deploy.ts --check && turbo run deploy",
15
16
  "dev": "turbo run dev",
17
+ "env:setup": "node packages/env/scripts/env-setup.ts",
18
+ "test": "turbo run test && vitest run",
16
19
  "lint": "pnpm run lint:types && pnpm run lint:code && pnpm run lint:css && pnpm run format:check",
17
- "lint:types": "oxlint -c oxlint.config.mjs --type-aware --deny-warnings packages/ui/src",
20
+ "lint:types": "oxlint -c oxlint.config.mjs --type-aware --deny-warnings packages/env/src packages/ui/src",
18
21
  "lint:code": "oxlint -c oxlint.config.mjs --deny-warnings .",
19
22
  "lint:css": "stylelint \"**/*.css\" --max-warnings 0",
20
- "lint:fix": "oxlint -c oxlint.config.mjs --type-aware --fix packages/ui/src && oxlint -c oxlint.config.mjs --fix . && stylelint \"**/*.css\" --fix",
23
+ "lint:fix": "oxlint -c oxlint.config.mjs --type-aware --fix packages/env/src packages/ui/src && oxlint -c oxlint.config.mjs --fix . && stylelint \"**/*.css\" --fix",
21
24
  "format": "prettier --write .",
22
25
  "format:check": "prettier --check .",
23
26
  "typecheck": "turbo run typecheck"
@@ -39,6 +42,7 @@
39
42
  "stylelint-prettier": "5.0.3",
40
43
  "turbo": "2.11.6",
41
44
  "typescript": "7.0.2",
42
- "ultracite": "7.12.2"
45
+ "ultracite": "7.12.2",
46
+ "vitest": "5.0.5"
43
47
  }
44
48
  }
@@ -0,0 +1,54 @@
1
+ # The one key list for this repository. Every `.env` that `pnpm env:setup` writes is
2
+ # written from this file. There is no second example file, and no `.dev.vars.example`.
3
+ #
4
+ # `.env` is gitignored; this file is tracked. Keep real secrets out of it. Every value
5
+ # below is the local development default.
6
+ #
7
+ # ADDING A KEY
8
+ #
9
+ # 1. Add its line here, under the `# @services` section that names the services that read
10
+ # it, with a comment saying where the value comes from and what unset looks like. Add a
11
+ # `# Blank on purpose` line when the key is meant to stay empty.
12
+ # 2. Declare it in the capability package's `src/env.ts` preset, so `createEnv` validates
13
+ # it and the type reaches the code that reads it.
14
+ #
15
+ # `saasaloy add` regenerates the key blocks below from the installed modules' `envVars`.
16
+ # A value you type here survives that, and nothing is ever removed.
17
+ #
18
+ # SECTIONS
19
+ #
20
+ # A `# @services <names>` line sets which services take every key below it, until the next
21
+ # such line. The names are the `name` fields of `SERVICES` in
22
+ # `packages/env/src/services.ts`. A key with no section above it fails the run.
23
+
24
+ # =============================================================================
25
+ # The web app
26
+ # =============================================================================
27
+ # @services web
28
+
29
+ # The public origin this site is served from (e.g. https://example.com). Astro reads it
30
+ # at build time for canonical URLs and the sitemap. Unset, the build fails rather than
31
+ # writing localhost links into a production bundle.
32
+ PUBLIC_SITE_URL=http://localhost:3000
33
+
34
+ # Origin this site calls for api and auth requests (e.g. https://api.x.com). The api
35
+ # Worker's pinned dev port is 4000. Unset, the build fails naming this key.
36
+ PUBLIC_API_URL=http://localhost:4000
37
+
38
+ # =============================================================================
39
+ # Deploy credentials
40
+ # =============================================================================
41
+ # @services deploy
42
+
43
+ # Cloudflare API token wrangler deploys with, from https://dash.cloudflare.com/profile/api-tokens.
44
+ # The "Edit Cloudflare Workers" template covers Workers; add "D1: Edit" with a database,
45
+ # and "Workers KV Storage: Edit", "Workers R2 Storage: Edit" and "Queues: Edit" for those.
46
+ # pnpm deploy:production never puts it in a Worker's secrets.
47
+ # Blank on purpose: unset, wrangler uses your `wrangler login` session, and the deploy
48
+ # stops before any build when there is none.
49
+ CLOUDFLARE_API_TOKEN=
50
+
51
+ # The Cloudflare account id the Workers and their resources deploy into.
52
+ # Blank on purpose: unset, wrangler uses the one account the token or login can reach,
53
+ # and asks or fails when there are several.
54
+ CLOUDFLARE_ACCOUNT_ID=
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@repo/env",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "bin": {
7
+ "env-deploy": "./src/deploy.ts"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./error": "./src/error.ts",
12
+ "./check": "./src/check.ts",
13
+ "./dotenv": "./src/dotenv.ts",
14
+ "./env-file": "./src/env-file.ts",
15
+ "./example": "./src/example.ts",
16
+ "./services": "./src/services.ts",
17
+ "./zod": "./src/zod.ts",
18
+ "./sources": "./src/sources.ts",
19
+ "./setup": "./src/setup.ts"
20
+ },
21
+ "scripts": {
22
+ "clean": "rimraf -g dist \"*.tsbuildinfo\"",
23
+ "typecheck": "tsc --noEmit",
24
+ "test": "node --test \"src/**/*.test.ts\""
25
+ },
26
+ "dependencies": {
27
+ "zod": "4.5.4"
28
+ },
29
+ "devDependencies": {
30
+ "@repo/tsconfig": "workspace:*",
31
+ "@types/node": "26.4.1",
32
+ "rimraf": "6.1.3",
33
+ "typescript": "7.0.2"
34
+ }
35
+ }
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
3
+ import { selectServices } from "../src/services.ts";
4
+ import type { Environment } from "../src/services.ts";
5
+ import { setupServices } from "../src/setup.ts";
6
+ import { sources } from "../src/sources.ts";
7
+
8
+ // `pnpm env:setup [--env dev|prod] [--only <service>]`: write each service's `.env` from
9
+ // the selected value source. `dev` writes every dev service; `--env prod --only <service>`
10
+ // writes one production file and nothing under `apps/`. A deploy writes no file at all:
11
+ // `pnpm deploy:production` loads the production values in memory.
12
+
13
+ function readOptions(args: string[]): {
14
+ environment: Environment;
15
+ only?: string;
16
+ } {
17
+ let environment: Environment = "dev";
18
+ let only: string | undefined;
19
+ for (let index = 0; index < args.length; index += 1) {
20
+ const arg = args[index];
21
+ if (arg === "--env") {
22
+ const value = args[index + 1];
23
+ if (value !== "dev" && value !== "prod") {
24
+ throw new Error("--env takes dev or prod.");
25
+ }
26
+ environment = value;
27
+ index += 1;
28
+ } else if (arg === "--only") {
29
+ only = args[index + 1];
30
+ if (!only) {
31
+ throw new Error("--only takes a service name.");
32
+ }
33
+ index += 1;
34
+ } else {
35
+ throw new Error(
36
+ `Unknown option ${arg}. This command takes --env and --only.`
37
+ );
38
+ }
39
+ }
40
+ return { environment, only };
41
+ }
42
+
43
+ function say(message: string): void {
44
+ process.stdout.write(`${message}\n`);
45
+ }
46
+
47
+ try {
48
+ const options = readOptions(process.argv.slice(2));
49
+ const services = selectServices(options.environment, options.only);
50
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
51
+ encoding: "utf-8",
52
+ }).trim();
53
+
54
+ const report = await setupServices({
55
+ root,
56
+ environment: options.environment,
57
+ services,
58
+ source: sources.select(process.env.ENV_SOURCE),
59
+ processEnv: process.env,
60
+ });
61
+
62
+ if (report.kind === "kept") {
63
+ say(`Warning: ${report.reason}`);
64
+ say(
65
+ `Kept ${report.files.join(", ")}: every listed key already has a value.`
66
+ );
67
+ } else {
68
+ for (const service of report.services) {
69
+ say(`${service.created ? "Created" : "Updated"} ${service.file}.`);
70
+ if (service.migrated) {
71
+ say(
72
+ `Carried the values of ${service.file.replace(/\.env$/, ".dev.vars")} across and deleted it. Wrangler loads .env only while no .dev.vars exists.`
73
+ );
74
+ }
75
+ if (service.extraKeys.length > 0) {
76
+ say(
77
+ `Warning: ${service.extraKeys.join(", ")} came from the value source but packages/env/.env.example does not list them for ${service.service}. Add them there, under a "# @services" section that names ${service.service}.`
78
+ );
79
+ }
80
+ }
81
+ }
82
+ } catch (error) {
83
+ process.stderr.write(
84
+ `${error instanceof Error ? error.message : String(error)}\n`
85
+ );
86
+ process.exitCode = 1;
87
+ }
@@ -0,0 +1,41 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { blankOnPurposeKeys, incompleteKeys } from "./check.ts";
4
+
5
+ describe("blankOnPurposeKeys", () => {
6
+ it("marks a key whose comment block carries the line", () => {
7
+ const keys = blankOnPurposeKeys([
8
+ "# Blank on purpose: unset means no proxy.",
9
+ "# A second comment line still belongs to the block.",
10
+ "A=",
11
+ "",
12
+ "B=",
13
+ ]);
14
+ assert.deepEqual([...keys], ["A"]);
15
+ });
16
+
17
+ it("ends the block at a blank line", () => {
18
+ const keys = blankOnPurposeKeys(["# Blank on purpose", "", "A="]);
19
+ assert.deepEqual([...keys], []);
20
+ });
21
+ });
22
+
23
+ describe("incompleteKeys", () => {
24
+ const example = ["A=", "# Blank on purpose", "B=", "C="];
25
+
26
+ it("names every key with no value", () => {
27
+ assert.deepEqual(incompleteKeys(example, new Map()), ["A", "C"]);
28
+ });
29
+
30
+ it("ignores a key the service omits", () => {
31
+ assert.deepEqual(incompleteKeys(example, new Map(), ["C"]), ["A"]);
32
+ });
33
+
34
+ it("is empty when every key has a value", () => {
35
+ const values = new Map([
36
+ ["A", "1"],
37
+ ["C", "3"],
38
+ ]);
39
+ assert.deepEqual(incompleteKeys(example, values), []);
40
+ });
41
+ });
@@ -0,0 +1,50 @@
1
+ import { parseValues } from "./dotenv.ts";
2
+
3
+ // The checks `env:setup` runs before it writes anything.
4
+
5
+ /** The comment that says a key is meant to stay empty. */
6
+ const BLANK_ON_PURPOSE = "# blank on purpose";
7
+
8
+ /**
9
+ * Every key whose comment block carries a `# Blank on purpose` line above it.
10
+ *
11
+ * A comment block is the run of comment lines directly above the key, so the marker may
12
+ * sit on any of them. A blank line ends the block.
13
+ */
14
+ export function blankOnPurposeKeys(lines: string[]): Set<string> {
15
+ const keys = new Set<string>();
16
+ let marked = false;
17
+ for (const line of lines) {
18
+ const trimmed = line.trim();
19
+ if (trimmed.startsWith("#")) {
20
+ marked ||= trimmed.toLowerCase().startsWith(BLANK_ON_PURPOSE);
21
+ continue;
22
+ }
23
+ if (trimmed === "") {
24
+ marked = false;
25
+ continue;
26
+ }
27
+ const [key] = [...parseValues([line]).keys()];
28
+ if (key !== undefined && marked) {
29
+ keys.add(key);
30
+ }
31
+ marked = false;
32
+ }
33
+ return keys;
34
+ }
35
+
36
+ /**
37
+ * Every key the example lists that `values` leaves empty, apart from a blank-on-purpose
38
+ * key and a key in `omit`. An empty result means the file is complete.
39
+ */
40
+ export function incompleteKeys(
41
+ example: string[],
42
+ values: Map<string, string>,
43
+ omit: readonly string[] = []
44
+ ): string[] {
45
+ const marked = blankOnPurposeKeys(example);
46
+ return [...parseValues(example).keys()].filter(
47
+ (key) =>
48
+ (values.get(key) ?? "") === "" && !marked.has(key) && !omit.includes(key)
49
+ );
50
+ }