saasaloy 0.5.2 → 0.6.0

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 (83) 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 +180 -53
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +26 -15
  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 +91 -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 +1 -0
  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/content/landing.ts +590 -125
  72. package/templates/base/packages/ui/src/index.ts +6 -2
  73. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  74. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  75. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  76. package/templates/base/packages/ui/src/lib/theme.ts +140 -56
  77. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  78. package/templates/base/packages/ui/src/styles/web.css +359 -0
  79. package/templates/base/pnpm-workspace.yaml +0 -6
  80. package/templates/base/saasaloy.json +1 -0
  81. package/templates/base/stylelint.config.js +3 -3
  82. package/templates/base/turbo.json +13 -0
  83. package/templates/base/vitest.config.ts +21 -0
@@ -0,0 +1,41 @@
1
+ import { createEnv } from "@repo/env";
2
+ import { z } from "@repo/env/zod";
3
+
4
+ // This app's typed environment. Every value the browser bundle carries passes through
5
+ // here, and nothing else does.
6
+ //
7
+ // Two rules make the file worth reading before you edit it.
8
+ //
9
+ // `runtimeEnvStrict` is hand-written, one literal `import.meta.env.PUBLIC_X` per key.
10
+ // Vite inlines a literal member expression and nothing else, so a derived or generated
11
+ // form — a spread, a loop, a computed key — compiles to `undefined` in the built bundle
12
+ // and fails only in production. A module that ships a page into this app patches one line
13
+ // into that map.
14
+ //
15
+ // `isServer: false` is what arms the Proxy. A server key that somehow reaches this bundle
16
+ // throws when something reads it, rather than shipping the value to a browser.
17
+
18
+ export const webEnv = createEnv({
19
+ /**
20
+ * Keep this an array literal. `saasaloy add` appends a capability's preset here with a
21
+ * `plugin-array` patch, and it fails silently otherwise.
22
+ */
23
+ extends: [],
24
+ client: {
25
+ PUBLIC_SITE_URL: z
26
+ .url()
27
+ .describe(
28
+ "The public origin this site is served from, e.g. https://example.com. Used for canonical URLs."
29
+ ),
30
+ PUBLIC_API_URL: z
31
+ .url()
32
+ .describe(
33
+ "Origin this site calls for api and auth requests, e.g. https://api.x.com. http://localhost:4000 in dev. Seeded here so a module shipping a form into this app has a typed key to read."
34
+ ),
35
+ },
36
+ runtimeEnvStrict: {
37
+ PUBLIC_SITE_URL: import.meta.env.PUBLIC_SITE_URL,
38
+ PUBLIC_API_URL: import.meta.env.PUBLIC_API_URL,
39
+ },
40
+ isServer: false,
41
+ });
@@ -1,9 +1,13 @@
1
1
  ---
2
2
  // The one document shell for every page. It owns <html>/<head>/<body> and the single
3
- // import of the shared theme, so a page is only ever its own content — no page may
4
- // duplicate the shell or import globals.css again.
5
- import "@repo/ui/globals.css";
3
+ // import of the web theme, so a page is only ever its own content — no page may
4
+ // duplicate the shell or import a stylesheet again. web.css imports the shared
5
+ // globals.css itself.
6
+ import "@repo/ui/styles/web.css";
7
+ import { siteName } from "@repo/ui";
8
+ import { ui } from "@repo/ui/content/landing";
6
9
  import { THEME_INIT_SCRIPT } from "@repo/ui/lib/theme";
10
+ import { webEnv } from "@web/env";
7
11
 
8
12
  interface Props {
9
13
  title: string;
@@ -11,6 +15,11 @@ interface Props {
11
15
  }
12
16
 
13
17
  const { title, description } = Astro.props;
18
+
19
+ // The one place this app reads an environment value. `webEnv()` validates on its first
20
+ // call and throws naming every key that is unset, so a build with a missing key fails
21
+ // here rather than shipping a page with a broken canonical URL.
22
+ const canonical = new URL(Astro.url.pathname, webEnv().PUBLIC_SITE_URL).href;
14
23
  ---
15
24
 
16
25
  <!doctype html>
@@ -19,19 +28,28 @@ const { title, description } = Astro.props;
19
28
  <meta charset="utf-8" />
20
29
  <meta name="viewport" content="width=device-width, initial-scale=1" />
21
30
  <title>{title}</title>
22
- {
23
- /*
31
+ {/*
24
32
  Explicit icon link, not the implicit /favicon.ico lookup. Without a link the
25
33
  browser guesses /favicon.ico, and Chrome tags that guess `Sec-Fetch-Site:
26
34
  cross-site`, which the Astro dev server blocks with a `[router]` warning
27
35
  (astro/dist/vite-plugin-astro-server/sec-fetch.js). A declared same-origin
28
36
  link never gets that treatment. Replace public/favicon.svg with the project mark.
29
- */
30
- }
37
+ */}
31
38
  <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
