bsmnt 0.5.0 → 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 (123) hide show
  1. package/README.md +1 -0
  2. package/dist/application/add-hooks/index.d.ts.map +1 -1
  3. package/dist/application/add-hooks/index.js +1 -0
  4. package/dist/application/add-hooks/index.js.map +1 -1
  5. package/dist/application/add-integration/index.d.ts +13 -6
  6. package/dist/application/add-integration/index.d.ts.map +1 -1
  7. package/dist/application/add-integration/index.js +145 -62
  8. package/dist/application/add-integration/index.js.map +1 -1
  9. package/dist/application/create-project/build-context.d.ts +3 -1
  10. package/dist/application/create-project/build-context.d.ts.map +1 -1
  11. package/dist/application/create-project/build-context.js +2 -1
  12. package/dist/application/create-project/build-context.js.map +1 -1
  13. package/dist/application/create-project/index.d.ts +3 -1
  14. package/dist/application/create-project/index.d.ts.map +1 -1
  15. package/dist/application/create-project/index.js +4 -2
  16. package/dist/application/create-project/index.js.map +1 -1
  17. package/dist/application/install-skills/index.js +1 -1
  18. package/dist/application/install-skills/index.js.map +1 -1
  19. package/dist/core/create/execute-plan.d.ts +4 -0
  20. package/dist/core/create/execute-plan.d.ts.map +1 -1
  21. package/dist/core/create/execute-plan.js +8 -0
  22. package/dist/core/create/execute-plan.js.map +1 -1
  23. package/dist/core/create/types.d.ts +2 -0
  24. package/dist/core/create/types.d.ts.map +1 -1
  25. package/dist/domain/skills.d.ts +26 -0
  26. package/dist/domain/skills.d.ts.map +1 -1
  27. package/dist/domain/skills.js +13 -0
  28. package/dist/domain/skills.js.map +1 -1
  29. package/dist/index.js +26 -8
  30. package/dist/index.js.map +1 -1
  31. package/dist/infrastructure/create/executor.d.ts.map +1 -1
  32. package/dist/infrastructure/create/executor.js +5 -0
  33. package/dist/infrastructure/create/executor.js.map +1 -1
  34. package/dist/infrastructure/create/lifecycle-registry.d.ts.map +1 -1
  35. package/dist/infrastructure/create/lifecycle-registry.js +2 -0
  36. package/dist/infrastructure/create/lifecycle-registry.js.map +1 -1
  37. package/dist/infrastructure/create/setup-agent.d.ts +2 -0
  38. package/dist/infrastructure/create/setup-agent.d.ts.map +1 -1
  39. package/dist/infrastructure/create/setup-agent.js +9 -8
  40. package/dist/infrastructure/create/setup-agent.js.map +1 -1
  41. package/dist/infrastructure/create/setup-dotenvx.d.ts +41 -0
  42. package/dist/infrastructure/create/setup-dotenvx.d.ts.map +1 -0
  43. package/dist/infrastructure/create/setup-dotenvx.js +502 -0
  44. package/dist/infrastructure/create/setup-dotenvx.js.map +1 -0
  45. package/dist/infrastructure/create/setup-sanity.d.ts +13 -1
  46. package/dist/infrastructure/create/setup-sanity.d.ts.map +1 -1
  47. package/dist/infrastructure/create/setup-sanity.js +71 -31
  48. package/dist/infrastructure/create/setup-sanity.js.map +1 -1
  49. package/dist/modules/features/cms/sanity/config.d.ts.map +1 -1
  50. package/dist/modules/features/cms/sanity/config.js +2 -1
  51. package/dist/modules/features/cms/sanity/config.js.map +1 -1
  52. package/dist/modules/features/cms/sanity/mergers/layout.d.ts.map +1 -1
  53. package/dist/modules/features/cms/sanity/mergers/layout.js +10 -0
  54. package/dist/modules/features/cms/sanity/mergers/layout.js.map +1 -1
  55. package/dist/modules/features/env/dotenvx/config.d.ts +13 -0
  56. package/dist/modules/features/env/dotenvx/config.d.ts.map +1 -0
  57. package/dist/modules/features/env/dotenvx/config.js +47 -0
  58. package/dist/modules/features/env/dotenvx/config.js.map +1 -0
  59. package/dist/paths.d.ts +8 -0
  60. package/dist/paths.d.ts.map +1 -1
  61. package/dist/paths.js +8 -0
  62. package/dist/paths.js.map +1 -1
  63. package/package.json +5 -1
  64. package/src/agent-skills/json-ld/SKILL.md +85 -0
  65. package/src/agent-skills/manage-env/SKILL.md +67 -0
  66. package/src/modules/features/cms/sanity/files/app/blog/[slug]/page.tsx +48 -21
  67. package/src/modules/features/cms/sanity/files/components/sanity/structured-data.tsx +83 -0
  68. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/queries.ts +14 -0
  69. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.config.ts +19 -3
  70. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/sanity.types.ts +1 -0
  71. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/index.ts +36 -1
  72. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/metadata.ts +12 -1
  73. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/structured-data-blocks.ts +416 -0
  74. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/schemas/structured-data.ts +100 -0
  75. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/structure.ts +37 -2
  76. package/src/modules/features/cms/sanity/files/lib/integrations/sanity/utils/page-json-ld.ts +208 -0
  77. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/same-as-input.tsx +66 -0
  78. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/fetchers/layout.ts +20 -1
  79. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +17 -0
  80. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +22 -0
  81. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +28 -0
  82. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/company-data.ts +8 -0
  83. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/layout/structured-data.ts +124 -0
  84. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/metadata.ts +12 -1
  85. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/structured-data-blocks.ts +436 -0
  86. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +13 -1
  87. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/utils/page-json-ld.ts +208 -0
  88. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/json-ld.tsx +324 -15
  89. package/src/modules/features/env/dotenvx/files/decrypt-env.mjs +45 -0
  90. package/src/modules/features/env/dotenvx/files/gitleaks.yml +25 -0
  91. package/src/modules/features/env/dotenvx/files/instrumentation.ts +14 -0
  92. package/src/modules/features/env/dotenvx/files/push-keys.mjs +129 -0
  93. package/src/modules/features/env/dotenvx/files/save-key.mjs +108 -0
  94. package/src/modules/features/env/dotenvx/files/setup-env.mjs +67 -0
  95. package/src/modules/features/env/dotenvx/files/vercelignore +4 -0
  96. package/src/modules/features/env/dotenvx/files/write-env.mjs +47 -0
  97. package/src/templates/next-default/.env.example +14 -6
  98. package/src/templates/next-default/.github/workflows/ci.yml +22 -1
  99. package/src/templates/next-default/.vscode/extensions.json +3 -0
  100. package/src/templates/next-default/README.md +31 -0
  101. package/src/templates/next-default/lib/utils/json-ld.tsx +327 -21
  102. package/src/templates/next-default/next.config.ts +6 -0
  103. package/src/templates/next-experiments/.env.example +14 -6
  104. package/src/templates/next-experiments/.github/workflows/ci.yml +22 -1
  105. package/src/templates/next-experiments/.vscode/extensions.json +3 -0
  106. package/src/templates/next-experiments/README.md +31 -0
  107. package/src/templates/next-experiments/lib/utils/json-ld.tsx +327 -21
  108. package/src/templates/next-experiments/next.config.ts +6 -0
  109. package/src/templates/next-pagebuilder/.env.example +19 -1
  110. package/src/templates/next-pagebuilder/.github/workflows/ci.yml +21 -1
  111. package/src/templates/next-pagebuilder/.vscode/extensions.json +3 -0
  112. package/src/templates/next-pagebuilder/README.md +31 -0
  113. package/src/templates/next-pagebuilder/components/layout/json-ld/index.tsx +32 -19
  114. package/src/templates/next-pagebuilder/components/page-document/index.tsx +38 -0
  115. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +36 -2
  116. package/src/templates/next-pagebuilder/next.config.ts +6 -0
  117. package/src/templates/next-webgl/.env.example +14 -6
  118. package/src/templates/next-webgl/.github/workflows/ci.yml +22 -1
  119. package/src/templates/next-webgl/.vscode/extensions.json +3 -0
  120. package/src/templates/next-webgl/README.md +31 -0
  121. package/src/templates/next-webgl/lib/utils/json-ld.tsx +327 -21
  122. package/src/templates/next-webgl/next.config.ts +6 -0
  123. package/src/modules/features/cms/sanity/files/lib/utils/json-ld.tsx +0 -244
