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.
- package/README.md +7 -7
- package/dist/index.js +637 -522
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/registry-item.schema.json +31 -1
- package/templates/base/AGENTS.md +204 -89
- package/templates/base/README.md +20 -8
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +38 -17
- package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
- package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
- package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
- package/templates/base/_gitignore +0 -3
- package/templates/base/_saasaloy-base.json +8 -1
- package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
- package/templates/base/apps/web/astro.config.mjs +36 -6
- package/templates/base/apps/web/package.json +2 -1
- package/templates/base/apps/web/src/env.ts +41 -0
- package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
- package/templates/base/apps/web/src/pages/index.astro +33 -24
- package/templates/base/apps/web/src/pages/privacy.astro +10 -2
- package/templates/base/apps/web/src/pages/terms.astro +10 -2
- package/templates/base/oxlint.config.mjs +125 -4
- package/templates/base/package.json +7 -3
- package/templates/base/packages/env/_env.example +54 -0
- package/templates/base/packages/env/package.json +35 -0
- package/templates/base/packages/env/scripts/env-setup.ts +87 -0
- package/templates/base/packages/env/src/check.test.ts +41 -0
- package/templates/base/packages/env/src/check.ts +50 -0
- package/templates/base/packages/env/src/define.test.ts +206 -0
- package/templates/base/packages/env/src/define.ts +315 -0
- package/templates/base/packages/env/src/deploy.test.ts +438 -0
- package/templates/base/packages/env/src/deploy.ts +518 -0
- package/templates/base/packages/env/src/dotenv.test.ts +179 -0
- package/templates/base/packages/env/src/dotenv.ts +173 -0
- package/templates/base/packages/env/src/env-file.ts +51 -0
- package/templates/base/packages/env/src/error.ts +38 -0
- package/templates/base/packages/env/src/example.test.ts +86 -0
- package/templates/base/packages/env/src/example.ts +90 -0
- package/templates/base/packages/env/src/index.ts +15 -0
- package/templates/base/packages/env/src/schema.ts +63 -0
- package/templates/base/packages/env/src/services.test.ts +83 -0
- package/templates/base/packages/env/src/services.ts +110 -0
- package/templates/base/packages/env/src/setup.test.ts +254 -0
- package/templates/base/packages/env/src/setup.ts +177 -0
- package/templates/base/packages/env/src/source.ts +20 -0
- package/templates/base/packages/env/src/sources.test.ts +137 -0
- package/templates/base/packages/env/src/sources.ts +93 -0
- package/templates/base/packages/env/src/zod.ts +8 -0
- package/templates/base/packages/env/tsconfig.json +13 -0
- package/templates/base/packages/tsconfig/base.json +7 -0
- package/templates/base/packages/ui/components.json +1 -1
- package/templates/base/packages/ui/package.json +4 -2
- package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
- package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
- package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
- package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
- package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
- package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
- package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
- package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
- package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
- package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
- package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
- package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
- package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
- package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
- package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
- package/templates/base/packages/ui/src/components/badge.tsx +1 -1
- package/templates/base/packages/ui/src/components/button.tsx +2 -2
- package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
- package/templates/base/packages/ui/src/containers/README.md +6 -5
- package/templates/base/packages/ui/src/content/landing.ts +590 -125
- package/templates/base/packages/ui/src/index.ts +6 -2
- package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
- package/templates/base/packages/ui/src/lib/icons.ts +131 -0
- package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
- package/templates/base/packages/ui/src/lib/theme.ts +140 -56
- package/templates/base/packages/ui/src/styles/globals.css +47 -125
- package/templates/base/packages/ui/src/styles/web.css +359 -0
- package/templates/base/pnpm-workspace.yaml +0 -6
- package/templates/base/saasaloy.json +1 -0
- package/templates/base/stylelint.config.js +3 -3
- package/templates/base/turbo.json +13 -0
- 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
|
|
13
|
-
// `
|
|
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
|
+
}
|