39
+ <link rel="canonical" href={canonical} />
32
40
  {description && <meta name="description" content={description} />}
33
- {
34
- /*
41
+ {/*
42
+ The link preview. Every page gets the title, the URL and the card type; a page that
43
+ passes a description gets it here too. To add a preview image, put a 1200×630 file
44
+ in public/ and add `og:image` and `twitter:image` tags beside these.
45
+ */}
46
+ <meta property="og:type" content="website" />
47
+ <meta property="og:site_name" content={siteName} />
48
+ <meta property="og:title" content={title} />
49
+ {description && <meta property="og:description" content={description} />}
50
+ <meta property="og:url" content={canonical} />
51
+ <meta name="twitter:card" content="summary" />
52
+ {/*
35
53
  Pre-paint theme resolution. This runs synchronously in <head>, BEFORE the browser
36
54
  paints, so a dark-mode visitor never sees a white flash. `is:inline` keeps Astro
37
55
  from bundling it into a deferred module, which would defeat the whole point —
@@ -42,11 +60,21 @@ const { title, description } = Astro.props;
42
60
  `[data-theme-toggle]` on the page through one delegated listener. That is why
43
61
  @repo/ui/blocks/theme-toggle needs no client directive. Do not inline a second
44
62
  resolver here, and do not turn this into an import.
45
- */
46
- }
63
+ */}
47
64
  <script is:inline set:html={THEME_INIT_SCRIPT} />
48
65
  </head>
49
66
  <body class="bg-background text-foreground min-h-dvh font-sans antialiased">
67
+ {/*
68
+ The first Tab stop on every page. It stays off screen until focused, then jumps past
69
+ the navbar to the page's <main>. Every page and the error block give their <main>
70
+ `id="main"` and `tabindex="-1"`, so focus lands there too.
71
+ */}
72
+ <a
73
+ href="#main"
74
+ class="bg-background focus-visible:ring-ring/50 sr-only z-100 rounded-lg border px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus-visible:ring-3"
75
+ >
76
+ {ui.layout.skipToContent}
77
+ </a>
50
78
  <slot />
51
79
  </body>
52
80
  </html>
@@ -1,13 +1,18 @@
1
1
  ---
2
2
  import { siteName } from "@repo/ui";
3
+ import { Audience } from "@repo/ui/blocks/audience";
3
4
  import { Cta } from "@repo/ui/blocks/cta";
4
5
  import { Faq } from "@repo/ui/blocks/faq";
5
6
  import { FeatureGrid } from "@repo/ui/blocks/feature-grid";
6
7
  import { Footer } from "@repo/ui/blocks/footer";
7
8
  import { Hero } from "@repo/ui/blocks/hero";
9
+ import { HowItWorks } from "@repo/ui/blocks/how-it-works";
10
+ import { LogoCloud } from "@repo/ui/blocks/logo-cloud";
8
11
  import { Navbar } from "@repo/ui/blocks/navbar";
12
+ import { PainPoints } from "@repo/ui/blocks/pain-points";
9
13
  import { PricingTable } from "@repo/ui/blocks/pricing-table";
10
- import { ThemeToggle } from "@repo/ui/blocks/theme-toggle";
14
+ import { Showcase } from "@repo/ui/blocks/showcase";
15
+ import { Testimonials } from "@repo/ui/blocks/testimonials";
11
16
  import { landing } from "@repo/ui/content/landing";
12
17
  import { interpolate } from "@repo/ui/lib/interpolate";
13
18
  import Layout from "../layouts/Layout.astro";
@@ -23,38 +28,42 @@ import Layout from "../layouts/Layout.astro";
23
28
  // suggests the component goes, then decide the position yourself.
24
29
  ---
25
30
 
