create-ailk 0.15.0 → 0.17.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 +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +67 -0
- package/dist/spoke-checkout.js +562 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +451 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/layout.tsx +28 -15
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -35
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke checkout slice (#7838) — what a spoke carrying `commerce` gets in
|
|
3
|
+
* `apps/web`: the buy route and the Stripe webhook route (the only two files
|
|
4
|
+
* allowed to import the checkout package, #7873), the site's own
|
|
5
|
+
* `lib/checkout-config.ts`, and the thank-you and unavailable return pages.
|
|
6
|
+
*
|
|
7
|
+
* Every guarantee lives in `@working-theory/spoke-checkout` and arrives by a
|
|
8
|
+
* package bump; these files are thin and, apart from the config's one stamp
|
|
9
|
+
* line, byte-identical across spokes at one create-ailk version (roadmap D3,
|
|
10
|
+
* AC-SPOKE-02). A hub never gets them: its checkout is `apps/api`'s.
|
|
11
|
+
*
|
|
12
|
+
* Spec: docs/specs/7838-spoke-web-checkout-slice-spec.md (D3–D7, D9).
|
|
13
|
+
*
|
|
14
|
+
* Pure module: no `import.meta.url`, so jest imports it directly. Every
|
|
15
|
+
* package specifier in a rendered file is interpolated from a constant, so the
|
|
16
|
+
* monorepo's own architecture lint never reads a rendered line as an import
|
|
17
|
+
* create-ailk makes (the `CONTENT_ADAPTERS_PACKAGE` idiom in topology.ts).
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { SPOKE_CHECKOUT_ROUTE_FILES } from "./derive-architecture.js";
|
|
23
|
+
import { ENV_CATALOG } from "./lib/env-catalog.js";
|
|
24
|
+
import { diffTwoStrings, } from "./lib/extract-module-bundle.js";
|
|
25
|
+
import { substituteWorkspaceVersions, } from "./substitute.js";
|
|
26
|
+
import { TopologyError } from "./topology.js";
|
|
27
|
+
/** The checkout package a spoke's two route files import. */
|
|
28
|
+
export const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
|
|
29
|
+
const API_CLIENT_PACKAGE = "@working-theory/api-client";
|
|
30
|
+
const UI_SECTIONS_PACKAGE = "@working-theory/ui/sections";
|
|
31
|
+
const VALIDATION_PACKAGE = "@working-theory/validation";
|
|
32
|
+
/** The site's checkout data — written once, the site's from then on. */
|
|
33
|
+
export const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
|
|
34
|
+
/** The two return pages: Stripe's success URL and every refused buy's target. */
|
|
35
|
+
export const SPOKE_CHECKOUT_PAGE_FILES = Object.freeze([
|
|
36
|
+
"apps/web/app/[locale]/checkout/thank-you/page.tsx",
|
|
37
|
+
"apps/web/app/[locale]/checkout/unavailable/page.tsx",
|
|
38
|
+
]);
|
|
39
|
+
/** The variables a commerce spoke depends on (D9). The package reads three of them. */
|
|
40
|
+
export const SPOKE_CHECKOUT_ENV_CATALOG = Object.freeze({
|
|
41
|
+
STRIPE_SECRET_KEY: {
|
|
42
|
+
description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: a test key (sk_test_ or rk_test_), or a restricted live key the operator sets with STRIPE_MODE=live.`,
|
|
43
|
+
secret: true,
|
|
44
|
+
},
|
|
45
|
+
STRIPE_WEBHOOK_SECRET: {
|
|
46
|
+
description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: the signing secret of this site's Stripe webhook endpoint.`,
|
|
47
|
+
secret: true,
|
|
48
|
+
},
|
|
49
|
+
STRIPE_MODE: {
|
|
50
|
+
description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: test (the default) or live. Live is the operator's switch, per site.`,
|
|
51
|
+
secret: false,
|
|
52
|
+
},
|
|
53
|
+
AILK_SPOKE_KEY: {
|
|
54
|
+
description: "The hub-issued key this site sends as x-ailk-spoke-key; the checkout fulfilment refuses to run without it. Never committed.",
|
|
55
|
+
secret: true,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
/** The names a commerce spoke declares although only the package reads them (D9). */
|
|
59
|
+
export const SPOKE_CHECKOUT_DECLARED_ENV = Object.freeze([
|
|
60
|
+
"STRIPE_SECRET_KEY",
|
|
61
|
+
"STRIPE_WEBHOOK_SECRET",
|
|
62
|
+
"STRIPE_MODE",
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* The catalog (and declared names) a spoke's `appendEnvRows` call passes: a
|
|
66
|
+
* commerce spoke adds the checkout's four rows (D9). `ENV_CATALOG` itself is
|
|
67
|
+
* unchanged — its test fails on an entry the monorepo's apps never read.
|
|
68
|
+
*/
|
|
69
|
+
export function spokeEnvRowOptions(commerce) {
|
|
70
|
+
return commerce
|
|
71
|
+
? {
|
|
72
|
+
catalog: { ...ENV_CATALOG, ...SPOKE_CHECKOUT_ENV_CATALOG },
|
|
73
|
+
declare: SPOKE_CHECKOUT_DECLARED_ENV,
|
|
74
|
+
}
|
|
75
|
+
: { catalog: ENV_CATALOG };
|
|
76
|
+
}
|
|
77
|
+
/** `metadata.site` / an offering slug — the package's own pattern. */
|
|
78
|
+
const SITE_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
79
|
+
/** A stamp a renderer may interpolate: the package's pattern, never `default` (D6). */
|
|
80
|
+
function isValidSite(site) {
|
|
81
|
+
return SITE_PATTERN.test(site) && site !== "default";
|
|
82
|
+
}
|
|
83
|
+
function assertSite(site) {
|
|
84
|
+
if (!isValidSite(site)) {
|
|
85
|
+
throw new TopologyError(`spoke checkout: site stamp ${JSON.stringify(site)} must match ^[a-z0-9][a-z0-9-]{0,39}$ and must not be "default"`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The site stamp for a project name (D6): lower-cased, every run outside
|
|
90
|
+
* `a-z0-9` collapsed to `-`, trimmed, cut to 40. A name that leaves nothing
|
|
91
|
+
* usable — or `default`, which is also an unset hub's own stamp — gets
|
|
92
|
+
* `site-` and the first 8 hex characters of its SHA-256. Never `AILK_SITE_ID`.
|
|
93
|
+
*/
|
|
94
|
+
export function spokeCheckoutSite(projectName) {
|
|
95
|
+
const slug = projectName
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
98
|
+
.replace(/^-+|-+$/g, "")
|
|
99
|
+
.slice(0, 40)
|
|
100
|
+
.replace(/-+$/, "");
|
|
101
|
+
if (isValidSite(slug))
|
|
102
|
+
return slug;
|
|
103
|
+
const digest = createHash("sha256").update(projectName).digest("hex");
|
|
104
|
+
return `site-${digest.slice(0, 8)}`;
|
|
105
|
+
}
|
|
106
|
+
/** One route file (D4): three lines of wiring, every guarantee in the package. */
|
|
107
|
+
function renderRoute(opts) {
|
|
108
|
+
return [
|
|
109
|
+
`// ${opts.title} — GENERATED by create-ailk (#7838). Do not edit: every`,
|
|
110
|
+
"// guarantee lives in the spoke-checkout package and arrives by a package",
|
|
111
|
+
"// bump. This site's data is lib/checkout-config.ts.",
|
|
112
|
+
`import { createSpokeCheckout } from "${SPOKE_CHECKOUT_PACKAGE}";`,
|
|
113
|
+
"",
|
|
114
|
+
`import { checkoutConfig } from "../../../../lib/checkout-config";`,
|
|
115
|
+
"",
|
|
116
|
+
`export const runtime = "nodejs";`,
|
|
117
|
+
`export const dynamic = "force-dynamic";`,
|
|
118
|
+
"",
|
|
119
|
+
`// platform-decisions-waiver: api-versioning — ${opts.waiver} (spec 7838 §7)`,
|
|
120
|
+
opts.exportLine,
|
|
121
|
+
"",
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
124
|
+
const BUY_ROUTE = renderRoute({
|
|
125
|
+
title: "The buy route",
|
|
126
|
+
waiver: "a browser buy link that redirects to Stripe Checkout, not a versioned API contract",
|
|
127
|
+
exportLine: "export const { GET, POST } = createSpokeCheckout(checkoutConfig).buy;",
|
|
128
|
+
});
|
|
129
|
+
const WEBHOOK_ROUTE = renderRoute({
|
|
130
|
+
title: "The Stripe webhook route",
|
|
131
|
+
waiver: "the endpoint URL registered in Stripe, not a versioned API contract",
|
|
132
|
+
exportLine: "export const { POST } = createSpokeCheckout(checkoutConfig).webhook;",
|
|
133
|
+
});
|
|
134
|
+
/** The site's checkout data (D5). Only the `const SITE` line varies by site. */
|
|
135
|
+
function renderConfig(site) {
|
|
136
|
+
return [
|
|
137
|
+
"// checkout-config.ts — this spoke's checkout, as data (#7838).",
|
|
138
|
+
"//",
|
|
139
|
+
"// Written once by create-ailk and yours from then on: create-ailk never",
|
|
140
|
+
"// rewrites it. The buy route and the Stripe webhook route build their",
|
|
141
|
+
"// handlers from it. Those two are the only files allowed to import the",
|
|
142
|
+
"// checkout package (architecture.yaml), so this file never imports it,",
|
|
143
|
+
"// stripe-client or the stripe SDK. Keep it to data and the fulfil callback.",
|
|
144
|
+
"//",
|
|
145
|
+
"// site Stamped on every Checkout Session as metadata.site. This site's",
|
|
146
|
+
"// webhook acts only on its own stamp, and the hub ignores any stamp",
|
|
147
|
+
"// that is not its own, so the stamp must be unique on the Stripe",
|
|
148
|
+
'// account this site shares. Never "default". Change it only before',
|
|
149
|
+
"// any price exists: every lookup key starts with it.",
|
|
150
|
+
"// offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>.",
|
|
151
|
+
"// scaffold-commerce replaces the placeholder below.",
|
|
152
|
+
"// fulfil Runs for each paid session, at least once. It posts one lead to",
|
|
153
|
+
"// the hub with the session id as its Idempotency-Key, so a repeat",
|
|
154
|
+
"// writes nothing new. The hub dedupes only an identical body: change",
|
|
155
|
+
"// the payload's shape only when no webhook is failing.",
|
|
156
|
+
`import { leads } from "${API_CLIENT_PACKAGE}";`,
|
|
157
|
+
"",
|
|
158
|
+
`import { resolveBaseUrl } from "../src/lib/routes";`,
|
|
159
|
+
"",
|
|
160
|
+
`const SITE = "${site}";`,
|
|
161
|
+
"",
|
|
162
|
+
"/** What this site's fulfil callback reads from a completed checkout. */",
|
|
163
|
+
"interface CompletedCheckout {",
|
|
164
|
+
" idempotencyKey: string;",
|
|
165
|
+
" sessionId: string;",
|
|
166
|
+
" offering: string;",
|
|
167
|
+
' kind: "fixed" | "quoted";',
|
|
168
|
+
' mode: "test" | "live";',
|
|
169
|
+
" amountTotal: number | null;",
|
|
170
|
+
" currency: string | null;",
|
|
171
|
+
" email: string | null;",
|
|
172
|
+
" customerId: string | null;",
|
|
173
|
+
"}",
|
|
174
|
+
"",
|
|
175
|
+
"export const checkoutConfig = {",
|
|
176
|
+
" site: SITE,",
|
|
177
|
+
" siteBaseUrl: resolveBaseUrl(),",
|
|
178
|
+
" offerings: {",
|
|
179
|
+
" // A placeholder until scaffold-commerce creates this site's prices.",
|
|
180
|
+
' example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },',
|
|
181
|
+
" },",
|
|
182
|
+
" async fulfil(checkout: CompletedCheckout): Promise<void> {",
|
|
183
|
+
" if (checkout.email === null) return;",
|
|
184
|
+
" // No spoke key: the hub cannot place the lead in this site's workspace.",
|
|
185
|
+
" // Fail, so Stripe retries and the dashboard shows a failed webhook.",
|
|
186
|
+
" if (!process.env.AILK_SPOKE_KEY)",
|
|
187
|
+
' throw new Error("AILK_SPOKE_KEY is not set");',
|
|
188
|
+
" const result = await leads.submit(",
|
|
189
|
+
" {",
|
|
190
|
+
" siteId: SITE,",
|
|
191
|
+
' source: "web",',
|
|
192
|
+
" email: checkout.email,",
|
|
193
|
+
" payload: {",
|
|
194
|
+
" site: SITE,",
|
|
195
|
+
' page: "checkout",',
|
|
196
|
+
" offering: checkout.offering,",
|
|
197
|
+
" checkoutKind: checkout.kind,",
|
|
198
|
+
" mode: checkout.mode,",
|
|
199
|
+
" amountTotal: checkout.amountTotal,",
|
|
200
|
+
" currency: checkout.currency,",
|
|
201
|
+
" stripeCustomerId: checkout.customerId,",
|
|
202
|
+
" checkoutSession: checkout.sessionId,",
|
|
203
|
+
" },",
|
|
204
|
+
" },",
|
|
205
|
+
' { headers: { "Idempotency-Key": checkout.idempotencyKey } },',
|
|
206
|
+
" );",
|
|
207
|
+
" // A replay under the same key answers 201 with the first lead; any",
|
|
208
|
+
" // failure (422 idempotency_key_reused included) makes Stripe retry.",
|
|
209
|
+
" if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);",
|
|
210
|
+
" },",
|
|
211
|
+
"};",
|
|
212
|
+
"",
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
/** One return page (D7): a server component that reads nothing but its copy. */
|
|
216
|
+
function renderPage(opts) {
|
|
217
|
+
return [
|
|
218
|
+
`// ${opts.title} — GENERATED by create-ailk (#7838).`,
|
|
219
|
+
...opts.about.map((line) => `// ${line}`),
|
|
220
|
+
`import { SectionRenderer } from "${UI_SECTIONS_PACKAGE}";`,
|
|
221
|
+
`import type { PageSection } from "${VALIDATION_PACKAGE}";`,
|
|
222
|
+
`import type { Metadata } from "next";`,
|
|
223
|
+
`import { getTranslations } from "next-intl/server";`,
|
|
224
|
+
`import type { ReactElement } from "react";`,
|
|
225
|
+
"",
|
|
226
|
+
"interface Props {",
|
|
227
|
+
" params: Promise<{ locale: string }>;",
|
|
228
|
+
"}",
|
|
229
|
+
"",
|
|
230
|
+
"export async function generateMetadata({ params }: Props): Promise<Metadata> {",
|
|
231
|
+
" const { locale } = await params;",
|
|
232
|
+
` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
|
|
233
|
+
` return { title: t("title"), robots: { index: false, follow: false } };`,
|
|
234
|
+
"}",
|
|
235
|
+
"",
|
|
236
|
+
"export default async function Page({ params }: Props): Promise<ReactElement> {",
|
|
237
|
+
" const { locale } = await params;",
|
|
238
|
+
` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
|
|
239
|
+
" const sections: PageSection[] = [",
|
|
240
|
+
...opts.sections.map((line) => ` ${line}`),
|
|
241
|
+
" ];",
|
|
242
|
+
" return (",
|
|
243
|
+
' <main id="main">',
|
|
244
|
+
" <SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />",
|
|
245
|
+
" </main>",
|
|
246
|
+
" );",
|
|
247
|
+
"}",
|
|
248
|
+
"",
|
|
249
|
+
].join("\n");
|
|
250
|
+
}
|
|
251
|
+
const THANK_YOU_PAGE = renderPage({
|
|
252
|
+
title: "The checkout thank-you page",
|
|
253
|
+
about: [
|
|
254
|
+
"Stripe returns a buyer here after paying. It reads no search parameter and",
|
|
255
|
+
"calls neither Stripe nor the hub: the webhook is what fulfils the order.",
|
|
256
|
+
],
|
|
257
|
+
namespace: "checkoutThankYou",
|
|
258
|
+
sections: [
|
|
259
|
+
"{",
|
|
260
|
+
' id: "checkout-thank-you",',
|
|
261
|
+
' type: "content-block",',
|
|
262
|
+
' data: { headline: t("title"), body: t("body") },',
|
|
263
|
+
"},",
|
|
264
|
+
],
|
|
265
|
+
});
|
|
266
|
+
const UNAVAILABLE_PAGE = renderPage({
|
|
267
|
+
title: "The checkout unavailable page",
|
|
268
|
+
about: [
|
|
269
|
+
"Every refused buy lands here. It never says why: a reason is a probe's",
|
|
270
|
+
"answer, and the buyer needs only that nothing was charged.",
|
|
271
|
+
],
|
|
272
|
+
namespace: "checkoutUnavailable",
|
|
273
|
+
sections: [
|
|
274
|
+
"{",
|
|
275
|
+
' id: "checkout-unavailable",',
|
|
276
|
+
' type: "content-block",',
|
|
277
|
+
' data: { headline: t("title"), body: t("body") },',
|
|
278
|
+
"},",
|
|
279
|
+
"{",
|
|
280
|
+
' id: "checkout-unavailable-cta",',
|
|
281
|
+
' type: "cta",',
|
|
282
|
+
" data: {",
|
|
283
|
+
' headline: t("ctaHeadline"),',
|
|
284
|
+
' primaryCta: { label: t("ctaLabel"), href: "/contact" },',
|
|
285
|
+
" },",
|
|
286
|
+
"},",
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
// ── #7839 D8 — the checkout e2e workflow ─────────────────────────────────────
|
|
290
|
+
/** Where a commerce spoke's end-to-end checkout workflow lives. */
|
|
291
|
+
export const SPOKE_CHECKOUT_E2E_WORKFLOW_PATH = ".github/workflows/checkout-e2e.yml";
|
|
292
|
+
/**
|
|
293
|
+
* The Stripe CLI release the workflow installs, and the SHA-256 of its
|
|
294
|
+
* `stripe_<v>_linux_x86_64.tar.gz` from that release's own
|
|
295
|
+
* `stripe-linux-checksums.txt`. Never bump one without re-reading the other.
|
|
296
|
+
*/
|
|
297
|
+
export const STRIPE_CLI_VERSION = "1.51.1";
|
|
298
|
+
export const STRIPE_CLI_LINUX_X64_SHA256 = "38c8de73a8cab810a4aab8a4015cd4e3398b7b87aa42b6944d09d89ed524ac53";
|
|
299
|
+
/** The Playwright the workflow installs — the monorepo's own pinned version. */
|
|
300
|
+
export const E2E_PLAYWRIGHT_VERSION = "1.61.1";
|
|
301
|
+
/**
|
|
302
|
+
* Every action the workflow uses, pinned to the commit its `v4` tag named
|
|
303
|
+
* when this was written (SEC-7839-2): `gh api repos/<action>/commits/v4`.
|
|
304
|
+
*/
|
|
305
|
+
export const E2E_ACTION_PINS = Object.freeze({
|
|
306
|
+
"actions/checkout": "11d5960a326750d5838078e36cf38b85af677262",
|
|
307
|
+
"pnpm/action-setup": "b906affcce14559ad1aafd4ab0e942779e9f58b1",
|
|
308
|
+
"actions/setup-node": "49933ea5288caeca8642d1e84afbd3f7d6820020",
|
|
309
|
+
"actions/upload-artifact": "ea165f8d65b6e75b540449e92b4886f43607fa02",
|
|
310
|
+
});
|
|
311
|
+
function uses(action) {
|
|
312
|
+
return `uses: ${action}@${E2E_ACTION_PINS[action]} # v4`;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The workflow text (D8). Identical for every spoke at one create-ailk
|
|
316
|
+
* version. Every `${{ … }}` sits in an env-style value, never in a script.
|
|
317
|
+
*/
|
|
318
|
+
export const CHECKOUT_E2E_WORKFLOW = `# Checkout e2e — GENERATED by create-ailk (#7839). Do not edit: it changes
|
|
319
|
+
# with the ${SPOKE_CHECKOUT_PACKAGE} command it runs.
|
|
320
|
+
#
|
|
321
|
+
# Pays each named offering once through Stripe's hosted Checkout in TEST
|
|
322
|
+
# mode, then checks Stripe's webhook was answered 2xx and this site's
|
|
323
|
+
# fulfilment ran. Start it by hand (Actions > Checkout e2e > Run workflow)
|
|
324
|
+
# after a checkout change and before any live flip. It never runs on a push
|
|
325
|
+
# or a pull request: it needs the network, the Stripe CLI and test-mode state.
|
|
326
|
+
#
|
|
327
|
+
# Setup, once: create the GitHub Environment "checkout-e2e", limit its
|
|
328
|
+
# deployment branches to the default branch, and add to it the secrets
|
|
329
|
+
# STRIPE_TEST_SECRET_KEY (an sk_test_ or rk_test_ key) and AILK_SPOKE_KEY,
|
|
330
|
+
# and the variable AILK_API_URL (https). Each paid session posts one
|
|
331
|
+
# test-mode lead to the hub. Test subscriptions it creates are cancelled.
|
|
332
|
+
name: Checkout e2e
|
|
333
|
+
|
|
334
|
+
on:
|
|
335
|
+
workflow_dispatch:
|
|
336
|
+
inputs:
|
|
337
|
+
offerings:
|
|
338
|
+
description: Comma-separated fixed offering slugs from apps/web/lib/checkout-config.ts
|
|
339
|
+
required: true
|
|
340
|
+
type: string
|
|
341
|
+
|
|
342
|
+
permissions:
|
|
343
|
+
contents: read
|
|
344
|
+
|
|
345
|
+
concurrency:
|
|
346
|
+
group: checkout-e2e
|
|
347
|
+
cancel-in-progress: false
|
|
348
|
+
|
|
349
|
+
jobs:
|
|
350
|
+
checkout-e2e:
|
|
351
|
+
runs-on: ubuntu-latest
|
|
352
|
+
timeout-minutes: 30
|
|
353
|
+
environment: checkout-e2e
|
|
354
|
+
env:
|
|
355
|
+
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
|
356
|
+
STRIPE_MODE: test
|
|
357
|
+
steps:
|
|
358
|
+
- name: Refuse a live key or a missing setting
|
|
359
|
+
env:
|
|
360
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
361
|
+
AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
|
|
362
|
+
AILK_API_URL: \${{ vars.AILK_API_URL }}
|
|
363
|
+
run: |
|
|
364
|
+
case "$STRIPE_SECRET_KEY" in
|
|
365
|
+
sk_test_*|rk_test_*) ;;
|
|
366
|
+
*) echo "::error::STRIPE_TEST_SECRET_KEY must be an sk_test_ or rk_test_ key"; exit 2 ;;
|
|
367
|
+
esac
|
|
368
|
+
if [ -z "$AILK_SPOKE_KEY" ]; then
|
|
369
|
+
echo "::error::set the AILK_SPOKE_KEY secret in the checkout-e2e environment"; exit 2
|
|
370
|
+
fi
|
|
371
|
+
case "$AILK_API_URL" in
|
|
372
|
+
https://*) ;;
|
|
373
|
+
*) echo "::error::AILK_API_URL must be the hub's https origin"; exit 2 ;;
|
|
374
|
+
esac
|
|
375
|
+
|
|
376
|
+
- ${uses("actions/checkout")}
|
|
377
|
+
|
|
378
|
+
- ${uses("pnpm/action-setup")}
|
|
379
|
+
|
|
380
|
+
- run: test -f pnpm-lock.yaml || touch pnpm-lock.yaml
|
|
381
|
+
|
|
382
|
+
- ${uses("actions/setup-node")}
|
|
383
|
+
with:
|
|
384
|
+
node-version: 20
|
|
385
|
+
cache: pnpm
|
|
386
|
+
cache-dependency-path: pnpm-lock.yaml
|
|
387
|
+
|
|
388
|
+
- name: Install
|
|
389
|
+
run: |
|
|
390
|
+
if [ -s pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else pnpm install; fi
|
|
391
|
+
|
|
392
|
+
- name: Install the Stripe CLI (pinned, checksum-checked)
|
|
393
|
+
run: |
|
|
394
|
+
curl -fsSL -o "$RUNNER_TEMP/stripe.tar.gz" \\
|
|
395
|
+
https://github.com/stripe/stripe-cli/releases/download/v${STRIPE_CLI_VERSION}/stripe_${STRIPE_CLI_VERSION}_linux_x86_64.tar.gz
|
|
396
|
+
echo "${STRIPE_CLI_LINUX_X64_SHA256} $RUNNER_TEMP/stripe.tar.gz" | sha256sum -c -
|
|
397
|
+
mkdir -p "$RUNNER_TEMP/stripe-cli"
|
|
398
|
+
tar -xzf "$RUNNER_TEMP/stripe.tar.gz" -C "$RUNNER_TEMP/stripe-cli" stripe
|
|
399
|
+
|
|
400
|
+
- name: Install Playwright (pinned) and Chromium
|
|
401
|
+
run: |
|
|
402
|
+
npm install --no-save --ignore-scripts --prefix "$RUNNER_TEMP/e2e-deps" playwright@${E2E_PLAYWRIGHT_VERSION}
|
|
403
|
+
"$RUNNER_TEMP/e2e-deps/node_modules/.bin/playwright" install --with-deps chromium
|
|
404
|
+
|
|
405
|
+
- name: Fetch the Stripe CLI's signing secret
|
|
406
|
+
env:
|
|
407
|
+
STRIPE_API_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
408
|
+
run: |
|
|
409
|
+
umask 077
|
|
410
|
+
secret="$("$RUNNER_TEMP/stripe-cli/stripe" listen --print-secret)"
|
|
411
|
+
echo "::add-mask::$secret"
|
|
412
|
+
printf '%s' "$secret" > "$RUNNER_TEMP/stripe-whsec"
|
|
413
|
+
|
|
414
|
+
- name: Build the site
|
|
415
|
+
run: pnpm --filter ./apps/web build
|
|
416
|
+
|
|
417
|
+
- name: Start the site
|
|
418
|
+
env:
|
|
419
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
420
|
+
AILK_SPOKE_KEY: \${{ secrets.AILK_SPOKE_KEY }}
|
|
421
|
+
AILK_API_URL: \${{ vars.AILK_API_URL }}
|
|
422
|
+
run: |
|
|
423
|
+
STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
|
|
424
|
+
export STRIPE_WEBHOOK_SECRET
|
|
425
|
+
cd apps/web
|
|
426
|
+
nohup pnpm exec next start -p 3000 -H localhost > "$RUNNER_TEMP/next.log" 2>&1 &
|
|
427
|
+
echo $! > "$RUNNER_TEMP/next.pid"
|
|
428
|
+
for _ in $(seq 1 60); do
|
|
429
|
+
curl -fsS -o /dev/null http://localhost:3000/checkout/thank-you && exit 0
|
|
430
|
+
sleep 2
|
|
431
|
+
done
|
|
432
|
+
echo "::error::the site did not start on localhost:3000; run it locally with the same settings to see why"
|
|
433
|
+
exit 1
|
|
434
|
+
|
|
435
|
+
- name: Pay each offering
|
|
436
|
+
working-directory: apps/web
|
|
437
|
+
env:
|
|
438
|
+
STRIPE_SECRET_KEY: \${{ secrets.STRIPE_TEST_SECRET_KEY }}
|
|
439
|
+
OFFERINGS: \${{ inputs.offerings }}
|
|
440
|
+
SPOKE_CHECKOUT_E2E_DEPS: \${{ runner.temp }}/e2e-deps
|
|
441
|
+
STRIPE_CLI_PATH: \${{ runner.temp }}/stripe-cli/stripe
|
|
442
|
+
run: |
|
|
443
|
+
STRIPE_WEBHOOK_SECRET="$(cat "$RUNNER_TEMP/stripe-whsec")"
|
|
444
|
+
export STRIPE_WEBHOOK_SECRET
|
|
445
|
+
pnpm exec spoke-checkout-e2e --base-url http://localhost:3000 --offerings "$OFFERINGS" --out "$RUNNER_TEMP/checkout-e2e"
|
|
446
|
+
|
|
447
|
+
- name: Stop the site
|
|
448
|
+
if: always()
|
|
449
|
+
run: |
|
|
450
|
+
if [ -f "$RUNNER_TEMP/next.pid" ]; then pkill -P "$(cat "$RUNNER_TEMP/next.pid")" || true; kill "$(cat "$RUNNER_TEMP/next.pid")" || true; fi
|
|
451
|
+
rm -f "$RUNNER_TEMP/stripe-whsec"
|
|
452
|
+
|
|
453
|
+
- name: Keep the report
|
|
454
|
+
if: always()
|
|
455
|
+
${uses("actions/upload-artifact")}
|
|
456
|
+
with:
|
|
457
|
+
name: checkout-e2e
|
|
458
|
+
path: \${{ runner.temp }}/checkout-e2e
|
|
459
|
+
if-no-files-found: ignore
|
|
460
|
+
retention-days: 7
|
|
461
|
+
`;
|
|
462
|
+
/**
|
|
463
|
+
* The slice for `site`, in order: the two route files, the config, the two
|
|
464
|
+
* pages (D3), then the checkout e2e workflow (#7839 D8), which does not
|
|
465
|
+
* depend on `site`. Throws `TopologyError` for an invalid stamp (SEC-7838-10).
|
|
466
|
+
*/
|
|
467
|
+
export function renderSpokeCheckoutFiles(site) {
|
|
468
|
+
assertSite(site);
|
|
469
|
+
const [buyPath, webhookPath] = SPOKE_CHECKOUT_ROUTE_FILES;
|
|
470
|
+
const [thankYouPath, unavailablePath] = SPOKE_CHECKOUT_PAGE_FILES;
|
|
471
|
+
return [
|
|
472
|
+
[buyPath, BUY_ROUTE],
|
|
473
|
+
[webhookPath, WEBHOOK_ROUTE],
|
|
474
|
+
[SPOKE_CHECKOUT_CONFIG_PATH, renderConfig(site)],
|
|
475
|
+
[thankYouPath, THANK_YOU_PAGE],
|
|
476
|
+
[unavailablePath, UNAVAILABLE_PAGE],
|
|
477
|
+
[SPOKE_CHECKOUT_E2E_WORKFLOW_PATH, CHECKOUT_E2E_WORKFLOW],
|
|
478
|
+
];
|
|
479
|
+
}
|
|
480
|
+
const WEB_PACKAGE_JSON = "apps/web/package.json";
|
|
481
|
+
/** `deps` with `name: version` added in sorted position, every other key kept in place. */
|
|
482
|
+
function withDependency(deps, name, version) {
|
|
483
|
+
const out = {};
|
|
484
|
+
let placed = false;
|
|
485
|
+
for (const [key, value] of Object.entries(deps)) {
|
|
486
|
+
// Code-unit order, as package managers sort — never the host's locale.
|
|
487
|
+
if (!placed && key > name) {
|
|
488
|
+
out[name] = version;
|
|
489
|
+
placed = true;
|
|
490
|
+
}
|
|
491
|
+
out[key] = value;
|
|
492
|
+
}
|
|
493
|
+
if (!placed)
|
|
494
|
+
out[name] = version;
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
/** A plain `x.y.z`, `^x.y.z` or `~x.y.z` pin's version; null for any other range. */
|
|
498
|
+
function pinnedVersion(range) {
|
|
499
|
+
const match = /^[\^~]?(\d+)\.(\d+)\.(\d+)$/.exec(range.trim());
|
|
500
|
+
return match === null
|
|
501
|
+
? null
|
|
502
|
+
: [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
503
|
+
}
|
|
504
|
+
/** True when `current` pins a version strictly older than `wanted` does. */
|
|
505
|
+
function isOlderPin(current, wanted) {
|
|
506
|
+
const a = pinnedVersion(current);
|
|
507
|
+
const b = pinnedVersion(wanted);
|
|
508
|
+
if (a === null || b === null)
|
|
509
|
+
return false;
|
|
510
|
+
for (let i = 0; i < 3; i++) {
|
|
511
|
+
if (a[i] !== b[i])
|
|
512
|
+
return a[i] < b[i];
|
|
513
|
+
}
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Write the slice into `targetDir` (D3). A file that already exists is left
|
|
518
|
+
* byte-for-byte and reported `skipped` — the config is the site's once
|
|
519
|
+
* written. Adds the package to `apps/web/package.json` when absent, and
|
|
520
|
+
* raises a plain pin older than the one it writes (#7839): the generated
|
|
521
|
+
* checkout e2e workflow runs a command a `^0.2.0` pin never reaches. Any
|
|
522
|
+
* other range — newer, `workspace:`, a tag — is the site's and is kept.
|
|
523
|
+
* Every refusal (an invalid stamp, a version map without the package, a
|
|
524
|
+
* missing or malformed `apps/web/package.json`) throws before the first write.
|
|
525
|
+
*/
|
|
526
|
+
export function writeSpokeCheckoutSlice(targetDir, opts) {
|
|
527
|
+
const files = renderSpokeCheckoutFiles(opts.site);
|
|
528
|
+
const range = JSON.parse(substituteWorkspaceVersions(`{"${SPOKE_CHECKOUT_PACKAGE}": "workspace:*"}`, opts.versions));
|
|
529
|
+
const version = range[SPOKE_CHECKOUT_PACKAGE];
|
|
530
|
+
const pkgPath = join(targetDir, WEB_PACKAGE_JSON);
|
|
531
|
+
const before = readFileSync(pkgPath, "utf8");
|
|
532
|
+
const pkg = JSON.parse(before);
|
|
533
|
+
const rows = [];
|
|
534
|
+
for (const [path, text] of files) {
|
|
535
|
+
const abs = join(targetDir, path);
|
|
536
|
+
if (existsSync(abs)) {
|
|
537
|
+
rows.push({ path, action: "skipped", wanted: text });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
541
|
+
writeFileSync(abs, text);
|
|
542
|
+
rows.push({ path, action: "written" });
|
|
543
|
+
}
|
|
544
|
+
const deps = pkg.dependencies ?? {};
|
|
545
|
+
const current = Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)
|
|
546
|
+
? deps[SPOKE_CHECKOUT_PACKAGE]
|
|
547
|
+
: undefined;
|
|
548
|
+
if (current === undefined || isOlderPin(current, version)) {
|
|
549
|
+
pkg.dependencies =
|
|
550
|
+
current === undefined
|
|
551
|
+
? withDependency(deps, SPOKE_CHECKOUT_PACKAGE, version)
|
|
552
|
+
: { ...deps, [SPOKE_CHECKOUT_PACKAGE]: version };
|
|
553
|
+
const after = `${JSON.stringify(pkg, null, 2)}${before.endsWith("\n") ? "\n" : ""}`;
|
|
554
|
+
writeFileSync(pkgPath, after);
|
|
555
|
+
rows.push({
|
|
556
|
+
path: WEB_PACKAGE_JSON,
|
|
557
|
+
action: "patched",
|
|
558
|
+
change: diffTwoStrings(WEB_PACKAGE_JSON, before, after, true),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return rows;
|
|
562
|
+
}
|
package/dist/surfaces.js
CHANGED
|
@@ -626,6 +626,10 @@ export const ROUTE_SINCE = {
|
|
|
626
626
|
"(authed)/account/inbox/[kind]/[id]": "0.30.0",
|
|
627
627
|
"(authed)/account/settings": "0.30.0",
|
|
628
628
|
"(authed)/account/support": "0.30.0",
|
|
629
|
+
// #7920 — the member file read a message's file action opens. Genuinely
|
|
630
|
+
// new, so it attaches at today's fixed-group version, the same rule #7430
|
|
631
|
+
// applied above.
|
|
632
|
+
"(authed)/account/inbox/message/[id]/file": "0.37.0",
|
|
629
633
|
// The leads inbox (#5678). The ROUTE ships in every scaffold, Free
|
|
630
634
|
// included — lead capture is a never-gate surface, so reading a lead back
|
|
631
635
|
// is one too. Whether it shows a ROW is a separate question with two
|
|
@@ -756,6 +760,9 @@ export const ROUTE_DEPENDENCIES = {
|
|
|
756
760
|
`${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
|
|
757
761
|
`${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
|
|
758
762
|
],
|
|
763
|
+
// #7920 — the member file read re-serves the hub's bytes with the
|
|
764
|
+
// credential `forwardedCookieInit` returns.
|
|
765
|
+
"(authed)/account/inbox/message/[id]/file": ["apps/web/lib/server-api.ts"],
|
|
759
766
|
"(authed)/account/settings": [
|
|
760
767
|
"apps/web/lib/account-config.ts",
|
|
761
768
|
"apps/web/lib/auth-env.ts",
|
package/dist/sync-routes.js
CHANGED
|
@@ -31,9 +31,11 @@ import { tmpdir } from "node:os";
|
|
|
31
31
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
32
32
|
import { parseArgs } from "node:util";
|
|
33
33
|
import { copyTemplate } from "./copy.js";
|
|
34
|
+
import { appendEnvRows } from "./lib/declared-env.js";
|
|
35
|
+
import { ENV_CATALOG } from "./lib/env-catalog.js";
|
|
34
36
|
import { substituteMarkers } from "./substitute.js";
|
|
35
37
|
import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
|
|
36
|
-
import { isSpokeExcluded,
|
|
38
|
+
import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
|
|
37
39
|
/** Template-relative prefix every `apps/web` route directory sits under. */
|
|
38
40
|
const ROUTE_ROOT = "apps/web/app/[locale]";
|
|
39
41
|
/**
|
|
@@ -99,9 +101,9 @@ function reportFor(route) {
|
|
|
99
101
|
* byte-for-byte in, the target's copy. Non-recursive: nested directories are
|
|
100
102
|
* route dirs of their own, or `__tests__`.
|
|
101
103
|
*/
|
|
102
|
-
function isModified(expectedDir, targetDir) {
|
|
104
|
+
function isModified(expectedDir, targetDir, skip = () => false) {
|
|
103
105
|
for (const entry of readdirSync(expectedDir, { withFileTypes: true })) {
|
|
104
|
-
if (!entry.isFile())
|
|
106
|
+
if (!entry.isFile() || skip(entry.name))
|
|
105
107
|
continue;
|
|
106
108
|
const targetFile = join(targetDir, entry.name);
|
|
107
109
|
if (!existsSync(targetFile))
|
|
@@ -167,10 +169,17 @@ export function syncRoutes(opts) {
|
|
|
167
169
|
// #7426 D13 — on a spoke, a route dir the spoke does not carry (the
|
|
168
170
|
// `(auth)` and `(authed)` groups) is out of scope: neither reported nor
|
|
169
171
|
// copied. This is the one filter point for add-module, add-route and the
|
|
170
|
-
// sync-routes subcommand alike; a hub's scope is unchanged.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// sync-routes subcommand alike; a hub's scope is unchanged. The spoke's
|
|
173
|
+
// sign-in mode rides along (#7894 D1): a hub sign-in spoke keeps its
|
|
174
|
+
// `(authed)` account area in scope and its operator surfaces out.
|
|
175
|
+
const spoke = readSpokeShape(targetAbs);
|
|
176
|
+
// A spoke's own render of a seam, and a file it does not carry, are not
|
|
177
|
+
// "modified" (#7894 D8): the template holds the hub's.
|
|
178
|
+
const spokeSkip = (route) => (name) => {
|
|
179
|
+
const path = `${ROUTE_ROOT}/${route}/${name}`;
|
|
180
|
+
return (spoke !== undefined &&
|
|
181
|
+
(SPOKE_SEAM_FILES.includes(path) || isSpokeExcluded(path, spoke)));
|
|
182
|
+
};
|
|
174
183
|
const inScope = listTemplateRouteDirs(opts.templatesDir)
|
|
175
184
|
.map(reportFor)
|
|
176
185
|
// "authed" (#5014): the `(authed)` route group ships unconditionally in
|
|
@@ -202,7 +211,7 @@ export function syncRoutes(opts) {
|
|
|
202
211
|
if (!existsSync(targetRoute)) {
|
|
203
212
|
missing.push(report);
|
|
204
213
|
}
|
|
205
|
-
else if (isModified(join(scratch, report.route), targetRoute)) {
|
|
214
|
+
else if (isModified(join(scratch, report.route), targetRoute, spokeSkip(report.route))) {
|
|
206
215
|
modified.push(report);
|
|
207
216
|
}
|
|
208
217
|
}
|
|
@@ -250,6 +259,12 @@ export function syncRoutes(opts) {
|
|
|
250
259
|
copyDependencyFile(join(opts.templatesDir, dep), dst, markers);
|
|
251
260
|
appliedDependencies.push(dep);
|
|
252
261
|
}
|
|
262
|
+
// #7874 D9 — a spoke declares what the copied routes read, as the last
|
|
263
|
+
// write. A malformed env_contract block throws EnvContractError naming
|
|
264
|
+
// its line; the files copied above stay.
|
|
265
|
+
if (spoke !== undefined) {
|
|
266
|
+
appendEnvRows(targetAbs, { topology: "spoke", catalog: ENV_CATALOG });
|
|
267
|
+
}
|
|
253
268
|
}
|
|
254
269
|
return {
|
|
255
270
|
targetDir: targetAbs,
|