@@ -1,8 +1,14 @@
1
1
  import type {
2
2
  Article,
3
3
  BreadcrumbList,
4
+ Event as EventSchema,
5
+ FAQPage,
6
+ ItemList,
7
+ JobPosting,
4
8
  Organization,
9
+ SoftwareApplication,
5
10
  Thing,
11
+ VideoObject,
6
12
  WebPage,
7
13
  WebSite,
8
14
  WithContext,
@@ -27,8 +33,8 @@ export const JsonLd = <T extends Thing>({ data }: JsonLdProps<T>) => (
27
33
 
28
34
  interface WebSiteJsonLdOptions {
29
35
  name: string;
30
- url?: string;
31
- description?: string;
36
+ url?: string | undefined;
37
+ description?: string | undefined;
32
38
  }
33
39
 
34
40
  export const generateWebSiteJsonLd = (
@@ -44,27 +50,95 @@ export const generateWebSiteJsonLd = (
44
50
  };
45
51
  };
46
52
 
53
+ // Sanity asset URLs are already absolute (cdn.sanity.io); only prefix the
54
+ // origin for app-relative paths like "/logo.svg".
55
+ const toAbsoluteUrl = (value: string) =>
56
+ /^https?:\/\//.test(value) ? value : `${BASE_URL}${value}`;
57
+
58
+ interface OrganizationContactPoint {
59
+ email?: string | null;
60
+ telephone?: string | null;
61
+ contactType?: string | null;
62
+ }
63
+
64
+ interface OrganizationAddress {
65
+ streetAddress?: string | null;
66
+ locality?: string | null;
67
+ region?: string | null;
68
+ postalCode?: string | null;
69
+ country?: string | null;
70
+ }
71
+
47
72
  interface OrganizationJsonLdOptions {
48
73
  name: string;
49
- url?: string;
50
- logo?: string;
51
- description?: string;
52
- sameAs?: string[];
74
+ url?: string | undefined;
75
+ logo?: string | undefined;
76
+ description?: string | undefined;
77
+ sameAs?: string[] | undefined;
78
+ legalName?: string | undefined;
79
+ contactPoint?: OrganizationContactPoint | null | undefined;
80
+ address?: OrganizationAddress | null | undefined;
53
81
  }
54
82
 
55
83
  export const generateOrganizationJsonLd = (
56
84
  options: OrganizationJsonLdOptions,
57
85
  ): WithContext<Organization> => {
58
- const { name, url = BASE_URL, logo, description, sameAs } = options;
86
+ const {
87
+ name,
88
+ url = BASE_URL,
89
+ logo,
90
+ description,
91
+ sameAs,
92
+ legalName,
93
+ contactPoint,
94
+ address,
95
+ } = options;
96
+
97
+ const contactPointNode =
98
+ contactPoint &&
99
+ (contactPoint.email || contactPoint.telephone || contactPoint.contactType)
100
+ ? {
101
+ "@type": "ContactPoint" as const,
102
+ ...(contactPoint.email && { email: contactPoint.email }),
103
+ ...(contactPoint.telephone && {
104
+ telephone: contactPoint.telephone,
105
+ }),
106
+ ...(contactPoint.contactType && {
107
+ contactType: contactPoint.contactType,
108
+ }),
109
+ }
110
+ : undefined;
111
+
112
+ const addressNode =
113
+ address &&
114
+ (address.streetAddress ||
115
+ address.locality ||
116
+ address.region ||
117
+ address.postalCode ||
118
+ address.country)
119
+ ? {
120
+ "@type": "PostalAddress" as const,
121
+ ...(address.streetAddress && {
122
+ streetAddress: address.streetAddress,
123
+ }),
124
+ ...(address.locality && { addressLocality: address.locality }),
125
+ ...(address.region && { addressRegion: address.region }),
126
+ ...(address.postalCode && { postalCode: address.postalCode }),
127
+ ...(address.country && { addressCountry: address.country }),
128
+ }
129
+ : undefined;
59
130
 
60
131
  return {
61
132
  "@context": "https://schema.org",
62
133
  "@type": "Organization",
63
134
  name,
64
135
  ...(url ? { url } : {}),
65
- ...(logo && { logo: `${BASE_URL}${logo}` }),
136
+ ...(logo && { logo: toAbsoluteUrl(logo) }),
66
137
  ...(description && { description }),
67
- ...(sameAs && { sameAs }),
138
+ ...(legalName && { legalName }),
139
+ ...(sameAs?.length && { sameAs }),
140
+ ...(contactPointNode && { contactPoint: contactPointNode }),
141
+ ...(addressNode && { address: addressNode }),
68
142
  };
69
143
  };
70
144
 
@@ -160,12 +234,7 @@ export function generateBreadcrumbJsonLd(
160
234
  };
161
235
  }
162
236
 
163
- /* ——————————————————————————————————————————————————————————
164
- * Sanity helpers
165
- *
166
- * Accept flat options instead of a document shape so they
167
- * work with any Sanity type without interface mismatches.
168
- * —————————————————————————————————————————————————————————— */
237
+ // Sanity helpers — flat options instead of document shapes, so they fit any type.
169
238
 
170
239
  interface SanityArticleJsonLdOptions {
171
240
  title: string;
@@ -210,3 +279,243 @@ export function generateSanityWebPageJsonLd(
210
279
  if (options.dateModified) opts.dateModified = options.dateModified;
211
280
  return generateWebPageJsonLd(opts);
212
281
  }
282
+
283
+ // Page-level generators for the schema.org blocks editors attach via
284
+ // `metadata.jsonLd`. Plain options keep them CMS-independent.
285
+
286
+ export interface FaqJsonLdItem {
287
+ question: string;
288
+ answer: string;
289
+ }
290
+
291
+ export function generateFaqJsonLd(
292
+ items: FaqJsonLdItem[],
293
+ ): WithContext<FAQPage> {
294
+ return {
295
+ "@context": "https://schema.org",
296
+ "@type": "FAQPage",
297
+ mainEntity: items.map((item) => ({
298
+ "@type": "Question" as const,
299
+ name: item.question,
300
+ acceptedAnswer: {
301
+ "@type": "Answer" as const,
302
+ text: item.answer,
303
+ },
304
+ })),
305
+ };
306
+ }
307
+
308
+ export interface JobPostingJsonLdOptions {
309
+ title: string;
310
+ description: string;
311
+ datePosted: string;
312
+ validThrough?: string | undefined;
313
+ employmentType?: string | undefined;
314
+ organizationName?: string | undefined;
315
+ organizationUrl?: string | undefined;
316
+ remote?: boolean | undefined;
317
+ locality?: string | undefined;
318
+ region?: string | undefined;
319
+ country?: string | undefined;
320
+ }
321
+
322
+ export function generateJobPostingJsonLd(
323
+ options: JobPostingJsonLdOptions,
324
+ ): WithContext<JobPosting> {
325
+ const {
326
+ title,
327
+ description,
328
+ datePosted,
329
+ validThrough,
330
+ employmentType,
331
+ organizationName,
332
+ organizationUrl,
333
+ remote,
334
+ locality,
335
+ region,
336
+ country,
337
+ } = options;
338
+
339
+ const hiringOrganization = organizationName
340
+ ? {
341
+ "@type": "Organization" as const,
342
+ name: organizationName,
343
+ ...(organizationUrl && { url: toAbsoluteUrl(organizationUrl) }),
344
+ }
345
+ : undefined;
346
+
347
+ const jobLocation =
348
+ locality || region || country
349
+ ? {
350
+ "@type": "Place" as const,
351
+ address: {
352
+ "@type": "PostalAddress" as const,
353
+ ...(locality && { addressLocality: locality }),
354
+ ...(region && { addressRegion: region }),
355
+ ...(country && { addressCountry: country }),
356
+ },
357
+ }
358
+ : undefined;
359
+
360
+ return {
361
+ "@context": "https://schema.org",
362
+ "@type": "JobPosting",
363
+ title,
364
+ description,
365
+ datePosted,
366
+ ...(validThrough && { validThrough }),
367
+ ...(employmentType && { employmentType }),
368
+ ...(hiringOrganization && { hiringOrganization }),
369
+ ...(remote && { jobLocationType: "TELECOMMUTE" }),
370
+ ...(jobLocation && { jobLocation }),
371
+ };
372
+ }
373
+
374
+ export interface SoftwareApplicationJsonLdOptions {
375
+ name: string;
376
+ description?: string | undefined;
377
+ applicationCategory?: string | undefined;
378
+ operatingSystem?: string | undefined;
379
+ price?: number | undefined;
380
+ currency?: string | undefined;
381
+ url?: string | undefined;
382
+ image?: string | undefined;
383
+ }
384
+
385
+ export function generateSoftwareApplicationJsonLd(
386
+ options: SoftwareApplicationJsonLdOptions,
387
+ ): WithContext<SoftwareApplication> {
388
+ const {
389
+ name,
390
+ description,
391
+ applicationCategory,
392
+ operatingSystem,
393
+ price,
394
+ currency,
395
+ url,
396
+ image,
397
+ } = options;
398
+
399
+ const offers =
400
+ typeof price === "number"
401
+ ? {
402
+ "@type": "Offer" as const,
403
+ price: price.toString(),
404
+ ...(currency && { priceCurrency: currency }),
405
+ }
406
+ : undefined;
407
+
408
+ return {
409
+ "@context": "https://schema.org",
410
+ "@type": "SoftwareApplication",
411
+ name,
412
+ ...(description && { description }),
413
+ ...(applicationCategory && { applicationCategory }),
414
+ ...(operatingSystem && { operatingSystem }),
415
+ ...(url && { url: toAbsoluteUrl(url) }),
416
+ ...(image && { image: toAbsoluteUrl(image) }),
417
+ ...(offers && { offers }),
418
+ };
419
+ }
420
+
421
+ export interface ItemListEntry {
422
+ name: string;
423
+ url?: string | undefined;
424
+ }
425
+
426
+ export interface ItemListJsonLdOptions {
427
+ name?: string | undefined;
428
+ items: ItemListEntry[];
429
+ }
430
+
431
+ export function generateItemListJsonLd(
432
+ options: ItemListJsonLdOptions,
433
+ ): WithContext<ItemList> {
434
+ const { name, items } = options;
435
+
436
+ return {
437
+ "@context": "https://schema.org",
438
+ "@type": "ItemList",
439
+ ...(name && { name }),
440
+ itemListElement: items.map((item, index) => ({
441
+ "@type": "ListItem" as const,
442
+ position: index + 1,
443
+ name: item.name,
444
+ ...(item.url && { url: toAbsoluteUrl(item.url) }),
445
+ })),
446
+ };
447
+ }
448
+
449
+ export interface EventJsonLdOptions {
450
+ name: string;
451
+ description?: string | undefined;
452
+ startDate: string;
453
+ endDate?: string | undefined;
454
+ locationName?: string | undefined;
455
+ locationAddress?: string | undefined;
456
+ url?: string | undefined;
457
+ image?: string | undefined;
458
+ }
459
+
460
+ export function generateEventJsonLd(
461
+ options: EventJsonLdOptions,
462
+ ): WithContext<EventSchema> {
463
+ const {
464
+ name,
465
+ description,
466
+ startDate,
467
+ endDate,
468
+ locationName,
469
+ locationAddress,
470
+ url,
471
+ image,
472
+ } = options;
473
+
474
+ const location =
475
+ locationName || locationAddress
476
+ ? {
477
+ "@type": "Place" as const,
478
+ ...(locationName && { name: locationName }),
479
+ ...(locationAddress && { address: locationAddress }),
480
+ }
481
+ : undefined;
482
+
483
+ return {
484
+ "@context": "https://schema.org",
485
+ "@type": "Event",
486
+ name,
487
+ startDate,
488
+ ...(endDate && { endDate }),
489
+ ...(description && { description }),
490
+ ...(url && { url: toAbsoluteUrl(url) }),
491
+ ...(image && { image: toAbsoluteUrl(image) }),
492
+ ...(location && { location }),
493
+ };
494
+ }
495
+
496
+ export interface VideoJsonLdOptions {
497
+ name: string;
498
+ description: string;
499
+ thumbnail: string;
500
+ uploadDate: string;
501
+ contentUrl?: string | undefined;
502
+ embedUrl?: string | undefined;
503
+ }
504
+
505
+ export function generateVideoJsonLd(
506
+ options: VideoJsonLdOptions,
507
+ ): WithContext<VideoObject> {
508
+ const { name, description, thumbnail, uploadDate, contentUrl, embedUrl } =
509
+ options;
510
+
511
+ return {
512
+ "@context": "https://schema.org",
513
+ "@type": "VideoObject",
514
+ name,
515
+ description,
516
+ thumbnailUrl: toAbsoluteUrl(thumbnail),
517
+ uploadDate,
518
+ ...(contentUrl && { contentUrl }),
519
+ ...(embedUrl && { embedUrl }),
520
+ };
521
+ }
@@ -0,0 +1,45 @@
1
+ // Print a decrypted env file to the terminal (read-only — never writes).
2
+ //
3
+ // bun run env:view # .env (shared / development)
4
+ // bun run env:view --preview # .env.preview
5
+ // bun run env:view --prod # .env.production
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync } from "node:fs";
9
+
10
+ // This prints decrypted secrets to stdout — never where logs are captured. Most
11
+ // CI sets `CI`; Vercel builds set `VERCEL` (not `CI`), so check both.
12
+ if (process.env.CI || process.env.VERCEL) {
13
+ console.error(
14
+ "env:view prints decrypted secrets — refusing to run in CI / on Vercel.",
15
+ );
16
+ process.exit(1);
17
+ }
18
+
19
+ const args = process.argv.slice(2);
20
+ const file =
21
+ args.includes("--prod") || args.includes("--production")
22
+ ? ".env.production"
23
+ : args.includes("--preview")
24
+ ? ".env.preview"
25
+ : ".env";
26
+
27
+ // Friendlier than dotenvx's MISSING_ENV_FILE error when the file isn't created yet.
28
+ if (!existsSync(file)) {
29
+ const flag =
30
+ file === ".env.production"
31
+ ? " --prod"
32
+ : file === ".env.preview"
33
+ ? " --preview"
34
+ : "";
35
+ console.error(
36
+ `No ${file} yet. Add values with: bun run env:set KEY value${flag}`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ // `dotenvx` resolves from node_modules/.bin when run via `bun run`.
42
+ const res = spawnSync("dotenvx", ["decrypt", "-f", file, "--stdout"], {
43
+ stdio: "inherit",
44
+ });
45
+ process.exit(res.status ?? 1);
@@ -0,0 +1,25 @@
1
+ name: gitleaks
2
+
3
+ # Scans the PR diff for committed secrets. Encrypted values and non-secret config
4
+ # pass; runs with --redact so values never reach the logs.
5
+
6
+ on:
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+ pull-requests: write # gitleaks comments findings on the PR
12
+
13
+ jobs:
14
+ gitleaks:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+
21
+ - uses: gitleaks/gitleaks-action@v3
22
+ env:
23
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24
+ # The action reads GITLEAKS_LICENSE; source it from your org secret.
25
+ GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE_KEY }}
@@ -0,0 +1,14 @@
1
+ // Decrypt env at runtime on Vercel serverless, where the `dotenvx run` wrapper
2
+ // doesn't run. Node only (dotenvx can't run on the edge).
3
+ export async function register() {
4
+ if (process.env.NEXT_RUNTIME !== "nodejs") return
5
+
6
+ const { config } = await import("@dotenvx/dotenvx")
7
+ const vercelEnv = process.env.VERCEL_ENV
8
+
9
+ config({
10
+ path: vercelEnv ? [".env", `.env.${vercelEnv}`] : [".env"],
11
+ overload: true, // Next pre-loads the encrypted .env; overwrite with decrypted values
12
+ ignore: ["MISSING_ENV_FILE"], // env-specific file may not exist
13
+ })
14
+ }
@@ -0,0 +1,129 @@
1
+ // Push the dotenvx private keys from .env.keys to GitHub / Vercel so encrypted
2
+ // envs decrypt in CI and at runtime. Each value is piped via stdin — never as a
3
+ // CLI argument — so it doesn't land in process listings or shell history.
4
+ //
5
+ // bun run env:setup-remote # pick a target from a menu
6
+ // bun run env:setup-remote github # gh secret set for each DOTENV_PRIVATE_KEY*
7
+ // bun run env:setup-remote vercel # vercel env add for each DOTENV_PRIVATE_KEY*
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { createInterface } from "node:readline/promises";
12
+
13
+ const TARGETS = ["github", "vercel"];
14
+
15
+ // Collect every DOTENV_PRIVATE_KEY* assignment (base + per-environment) from
16
+ // .env.keys content, stripping surrounding quotes. Skips empty values.
17
+ export function parsePrivateKeys(content) {
18
+ const keys = [];
19
+ for (const raw of content.split("\n")) {
20
+ const match = raw.match(/^\s*(DOTENV_PRIVATE_KEY[A-Z_]*)\s*=\s*(.+?)\s*$/);
21
+ if (!match) continue;
22
+ const value = match[2].replace(/^["']|["']$/g, "");
23
+ if (value) keys.push({ name: match[1], value });
24
+ }
25
+ return keys;
26
+ }
27
+
28
+ // The base key decrypts the shared .env (loaded in every environment); a
29
+ // suffixed key (…_PRODUCTION / …_PREVIEW) decrypts only that environment's file.
30
+ export function vercelEnvsFor(name) {
31
+ const suffix = name.slice("DOTENV_PRIVATE_KEY".length).replace(/^_/, "");
32
+ return suffix
33
+ ? [suffix.toLowerCase()]
34
+ : ["production", "preview", "development"];
35
+ }
36
+
37
+ // Ask which remote when no arg was given. Never hang a non-interactive shell.
38
+ async function promptTarget() {
39
+ if (!process.stdin.isTTY) {
40
+ console.error("Usage: bun run env:setup-remote github | vercel");
41
+ process.exit(1);
42
+ }
43
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
44
+ try {
45
+ const answer = (
46
+ await rl.question(
47
+ "Push private keys to:\n 1) GitHub secrets (CI)\n 2) Vercel env (runtime)\nSelect [1/2]: ",
48
+ )
49
+ )
50
+ .trim()
51
+ .toLowerCase();
52
+ if (answer === "1" || answer === "github") return "github";
53
+ if (answer === "2" || answer === "vercel") return "vercel";
54
+ console.error("Cancelled — choose 1 (GitHub) or 2 (Vercel).");
55
+ process.exit(1);
56
+ } finally {
57
+ rl.close();
58
+ }
59
+ }
60
+
61
+ // Pipe the value through stdin so it never appears in argv.
62
+ function pipe(cmd, args, input) {
63
+ const res = spawnSync(cmd, args, {
64
+ input,
65
+ stdio: ["pipe", "inherit", "inherit"],
66
+ });
67
+ if (res.error?.code === "ENOENT") {
68
+ console.error(`\n${cmd} not found — install its CLI and sign in first.`);
69
+ process.exit(1);
70
+ }
71
+ return res.status === 0;
72
+ }
73
+
74
+ async function main() {
75
+ // Validate an explicit arg up front so a typo fails before anything else.
76
+ const arg = process.argv[2];
77
+ if (arg && !TARGETS.includes(arg)) {
78
+ console.error(`Unknown target "${arg}". Use: github | vercel`);
79
+ process.exit(1);
80
+ }
81
+
82
+ if (!existsSync(".env.keys")) {
83
+ console.error(
84
+ "No .env.keys found — run `bun run env:setup` to restore it first.",
85
+ );
86
+ process.exit(1);
87
+ }
88
+
89
+ const keys = parsePrivateKeys(readFileSync(".env.keys", "utf-8"));
90
+ if (keys.length === 0) {
91
+ console.error("No DOTENV_PRIVATE_KEY* entries found in .env.keys.");
92
+ process.exit(1);
93
+ }
94
+
95
+ const target = arg ?? (await promptTarget());
96
+
97
+ let ok = true;
98
+ for (const { name, value } of keys) {
99
+ if (target === "github") {
100
+ console.log(`→ gh secret set ${name}`);
101
+ ok = pipe("gh", ["secret", "set", name], value) && ok;
102
+ } else {
103
+ for (const env of vercelEnvsFor(name)) {
104
+ console.log(`→ vercel env add ${name} ${env}`);
105
+ ok = pipe("vercel", ["env", "add", name, env], value) && ok;
106
+ }
107
+ }
108
+ }
109
+
110
+ if (!ok) {
111
+ console.error(
112
+ target === "github"
113
+ ? "\nSome secrets failed. Check the GitHub CLI is authenticated (`gh auth login`) and the repo exists on GitHub."
114
+ : "\nSome env vars failed. Check the Vercel CLI is linked (`vercel link`); a var that already exists must be removed first (`vercel env rm NAME ENV`).",
115
+ );
116
+ process.exit(1);
117
+ }
118
+
119
+ console.log(
120
+ target === "vercel"
121
+ ? "\nDone. Redeploy for the new env vars to take effect."
122
+ : "\nDone.",
123
+ );
124
+ }
125
+
126
+ // Only run the script when invoked directly (not when imported by a test).
127
+ if (import.meta.main) {
128
+ await main();
129
+ }
@@ -0,0 +1,108 @@
1
+ // Save the private key to 1Password (Development vault) and point .env's op-ref
2
+ // at it, so teammates restore with `bun run env:setup`. The key is piped via
3
+ // stdin (JSON template), never passed as an argument.
4
+ //
5
+ // bun run env:save
6
+
7
+ import { spawnSync } from "node:child_process";
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { createInterface } from "node:readline/promises";
10
+
11
+ const VAULT = "Development";
12
+
13
+ function fail(message) {
14
+ console.error(message);
15
+ process.exit(1);
16
+ }
17
+
18
+ // Write the op-ref into .env (it's a plaintext line, never encrypted).
19
+ function setOpRef(ref) {
20
+ let content = existsSync(".env") ? readFileSync(".env", "utf-8") : "";
21
+ if (/^DOTENV_PRIVATE_KEY_OP_REF=.*$/m.test(content)) {
22
+ content = content.replace(
23
+ /^DOTENV_PRIVATE_KEY_OP_REF=.*$/m,
24
+ `DOTENV_PRIVATE_KEY_OP_REF=${ref}`,
25
+ );
26
+ } else {
27
+ if (content.length > 0 && !content.endsWith("\n")) content += "\n";
28
+ content += `DOTENV_PRIVATE_KEY_OP_REF=${ref}\n`;
29
+ }
30
+ writeFileSync(".env", content);
31
+ }
32
+
33
+ // 1. Read the base private key from .env.keys.
34
+ if (!existsSync(".env.keys")) {
35
+ fail("No .env.keys found — set up dotenvx (or run `bun run env:setup`) first.");
36
+ }
37
+ const keyMatch = readFileSync(".env.keys", "utf-8").match(
38
+ /^\s*DOTENV_PRIVATE_KEY\s*=\s*(.+?)\s*$/m,
39
+ );
40
+ const privateKey = keyMatch?.[1]?.replace(/^["']|["']$/g, "");
41
+ if (!privateKey) fail("No DOTENV_PRIVATE_KEY found in .env.keys.");
42
+
43
+ // 2. Require the 1Password CLI, signed in.
44
+ const whoami = spawnSync("op", ["whoami"], { stdio: "ignore" });
45
+ if (whoami.error?.code === "ENOENT") {
46
+ fail("1Password CLI not found — install `op` and run `op signin` first.");
47
+ }
48
+ if (whoami.status !== 0) {
49
+ fail("Not signed in to 1Password — run `op signin` first.");
50
+ }
51
+
52
+ // 3. Prompt for the item title (default: package.json name) and an optional tag.
53
+ if (!process.stdin.isTTY) fail("env:save is interactive — run it in a terminal.");
54
+ let defaultTitle = "";
55
+ try {
56
+ defaultTitle = JSON.parse(readFileSync("package.json", "utf-8")).name ?? "";
57
+ } catch {
58
+ // no package.json name — leave the title prompt empty
59
+ }
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ const title =
62
+ (
63
+ await rl.question(
64
+ `1Password item title${defaultTitle ? ` [${defaultTitle}]` : ""}: `,
65
+ )
66
+ ).trim() || defaultTitle;
67
+ const tag = (await rl.question("Tag (optional): ")).trim();
68
+ rl.close();
69
+ if (!title) fail("A title is required.");
70
+
71
+ // 4. Refuse if an item with that title already exists in the vault.
72
+ const existing = spawnSync("op", ["item", "get", title, "--vault", VAULT], {
73
+ stdio: "ignore",
74
+ });
75
+ if (existing.status === 0) {
76
+ fail(
77
+ `An item "${title}" already exists in ${VAULT} — rename or remove it first.`,
78
+ );
79
+ }
80
+
81
+ // 5. Create the item from a JSON template piped via stdin.
82
+ const template = {
83
+ title,
84
+ category: "SECURE_NOTE",
85
+ ...(tag ? { tags: [tag] } : {}),
86
+ fields: [{ label: "private-key", type: "CONCEALED", value: privateKey }],
87
+ };
88
+ const created = spawnSync(
89
+ "op",
90
+ ["item", "create", "--vault", VAULT, "--format", "json", "-"],
91
+ { input: JSON.stringify(template), encoding: "utf-8", stdio: ["pipe", "pipe", "inherit"] },
92
+ );
93
+ if (created.status !== 0) fail("\nFailed to create the 1Password item.");
94
+
95
+ // 6. Reference the item by id (stable; survives renames), falling back to title.
96
+ let itemRef = title;
97
+ try {
98
+ const id = JSON.parse(created.stdout)?.id;
99
+ if (id) itemRef = id;
100
+ } catch {
101
+ // non-JSON output — keep the title-based ref
102
+ }
103
+ const ref = `op://${VAULT}/${itemRef}/private-key`;
104
+ setOpRef(ref);
105
+
106
+ console.log(`\n✓ Saved to 1Password (${VAULT} / "${title}").`);
107
+ console.log(` Set DOTENV_PRIVATE_KEY_OP_REF=${ref} in .env.`);
108
+ console.log(" Teammates can now restore with `bun run env:setup`.");