26
- {
27
- /*
31
+ {/*
32
+ THE SECTION RECIPE. Each section is one import above and one line below.
33
+ - Remove a section: delete its line here, its import above, and its file in
34
+ packages/ui/src/blocks/. Blank its navbar and footer labels in landing.ts so no link
35
+ points at a section that is gone.
36
+ - Reorder: move the lines. The blocks share one spacing rhythm, so any order works.
37
+ - Add one back: copy the block file in, then add the import and the line.
38
+
28
39
  The page is composed from @repo/ui/blocks/*, each reached by its own subpath export.
29
- Blocks render to static HTML by default; only the three that need browser state carry
30
- a client directive, and each one is deliberate:
31
- - Navbar client:idle — mobile menu toggle, above the fold, so hydrate when
32
- the browser goes idle rather than waiting for scroll.
40
+ Blocks render to static HTML by default; only the two that need browser state carry a
41
+ client directive, and each one is deliberate:
42
+ - Navbar client:idle — mobile menu, above the fold, so hydrate when the
43
+ browser goes idle rather than waiting for scroll.
33
44
  - PricingTable client:visible — monthly/annual toggle, below the fold.
34
- - Faq client:visible — accordion, below the fold.
35
- Hero, FeatureGrid, Cta and Footer take no directive and ship no JavaScript at all.
36
- ThemeToggle is the interesting case: it is interactive yet takes no directive either,
37
- because its behaviour comes from the pre-paint inline script Layout.astro emits, not
38
- from React. Giving it a `client:*` would ship a hydration runtime for a button that
39
- has no onClick. It sits here rather than inside Navbar so that navbar.tsx stays the
40
- landing blocks' file alone, and it carries its own positioning: fixed to the corner,
41
- clear of the sticky header's own controls at every width.
45
+ Every other block ships no JavaScript at all. The FAQ and the pain points open and close
46
+ with native <details>, and all motion is CSS. The footer adds one inline script that
47
+ writes the current year. The theme toggle sits inside the navbar and needs no island of
48
+ its own: the pre-paint script Layout.astro emits drives it.
42
49
  Never reach for a blanket `client:load` here — it would hydrate the whole page.
43
- */
44
- }
50
+ */}
45
51
  <Layout
46
52
  title={interpolate(landing.meta.title, { siteName })}
47
53
  description={interpolate(landing.meta.description, { siteName })}
48
54
  >
49
55
  <Navbar client:idle siteName={siteName} />
50
- <ThemeToggle
51
- className="border-border/60 bg-background/80 fixed right-4 bottom-4 z-50 shadow-sm backdrop-blur"
52
- />
53
- <main>
56
+ <main id="main" tabindex="-1">
54
57
  <Hero siteName={siteName} />
55
- <FeatureGrid />
56
- <PricingTable client:visible />
57
- <Faq client:visible />
58
+ <LogoCloud />
59
+ <PainPoints siteName={siteName} />
60
+ <HowItWorks siteName={siteName} />
61
+ <FeatureGrid siteName={siteName} />
62
+ <Showcase siteName={siteName} />
63
+ <Audience siteName={siteName} />
64
+ <Testimonials siteName={siteName} />
65
+ <PricingTable client:visible siteName={siteName} />
66
+ <Faq siteName={siteName} />
58
67
  <Cta siteName={siteName} />
59
68
  </main>
60
69
  <Footer siteName={siteName} />
@@ -4,12 +4,20 @@ import Layout from "../layouts/Layout.astro";
4
4
  ---
5
5
 
6
6
  <Layout title={`Privacy Policy — ${siteName}`}>
7
- <main class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]">
7
+ <main
8
+ id="main"
9
+ tabindex="-1"
10
+ class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]"
11
+ >
8
12
  <h1 class="mb-6 text-3xl font-bold tracking-tight">Privacy Policy</h1>
9
13
  <p class="mb-4">
10
14
  Placeholder privacy policy for {siteName}. Replace with your own before
11
15
  launch.
12
16
  </p>
13
- <p><a href="/" class="hover:underline">← Home</a></p>
17
+ <p>
18
+ <a href="/" class="hover:underline">
19
+ ← Home
20
+ </a>
21
+ </p>
14
22
  </main>
15
23
  </Layout>
@@ -4,11 +4,19 @@ import Layout from "../layouts/Layout.astro";
4
4
  ---
5
5
 
