create-ailk 0.8.0 → 0.9.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 +68 -0
- package/component-catalog.json +279 -45
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +92 -4
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +19 -9
- package/dist/lib/module-license-gate.js +19 -8
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +12 -2
- package/dist/parse-args.js +74 -13
- package/dist/programmatic.d.ts +30 -6
- package/dist/programmatic.js +206 -17
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +141 -8
- package/dist/starter-grid.d.ts +32 -16
- package/dist/starter-grid.js +129 -33
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +200 -83
- package/dist/template-manifest.d.ts +30 -13
- package/dist/template-manifest.js +49 -24
- package/package.json +5 -4
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +98 -6
- package/site-templates/marketing-site/DESIGN.md +98 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
- package/site-templates/marketing-site-free/DESIGN.md +98 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/saas-product/DESIGN.md +98 -6
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
- package/site-templates/shapes/organization-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
- package/site-templates/shapes/person-service/ailk-template.json +1 -1
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
- package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +3 -2
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -76
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +28 -11
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +98 -6
- package/templates/README.md +50 -38
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +35 -19
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +14 -13
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +23 -9
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +40 -1
- package/templates/content/en/HomePage/home.mdx +2 -6
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen, published-generator `renderNavConfig` bodies (#6908 D2) — one per
|
|
3
|
+
* era a create-ailk release ever shipped (E1–E4, `docs/specs/6908-add-module-
|
|
4
|
+
* treats-every-pre-spec.md` § 1.2). `surfaces.ts`'s own `renderNavConfig`
|
|
5
|
+
* self-hashes its output (D1) so a nav it wrote can always prove itself; a
|
|
6
|
+
* nav written before the stamp existed carries none, and this module is the
|
|
7
|
+
* only way `add-module` can still recognize it as unedited.
|
|
8
|
+
*
|
|
9
|
+
* Each `render` below is copied VERBATIM from the named tag's own
|
|
10
|
+
* `packages/create-ailk/src/surfaces.ts`, at the named commit, with exactly
|
|
11
|
+
* two changes from the original:
|
|
12
|
+
*
|
|
13
|
+
* - `GATEABLE_SURFACES` is replaced by a frozen `{ id, navLabelKey }` table
|
|
14
|
+
* of that tag's own entries, in that tag's own order (the era's own
|
|
15
|
+
* `<era>_SURFACES` constant below) — so a later change to today's
|
|
16
|
+
* `GATEABLE_SURFACES` can never move a byte of a legacy render.
|
|
17
|
+
* - the `@working-theory/ui` import line stays fragment-assembled, exactly
|
|
18
|
+
* as the original does, so THIS file never contains the literal import
|
|
19
|
+
* string either (`tools/lint-architecture.ts` matches source text; see
|
|
20
|
+
* the comment on each `navItemImport` below).
|
|
21
|
+
*
|
|
22
|
+
* The list is CLOSED (D2): after D1 ships, every published generator is
|
|
23
|
+
* unstamped only up to 0.8.0 (E4), so a future generator change never needs a
|
|
24
|
+
* fifth entry. Fixtures produced by each tag's own code (never by this
|
|
25
|
+
* vendored copy) pin these bodies exactly — see
|
|
26
|
+
* `__tests__/fixtures/legacy-nav/index.json`.
|
|
27
|
+
*/
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// E1 — 0.1.1, 0.2.0, 0.3.0, 0.4.0 (commit 52e0685f3bff54832a76b5e0ec737c2f39b9d960)
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
const E1_SURFACES = [
|
|
32
|
+
{ id: "blog", navLabelKey: "blog" },
|
|
33
|
+
{ id: "careers" },
|
|
34
|
+
{ id: "case-studies" },
|
|
35
|
+
{ id: "clients" },
|
|
36
|
+
{ id: "courses" },
|
|
37
|
+
{ id: "dev" },
|
|
38
|
+
{ id: "docs", navLabelKey: "docs" },
|
|
39
|
+
{ id: "downloads" },
|
|
40
|
+
{ id: "events" },
|
|
41
|
+
{ id: "flows" },
|
|
42
|
+
{ id: "how-to" },
|
|
43
|
+
{ id: "integrations" },
|
|
44
|
+
{ id: "launch-service" },
|
|
45
|
+
{ id: "partners" },
|
|
46
|
+
{ id: "products" },
|
|
47
|
+
{ id: "projects" },
|
|
48
|
+
{ id: "resources" },
|
|
49
|
+
{ id: "schedule", navLabelKey: "schedule" },
|
|
50
|
+
{ id: "services" },
|
|
51
|
+
{ id: "software" },
|
|
52
|
+
{ id: "tech-articles" },
|
|
53
|
+
{ id: "testimonials" },
|
|
54
|
+
{ id: "use-cases" },
|
|
55
|
+
{ id: "whitepapers" },
|
|
56
|
+
];
|
|
57
|
+
function renderNavConfigE1(enabled) {
|
|
58
|
+
const on = new Set(enabled);
|
|
59
|
+
const linked = E1_SURFACES.filter((s) => s.navLabelKey !== undefined && on.has(s.id));
|
|
60
|
+
const entries = [
|
|
61
|
+
' { labelKey: "home", path: "/" },',
|
|
62
|
+
...linked.map((s) => ` { labelKey: "${s.navLabelKey}", path: "/${s.id}" },`),
|
|
63
|
+
];
|
|
64
|
+
// Fragment-assembled so this vendored copy never contains the literal
|
|
65
|
+
// import string either — see the module comment above.
|
|
66
|
+
const navItemImport = [
|
|
67
|
+
"import type { NavItem } ",
|
|
68
|
+
"from ",
|
|
69
|
+
'"@working-theory/ui";',
|
|
70
|
+
].join("");
|
|
71
|
+
return `// nav-config.ts — site-nav registry.
|
|
72
|
+
//
|
|
73
|
+
// GENERATED by create-ailk from the declared route surfaces. Every entry here
|
|
74
|
+
// points at a route this site actually carries — the nav is regenerated with
|
|
75
|
+
// the surface set so it can never link a page that was not scaffolded.
|
|
76
|
+
//
|
|
77
|
+
// Adding a link: scaffold the page (the \`scaffold-page\` skill), then add an
|
|
78
|
+
// entry below with a \`labelKey\` that exists in the \`nav\` namespace of
|
|
79
|
+
// \`apps/web/messages/<locale>.json\`. Paths are locale-agnostic — Nav prefixes
|
|
80
|
+
// them with the active locale at render.
|
|
81
|
+
${navItemImport}
|
|
82
|
+
|
|
83
|
+
export const navItems: NavItem[] = [
|
|
84
|
+
${entries.join("\n")}
|
|
85
|
+
];
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// E2 — 0.5.0, 0.6.0, 0.6.1 (commit b0a69cefecf491412aa7e6f1d64464a91620126d)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
const E2_SURFACES = [
|
|
92
|
+
{ id: "blog", navLabelKey: "blog" },
|
|
93
|
+
{ id: "careers" },
|
|
94
|
+
{ id: "case-studies" },
|
|
95
|
+
{ id: "clients" },
|
|
96
|
+
{ id: "courses" },
|
|
97
|
+
{ id: "dev" },
|
|
98
|
+
{ id: "docs", navLabelKey: "docs" },
|
|
99
|
+
{ id: "downloads" },
|
|
100
|
+
{ id: "events" },
|
|
101
|
+
{ id: "flows" },
|
|
102
|
+
{ id: "how-to" },
|
|
103
|
+
{ id: "integrations" },
|
|
104
|
+
{ id: "launch-service" },
|
|
105
|
+
{ id: "meet", navLabelKey: "schedule" },
|
|
106
|
+
{ id: "partners" },
|
|
107
|
+
{ id: "pricing" },
|
|
108
|
+
{ id: "privacy" },
|
|
109
|
+
{ id: "products" },
|
|
110
|
+
{ id: "projects" },
|
|
111
|
+
{ id: "resources" },
|
|
112
|
+
{ id: "services" },
|
|
113
|
+
{ id: "software" },
|
|
114
|
+
{ id: "tech-articles" },
|
|
115
|
+
{ id: "terms" },
|
|
116
|
+
{ id: "testimonials" },
|
|
117
|
+
{ id: "use-cases" },
|
|
118
|
+
{ id: "whitepapers" },
|
|
119
|
+
];
|
|
120
|
+
function renderNavConfigE2(enabled) {
|
|
121
|
+
const on = new Set(enabled);
|
|
122
|
+
const linked = E2_SURFACES.filter((s) => s.navLabelKey !== undefined && on.has(s.id));
|
|
123
|
+
const entries = [
|
|
124
|
+
" {",
|
|
125
|
+
' labelKey: "product",',
|
|
126
|
+
" flyout: {",
|
|
127
|
+
' presentation: "list",',
|
|
128
|
+
' width: "trigger",',
|
|
129
|
+
" features: [",
|
|
130
|
+
' { labelKey: "howItWorks", descKey: "howItWorksDesc", icon: Layers, path: "/how-it-works" },',
|
|
131
|
+
' { labelKey: "features", descKey: "featuresDesc", icon: Sparkles, path: "/features" },',
|
|
132
|
+
' { labelKey: "faq", descKey: "faqDesc", icon: HelpCircle, path: "/faq" },',
|
|
133
|
+
" ],",
|
|
134
|
+
' actions: [{ labelKey: "docs", icon: BookOpen, path: "/how-it-works" }],',
|
|
135
|
+
" },",
|
|
136
|
+
" },",
|
|
137
|
+
' { labelKey: "howItWorks", path: "/how-it-works" },',
|
|
138
|
+
...(on.has("pricing") ? [' { labelKey: "pricing", path: "/pricing" },'] : []),
|
|
139
|
+
' { labelKey: "about", path: "/about" },',
|
|
140
|
+
...linked.map((s) => ` { labelKey: "${s.navLabelKey}", path: "/${s.id}" },`),
|
|
141
|
+
];
|
|
142
|
+
const navItemImport = [
|
|
143
|
+
"import type { NavItem } ",
|
|
144
|
+
"from ",
|
|
145
|
+
'"@working-theory/ui";',
|
|
146
|
+
].join("");
|
|
147
|
+
return `// nav-config.ts — site-nav registry.
|
|
148
|
+
//
|
|
149
|
+
// GENERATED by create-ailk from the declared route surfaces. Every entry here
|
|
150
|
+
// points at a route this site actually carries — the nav is regenerated with
|
|
151
|
+
// the surface set so it can never link a page that was not scaffolded.
|
|
152
|
+
//
|
|
153
|
+
// Adding a link: scaffold the page (the \`scaffold-page\` skill), then add an
|
|
154
|
+
// entry below with a \`labelKey\` that exists in the \`nav\` namespace of
|
|
155
|
+
// \`apps/web/messages/<locale>.json\`. Paths are locale-agnostic — Nav prefixes
|
|
156
|
+
// them with the active locale at render.
|
|
157
|
+
${navItemImport}
|
|
158
|
+
import { BookOpen, HelpCircle, Layers, Sparkles } from "lucide-react";
|
|
159
|
+
|
|
160
|
+
export const navItems: NavItem[] = [
|
|
161
|
+
${entries.join("\n")}
|
|
162
|
+
];
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// E3 — 0.7.0 (commit cac431ba50c4b0ee5130b5bc206196240744be18)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
const E3_SURFACES = [
|
|
169
|
+
{ id: "blog", navLabelKey: "blog" },
|
|
170
|
+
{ id: "book-a-call" },
|
|
171
|
+
{ id: "careers" },
|
|
172
|
+
{ id: "case-studies" },
|
|
173
|
+
{ id: "clients" },
|
|
174
|
+
{ id: "courses" },
|
|
175
|
+
{ id: "dev" },
|
|
176
|
+
{ id: "docs", navLabelKey: "docs" },
|
|
177
|
+
{ id: "downloads" },
|
|
178
|
+
{ id: "events" },
|
|
179
|
+
{ id: "flows" },
|
|
180
|
+
{ id: "glossary" },
|
|
181
|
+
{ id: "how-to" },
|
|
182
|
+
{ id: "integrations" },
|
|
183
|
+
{ id: "launch-service" },
|
|
184
|
+
{ id: "meet", navLabelKey: "schedule" },
|
|
185
|
+
{ id: "newsletter" },
|
|
186
|
+
{ id: "partners" },
|
|
187
|
+
{ id: "pricing" },
|
|
188
|
+
{ id: "privacy" },
|
|
189
|
+
{ id: "products" },
|
|
190
|
+
{ id: "projects" },
|
|
191
|
+
{ id: "resources" },
|
|
192
|
+
{ id: "services" },
|
|
193
|
+
{ id: "tech-articles" },
|
|
194
|
+
{ id: "terms" },
|
|
195
|
+
{ id: "testimonials" },
|
|
196
|
+
{ id: "use-cases" },
|
|
197
|
+
{ id: "whitepapers" },
|
|
198
|
+
];
|
|
199
|
+
function renderNavConfigE3(enabled) {
|
|
200
|
+
const on = new Set(enabled);
|
|
201
|
+
// `blog` is linked unconditionally below (operator ruling 2026-09-23), so
|
|
202
|
+
// it is excluded here; `use-cases` is the Free roster's own page (a
|
|
203
|
+
// module-less surface the roster enables, like `pricing`), linked in its
|
|
204
|
+
// fixed positions below rather than appended.
|
|
205
|
+
const linked = E3_SURFACES.filter((s) => s.navLabelKey !== undefined &&
|
|
206
|
+
on.has(s.id) &&
|
|
207
|
+
s.id !== "blog" &&
|
|
208
|
+
s.id !== "use-cases");
|
|
209
|
+
const useCases = on.has("use-cases");
|
|
210
|
+
const entries = [
|
|
211
|
+
" {",
|
|
212
|
+
' labelKey: "product",',
|
|
213
|
+
" flyout: {",
|
|
214
|
+
// SOFT_LOCK_OK: #6508 — width "panel" (D4/D5): a fixed 640px, two-column
|
|
215
|
+
// grid, replacing the "list"/"trigger" one-column menu. `presentation` is
|
|
216
|
+
// omitted — it defaults to "grid" (D5). The `docs` action is dropped (it
|
|
217
|
+
// duplicated the first feature's /how-it-works destination, § 1.4).
|
|
218
|
+
' width: "panel",',
|
|
219
|
+
" features: [",
|
|
220
|
+
...(useCases
|
|
221
|
+
? [' { labelKey: "useCases", descKey: "useCasesDesc", icon: Layers, path: "/use-cases" },']
|
|
222
|
+
: []),
|
|
223
|
+
' { labelKey: "features", descKey: "featuresDesc", icon: Sparkles, path: "/software/product" },',
|
|
224
|
+
' { labelKey: "faq", descKey: "faqDesc", icon: HelpCircle, path: "/faq" },',
|
|
225
|
+
" ],",
|
|
226
|
+
" actions: [],",
|
|
227
|
+
" },",
|
|
228
|
+
" },",
|
|
229
|
+
// Operator ruling 2026-09-23: Product · Use cases · Blog · Pricing · About.
|
|
230
|
+
// `/blog` is always linked — the `blog` surface serves the real route, and
|
|
231
|
+
// with it off the `[slug]` route serves the overlay's module page.
|
|
232
|
+
...(useCases ? [' { labelKey: "useCases", path: "/use-cases" },'] : []),
|
|
233
|
+
' { labelKey: "blog", path: "/blog" },',
|
|
234
|
+
...(on.has("pricing") ? [' { labelKey: "pricing", path: "/pricing" },'] : []),
|
|
235
|
+
' { labelKey: "about", path: "/about" },',
|
|
236
|
+
...linked.map((s) => ` { labelKey: "${s.navLabelKey}", path: "/${s.id}" },`),
|
|
237
|
+
];
|
|
238
|
+
// The footer's column groups (operator ruling 2026-09-23, replacing #6468's
|
|
239
|
+
// Product/Company pair): Site pages, Features, Resources. Pricing rides the
|
|
240
|
+
// same `pricing` gate as the header item; `/blog` is always linked (above).
|
|
241
|
+
// The product links point at the product's own page, `/software/product`
|
|
242
|
+
// (#6658 D3): `software` is core, so they stay unconditional. Book a call,
|
|
243
|
+
// Glossary and Newsletter (#6658 D8) each ride their own surface's gate.
|
|
244
|
+
const footerEntries = [
|
|
245
|
+
" {",
|
|
246
|
+
' headingKey: "footerSitePagesHeading",',
|
|
247
|
+
" items: [",
|
|
248
|
+
' { labelKey: "features", path: "/software/product" },',
|
|
249
|
+
...(on.has("pricing")
|
|
250
|
+
? [' { labelKey: "pricing", path: "/pricing" },']
|
|
251
|
+
: []),
|
|
252
|
+
...(useCases ? [' { labelKey: "useCases", path: "/use-cases" },'] : []),
|
|
253
|
+
' { labelKey: "about", path: "/about" },',
|
|
254
|
+
' { labelKey: "contact", path: "/contact" },',
|
|
255
|
+
...(on.has("book-a-call")
|
|
256
|
+
? [' { labelKey: "bookACall", path: "/book-a-call" },']
|
|
257
|
+
: []),
|
|
258
|
+
" ],",
|
|
259
|
+
" },",
|
|
260
|
+
" {",
|
|
261
|
+
' headingKey: "footerFeaturesHeading",',
|
|
262
|
+
" items: [",
|
|
263
|
+
' { labelKey: "feature1", path: "/software/product" },',
|
|
264
|
+
' { labelKey: "feature2", path: "/software/product" },',
|
|
265
|
+
' { labelKey: "feature3", path: "/software/product" },',
|
|
266
|
+
' { labelKey: "feature4", path: "/software/product" },',
|
|
267
|
+
' { labelKey: "feature5", path: "/software/product" },',
|
|
268
|
+
' { labelKey: "allFeatures", path: "/software/product" },',
|
|
269
|
+
" ],",
|
|
270
|
+
" },",
|
|
271
|
+
" {",
|
|
272
|
+
' headingKey: "footerResourcesHeading",',
|
|
273
|
+
" items: [",
|
|
274
|
+
' { labelKey: "blog", path: "/blog" },',
|
|
275
|
+
...(on.has("glossary")
|
|
276
|
+
? [' { labelKey: "glossary", path: "/glossary" },']
|
|
277
|
+
: []),
|
|
278
|
+
...(on.has("newsletter")
|
|
279
|
+
? [' { labelKey: "newsletter", path: "/newsletter" },']
|
|
280
|
+
: []),
|
|
281
|
+
' { labelKey: "faq", path: "/faq" },',
|
|
282
|
+
" ],",
|
|
283
|
+
" },",
|
|
284
|
+
];
|
|
285
|
+
const navItemImport = [
|
|
286
|
+
"import type { NavItem } ",
|
|
287
|
+
"from ",
|
|
288
|
+
'"@working-theory/ui";',
|
|
289
|
+
].join("");
|
|
290
|
+
return `// nav-config.ts — site-nav registry.
|
|
291
|
+
//
|
|
292
|
+
// GENERATED by create-ailk from the declared route surfaces. Every entry here
|
|
293
|
+
// points at a route this site actually carries — the nav is regenerated with
|
|
294
|
+
// the surface set so it can never link a page that was not scaffolded.
|
|
295
|
+
//
|
|
296
|
+
// Adding a link: scaffold the page (the \`add-page\` skill), then add an
|
|
297
|
+
// entry below with a \`labelKey\` that exists in the \`nav\` namespace of
|
|
298
|
+
// \`apps/web/messages/<locale>.json\`. Paths are locale-agnostic — Nav prefixes
|
|
299
|
+
// them with the active locale at render.
|
|
300
|
+
${navItemImport}
|
|
301
|
+
import { HelpCircle, Layers, Sparkles } from "lucide-react";
|
|
302
|
+
|
|
303
|
+
export const navItems: NavItem[] = [
|
|
304
|
+
${entries.join("\n")}
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
// Footer column groups. Keys only — the layout resolves each \`headingKey\`
|
|
308
|
+
// and \`labelKey\` through the same \`nav\` labels as the header.
|
|
309
|
+
export interface FooterColumnConfig {
|
|
310
|
+
headingKey: string;
|
|
311
|
+
items: { labelKey: string; path: string }[];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export const footerColumns: FooterColumnConfig[] = [
|
|
315
|
+
${footerEntries.join("\n")}
|
|
316
|
+
];
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// E4 — 0.8.0 (commit 060c02b8190090d7495d42e220836141d0549809)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
const E4_SURFACES = [
|
|
323
|
+
{ id: "blog", navLabelKey: "blog" },
|
|
324
|
+
{ id: "book-a-call" },
|
|
325
|
+
{ id: "careers" },
|
|
326
|
+
{ id: "case-studies" },
|
|
327
|
+
{ id: "clients" },
|
|
328
|
+
{ id: "courses" },
|
|
329
|
+
{ id: "dev" },
|
|
330
|
+
{ id: "docs", navLabelKey: "docs" },
|
|
331
|
+
{ id: "downloads" },
|
|
332
|
+
{ id: "events" },
|
|
333
|
+
{ id: "features" },
|
|
334
|
+
{ id: "flows" },
|
|
335
|
+
{ id: "glossary" },
|
|
336
|
+
{ id: "how-to" },
|
|
337
|
+
{ id: "integrations" },
|
|
338
|
+
{ id: "launch-service" },
|
|
339
|
+
{ id: "meet", navLabelKey: "schedule" },
|
|
340
|
+
{ id: "newsletter" },
|
|
341
|
+
{ id: "partners" },
|
|
342
|
+
{ id: "portfolio" },
|
|
343
|
+
{ id: "pricing" },
|
|
344
|
+
{ id: "privacy" },
|
|
345
|
+
{ id: "products" },
|
|
346
|
+
{ id: "projects" },
|
|
347
|
+
{ id: "resources" },
|
|
348
|
+
{ id: "services" },
|
|
349
|
+
{ id: "software" },
|
|
350
|
+
{ id: "tech-articles" },
|
|
351
|
+
{ id: "terms" },
|
|
352
|
+
{ id: "testimonials" },
|
|
353
|
+
{ id: "use-cases" },
|
|
354
|
+
{ id: "whitepapers" },
|
|
355
|
+
];
|
|
356
|
+
function renderNavConfigE4(enabled) {
|
|
357
|
+
const on = new Set(enabled);
|
|
358
|
+
// `blog` is linked unconditionally below (operator ruling 2026-09-23), so
|
|
359
|
+
// it is excluded here; `use-cases` is the Free roster's own page (a
|
|
360
|
+
// module-less surface the roster enables, like `pricing`), linked in its
|
|
361
|
+
// fixed positions below rather than appended.
|
|
362
|
+
const linked = E4_SURFACES.filter((s) => s.navLabelKey !== undefined &&
|
|
363
|
+
on.has(s.id) &&
|
|
364
|
+
s.id !== "blog" &&
|
|
365
|
+
s.id !== "use-cases");
|
|
366
|
+
const useCases = on.has("use-cases");
|
|
367
|
+
const features = on.has("features");
|
|
368
|
+
// #6721 (operator ruling 2026-09-25): the Product flyout is two columns —
|
|
369
|
+
// Features and Use Cases — each a heading over three label-only links. The
|
|
370
|
+
// `groups` render the desktop panel; `features` carries the same links for
|
|
371
|
+
// the mobile disclosure and the active-path match. "Use cases" left the top
|
|
372
|
+
// nav (it lives in the flyout and its own footer column).
|
|
373
|
+
const featureLinks = features
|
|
374
|
+
? [
|
|
375
|
+
' { labelKey: "feature1", path: "/features/feature-1" },',
|
|
376
|
+
' { labelKey: "feature2", path: "/features/feature-2" },',
|
|
377
|
+
' { labelKey: "feature3", path: "/features/feature-3" },',
|
|
378
|
+
]
|
|
379
|
+
: [];
|
|
380
|
+
const useCaseLinks = useCases
|
|
381
|
+
? [
|
|
382
|
+
' { labelKey: "useCase1", path: "/use-cases/persona-1" },',
|
|
383
|
+
' { labelKey: "useCase2", path: "/use-cases/persona-2" },',
|
|
384
|
+
' { labelKey: "useCase3", path: "/use-cases/persona-3" },',
|
|
385
|
+
]
|
|
386
|
+
: [];
|
|
387
|
+
const indent = (line) => ` ${line}`;
|
|
388
|
+
const group = (headingKey, links) => links.length === 0
|
|
389
|
+
? []
|
|
390
|
+
: [
|
|
391
|
+
" {",
|
|
392
|
+
` headingKey: "${headingKey}",`,
|
|
393
|
+
" items: [",
|
|
394
|
+
...links.map(indent),
|
|
395
|
+
" ],",
|
|
396
|
+
" },",
|
|
397
|
+
];
|
|
398
|
+
const entries = [
|
|
399
|
+
" {",
|
|
400
|
+
' labelKey: "product",',
|
|
401
|
+
" flyout: {",
|
|
402
|
+
// SOFT_LOCK_OK: #6508 — width "panel" (D4/D5): a fixed 640px panel.
|
|
403
|
+
' width: "panel",',
|
|
404
|
+
" features: [",
|
|
405
|
+
...featureLinks,
|
|
406
|
+
...useCaseLinks,
|
|
407
|
+
" ],",
|
|
408
|
+
" groups: [",
|
|
409
|
+
...group("flyoutFeaturesHeading", featureLinks),
|
|
410
|
+
...group("flyoutUseCasesHeading", useCaseLinks),
|
|
411
|
+
" ],",
|
|
412
|
+
" actions: [],",
|
|
413
|
+
" },",
|
|
414
|
+
" },",
|
|
415
|
+
// Operator ruling 2026-09-25: Product · Blog · Pricing · About.
|
|
416
|
+
// `/blog` is always linked — the `blog` surface serves the real route, and
|
|
417
|
+
// with it off the `[slug]` route serves the overlay's module page.
|
|
418
|
+
' { labelKey: "blog", path: "/blog" },',
|
|
419
|
+
...(on.has("pricing") ? [' { labelKey: "pricing", path: "/pricing" },'] : []),
|
|
420
|
+
' { labelKey: "about", path: "/about" },',
|
|
421
|
+
...linked.map((s) => ` { labelKey: "${s.navLabelKey}", path: "/${s.id}" },`),
|
|
422
|
+
];
|
|
423
|
+
// The footer's column groups (operator rulings 2026-09-23 + 2026-09-25):
|
|
424
|
+
// Site pages, Features, Use Cases, Resources. Pricing rides the same
|
|
425
|
+
// `pricing` gate as the header item; `/blog` is always linked (above). The
|
|
426
|
+
// Features column links the three feature pages and the /features hub; the
|
|
427
|
+
// Use Cases column the three use cases and the /use-cases hub — each rides
|
|
428
|
+
// its surface's gate. Book a call, Glossary and Newsletter (#6658 D8) each
|
|
429
|
+
// ride their own surface's gate.
|
|
430
|
+
const footerEntries = [
|
|
431
|
+
" {",
|
|
432
|
+
' headingKey: "footerSitePagesHeading",',
|
|
433
|
+
" items: [",
|
|
434
|
+
...(features ? [' { labelKey: "features", path: "/features" },'] : []),
|
|
435
|
+
...(on.has("pricing")
|
|
436
|
+
? [' { labelKey: "pricing", path: "/pricing" },']
|
|
437
|
+
: []),
|
|
438
|
+
' { labelKey: "about", path: "/about" },',
|
|
439
|
+
' { labelKey: "contact", path: "/contact" },',
|
|
440
|
+
...(on.has("book-a-call")
|
|
441
|
+
? [' { labelKey: "bookACall", path: "/book-a-call" },']
|
|
442
|
+
: []),
|
|
443
|
+
" ],",
|
|
444
|
+
" },",
|
|
445
|
+
...(features
|
|
446
|
+
? [
|
|
447
|
+
" {",
|
|
448
|
+
' headingKey: "footerFeaturesHeading",',
|
|
449
|
+
" items: [",
|
|
450
|
+
...featureLinks.map((l) => l.slice(2)),
|
|
451
|
+
' { labelKey: "allFeatures", path: "/features" },',
|
|
452
|
+
" ],",
|
|
453
|
+
" },",
|
|
454
|
+
]
|
|
455
|
+
: []),
|
|
456
|
+
...(useCases
|
|
457
|
+
? [
|
|
458
|
+
" {",
|
|
459
|
+
' headingKey: "footerUseCasesHeading",',
|
|
460
|
+
" items: [",
|
|
461
|
+
...useCaseLinks.map((l) => l.slice(2)),
|
|
462
|
+
' { labelKey: "allUseCases", path: "/use-cases" },',
|
|
463
|
+
" ],",
|
|
464
|
+
" },",
|
|
465
|
+
]
|
|
466
|
+
: []),
|
|
467
|
+
" {",
|
|
468
|
+
' headingKey: "footerResourcesHeading",',
|
|
469
|
+
" items: [",
|
|
470
|
+
' { labelKey: "blog", path: "/blog" },',
|
|
471
|
+
...(on.has("glossary")
|
|
472
|
+
? [' { labelKey: "glossary", path: "/glossary" },']
|
|
473
|
+
: []),
|
|
474
|
+
...(on.has("newsletter")
|
|
475
|
+
? [' { labelKey: "newsletter", path: "/newsletter" },']
|
|
476
|
+
: []),
|
|
477
|
+
' { labelKey: "faq", path: "/faq" },',
|
|
478
|
+
" ],",
|
|
479
|
+
" },",
|
|
480
|
+
];
|
|
481
|
+
const navItemImport = [
|
|
482
|
+
"import type { NavItem } ",
|
|
483
|
+
"from ",
|
|
484
|
+
'"@working-theory/ui";',
|
|
485
|
+
].join("");
|
|
486
|
+
return `// nav-config.ts — site-nav registry.
|
|
487
|
+
//
|
|
488
|
+
// GENERATED by create-ailk from the declared route surfaces. Every entry here
|
|
489
|
+
// points at a route this site actually carries — the nav is regenerated with
|
|
490
|
+
// the surface set so it can never link a page that was not scaffolded.
|
|
491
|
+
//
|
|
492
|
+
// Adding a link: scaffold the page (the \`add-page\` skill), then add an
|
|
493
|
+
// entry below with a \`labelKey\` that exists in the \`nav\` namespace of
|
|
494
|
+
// \`apps/web/messages/<locale>.json\`. Paths are locale-agnostic — Nav prefixes
|
|
495
|
+
// them with the active locale at render.
|
|
496
|
+
${navItemImport}
|
|
497
|
+
|
|
498
|
+
export const navItems: NavItem[] = [
|
|
499
|
+
${entries.join("\n")}
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
// Footer column groups. Keys only — the layout resolves each \`headingKey\`
|
|
503
|
+
// and \`labelKey\` through the same \`nav\` labels as the header.
|
|
504
|
+
export interface FooterColumnConfig {
|
|
505
|
+
headingKey: string;
|
|
506
|
+
items: { labelKey: string; path: string }[];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const footerColumns: FooterColumnConfig[] = [
|
|
510
|
+
${footerEntries.join("\n")}
|
|
511
|
+
];
|
|
512
|
+
`;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The closed set of published, pre-stamp `renderNavConfig` bodies (D2).
|
|
516
|
+
* `add-module`'s `isUneditedNav` tries these, in order, against an unstamped
|
|
517
|
+
* nav (`surfaces.ts` `readNavConfigStamp` returning `"absent"`) before giving
|
|
518
|
+
* up and reporting it as customer-edited.
|
|
519
|
+
*/
|
|
520
|
+
export const LEGACY_NAV_RENDERERS = [
|
|
521
|
+
{
|
|
522
|
+
era: "E1",
|
|
523
|
+
tags: ["0.1.1", "0.2.0", "0.3.0", "0.4.0"],
|
|
524
|
+
commit: "52e0685f3bff54832a76b5e0ec737c2f39b9d960",
|
|
525
|
+
render: renderNavConfigE1,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
era: "E2",
|
|
529
|
+
tags: ["0.5.0", "0.6.0", "0.6.1"],
|
|
530
|
+
commit: "b0a69cefecf491412aa7e6f1d64464a91620126d",
|
|
531
|
+
render: renderNavConfigE2,
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
era: "E3",
|
|
535
|
+
tags: ["0.7.0"],
|
|
536
|
+
commit: "cac431ba50c4b0ee5130b5bc206196240744be18",
|
|
537
|
+
render: renderNavConfigE3,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
era: "E4",
|
|
541
|
+
tags: ["0.8.0"],
|
|
542
|
+
commit: "060c02b8190090d7495d42e220836141d0549809",
|
|
543
|
+
render: renderNavConfigE4,
|
|
544
|
+
},
|
|
545
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const AUTH_SECRET_ENV_VAR = "BETTER_AUTH_SECRET";
|
|
2
|
+
/** The files the writer owns, web first. */
|
|
3
|
+
export declare const AUTH_SECRET_ENV_FILES: readonly [string, string];
|
|
4
|
+
/**
|
|
5
|
+
* `.gitignore` lines that already ignore `apps/<app>/.env.local`. An explicit
|
|
6
|
+
* set, never a glob evaluation; `/.env.local` is absent because, anchored to
|
|
7
|
+
* the root, it does not cover `apps/web/.env.local`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ENV_LOCAL_IGNORE_LINES: ReadonlySet<string>;
|
|
10
|
+
export interface WriteAuthSecretOptions {
|
|
11
|
+
/** Injectable for tests; defaults to `node:crypto` `randomBytes`. */
|
|
12
|
+
randomBytes?: (size: number) => Buffer;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Give the scaffold at `dir` a `BETTER_AUTH_SECRET` (F1–F4 of the #6107
|
|
16
|
+
* spec). Generates at most one value, and only when no app already has one.
|
|
17
|
+
* Idempotent — a second call changes no byte.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeAuthSecretFile(dir: string, opts?: WriteAuthSecretOptions): void;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a scaffold keeps its `BETTER_AUTH_SECRET` (#6107).
|
|
3
|
+
*
|
|
4
|
+
* `apps/web/auth.ts` refuses to serve in production without the secret
|
|
5
|
+
* (#6044), and so does the Admin app's `auth.ts`. A fresh scaffold therefore
|
|
6
|
+
* gets one random value, written to each app's gitignored `.env.local` — the
|
|
7
|
+
* file `next dev` and `next start` both load:
|
|
8
|
+
*
|
|
9
|
+
* - `apps/web/.env.local` — always;
|
|
10
|
+
* - `apps/admin/.env.local` — when the scaffold carries `apps/admin`, with
|
|
11
|
+
* the SAME value (both apps sign cookies for one site).
|
|
12
|
+
*
|
|
13
|
+
* Never clobber, never shadow: a value already assigned in any env file Next
|
|
14
|
+
* loads for the app under `next start` is kept, and no new `.env.local` is
|
|
15
|
+
* created above it. Never print: no message, log line or return value
|
|
16
|
+
* carries the secret.
|
|
17
|
+
*
|
|
18
|
+
* Pure module: no `import.meta.url`, so jest imports it directly.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes as nodeRandomBytes } from "node:crypto";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { appendLine, writeOwnerOnly } from "./license-key-files.js";
|
|
24
|
+
export const AUTH_SECRET_ENV_VAR = "BETTER_AUTH_SECRET";
|
|
25
|
+
/** The files the writer owns, web first. */
|
|
26
|
+
export const AUTH_SECRET_ENV_FILES = [
|
|
27
|
+
join("apps", "web", ".env.local"),
|
|
28
|
+
join("apps", "admin", ".env.local"),
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* `.gitignore` lines that already ignore `apps/<app>/.env.local`. An explicit
|
|
32
|
+
* set, never a glob evaluation; `/.env.local` is absent because, anchored to
|
|
33
|
+
* the root, it does not cover `apps/web/.env.local`.
|
|
34
|
+
*/
|
|
35
|
+
export const ENV_LOCAL_IGNORE_LINES = new Set([
|
|
36
|
+
".env.local",
|
|
37
|
+
"**/.env.local",
|
|
38
|
+
".env*.local",
|
|
39
|
+
"*.local",
|
|
40
|
+
]);
|
|
41
|
+
/** Env files `next start` loads, highest precedence first. */
|
|
42
|
+
const NEXT_START_ENV_FILES = [
|
|
43
|
+
".env.production.local",
|
|
44
|
+
".env.local",
|
|
45
|
+
".env.production",
|
|
46
|
+
".env",
|
|
47
|
+
];
|
|
48
|
+
/** A line assigning the variable, with or without `export`; group 1 is the value. */
|
|
49
|
+
const ENV_LINE = new RegExp(`^\\s*(?:export\\s+)?${AUTH_SECRET_ENV_VAR}\\s*=(.*)$`);
|
|
50
|
+
/**
|
|
51
|
+
* The value `text` assigns — the LAST assignment, as dotenv reads it — or
|
|
52
|
+
* `undefined` when it assigns none. An empty value counts as none, exactly as
|
|
53
|
+
* `auth.ts` treats it.
|
|
54
|
+
*/
|
|
55
|
+
function assignedValue(text) {
|
|
56
|
+
let value;
|
|
57
|
+
for (const line of text.split("\n")) {
|
|
58
|
+
const m = ENV_LINE.exec(line);
|
|
59
|
+
if (m !== null) {
|
|
60
|
+
const raw = (m[1] ?? "").trim();
|
|
61
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
62
|
+
value = quoted !== null ? (quoted[2] ?? "") : raw;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return value === "" ? undefined : value;
|
|
66
|
+
}
|
|
67
|
+
/** The effective value for the app at `appDir`, by Next's precedence. */
|
|
68
|
+
function effectiveValue(appDir) {
|
|
69
|
+
for (const name of NEXT_START_ENV_FILES) {
|
|
70
|
+
const path = join(appDir, name);
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
continue;
|
|
73
|
+
const value = assignedValue(readFileSync(path, "utf8"));
|
|
74
|
+
if (value !== undefined)
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Give the scaffold at `dir` a `BETTER_AUTH_SECRET` (F1–F4 of the #6107
|
|
81
|
+
* spec). Generates at most one value, and only when no app already has one.
|
|
82
|
+
* Idempotent — a second call changes no byte.
|
|
83
|
+
*/
|
|
84
|
+
export function writeAuthSecretFile(dir, opts = {}) {
|
|
85
|
+
// Web always; Admin only when the scaffold carries `apps/admin`.
|
|
86
|
+
const apps = AUTH_SECRET_ENV_FILES.map((file) => ({
|
|
87
|
+
file: join(dir, file),
|
|
88
|
+
appDir: dirname(join(dir, file)),
|
|
89
|
+
})).filter(({ appDir }, i) => i === 0 || existsSync(appDir));
|
|
90
|
+
// An existing value — web's first — is shared; a fresh one is generated
|
|
91
|
+
// only when no app has one.
|
|
92
|
+
let secret = apps
|
|
93
|
+
.map(({ appDir }) => effectiveValue(appDir))
|
|
94
|
+
.find((v) => v !== undefined);
|
|
95
|
+
const secretFor = () => {
|
|
96
|
+
secret ??= (opts.randomBytes ?? nodeRandomBytes)(32).toString("base64url");
|
|
97
|
+
return secret;
|
|
98
|
+
};
|
|
99
|
+
for (const { file, appDir } of apps) {
|
|
100
|
+
if (effectiveValue(appDir) !== undefined)
|
|
101
|
+
continue;
|
|
102
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
103
|
+
writeOwnerOnly(file, appendLine(existing, `${AUTH_SECRET_ENV_VAR}=${secretFor()}`));
|
|
104
|
+
}
|
|
105
|
+
const ignorePath = join(dir, ".gitignore");
|
|
106
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
107
|
+
const covered = ignore
|
|
108
|
+
.split("\n")
|
|
109
|
+
.some((l) => ENV_LOCAL_IGNORE_LINES.has(l.trim()));
|
|
110
|
+
if (!covered)
|
|
111
|
+
writeFileSync(ignorePath, appendLine(ignore, ".env.local"));
|
|
112
|
+
}
|