create-ailk 0.3.0 → 0.4.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 (37) hide show
  1. package/dist/cli.js +19 -9
  2. package/dist/lib/extract-module-bundle.d.ts +39 -0
  3. package/dist/lib/extract-module-bundle.js +165 -78
  4. package/dist/lib/fetch-template.d.ts +20 -0
  5. package/dist/lib/fetch-template.js +129 -0
  6. package/dist/lib/module-license-gate.d.ts +8 -0
  7. package/dist/lib/module-license-gate.js +12 -4
  8. package/dist/module-architecture.d.ts +58 -0
  9. package/dist/module-architecture.js +156 -4
  10. package/dist/parse-args.d.ts +8 -1
  11. package/dist/parse-args.js +43 -0
  12. package/dist/programmatic.d.ts +26 -1
  13. package/dist/programmatic.js +100 -4
  14. package/dist/surfaces.js +42 -0
  15. package/dist/template-manifest.d.ts +92 -0
  16. package/dist/template-manifest.js +168 -0
  17. package/dist/template-names.d.ts +41 -0
  18. package/dist/template-names.js +44 -0
  19. package/package.json +2 -1
  20. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  21. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  22. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  23. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  24. package/site-templates/marketing-site-free/provenance.json +4 -0
  25. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  26. package/templates/apps/api/src/config/modules.ts +1 -1
  27. package/templates/apps/api/src/server.ts +46 -3
  28. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  29. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  30. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  31. package/templates/apps/web/jest.config.cjs +5 -0
  32. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  33. package/templates/apps/web/lib/server-api.ts +28 -12
  34. package/templates/apps/web/messages/en.json +58 -0
  35. package/templates/database/CHANGELOG.md +14 -0
  36. package/templates/database/package.json +1 -1
  37. package/templates/package.json +1 -1
@@ -29,6 +29,39 @@ export interface ArchNamespaces {
29
29
  members: Record<string, ArchNamespaceMember>;
30
30
  rules: string[];
31
31
  }
32
+ /**
33
+ * One page a site template's roster declares (spec 5478 § D1).
34
+ *
35
+ * Both producers emit this same row type: a template's BASE roster
36
+ * (`TemplateManifest.roster`, template-manifest.ts) and a module's
37
+ * `pageContributions` below. One vocabulary, so the merge has exactly one
38
+ * collision key to reconcile rather than two (§ D1/D2).
39
+ *
40
+ * Declared HERE rather than in `template-manifest.ts` (§ D3) because
41
+ * `ModuleSlice` carries it and `template-manifest.ts` already imports
42
+ * `MODULE_SLICES` + `normalizeModules` from this file — so the dependency
43
+ * stays a DAG (`parse-args` ← `module-architecture` ← `template-manifest`)
44
+ * instead of becoming a cycle in the value graph.
45
+ *
46
+ * A row carries no nav label: `GATEABLE_SURFACES` already carries
47
+ * `navLabelKey?` per surface (`surfaces.ts`) and the nav is derived from the
48
+ * roster (parent spec 5462 § D9), so a label here would be a second authority
49
+ * for one fact.
50
+ */
51
+ export interface PageRosterRow {
52
+ /**
53
+ * Site-relative route path, e.g. `/blog` or `/flows/[slug]/start`. This is
54
+ * the row's IDENTITY and the merge's collision key (§ D2): a path claimed by
55
+ * two sources is refused at build time, never resolved by a precedence rule
56
+ * (parent § D8).
57
+ */
58
+ route: string;
59
+ /**
60
+ * Repo-relative seed content this row renders. Absent for an API-backed row
61
+ * (`/schedule` renders from the scheduling API, not from an MDX file).
62
+ */
63
+ content?: readonly string[];
64
+ }
32
65
  /**
33
66
  * The architecture fragment one module contributes.
34
67
  *
@@ -104,6 +137,31 @@ export interface ModuleSlice {
104
137
  * which miss eleven and over-mark three.
105
138
  */
106
139
  ownedFiles?: string[];
140
+ /**
141
+ * The page rows and seed content this module contributes to any site
142
+ * template that takes it (spec 5478; parent spec 5462 § D5) — `ownedFiles`'s
143
+ * sibling, and a DIFFERENT kind of fact again from every field above.
144
+ * `ownedFiles` names which files in THIS monorepo physically belong to the
145
+ * module; `pageContributions` names which PAGES the module brings to a
146
+ * template's roster. `template-manifest.ts`'s `mergeRoster` reads it.
147
+ *
148
+ * A contribution never carries route CODE. How the route code reaches a
149
+ * scaffold is a property of the module, not of the contribution (parent
150
+ * § D6): `docs-blog` and `scheduling` list their route files in `ownedFiles`
151
+ * so the code rides their own licence-checked bundle, while
152
+ * `signal-collectors` deliberately does not — its `flows` routes reach a
153
+ * scaffold from the `templates/` snapshot, retained because
154
+ * `MODULE_SURFACES` enables the surface (see this file's
155
+ * `signal-collectors.ownedFiles` comment for why listing them there broke
156
+ * `sync-routes`, #5366). Both shapes declare their rows here identically.
157
+ *
158
+ * Unset for a module that brings no pages to any declared template:
159
+ * `marketing` (base, no `ownedFiles` and `MODULE_SURFACES.marketing` is
160
+ * `[]`), and — deferred, not absent — `multi-tenant` and `commerce`, whose
161
+ * rows are their own instances' to enumerate. An unset field contributes
162
+ * zero rows openly; it is not a partial merge.
163
+ */
164
+ pageContributions?: readonly PageRosterRow[];
107
165
  /**
108
166
  * Bundle-relative source → scaffold-relative destination (spec 5373 § D7).
109
167
  * Every source must be a file the validated tar extraction produced in the
@@ -266,6 +266,28 @@ export const MODULE_SLICES = {
266
266
  "packages/ui/src/chrome/DocsSidebar.tsx",
267
267
  "packages/ui/src/chrome/slices/docs-blog.ts",
268
268
  ],
269
+ // pageContributions (spec 5478; parent § D6 shape 1) — the two route
270
+ // surfaces this module mounts, each with the seed MDX it renders. The
271
+ // three MDX files are exactly the three on the tree and cannot come from
272
+ // the `templates/` snapshot: `apps/web/content` is in build-templates.mjs's
273
+ // EXCLUDE_TEMPLATE_DIRS and `template-overlay/` carries no blog or docs
274
+ // stub. They ride this module's own bundle, consistent with the route
275
+ // files above — STAGING them into it is #5481's change to
276
+ // build-module-bundles.mjs, since `collectModuleFiles` unions `ownedFiles`
277
+ // with `installMap` keys and nothing else. This unit only DECLARES them.
278
+ pageContributions: [
279
+ {
280
+ route: "/blog",
281
+ content: [
282
+ "apps/web/content/en/blog/index.mdx",
283
+ "apps/web/content/en/blog/launching-ailk.mdx",
284
+ ],
285
+ },
286
+ {
287
+ route: "/docs",
288
+ content: ["apps/web/content/en/docs/getting-started.mdx"],
289
+ },
290
+ ],
269
291
  // patches (spec 5418 § 3 D1, Group G) — every Group G row that edits a
270
292
  // file the buyer's scaffold already has. Values transcribed verbatim
271
293
  // from docs/specs/5365-docs-blog-module-spec.md § 4 Group G via
@@ -387,6 +409,12 @@ export const MODULE_SLICES = {
387
409
  "packages/theme",
388
410
  "packages/ui",
389
411
  "packages/validation",
412
+ // The `workspace` module's shared settings components (#5371 req. 6)
413
+ // — the scaffolded-site twin of the root architecture.yaml grant on
414
+ // `apps_paid.workspace`. Pro Plus inherits Pro's module set, so a
415
+ // saas-product scaffold carries both modules and this app composes
416
+ // the identical panels apps/web does, with no forked copy.
417
+ "packages/workspace-ui",
390
418
  ],
391
419
  may_not_import_from: [
392
420
  "apps/api",
@@ -649,6 +677,90 @@ export const MODULE_SLICES = {
649
677
  "apps-paid/workspace/**",
650
678
  ],
651
679
  },
680
+ // ── workspace — Pro module: the single-workspace settings shell (D-27/D-49)
681
+ //
682
+ // `/workspace` in apps/web's `(authed)` segment — General (rename), Site
683
+ // (identity / SEO / theme, over the already-shipped apps/api config routes)
684
+ // and Team (a read-only roster). The three panels live in the shared
685
+ // `packages/workspace-ui` package so the multi-tenant workspace app (the
686
+ // Pro Plus app) composes the identical components rather than a fork.
687
+ //
688
+ // No `apps` / `namespaces` / `forbiddenPatterns` block: this module
689
+ // contributes to already-declared apps only (apps/web is BASE; apps/api is
690
+ // declared by multi-tenant / scheduling / commerce and is snapshotted whole).
691
+ workspace: {
692
+ // A PERMISSION, not a physical slice (#5371 D1a) — the identical shape
693
+ // docs-blog uses for `packages/fumadocs-schema`. `packages/` is never in
694
+ // SNAPSHOT_DIRS, so the package reaches a buyer as an npm/bundle
695
+ // dependency under `node_modules/`, never as a `packages/workspace-ui`
696
+ // source sibling; the `patches` entry below is what puts it in the
697
+ // buyer's apps/web/package.json.
698
+ webImports: ["packages/workspace-ui"],
699
+ packages: {
700
+ "workspace-ui": {
701
+ description: "Shared workspace-settings components — the settings shell plus the General / Site / Team panels, presentational only (data + save callbacks as props)",
702
+ // packages/templates is required because WorkspaceSettingsShell
703
+ // composes AppShell from @working-theory/templates/shells — the same
704
+ // composition apps/web's own account page uses.
705
+ may_import_from: [
706
+ "packages/templates",
707
+ "packages/theme",
708
+ "packages/ui",
709
+ "packages/validation",
710
+ ],
711
+ },
712
+ },
713
+ // SOURCE files only, and only the ones this module physically owns: the
714
+ // three apps/web route files and the two apps/api route files.
715
+ //
716
+ // `packages/workspace-ui/**` is deliberately NOT listed (D1a). It is a
717
+ // whole package that is paid end to end, whose strip disposition is
718
+ // carried by architecture.yaml's `commercial_tier_moves_to` field (its
719
+ // future paid-tree home) rather than by a per-file inventory —
720
+ // exactly the disposition `packages/fumadocs-schema` carries for
721
+ // docs-blog. `multi-tenant`'s whole-app glob (above) works because
722
+ // SNAPSHOT_DIRS physically copies apps wholesale; a package is never in a
723
+ // snapshot root, so an ownedFiles enumeration would add nothing.
724
+ //
725
+ // `packages/api-client/src/workspace.ts` is NOT listed either (D1b):
726
+ // api-client is a base package every scaffold receives regardless of
727
+ // `--modules`, and the field doc above names it by name as a shared
728
+ // package deliberately left out even when genuinely single-purpose. The
729
+ // wrapper is harmless on a scaffold without this module — its calls 404,
730
+ // the same non-event any client wrapper produces against an unregistered
731
+ // route.
732
+ //
733
+ // Test companions (`__tests__/`) are never listed — the SOURCE-files-only
734
+ // rule above.
735
+ ownedFiles: [
736
+ "apps/web/app/[locale]/(authed)/workspace/page.tsx",
737
+ "apps/web/app/[locale]/(authed)/workspace/site/page.tsx",
738
+ "apps/web/app/[locale]/(authed)/workspace/team/page.tsx",
739
+ "apps/api/src/routes/workspaces/general.ts",
740
+ "apps/api/src/routes/workspaces/members.ts",
741
+ ],
742
+ // patches — the dependency add that delivers the shared package to the
743
+ // buyer's apps/web, mirroring docs-blog's own fumadocs-schema merge-keys
744
+ // op. No next.config.mjs `add-transpile-package` op: the package ships
745
+ // compiled ESM in dist/ the same way every other @working-theory/*
746
+ // dependency apps/web already consumes does, and docs-blog's transpile op
747
+ // exists for the fumadocs SDK boundary, not for the dependency add itself.
748
+ patches: [
749
+ {
750
+ target: "apps/web/package.json",
751
+ kind: "json",
752
+ ops: [
753
+ {
754
+ op: "merge-keys",
755
+ path: ["dependencies"],
756
+ entries: {
757
+ "@working-theory/workspace-ui": "workspace:*",
758
+ },
759
+ },
760
+ ],
761
+ },
762
+ ],
763
+ },
652
764
  // ── scheduling — full-stack module (provider + routes + booker pages) ────
653
765
  //
654
766
  // The #4416 scheduling seam, made installable. Three tiers into the
@@ -816,6 +928,14 @@ export const MODULE_SLICES = {
816
928
  "apps/web/app/[locale]/schedule/page.tsx",
817
929
  "apps/web/app/[locale]/schedule/confirmed/page.tsx",
818
930
  ],
931
+ // pageContributions (spec 5478; parent § D6 shape 1) — the booker and its
932
+ // confirmation page, whose route files `ownedFiles` above already carries.
933
+ // Neither row names seed content: both render from the /v1/schedule/*
934
+ // routes, not from an MDX file.
935
+ pageContributions: [
936
+ { route: "/schedule" },
937
+ { route: "/schedule/confirmed" },
938
+ ],
819
939
  },
820
940
  // ── signal-collectors — flows, deliverables, project listings, waitlist
821
941
  // signups (D-48, #5366) ───────────────────────────────────────────────
@@ -898,10 +1018,23 @@ export const MODULE_SLICES = {
898
1018
  // packages/ui: eleven components (D8) plus the NEW slice registration
899
1019
  // entry point (D8a, mirroring sections/slices/scheduling.ts). Four of the
900
1020
  // eleven (ProjectListingShelves, ListingPrivacyNotice, ProjectListingCard,
901
- // ResultsReveal) have no consumer anywhere in the repo today — grep-
902
- // confirmed orphaned exports, not a module-boundary fact; D8 places them
903
- // here by subject (project-listing / results-reveal UI) rather than by
904
- // reachability, since reachability cannot place an unreached file either.
1021
+ // ResultsReveal) had no consumer anywhere IN THIS REPO at D8 authoring
1022
+ // time — grep-confirmed, a module-boundary fact only in-repo. Three of the
1023
+ // four (ProjectListingShelves, ProjectListingCard, ListingPrivacyNotice —
1024
+ // plus the ListingWindowBand type ProjectListingCard exports) are, in
1025
+ // fact, consumed CROSS-ORG: the samhenry site (salessmyth org) imports all
1026
+ // four from the signal-collectors slice's re-export entry point
1027
+ // (packages/ui/src/sections/slices/signal-collectors.ts, rules/cross-org-
1028
+ // sharing.md), which #5488 additionally re-exports them from so that
1029
+ // consumer can reach them by subpath. "No consumer anywhere in the repo"
1030
+ // was therefore true in-repo and false across the org boundary — not a
1031
+ // reachability fact a repo-scoped grep can see. ResultsReveal remains the
1032
+ // one component of the four with no known consumer anywhere (in-repo or
1033
+ // out) and is NOT re-exported by #5488 — it is not this card's claim to
1034
+ // make it non-orphaned. D8's placement rule (by subject — project-listing
1035
+ // / results-reveal UI — rather than by reachability, since reachability
1036
+ // cannot place an unreached file either) is unchanged and still governs
1037
+ // where all eleven live.
905
1038
  "signal-collectors": {
906
1039
  ownedFiles: [
907
1040
  "apps/api/src/routes/flows/index.ts",
@@ -995,6 +1128,25 @@ export const MODULE_SLICES = {
995
1128
  "packages/ui/src/blocks/ResultsReveal.tsx",
996
1129
  "packages/ui/src/sections/slices/signal-collectors.ts",
997
1130
  ],
1131
+ // pageContributions (spec 5478; parent § D6 shape 2) — the three flow
1132
+ // routes, named exactly as `surfaces.ts`'s ROUTE_SINCE keys name them.
1133
+ // ROWS AND SEED ONLY: unlike docs-blog and scheduling above, this module's
1134
+ // route code is deliberately NOT in `ownedFiles` (see the comment inside
1135
+ // that array). The first two rows' code reaches a scaffold from the
1136
+ // `templates/` snapshot, retained because the `flows` surface is enabled;
1137
+ // the results row's code is `commerce`'s (`commerce.ownedFiles` carries
1138
+ // `results/[payload]/page.tsx` + its two client files below, and
1139
+ // `paidModuleTemplateExclusions` strips every paid module's owned files
1140
+ // from the snapshot unconditionally), so that one row rides a DIFFERENT
1141
+ // module's bundle — recorded in spec 5478 § 8 item 1, whose delivery
1142
+ // consequence is #5481's and #5464's. Declaring the rows here changes none
1143
+ // of it: a contribution is data, and which delivery shape carries the code
1144
+ // is a property of the module, not of the contribution.
1145
+ pageContributions: [
1146
+ { route: "/flows/[slug]" },
1147
+ { route: "/flows/[slug]/start" },
1148
+ { route: "/flows/[slug]/results/[payload]" },
1149
+ ],
998
1150
  },
999
1151
  // ── commerce — per-site paid add-on (D-47, amending D-26) ─────────────────
1000
1152
  //
@@ -7,12 +7,19 @@
7
7
  * composition is downstream work, so the default (`marketing`) reproduces
8
8
  * today's fixed-tree scaffold exactly.
9
9
  */
10
- export declare const RECOGNIZED_MODULES: readonly ["marketing", "docs-blog", "multi-tenant", "scheduling", "signal-collectors", "commerce"];
10
+ export declare const RECOGNIZED_MODULES: readonly ["marketing", "docs-blog", "multi-tenant", "workspace", "scheduling", "signal-collectors", "commerce"];
11
11
  export type ModuleName = (typeof RECOGNIZED_MODULES)[number];
12
12
  export interface CliOptions {
13
13
  /** Positional target directory (the dir to scaffold into). Optional only
14
14
  * when `help` or `version` is set. */
15
15
  target: string | undefined;
16
+ /**
17
+ * The site template to install (`--template`), validated against
18
+ * `RECOGNIZED_TEMPLATES` at parse time. Undefined when the flag is absent —
19
+ * and a run naming no template scaffolds exactly what it did before the
20
+ * flag existed (spec 5481 § 3 DD3).
21
+ */
22
+ template: string | undefined;
16
23
  install: boolean;
17
24
  help: boolean;
18
25
  version: boolean;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { parseArgs } from "node:util";
11
11
  import { RECOGNIZED_SURFACES, parseSurfaces } from "./surfaces.js";
12
+ import { RECOGNIZED_TEMPLATES } from "./template-names.js";
12
13
  /**
13
14
  * The recognized module vocabulary for the `--modules` selector.
14
15
  *
@@ -22,6 +23,7 @@ export const RECOGNIZED_MODULES = [
22
23
  "marketing",
23
24
  "docs-blog",
24
25
  "multi-tenant",
26
+ "workspace",
25
27
  "scheduling",
26
28
  "signal-collectors",
27
29
  "commerce",
@@ -51,6 +53,16 @@ Options:
51
53
  Defaults to \`marketing\`, which reproduces today's
52
54
  scaffold. A module is a composable unit across the
53
55
  three tiers (data · API · presentation).
56
+ --template <name> The site template to install (recognized:
57
+ ${RECOGNIZED_TEMPLATES.join(", ")}).
58
+ A template is a page roster plus seed content and
59
+ config — never code. Its required modules are ADDED
60
+ to whatever \`--modules\` names, so the two compose.
61
+ A template needing only free modules installs from
62
+ the tree this package ships, with no key and no
63
+ network call; one needing a paid module needs
64
+ AILK_LICENSE_KEY to cover every module it requires,
65
+ and refuses naming whichever it does not.
54
66
  --surfaces <csv> Comma-separated route surfaces to scaffold IN
55
67
  ADDITION to the core set (/, /about, /contact, /faq,
56
68
  /thanks). Recognized: ${RECOGNIZED_SURFACES.join(", ")}.
@@ -102,6 +114,7 @@ export function parseCliArgs(argv) {
102
114
  allowPositionals: true,
103
115
  options: {
104
116
  modules: { type: "string" },
117
+ template: { type: "string" },
105
118
  surfaces: { type: "string" },
106
119
  "sync-architecture": { type: "string" },
107
120
  "sync-catalog": { type: "boolean", default: false },
@@ -119,6 +132,7 @@ export function parseCliArgs(argv) {
119
132
  const version = parsed.values.version === true;
120
133
  const install = parsed.values.install === true;
121
134
  const modules = parseModules(parsed.values.modules);
135
+ const template = parseTemplate(parsed.values.template);
122
136
  const surfaces = parseSurfaceList(parsed.values.surfaces);
123
137
  const syncArchitecture = parsed.values["sync-architecture"];
124
138
  const syncCatalog = parsed.values["sync-catalog"] === true;
@@ -138,6 +152,7 @@ export function parseCliArgs(argv) {
138
152
  if (help || version) {
139
153
  return {
140
154
  target: undefined,
155
+ template: undefined,
141
156
  install,
142
157
  help,
143
158
  version,
@@ -155,6 +170,7 @@ export function parseCliArgs(argv) {
155
170
  }
156
171
  return {
157
172
  target: parsed.positionals[0],
173
+ template,
158
174
  install,
159
175
  help,
160
176
  version,
@@ -173,6 +189,7 @@ export function parseCliArgs(argv) {
173
189
  }
174
190
  return {
175
191
  target: parsed.positionals[0],
192
+ template,
176
193
  install,
177
194
  help,
178
195
  version,
@@ -183,6 +200,32 @@ export function parseCliArgs(argv) {
183
200
  syncArchitecture: undefined,
184
201
  };
185
202
  }
203
+ /**
204
+ * Validate the `--template` value against the declared template set
205
+ * (spec 5481 § 3 DD1).
206
+ *
207
+ * Validated HERE, in the parser, so an unrecognized template fails at parse
208
+ * time as a `ParseError` exactly like an unrecognized module or surface does.
209
+ * Splitting it out to `programmatic.ts` would make `--template` the one flag
210
+ * in this CLI whose bad value surfaces somewhere else.
211
+ *
212
+ * The names come from `template-names.ts` rather than from
213
+ * `TEMPLATE_MANIFESTS` itself, and that file's doc comment records the
214
+ * measured reason: importing the registry here closes a runtime cycle
215
+ * through `lib/module-tier.ts`. `TEMPLATE_MANIFESTS` remains the registry;
216
+ * the two sets are asserted equal by the suite.
217
+ *
218
+ * @throws ParseError on an undeclared template name, naming the declared set.
219
+ */
220
+ function parseTemplate(raw) {
221
+ if (raw === undefined)
222
+ return undefined;
223
+ const name = raw.trim();
224
+ if (!RECOGNIZED_TEMPLATES.includes(name)) {
225
+ throw new ParseError(`unknown template "${raw}". Recognized: ${RECOGNIZED_TEMPLATES.join(", ")}`);
226
+ }
227
+ return name;
228
+ }
186
229
  /**
187
230
  * Parse + validate the `--surfaces` CSV, re-throwing an unrecognized-surface
188
231
  * failure as a ParseError so every CLI parse failure reaches the orchestrator's
@@ -28,6 +28,21 @@ export interface ScaffoldOptions {
28
28
  * Ignored at runtime until named presets are implemented.
29
29
  */
30
30
  preset?: string;
31
+ /**
32
+ * The site template to install (`--template`), a `TEMPLATE_MANIFESTS` key.
33
+ *
34
+ * Consumed ONLY by the async `scaffold()` below. `scaffoldSync` stays
35
+ * template-oblivious for exactly the reason it stays licensing-oblivious
36
+ * (spec 5481 § 3 DD3): a call naming no template takes a code path with no
37
+ * new statement in it, which is a stronger byte-identity guarantee than a
38
+ * test asserting the output happens to match.
39
+ *
40
+ * What a template DOES change is `modules` — `scaffold()` unions the
41
+ * template's `requires` with whatever `--modules` named and passes THAT
42
+ * list to `scaffoldSync` (DD2), so the resolved surfaces, the emitted
43
+ * `architecture.yaml` and the generated nav are all functions of the union.
44
+ */
45
+ template?: string;
31
46
  /**
32
47
  * Selected modules (recognized: marketing, docs-blog, multi-tenant,
33
48
  * scheduling). Defaults to
@@ -70,6 +85,8 @@ export interface ScaffoldOptions {
70
85
  licenseKey?: string;
71
86
  /** Overrides `AILK_MODULE_REGISTRY_URL` / the built-in module-distribution default. */
72
87
  moduleRegistryUrl?: string;
88
+ /** Overrides `AILK_TEMPLATE_REGISTRY_URL` / the built-in template-distribution default. */
89
+ templateRegistryUrl?: string;
73
90
  /** Injected fetch for tests. Never a live network call in CI or in this repo's evidence runs. */
74
91
  fetchImpl?: typeof fetch;
75
92
  }
@@ -98,9 +115,17 @@ export declare function scaffoldSync(opts: ScaffoldOptions): ScaffoldResult;
98
115
  * read and no network call (the regression floor). A paid module with
99
116
  * no key, or a licence the module distribution rejects, throws
100
117
  * `LicenseRequiredError` HERE — before anything is written to disk.
101
- * 3. `scaffoldSync` — unchanged, today's local `templates/` copy.
118
+ * 3. `scaffoldSync` — unchanged, today's local `templates/` copy, over the
119
+ * EFFECTIVE module set (see 0 below).
102
120
  * 4. Merge each resolved paid module's fetched bundle into the freshly
103
121
  * scaffolded tree (`extractModuleBundle`).
122
+ * 5. Install the named site template LAST (#5481; parent record D9), from
123
+ * its fetched bundle or — when it requires only free modules — from the
124
+ * `site-templates/` tree this package ships.
125
+ *
126
+ * And, ahead of all five when `opts.template` is set (0): resolve the
127
+ * template and union its `requires` with `opts.modules`, so every step above
128
+ * runs over the EFFECTIVE module set rather than the requested one.
104
129
  *
105
130
  * `scaffoldSync` itself stays licensing-oblivious on purpose: every existing
106
131
  * synchronous caller (this package's own test suite, overwhelmingly) is
@@ -21,15 +21,26 @@ import { fileURLToPath } from "node:url";
21
21
  import { CATALOG_TARGET_PATH, writeComponentCatalog, } from "./component-catalog.js";
22
22
  import { copyTemplate } from "./copy.js";
23
23
  import { deriveArchitecture } from "./derive-architecture.js";
24
- import { extractModuleBundle } from "./lib/extract-module-bundle.js";
24
+ import { extractModuleBundle, installBundleTree, } from "./lib/extract-module-bundle.js";
25
+ import { fetchTemplate } from "./lib/fetch-template.js";
25
26
  import { resolvePaidModules } from "./lib/module-license-gate.js";
26
27
  import { classifyModules } from "./lib/module-tier.js";
27
28
  import { normalizeModules } from "./module-architecture.js";
28
29
  import { NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
29
30
  import { syncRoutes } from "./sync-routes.js";
31
+ import { resolveTemplate, templateDelivery } from "./template-manifest.js";
30
32
  import { validateTarget } from "./validate.js";
31
33
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
32
34
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
35
+ /**
36
+ * Where a Free site template's files are read from on the keyless path — and
37
+ * why it is BESIDE `templates/` rather than inside it (parent record D14):
38
+ * `copyTemplate` copies the whole `templates/` tree into EVERY scaffold, so a
39
+ * per-template subtree there would land in scaffolds that asked for a
40
+ * different template, or for none. It reaches the published tarball through
41
+ * `package.json` `files` instead.
42
+ */
43
+ const SITE_TEMPLATES_DIR = resolve(PKG_ROOT, "site-templates");
33
44
  const VERSIONS_FILE = resolve(PKG_ROOT, "versions.json");
34
45
  function readVersionMap() {
35
46
  if (!existsSync(VERSIONS_FILE)) {
@@ -114,9 +125,17 @@ export function scaffoldSync(opts) {
114
125
  * read and no network call (the regression floor). A paid module with
115
126
  * no key, or a licence the module distribution rejects, throws
116
127
  * `LicenseRequiredError` HERE — before anything is written to disk.
117
- * 3. `scaffoldSync` — unchanged, today's local `templates/` copy.
128
+ * 3. `scaffoldSync` — unchanged, today's local `templates/` copy, over the
129
+ * EFFECTIVE module set (see 0 below).
118
130
  * 4. Merge each resolved paid module's fetched bundle into the freshly
119
131
  * scaffolded tree (`extractModuleBundle`).
132
+ * 5. Install the named site template LAST (#5481; parent record D9), from
133
+ * its fetched bundle or — when it requires only free modules — from the
134
+ * `site-templates/` tree this package ships.
135
+ *
136
+ * And, ahead of all five when `opts.template` is set (0): resolve the
137
+ * template and union its `requires` with `opts.modules`, so every step above
138
+ * runs over the EFFECTIVE module set rather than the requested one.
120
139
  *
121
140
  * `scaffoldSync` itself stays licensing-oblivious on purpose: every existing
122
141
  * synchronous caller (this package's own test suite, overwhelmingly) is
@@ -124,16 +143,59 @@ export function scaffoldSync(opts) {
124
143
  * with a paid module in `opts.modules` exercises the gate at all.
125
144
  */
126
145
  export async function scaffold(opts) {
127
- const { paid } = classifyModules(normalizeModules(opts.modules ?? ["marketing"]));
146
+ // Resolved first, and by the registry rather than re-validated here: the
147
+ // name already passed `parse-args.ts`, and `resolveTemplate` throws a
148
+ // `TemplateError` for a programmatic caller that skipped the parser.
149
+ const template = opts.template !== undefined ? resolveTemplate(opts.template) : undefined;
150
+ // The effective module set: union(template.requires, parsed --modules),
151
+ // normalized through `normalizeModules` — the single ordering authority
152
+ // (parent record D7). Order-independence falls out rather than being
153
+ // engineered: it returns `RECOGNIZED_MODULES.filter(...)`, declaration-
154
+ // ordered and deduped, so the same SET in any argument order is the same
155
+ // list.
156
+ const modules = normalizeModules([
157
+ ...(template?.requires ?? []),
158
+ ...(opts.modules ?? ["marketing"]),
159
+ ]);
160
+ const { paid } = classifyModules(modules);
128
161
  const bundles = await resolvePaidModules({
129
162
  paidModules: paid,
130
163
  licenseKey: opts.licenseKey,
164
+ ...(template !== undefined ? { templateName: template.name } : {}),
131
165
  ...(opts.moduleRegistryUrl !== undefined
132
166
  ? { registryUrl: opts.moduleRegistryUrl }
133
167
  : {}),
134
168
  ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
135
169
  });
136
- const result = scaffoldSync(opts);
170
+ // The template's OWN bundle, fetched here — before `scaffoldSync` — so the
171
+ // fourth refusal (the template's bundle unpublished, parent record D17 /
172
+ // spec 5481 § 3 DD5 row 4) throws with the target still non-existent, like
173
+ // the three module refusals above it (DD6). It is EXTRACTED last, below.
174
+ //
175
+ // The keyless path never reaches this at all: a template requiring only
176
+ // free modules classifies to an empty paid set, so `resolvePaidModules`
177
+ // returned at its first branch without reading `process.env` or the
178
+ // network, and `templateDelivery` sends us past this too (DD7). That is
179
+ // what makes record D11's no-network guarantee structural rather than
180
+ // merely likely.
181
+ const templateBundle = template !== undefined && templateDelivery(template) === "paid"
182
+ ? await fetchTemplate({
183
+ templateName: template.name,
184
+ token: opts.licenseKey ?? process.env["AILK_LICENSE_KEY"] ?? "",
185
+ ...(opts.templateRegistryUrl !== undefined
186
+ ? { registryUrl: opts.templateRegistryUrl }
187
+ : {}),
188
+ ...(opts.fetchImpl !== undefined ? { fetchImpl: opts.fetchImpl } : {}),
189
+ })
190
+ : undefined;
191
+ // `modules` — the union — is what `scaffoldSync` receives, not `opts`
192
+ // (spec 5481 § 3 DD2). `resolveSurfaces`, `deriveArchitecture` and
193
+ // `renderNavConfig` are all computed INSIDE `scaffoldSync` from
194
+ // `opts.modules`, so a union computed for the licence classification alone
195
+ // would gate correctly and emit the wrong surfaces, the wrong
196
+ // `architecture.yaml` and the wrong nav — a silent wrong result, not a
197
+ // failure.
198
+ const result = scaffoldSync({ ...opts, modules });
137
199
  // Threaded to extractModuleBundle for `package.json` patch targets'
138
200
  // `workspace:*` substitution (spec 5418 § 3 D2) — the same version map
139
201
  // `scaffoldSync` already reads for `copyTemplate`, re-read here since
@@ -147,6 +209,40 @@ export async function scaffold(opts) {
147
209
  versions,
148
210
  });
149
211
  }
212
+ // The template lands LAST — after `scaffoldSync` and after every module
213
+ // bundle (parent record D9) — so its content and config files sit on top of
214
+ // everything derived. It shapes the derived artifacts by DECLARING (its
215
+ // `requires` are in the union above; its roster rows are what the nav is
216
+ // generated from), never by landing a code file: the nav is already written
217
+ // by `scaffoldSync` from `renderNavConfig(enabledSurfaces)`, and record
218
+ // §5.2 forbids a `.ts` in any `site-templates/` `installMap` destination.
219
+ //
220
+ // One install step, two source roots (DD7): an extracted staging dir on the
221
+ // paid path, `site-templates/<name>/` on disk on the Free one.
222
+ if (template !== undefined) {
223
+ if (templateBundle !== undefined) {
224
+ await extractModuleBundle({
225
+ bytes: templateBundle,
226
+ targetDir: result.targetDir,
227
+ moduleName: template.name,
228
+ templateRequires: template.requires,
229
+ versions,
230
+ });
231
+ }
232
+ else {
233
+ const sourceRoot = join(SITE_TEMPLATES_DIR, template.name);
234
+ if (!existsSync(sourceRoot)) {
235
+ throw new Error(`template "${template.name}": ${sourceRoot} is missing — the published package must carry site-templates/ in its "files" list`);
236
+ }
237
+ installBundleTree({
238
+ sourceRoot,
239
+ targetDir: result.targetDir,
240
+ name: template.name,
241
+ templateRequires: template.requires,
242
+ versions,
243
+ });
244
+ }
245
+ }
150
246
  return result;
151
247
  }
152
248
  // Re-exported so a consumer of the async `scaffold()` above can type its own