6
6
  <Layout title={`Terms of Service — ${siteName}`}>
7
- <main class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]">
7
+ <main
8
+ id="main"
9
+ tabindex="-1"
10
+ class="mx-auto my-16 max-w-2xl px-6 leading-[1.6]"
11
+ >
8
12
  <h1 class="mb-6 text-3xl font-bold tracking-tight">Terms of Service</h1>
9
13
  <p class="mb-4">
10
14
  Placeholder terms for {siteName}. Replace with your own before launch.
11
15
  </p>
12
- <p><a href="/" class="hover:underline">← Home</a></p>
16
+ <p>
17
+ <a href="/" class="hover:underline">
18
+ ← Home
19
+ </a>
20
+ </p>
13
21
  </main>
14
22
  </Layout>
@@ -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: [
@@ -151,5 +177,66 @@ export default defineConfig({
151
177
  files: ["packages/*/src/providers/console.ts", "packages/logger*/**"],
152
178
  rules: { "no-console": "off" },
153
179
  },
180
+
181
+ // Two more places where `console` is the right call, both from the queue module.
182
+ // A queue provider's batch consumer and cron tick run outside a request, so there is
183
+ // no `c.get("log")` to reach for, and `@repo/queue` has no runtime dependencies to
184
+ // import `@repo/logger` with. Workers Logs indexes `console` directly. The example
185
+ // job's whole body is one log line, which is what makes it a readable example.
186
+ {
187
+ files: [
188
+ "packages/queue/src/providers/**",
189
+ "packages/queue/src/jobs/example.ts",
190
+ ],
191
+ rules: { "no-console": "off" },
192
+ },
193
+
194
+ // The queue barrel registers its providers, and each provider calls back into the
195
+ // barrel for `createQueue`, `dispatch` and `dueSchedules`. The cycle is deliberate:
196
+ // a provider reads those hoisted functions inside a function body, never while the
197
+ // module loads. `packages/queue/src/providers/memory.ts` explains it in full.
198
+ {
199
+ files: ["packages/queue/src/index.ts", "packages/queue/src/providers/**"],
200
+ rules: { "import/no-cycle": "off" },
201
+ },
202
+
203
+ // `packages/env` runs on `node:test`, not vitest. Its tests predate the base's Vitest
204
+ // setup, and the root `vitest.config.ts` leaves the package out. Three
205
+ // vitest rules point these files at a runner they cannot use, and
206
+ // `no-floating-promises` fires on every `describe`, whose promise the runner itself
207
+ // awaits. Prefixing twenty call sites with `void` would say nothing the runner does
208
+ // not already guarantee.
209
+ //
210
+ // `env:setup` and `env-deploy` are node scripts, so `process` and `console` are theirs
211
+ // to use. Nothing a Worker imports reaches them (ADR 0041).
212
+ {
213
+ files: ["packages/env/**/*.test.ts"],
214
+ plugins: ["vitest"],
215
+ rules: {
216
+ "typescript/no-floating-promises": "off",
217
+ "vitest/no-import-node-test": "off",
218
+ "vitest/prefer-importing-vitest-globals": "off",
219
+ "vitest/prefer-each": "off",
220
+ },
221
+ },
222
+ // `createEnv` is a generic mapped-type API: it takes `server`, `client` and `shared`
223
+ // as distinct type parameters, erases them to one `SchemaRecord` to iterate the keys,
224
+ // and puts the specific type back on the way out. Every assertion this file makes is
225
+ // that erase-and-restore, which the type system cannot express and the rules below
226
+ // cannot see through. The Proxy adds two more — `Reflect.get` on a mapped type is
227
+ // `any` by definition. Scoped to this one file; every rule stays on everywhere else.
228
+ {
229
+ files: ["packages/env/src/define.ts", "packages/env/src/define.test.ts"],
230
+ rules: {
231
+ "typescript/no-unsafe-type-assertion": "off",
232
+ "typescript/no-unnecessary-type-assertion": "off",
233
+ "typescript/no-unsafe-assignment": "off",
234
+ "typescript/no-unsafe-return": "off",
235
+ },
236
+ },
237
+ {
238
+ files: ["packages/env/scripts/**", "packages/env/src/deploy.ts"],
239
+ rules: { "no-console": "off", "unicorn/no-process-exit": "off" },
240
+ },
154
241
  ],
155
242
  });
@@ -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
+ });