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.
- 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 +180 -53
- package/templates/base/README.md +20 -8
- package/templates/base/_agents/skills/saasaloy-design/SKILL.md +26 -15
- 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 +91 -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 +1 -0
- 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/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
|
@@ -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
|
|
4
|
-
// duplicate the shell or import
|
|
5
|
-
|
|
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 {
|
|
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
|
|
30
|
-
|
|
31
|
-
- Navbar client:idle — mobile menu
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
56
|
-
<
|
|
57
|
-
<
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: [
|
|
@@ -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
|
+
});
|