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,7 +1,7 @@
1
1
  import { stegaClean } from "next-sanity"
2
2
  import {
3
- getCompanyData,
4
3
  getNavbarData,
4
+ getStructuredData,
5
5
  } from "@/lib/integrations/sanity/fetchers/layout"
6
6
  import {
7
7
  generateOrganizationJsonLd,
@@ -9,35 +9,48 @@ import {
9
9
  JsonLd as JsonLdBase,
10
10
  } from "@/lib/utils/json-ld"
11
11
 
12
- const APP_DESCRIPTION =
13
- "Basement is the AI-native platform for audit and advisory firms. Automate engagement workflows using AI agents trusted by 50% of top 100 firms."
12
+ const nonEmpty = (value: unknown): string | undefined =>
13
+ typeof value === "string" && value.trim() !== "" ? value : undefined
14
14
 
15
15
  export const JsonLd = async () => {
16
- const [navbar, companyData] = await Promise.all([
16
+ const [structuredData, navbar] = await Promise.all([
17
+ getStructuredData(),
17
18
  getNavbarData(),
18
- getCompanyData(),
19
19
  ])
20
20
 
21
- const sameAs = companyData?.socialLinks
22
- ?.map((link) => stegaClean(link.url))
23
- .filter((url): url is string => Boolean(url))
21
+ // Deep-clean stega artifacts before any value reaches the emitted markup.
22
+ const data = stegaClean(structuredData)
24
23
 
25
- const logoUrl = navbar?.logo?.asset?.url
24
+ const name = nonEmpty(data?.name)
25
+
26
+ // Without an organization name there's nothing meaningful to emit. Editors
27
+ // fill in Layout -> Structured Data to enable site-wide JSON-LD.
28
+ if (!name) return null
29
+
30
+ const description = nonEmpty(data?.description)
31
+ const url = nonEmpty(data?.url)
32
+
33
+ // Prefer the structured-data logo, fall back to the navbar logo.
34
+ const logo = nonEmpty(data?.logo) ?? nonEmpty(navbar?.logo?.asset?.url)
35
+
36
+ // Merged and deduped in GROQ (flagged Company Data links + manual profiles).
37
+ const sameAs = (data?.sameAs ?? [])
38
+ .map(nonEmpty)
39
+ .filter((value): value is string => Boolean(value))
26
40
 
27
41
  return (
28
42
  <>
29
- <JsonLdBase
30
- data={generateWebSiteJsonLd({
31
- name: "Basement",
32
- description: APP_DESCRIPTION,
33
- })}
34
- />
43
+ <JsonLdBase data={generateWebSiteJsonLd({ name, url, description })} />
35
44
  <JsonLdBase
36
45
  data={generateOrganizationJsonLd({
37
- name: "Basement",
38
- ...(logoUrl ? { logo: logoUrl } : {}),
39
- description: APP_DESCRIPTION,
40
- ...(sameAs?.length ? { sameAs } : {}),
46
+ name,
47
+ url,
48
+ logo,
49
+ description,
50
+ legalName: nonEmpty(data?.legalName),
51
+ sameAs: sameAs.length ? sameAs : undefined,
52
+ contactPoint: data?.contactPoint,
53
+ address: data?.address,
41
54
  })}
42
55
  />
43
56
  </>
@@ -3,8 +3,10 @@ import type { ContentCollectionSearchParams } from "@/components/page-builder/co
3
3
  import { Renderer } from "@/components/page-builder/renderer"
4
4
  import type { PAGE_QUERY_RESULT } from "@/lib/integrations/sanity/sanity.types"
5
5
  import { urlForImage } from "@/lib/integrations/sanity/utils/image"
6
+ import { buildPageJsonLd } from "@/lib/integrations/sanity/utils/page-json-ld"
6
7
  import {
7
8
  generateBreadcrumbJsonLd,
9
+ generateSanityArticleJsonLd,
8
10
  generateSanityWebPageJsonLd,
9
11
  JsonLd,
10
12
  } from "@/lib/utils/json-ld"
@@ -15,6 +17,11 @@ type PageDocumentProps = {
15
17
  searchParams: ContentCollectionSearchParams
16
18
  }
17
19
 
20
+ type PageBuilderBlock = NonNullable<
21
+ NonNullable<PAGE_QUERY_RESULT>["pageBuilder"]
22
+ >[number]
23
+ type BlogContentBlock = Extract<PageBuilderBlock, { _type: "blogContent" }>
24
+
18
25
  export const getPageSections = (
19
26
  page: NonNullable<PAGE_QUERY_RESULT>,
20
27
  searchParams: ContentCollectionSearchParams
@@ -79,6 +86,33 @@ export const PageDocument = ({
79
86
  const breadcrumbs = buildBreadcrumbs(path, cleanTitle)
80
87
  const hasBreadcrumbs = breadcrumbs.length > 1
81
88
 
89
+ // Editor-selected page-level structured data (FAQ, Event, custom, etc.).
90
+ const pageJsonLd = buildPageJsonLd(page.metadata?.jsonLd)
91
+
92
+ // Blog posts (pages with a Blog Content block) emit Article JSON-LD with
93
+ // fields derived from the post — no Custom block needed. Skipped when the
94
+ // editor already supplied an Article (via a Custom block) to avoid duplicates.
95
+ const blogBlock = page.pageBuilder?.find(
96
+ (block): block is BlogContentBlock => block?._type === "blogContent"
97
+ )
98
+ const hasManualArticle = pageJsonLd.some(
99
+ ({ data }) => (data as { "@type"?: unknown })["@type"] === "Article"
100
+ )
101
+ const articleJsonLd =
102
+ blogBlock && !hasManualArticle
103
+ ? generateSanityArticleJsonLd({
104
+ title: cleanTitle,
105
+ url: pageUrl,
106
+ description: cleanDescription || undefined,
107
+ image: blogBlock.thumbnail
108
+ ? urlForImage(blogBlock.thumbnail).width(1200).height(630).url()
109
+ : undefined,
110
+ datePublished: stegaClean(blogBlock.date) || undefined,
111
+ dateModified: page._updatedAt,
112
+ authorName: stegaClean(blogBlock.author?.name) || undefined,
113
+ })
114
+ : null
115
+
82
116
  return (
83
117
  <>
84
118
  <JsonLd
@@ -90,9 +124,13 @@ export const PageDocument = ({
90
124
  dateModified: page._updatedAt,
91
125
  })}
92
126
  />
127
+ {articleJsonLd ? <JsonLd data={articleJsonLd} /> : null}
93
128
  {hasBreadcrumbs ? (
94
129
  <JsonLd data={generateBreadcrumbJsonLd(breadcrumbs)} />
95
130
  ) : null}
131
+ {pageJsonLd.map(({ key, data }) => (
132
+ <JsonLd key={key} data={data} />
133
+ ))}
96
134
  <article className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-14 px-4 py-14 md:px-6 md:py-20">
97
135
  <h1>{title}</h1>
98
136
  {sections.length ? (
@@ -9,11 +9,18 @@ type SanityImageField = {
9
9
 
10
10
  type SanitySlug = { current?: string | null } | null
11
11
 
12
+ type JsonLdBlock = {
13
+ _type: string
14
+ _key: string
15
+ [key: string]: unknown
16
+ }
17
+
12
18
  type SanityPageMetadata = {
13
19
  metaTitle?: string | null
14
20
  metaDescription?: string | null
15
21
  og?: { image?: SanityImageField | null } | null
16
22
  index?: boolean | null
23
+ jsonLd?: JsonLdBlock[] | null
17
24
  title?: string | null
18
25
  description?: string | null
19
26
  image?: SanityImageField | null
@@ -89,6 +96,7 @@ type SocialLink = {
89
96
  icon?: unknown
90
97
  url?: string | null
91
98
  label?: string | null
99
+ includeInStructuredData?: boolean | null
92
100
  }
93
101
 
94
102
  type BlogCategory = {
@@ -194,13 +202,39 @@ export type PAGE_QUERY_RESULT = PageDocument | null
194
202
  export type PAGE_SITEMAP_QUERY_RESULT = PageSitemap[]
195
203
  export type ALL_BLOG_ARTICLES_QUERY_RESULT = BlogArticle[]
196
204
  export type P_REF_PB_COMP_QUERY_RESULT = PageRef[]
197
- export type COMPANY_DATA_QUERY_RESULT = { socialLinks?: SocialLink[] | null } | null
205
+ export type COMPANY_DATA_QUERY_RESULT = {
206
+ socialLinks?: SocialLink[] | null
207
+ } | null
208
+ export type STRUCTURED_DATA_QUERY_RESULT = {
209
+ name?: string | null
210
+ legalName?: string | null
211
+ description?: string | null
212
+ url?: string | null
213
+ logo?: string | null
214
+ contactPoint?: {
215
+ email?: string | null
216
+ telephone?: string | null
217
+ contactType?: string | null
218
+ } | null
219
+ address?: {
220
+ streetAddress?: string | null
221
+ locality?: string | null
222
+ region?: string | null
223
+ postalCode?: string | null
224
+ country?: string | null
225
+ } | null
226
+ sameAs?: string[] | null
227
+ } | null
198
228
  export type FOOTER_QUERY_RESULT = { links?: FooterLinkGroup[] | null } | null
199
229
  export type NAVBAR_QUERY_RESULT = {
200
230
  logo?: {
201
231
  url?: string | null
202
232
  metadata?: { dimensions?: unknown } | null
203
- asset?: { _id: string; url: string; metadata?: { dimensions?: unknown } | null } | null
233
+ asset?: {
234
+ _id: string
235
+ url: string
236
+ metadata?: { dimensions?: unknown } | null
237
+ } | null
204
238
  [key: string]: unknown
205
239
  } | null
206
240
  navigationItems?: NavItem[] | null
@@ -5,6 +5,12 @@ const nextConfig: NextConfig = {
5
5
  reactStrictMode: true,
6
6
  reactCompiler: true,
7
7
  typedRoutes: true,
8
+ // Bundle the committed encrypted env files into serverless functions so
9
+ // instrumentation.ts can decrypt them at runtime. .env.keys is never listed
10
+ // (and is git/vercel-ignored); missing files are skipped.
11
+ outputFileTracingIncludes: {
12
+ "/**": ["./.env", "./.env.production", "./.env.preview"],
13
+ },
8
14
  turbopack: {
9
15
  rules: {
10
16
  "*.svg": {
@@ -1,11 +1,19 @@
1
1
  # ============================================
2
2
  # Basement Next Starter - Environment Variables
3
3
  # ============================================
4
- # Copy this file to .env.local and fill in the values
5
- # Run: cp .env.example .env.local
4
+ # This project uses dotenvx (https://dotenvx.com): the committed `.env` holds
5
+ # shared values, with secrets encrypted there. The private keys live in
6
+ # `.env.keys` (gitignored) — restore them with `bun run env:setup`.
6
7
  #
7
- # Only configure the integrations you need.
8
- # Remove unused sections to keep your env file clean.
8
+ # Add or change a variable in `.env` (not .env.local):
9
+ # - Secret (API key, token, …): bun run env:set KEY value (--preview / --prod)
10
+ # - Trivial / NEXT_PUBLIC_*: bun run env:set KEY value --plain
11
+ # - Bulk-encrypt a file edited by hand: bun run env:encrypt-all (--preview / --prod)
12
+ #
13
+ # Edge runtime (middleware, `runtime = "edge"`): dotenvx can't decrypt there.
14
+ # Set any secret those read directly in Vercel's env vars, not here.
15
+ #
16
+ # This file just documents the available variables; real values live in `.env`.
9
17
  # ============================================
10
18
 
11
19
 
@@ -16,7 +24,7 @@
16
24
  # Base URL for your site (used for SEO, canonical URLs, etc.)
17
25
  # Development: http://localhost:3000
18
26
  # Production: https://your-domain.com
19
- NEXT_PUBLIC_BASE_URL="http://localhost:3000"
27
+ NEXT_PUBLIC_BASE_URL=""
20
28
 
21
29
  # Draft mode secret for preview functionality
22
30
  # Generate a random string: openssl rand -base64 32
@@ -32,7 +40,7 @@ DRAFT_MODE_TOKEN=""
32
40
 
33
41
  # Required for Sanity
34
42
  NEXT_PUBLIC_SANITY_PROJECT_ID=""
35
- NEXT_PUBLIC_SANITY_DATASET="production"
43
+ NEXT_PUBLIC_SANITY_DATASET=""
36
44
  NEXT_PUBLIC_SANITY_API_READ_TOKEN=""
37
45
 
38
46
  # Optional - for server-side mutations and visual editing
@@ -46,8 +46,29 @@ jobs:
46
46
  - name: Lint
47
47
  run: bun run lint
48
48
 
49
+ # dotenvx needs the private key to decrypt secrets at build time. It's
50
+ # pulled from 1Password via an org-wide service-account token + the op-ref
51
+ # committed in .env. Optional: builds pass without it (only plaintext /
52
+ # NEXT_PUBLIC_* is read), and a per-repo DOTENV_PRIVATE_KEY secret still
53
+ # works as a fallback.
54
+ - name: Install 1Password CLI
55
+ if: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN != '' }}
56
+ uses: 1password/install-cli-action@v1
57
+
49
58
  - name: Build
50
- run: bun run build
59
+ env:
60
+ OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
61
+ DOTENV_PRIVATE_KEY: ${{ secrets.DOTENV_PRIVATE_KEY }}
62
+ run: |
63
+ if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
64
+ ref="$(grep -m1 '^DOTENV_PRIVATE_KEY_OP_REF=' .env | cut -d= -f2-)"
65
+ if [ -n "$ref" ]; then
66
+ DOTENV_PRIVATE_KEY="$(op read "$ref")"
67
+ echo "::add-mask::$DOTENV_PRIVATE_KEY"
68
+ export DOTENV_PRIVATE_KEY
69
+ fi
70
+ fi
71
+ bun run build
51
72
 
52
73
  # Runs your tests if any exist; a fresh project with none passes cleanly.
53
74
  - name: Test
@@ -10,6 +10,9 @@
10
10
  // Sanity CMS (GROQ syntax + validation)
11
11
  "sanity-io.vscode-sanity",
12
12
 
13
+ // Env (dotenvx — view decrypted .env values inline)
14
+ "dotenv.dotenvx-vscode",
15
+
13
16
  // DX Enhancements
14
17
  "yoavbls.pretty-ts-errors"
15
18
  ]
@@ -22,6 +22,35 @@ bun dev
22
22
  | `bun format` | Format the codebase |
23
23
  | `bun typecheck` | Run TypeScript |
24
24
  | `bun analyze` | Analyze the Next.js bundle |
25
+ | `bun env:set KEY value` | Encrypt one value into `.env` (`--plain`, `--preview`/`--prod`) |
26
+ | `bun env:encrypt-all` | Bulk-encrypt every plaintext value in a file (`--preview`/`--prod`) |
27
+ | `bun env:setup` | Restore `.env.keys` from 1Password |
28
+ | `bun env:view` | Print the decrypted `.env` (`--preview` / `--prod`) |
29
+ | `bun env:setup-remote` | Push private keys to GitHub / Vercel (`github`/`vercel`, or pick) |
30
+ | `bun env:save` | Save the private key to 1Password (Development vault) |
31
+
32
+ ## Environment variables
33
+
34
+ Secrets are encrypted at rest with [dotenvx](https://dotenvx.com): the committed `.env` holds encrypted values, the private keys live in `.env.keys` (gitignored), and dev/build run through `dotenvx run` so values decrypt automatically.
35
+
36
+ **First run.** `bun install` restores `.env.keys` from 1Password (via `DOTENV_PRIVATE_KEY_OP_REF` in `.env`), or run `bun run env:setup`. Needs 1Password access + the `op` CLI:
37
+
38
+ - macOS: `brew install --cask 1password-cli`
39
+ - Windows: `winget install 1password-cli`
40
+ - Linux / other: [1Password CLI docs](https://developer.1password.com/docs/cli/get-started/)
41
+
42
+ **Add or change a variable** (in `.env`):
43
+
44
+ - Secret: `bun run env:set KEY value` (`--preview`/`--prod` to target an environment)
45
+ - Public / `NEXT_PUBLIC_*`: `bun run env:set KEY value --plain`
46
+ - Bulk-encrypt a hand-edited file: `bun run env:encrypt-all`
47
+ - Inspect: `bun run env:view` (`--preview`/`--prod`)
48
+
49
+ **Per environment.** Scripts load `.env.$VERCEL_ENV` over `.env`. After adding a per-env file, push its key with `bun run env:setup-remote vercel` — `instrumentation.ts` uses it to decrypt at runtime.
50
+
51
+ **Edge runtime.** dotenvx decrypts only in the Node runtime; secrets read in `middleware.ts` or a `runtime = "edge"` route must be set as plain Vercel env vars.
52
+
53
+ A gitleaks pre-commit hook + CI scan block committed plaintext secrets (install: `brew install gitleaks`).
25
54
 
26
55
  ## Project Structure
27
56
 
@@ -61,6 +90,8 @@ The workflow takes its runtime versions from your manifests, so there's a single
61
90
 
62
91
  It also caches Bun's install cache between runs, and the test step is a no-op until you add real test files.
63
92
 
93
+ The build decrypts the committed `.env` with the dotenvx private key, pulled from 1Password: set an **org-level** `OP_SERVICE_ACCOUNT_TOKEN` (a read-only [service account](https://developer.1password.com/docs/service-accounts/) scoped to the `Development` vault) — one secret for every repo — and CI reads the key via the op-ref committed in `.env`. A per-repo `DOTENV_PRIVATE_KEY` secret (`bun run env:setup-remote github`) works as a fallback; builds pass without either (only plaintext / `NEXT_PUBLIC_*` is read).
94
+
64
95
  ### Branch protection
65
96
 
66
97
  To block merges when CI fails, enable branch protection on `main`: