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,438 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import { after, describe, it } from "node:test";
14
+ import {
15
+ checkProduction,
16
+ childEnv,
17
+ deployService,
18
+ parseArgs,
19
+ parseJsonc,
20
+ readWorkerConfig,
21
+ runWithProduction,
22
+ secretsFor,
23
+ } from "./deploy.ts";
24
+ import type { LoginCheck, Runner } from "./deploy.ts";
25
+ import { SERVICES } from "./services.ts";
26
+ import { local } from "./sources.ts";
27
+
28
+ // Every file lands in a temp directory, and the runner is a fake. Nothing here builds,
29
+ // deploys or reaches Cloudflare.
30
+
31
+ // The base seeds `web` and `deploy`; `saasaloy add api` appends this row.
32
+ if (!SERVICES.some((row) => row.name === "api")) {
33
+ SERVICES.push({
34
+ name: "api",
35
+ file: "apps/api/.env",
36
+ environment: "dev",
37
+ omit: [],
38
+ });
39
+ }
40
+
41
+ const roots: string[] = [];
42
+
43
+ after(() => {
44
+ for (const root of roots) {
45
+ rmSync(root, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ const EXAMPLE = [
50
+ "# @services web",
51
+ "PUBLIC_SITE_URL=http://localhost:3000",
52
+ "# Blank on purpose: no analytics.",
53
+ "PUBLIC_ANALYTICS_ID=",
54
+ "# @services api",
55
+ "SECRET=",
56
+ "PLAIN=",
57
+ "# @services deploy",
58
+ "# Blank on purpose: wrangler login.",
59
+ "CLOUDFLARE_API_TOKEN=",
60
+ "# Blank on purpose: the one account.",
61
+ "CLOUDFLARE_ACCOUNT_ID=",
62
+ "",
63
+ ].join("\n");
64
+
65
+ const PROD = [
66
+ "PUBLIC_SITE_URL=https://example.com",
67
+ "SECRET=s3cret",
68
+ "PLAIN=plain",
69
+ "CLOUDFLARE_API_TOKEN=token",
70
+ "CLOUDFLARE_ACCOUNT_ID=account",
71
+ "",
72
+ ].join("\n");
73
+
74
+ /** A project root with the key list, the production values and an `apps/api` Worker. */
75
+ function makeRoot(prod: string | null = PROD): string {
76
+ const root = mkdtempSync(path.join(tmpdir(), "env-deploy-"));
77
+ roots.push(root);
78
+ mkdirSync(path.join(root, "packages/env"), { recursive: true });
79
+ writeFileSync(path.join(root, "packages/env/.env.example"), EXAMPLE);
80
+ writeFileSync(path.join(root, "packages/env/.env"), "SECRET=dev\n");
81
+ if (prod !== null) {
82
+ writeFileSync(path.join(root, "packages/env/.env.prod"), prod);
83
+ }
84
+ mkdirSync(path.join(root, "apps/api"), { recursive: true });
85
+ writeFileSync(
86
+ path.join(root, "apps/api/wrangler.jsonc"),
87
+ '{\n // the api\n "name": "api",\n "main": "src/worker.ts",\n "vars": { "PLAIN": "plain" },\n}\n'
88
+ );
89
+ mkdirSync(path.join(root, "apps/web"), { recursive: true });
90
+ writeFileSync(
91
+ path.join(root, "apps/web/wrangler.jsonc"),
92
+ '{ "name": "web", "assets": { "directory": "./dist" } }\n'
93
+ );
94
+ return root;
95
+ }
96
+
97
+ interface Call {
98
+ command: string;
99
+ args: string[];
100
+ env: Record<string, string | undefined>;
101
+ /** The secrets file's contents while the command ran, when it named one. */
102
+ secrets?: string;
103
+ /** The file's permission bits in octal, such as `600`. */
104
+ secretsMode?: string;
105
+ }
106
+
107
+ /** A runner that records each call, and fails the one `failOn` names. */
108
+ function fakeRunner(failOn?: string): { run: Runner; calls: Call[] } {
109
+ const calls: Call[] = [];
110
+ const run: Runner = async (command, args, { env }) => {
111
+ const index = args.indexOf("--secrets-file");
112
+ const file = index === -1 ? undefined : args[index + 1];
113
+ calls.push({
114
+ command,
115
+ args,
116
+ env,
117
+ secrets: file === undefined ? undefined : readFileSync(file, "utf-8"),
118
+ secretsMode:
119
+ file === undefined
120
+ ? undefined
121
+ : statSync(file).mode.toString(8).slice(-3),
122
+ });
123
+ return await Promise.resolve(command === failOn ? 1 : 0);
124
+ };
125
+ return { run, calls };
126
+ }
127
+
128
+ const loggedOut: LoginCheck = () => false;
129
+ const loggedIn: LoginCheck = () => true;
130
+
131
+ function secretsPath(call: Call | undefined): string | undefined {
132
+ const index = call?.args.indexOf("--secrets-file") ?? -1;
133
+ return index === -1 ? undefined : call?.args[index + 1];
134
+ }
135
+
136
+ describe("secretsFor", () => {
137
+ const values = new Map([
138
+ ["SECRET", "s3cret"],
139
+ ["PLAIN", "plain"],
140
+ ["CLOUDFLARE_API_TOKEN", "token"],
141
+ ["EMPTY", ""],
142
+ ]);
143
+
144
+ it("drops the credentials, the keys in vars and an empty value", () => {
145
+ assert.deepEqual(
146
+ secretsFor(values, { main: "src/worker.ts", vars: { PLAIN: "plain" } }),
147
+ { SECRET: "s3cret" }
148
+ );
149
+ });
150
+
151
+ it("gives an assets-only Worker no secrets", () => {
152
+ assert.deepEqual(secretsFor(values, { vars: {} }), {});
153
+ });
154
+ });
155
+
156
+ describe("childEnv", () => {
157
+ it("lets an exported variable win over the value source", () => {
158
+ const merged = childEnv(new Map([["A", "source"]]), {
159
+ A: "shell",
160
+ B: "shell",
161
+ });
162
+ assert.deepEqual(merged, { A: "shell", B: "shell" });
163
+ });
164
+
165
+ it("keeps a blank credential out, so wrangler falls back to its login", () => {
166
+ const merged = childEnv(
167
+ new Map([
168
+ ["CLOUDFLARE_API_TOKEN", ""],
169
+ ["PUBLIC_EMPTY", ""],
170
+ ]),
171
+ {}
172
+ );
173
+ assert.deepEqual(merged, { PUBLIC_EMPTY: "" });
174
+ });
175
+ });
176
+
177
+ describe("parseJsonc", () => {
178
+ it("strips comments and trailing commas outside strings", () => {
179
+ assert.deepEqual(
180
+ parseJsonc(
181
+ '{\n // note\n "a": "http://x", /* b */\n "c": [1, 2,],\n}\n'
182
+ ),
183
+ { a: "http://x", c: [1, 2] }
184
+ );
185
+ });
186
+ });
187
+
188
+ describe("readWorkerConfig", () => {
189
+ it("follows the build's redirect to the config wrangler deploys", () => {
190
+ const root = makeRoot();
191
+ const web = path.join(root, "apps/web");
192
+ mkdirSync(path.join(web, ".wrangler/deploy"), { recursive: true });
193
+ mkdirSync(path.join(web, "dist/server"), { recursive: true });
194
+ writeFileSync(
195
+ path.join(web, ".wrangler/deploy/config.json"),
196
+ '{ "configPath": "../../dist/server/wrangler.json" }'
197
+ );
198
+ writeFileSync(
199
+ path.join(web, "dist/server/wrangler.json"),
200
+ '{ "name": "web", "main": "entry.mjs" }'
201
+ );
202
+ assert.equal(readWorkerConfig(web).main, "entry.mjs");
203
+ });
204
+ });
205
+
206
+ describe("parseArgs", () => {
207
+ it("reads the three forms", () => {
208
+ assert.deepEqual(parseArgs(["--check"]), { kind: "check" });
209
+ assert.deepEqual(parseArgs(["api"]), {
210
+ kind: "deploy",
211
+ service: "api",
212
+ wranglerArgs: [],
213
+ });
214
+ assert.deepEqual(parseArgs(["api", "--", "--dry-run"]), {
215
+ kind: "deploy",
216
+ service: "api",
217
+ wranglerArgs: ["--dry-run"],
218
+ });
219
+ assert.deepEqual(parseArgs(["--run", "deploy", "--", "node", "x.ts"]), {
220
+ kind: "run",
221
+ service: "deploy",
222
+ command: "node",
223
+ args: ["x.ts"],
224
+ });
225
+ });
226
+
227
+ it("refuses anything else", () => {
228
+ assert.throws(() => parseArgs([]), /Usage: env-deploy/);
229
+ assert.throws(() => parseArgs(["--run", "deploy"]), /Usage: env-deploy/);
230
+ assert.throws(() => parseArgs(["api", "--dry-run"]), /Usage: env-deploy/);
231
+ });
232
+ });
233
+
234
+ describe("checkProduction", () => {
235
+ it("passes when every service has its production values", async () => {
236
+ const problems = await checkProduction({
237
+ root: makeRoot(),
238
+ source: local,
239
+ processEnv: {},
240
+ loggedIn: loggedOut,
241
+ });
242
+ assert.deepEqual(problems, []);
243
+ });
244
+
245
+ it("names each blank key across every service", async () => {
246
+ const problems = await checkProduction({
247
+ root: makeRoot("CLOUDFLARE_API_TOKEN=token\nPLAIN=plain\n"),
248
+ source: local,
249
+ processEnv: {},
250
+ loggedIn: loggedOut,
251
+ });
252
+ assert.deepEqual(problems, [
253
+ "web has no production value for PUBLIC_SITE_URL.",
254
+ "api has no production value for SECRET.",
255
+ ]);
256
+ });
257
+
258
+ it("takes a value the shell exports", async () => {
259
+ const problems = await checkProduction({
260
+ root: makeRoot("CLOUDFLARE_API_TOKEN=token\nPLAIN=plain\n"),
261
+ source: local,
262
+ processEnv: { PUBLIC_SITE_URL: "https://example.com", SECRET: "x" },
263
+ loggedIn: loggedOut,
264
+ });
265
+ assert.deepEqual(problems, []);
266
+ });
267
+
268
+ it("stops on a blank token with no wrangler login", async () => {
269
+ const prod = PROD.replace("CLOUDFLARE_API_TOKEN=token", "");
270
+ const root = makeRoot(prod);
271
+ const problems = await checkProduction({
272
+ root,
273
+ source: local,
274
+ processEnv: {},
275
+ loggedIn: loggedOut,
276
+ });
277
+ assert.equal(problems.length, 1);
278
+ assert.match(problems[0] ?? "", /CLOUDFLARE_API_TOKEN has no value/);
279
+ assert.deepEqual(
280
+ await checkProduction({
281
+ root,
282
+ source: local,
283
+ processEnv: {},
284
+ loggedIn,
285
+ }),
286
+ []
287
+ );
288
+ });
289
+
290
+ it("never reads the dev values", async () => {
291
+ await assert.rejects(
292
+ checkProduction({
293
+ root: makeRoot(null),
294
+ source: local,
295
+ processEnv: {},
296
+ loggedIn,
297
+ }),
298
+ /packages\/env\/\.env\.prod does not exist/
299
+ );
300
+ });
301
+ });
302
+
303
+ describe("deployService", () => {
304
+ it("builds with the production values, then deploys with a secrets file", async () => {
305
+ const root = makeRoot();
306
+ const { run, calls } = fakeRunner();
307
+ await deployService({
308
+ root,
309
+ name: "api",
310
+ cwd: path.join(root, "apps/api"),
311
+ source: local,
312
+ processEnv: { PATH: "/bin" },
313
+ run,
314
+ loggedIn: loggedOut,
315
+ });
316
+
317
+ const [build, deploy] = calls;
318
+ assert.deepEqual([build?.command, build?.args], ["pnpm", ["run", "build"]]);
319
+ assert.equal(build?.env.SECRET, "s3cret");
320
+ assert.equal(build?.env.CLOUDFLARE_API_TOKEN, "token");
321
+ assert.equal(build?.env.PATH, "/bin");
322
+ assert.equal(build?.env.CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV, "false");
323
+ assert.equal(deploy?.command, "wrangler");
324
+ assert.equal(deploy?.args[0], "deploy");
325
+ assert.deepEqual(JSON.parse(deploy?.secrets ?? ""), { SECRET: "s3cret" });
326
+ assert.equal(deploy?.secretsMode, "600");
327
+ assert.equal(existsSync(secretsPath(deploy) ?? ""), false);
328
+ });
329
+
330
+ it("deletes the secrets file when the deploy fails", async () => {
331
+ const root = makeRoot();
332
+ const { run, calls } = fakeRunner("wrangler");
333
+ await assert.rejects(
334
+ deployService({
335
+ root,
336
+ name: "api",
337
+ cwd: path.join(root, "apps/api"),
338
+ source: local,
339
+ processEnv: {},
340
+ run,
341
+ loggedIn: loggedOut,
342
+ }),
343
+ /wrangler deploy failed for api/
344
+ );
345
+ const file = secretsPath(calls[1]);
346
+ assert.ok(file !== undefined);
347
+ assert.equal(existsSync(file), false);
348
+ });
349
+
350
+ it("sets a key the source leaves out to empty, so no dev .env fills it", async () => {
351
+ const root = makeRoot();
352
+ const { run, calls } = fakeRunner();
353
+ await deployService({
354
+ root,
355
+ name: "web",
356
+ cwd: path.join(root, "apps/web"),
357
+ source: local,
358
+ processEnv: {},
359
+ run,
360
+ loggedIn: loggedOut,
361
+ });
362
+ assert.equal(calls[0]?.env.PUBLIC_ANALYTICS_ID, "");
363
+ });
364
+
365
+ it("sends an assets-only Worker no secrets file", async () => {
366
+ const root = makeRoot();
367
+ const { run, calls } = fakeRunner();
368
+ await deployService({
369
+ root,
370
+ name: "web",
371
+ cwd: path.join(root, "apps/web"),
372
+ source: local,
373
+ processEnv: {},
374
+ run,
375
+ loggedIn: loggedOut,
376
+ wranglerArgs: ["--dry-run"],
377
+ });
378
+ assert.equal(calls[0]?.env.PUBLIC_SITE_URL, "https://example.com");
379
+ assert.deepEqual(calls[1]?.args, ["deploy", "--dry-run"]);
380
+ });
381
+
382
+ it("builds nothing when a key is blank", async () => {
383
+ const root = makeRoot(PROD.replace("SECRET=s3cret", "SECRET="));
384
+ const { run, calls } = fakeRunner();
385
+ await assert.rejects(
386
+ deployService({
387
+ root,
388
+ name: "api",
389
+ cwd: path.join(root, "apps/api"),
390
+ source: local,
391
+ processEnv: {},
392
+ run,
393
+ loggedIn: loggedOut,
394
+ }),
395
+ /api has no production value for SECRET/
396
+ );
397
+ assert.deepEqual(calls, []);
398
+ });
399
+
400
+ it("deploys nothing when the build fails", async () => {
401
+ const root = makeRoot();
402
+ const { run, calls } = fakeRunner("pnpm");
403
+ await assert.rejects(
404
+ deployService({
405
+ root,
406
+ name: "api",
407
+ cwd: path.join(root, "apps/api"),
408
+ source: local,
409
+ processEnv: {},
410
+ run,
411
+ loggedIn: loggedOut,
412
+ }),
413
+ /The api build failed/
414
+ );
415
+ assert.equal(calls.length, 1);
416
+ });
417
+ });
418
+
419
+ describe("runWithProduction", () => {
420
+ it("runs the command with the deploy credentials", async () => {
421
+ const root = makeRoot();
422
+ const { run, calls } = fakeRunner();
423
+ const code = await runWithProduction({
424
+ root,
425
+ name: "deploy",
426
+ cwd: root,
427
+ source: local,
428
+ processEnv: {},
429
+ run,
430
+ loggedIn: loggedOut,
431
+ command: "node",
432
+ args: ["scripts/migrate-prod.ts"],
433
+ });
434
+ assert.equal(code, 0);
435
+ assert.equal(calls[0]?.env.CLOUDFLARE_ACCOUNT_ID, "account");
436
+ assert.equal(calls[0]?.env.SECRET, undefined);
437
+ });
438
+ });