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.
Files changed (128) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +67 -0
  29. package/dist/spoke-checkout.js +562 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +451 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
  93. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  94. package/templates/apps/web/lib/account-config.ts +130 -1
  95. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  96. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  97. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  98. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  99. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  100. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  101. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  102. package/templates/apps/web/lib/listings-config.ts +31 -0
  103. package/templates/apps/web/lib/listings.tsx +7 -3
  104. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  105. package/templates/apps/web/lib/next-path.ts +35 -0
  106. package/templates/apps/web/lib/relay.ts +3 -1
  107. package/templates/apps/web/lib/server-api.ts +11 -1
  108. package/templates/apps/web/lib/topology.generated.ts +15 -5
  109. package/templates/apps/web/messages/en.json +32 -3
  110. package/templates/database/CHANGELOG.md +26 -0
  111. package/templates/database/__tests__/agents.db.test.ts +25 -5
  112. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  113. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  114. package/templates/database/__tests__/person.db.test.ts +584 -0
  115. package/templates/database/auth/schema.prisma +3 -2
  116. package/templates/database/config/schema.prisma +13 -0
  117. package/templates/database/inbox/schema.prisma +98 -0
  118. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  119. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  120. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  121. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  122. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  123. package/templates/database/package.json +1 -1
  124. package/templates/database/scripts/person-backfill.sql +132 -0
  125. package/templates/database/tenancy.ts +53 -0
  126. package/templates/tools/check-declared-env.ts +37 -0
  127. package/templates/tools/declared-env.ts +1163 -0
  128. package/versions.json +35 -35
@@ -29,22 +29,26 @@ import { HOME_PAGE_PATH, designHomeSeeds, planDesignHome, shapeHomePath, shipped
29
29
  import { applyDesignPresets, readSectionLookCatalog, shippedPageTypes, validateDesignPresets, } from "./design-presets.js";
30
30
  import { resolveDesign, spliceThemeBlock, } from "./design-scaffold.js";
31
31
  import { writeAuthSecretFile } from "./lib/auth-secret-file.js";
32
+ import { appendEnvRows } from "./lib/declared-env.js";
33
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
32
34
  import { extractModuleBundle, installBundleTree, } from "./lib/extract-module-bundle.js";
33
35
  import { fetchTemplate } from "./lib/fetch-template.js";
34
36
  import { assertLicenseKeyShape, writeLicenseKeyFiles, } from "./lib/license-key-files.js";
35
37
  import { assertDesignAllowed, assertReleased, deriveDesignLevel, deriveLevel, resolveModuleSet, resolvePaidModules, } from "./lib/module-license-gate.js";
36
- import { classifyModules } from "./lib/module-tier.js";
38
+ import { classifyModules, isAddition } from "./lib/module-tier.js";
37
39
  import { normalizeModules } from "./module-architecture.js";
38
40
  import { writePaidSections } from "./paid-sections.js";
39
41
  import { RECOGNIZED_SHAPES, RESERVED_SHAPES, } from "./parse-args.js";
42
+ import { writeSpokeFlowsReadme } from "./seed-flows.js";
40
43
  import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
41
44
  import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
42
45
  import { planSiteManifest } from "./site-manifest.js";
43
46
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, withMeetPlaceholderSeeds, } from "./site-roster.js";
47
+ import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
44
48
  import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
45
49
  import { syncRoutes } from "./sync-routes.js";
46
50
  import { BASE_LAYER_DIR, SHAPE_STARTERS, TEMPLATE_MANIFESTS, TemplateError, mergeRoster, resolveTemplate, resolveTemplateLineage, rosterDeclares, templateDelivery, templateTreeDir, } from "./template-manifest.js";
47
- import { TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeModuleRefusal, spokeModules, writeHubOriginEnv, } from "./topology.js";
51
+ import { SPOKE_ONLY_FILES, TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubSignInUrl, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeDestination, spokeModuleRefusal, spokeModules, writeHubOriginEnv, writeHubSignInEnv, } from "./topology.js";
48
52
  import { validateTarget } from "./validate.js";
49
53
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
50
54
  const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
@@ -154,7 +158,12 @@ export function scaffoldSync(opts) {
154
158
  const enabledSurfaces = resolveSurfaces(opts.modules ?? ["marketing"], opts.surfaces ?? []);
155
159
  // #7426 D4 — a spoke is this same copy with the spoke's exclusions added:
156
160
  // one registry over the one template tree, never a second tree.
157
- const spoke = opts.topology === "spoke" ? { mcp: opts.mcp !== false } : undefined;
161
+ const spoke = opts.topology === "spoke"
162
+ ? {
163
+ mcp: opts.mcp !== false,
164
+ ...(opts.hubSignInUrl !== undefined ? { signIn: "hub" } : {}),
165
+ }
166
+ : undefined;
158
167
  const versions = readVersionMap();
159
168
  copyTemplate({
160
169
  templatesDir: TEMPLATES_DIR,
@@ -162,7 +171,10 @@ export function scaffoldSync(opts) {
162
171
  versions,
163
172
  excludePaths: [
164
173
  ...excludedRoutePaths(enabledSurfaces),
165
- ...(spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : []),
174
+ // #7874 D11 — the declared-env check is a spoke's alone.
175
+ ...(spoke !== undefined
176
+ ? spokeExclusions(TEMPLATES_DIR, spoke)
177
+ : SPOKE_ONLY_FILES),
166
178
  ],
167
179
  markers: buildScaffoldMarkers(projectName),
168
180
  });
@@ -197,6 +209,11 @@ export function scaffoldSync(opts) {
197
209
  // scaffold with `withCatalog` unset touches exactly the files it did before.
198
210
  if (opts.withCatalog === true)
199
211
  writeComponentCatalog(targetAbs);
212
+ // #7874 D9 — a spoke's env_contract rows, as its last write, so every file
213
+ // above is in the scan. A hub's project.yaml is never touched.
214
+ if (spoke !== undefined) {
215
+ appendEnvRows(targetAbs, { topology: "spoke", catalog: ENV_CATALOG });
216
+ }
200
217
  const filesCreated = countFiles(targetAbs);
201
218
  return {
202
219
  targetDir: targetAbs,
@@ -376,8 +393,12 @@ export async function scaffold(opts) {
376
393
  bundles = chosen.bundles;
377
394
  level = chosen.level;
378
395
  // #7426 D7 — a key-derived default set keeps only what a spoke admits;
379
- // the run names what it dropped (`droppedModules`).
380
- if (spoke !== undefined && opts.modules === undefined) {
396
+ // the run names what it dropped (`droppedModules`). Naming only
397
+ // additions (`--modules commerce`) keeps the level default too, so it is
398
+ // filtered the same way (#7838 D12); a named keep set is never touched.
399
+ if (spoke !== undefined &&
400
+ (opts.modules === undefined ||
401
+ opts.modules.every(isAddition))) {
381
402
  const admitted = new Set(spokeModules());
382
403
  dropped = chosen.modules.filter((m) => !admitted.has(m));
383
404
  chosen.modules = chosen.modules.filter((m) => admitted.has(m));
@@ -501,7 +522,8 @@ export async function scaffold(opts) {
501
522
  // it a `{{MARKER}}` authored in a base-layer or site-template file would
502
523
  // survive into the scaffold verbatim — the state before this card, and the
503
524
  // reason a template file could not carry the version stamp.
504
- const markers = buildScaffoldMarkers(opts.projectName ?? basename(result.targetDir));
525
+ const projectName = opts.projectName ?? basename(result.targetDir);
526
+ const markers = buildScaffoldMarkers(projectName);
505
527
  // #5618 D4 — the file-grain ownership registry for THIS scaffold: which
506
528
  // layer wrote which destination. Call-scoped (a fresh Map per scaffold(),
507
529
  // never module-level), threaded through every layer install below in the
@@ -513,10 +535,12 @@ export async function scaffold(opts) {
513
535
  const ownership = new Map();
514
536
  // #7426 D4 — every layer below lands through the spoke filter: a module
515
537
  // bundle's or the template's destination a spoke does not carry is dropped.
538
+ // #7876 D10 — and a module flow file lands in `flows/` as declared data.
516
539
  const exclusions = spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : [];
517
540
  const spokeSkip = spoke !== undefined
518
541
  ? {
519
- skip: (destination) => isSpokeExcluded(destination, { mcp: spoke.mcp, exclusions }),
542
+ skip: (destination) => isSpokeExcluded(destination, { ...spoke, exclusions }),
543
+ remapDestination: spokeDestination,
520
544
  }
521
545
  : {};
522
546
  // The BASE LAYER lands first (#5618 D1) — the seed files every template
@@ -719,6 +743,17 @@ export async function scaffold(opts) {
719
743
  writeFileSync(rosterPath, nextManifest);
720
744
  }
721
745
  }
746
+ // #7838 D8 — a commerce spoke's own checkout: two routes, its config and
747
+ // two return pages, once every layer has landed (so the spoke filter has
748
+ // already dropped the hub-checkout files) and before the manifest, whose
749
+ // code-route rows then record /checkout/thank-you and /checkout/unavailable.
750
+ const spokeCommerce = spoke !== undefined && result.modules.includes("commerce");
751
+ if (spokeCommerce) {
752
+ writeSpokeCheckoutSlice(result.targetDir, {
753
+ site: spokeCheckoutSite(projectName),
754
+ versions,
755
+ });
756
+ }
722
757
  // #6944 — the manifest records what this scaffold IS, once every layer has
723
758
  // landed: the effective module set, a row for every route on disk, and the
724
759
  // level only when the key path above derived one — `resolveModuleSet`, or
@@ -734,6 +769,7 @@ export async function scaffold(opts) {
734
769
  modules: result.modules,
735
770
  level,
736
771
  topology: spoke !== undefined ? "spoke" : "hub",
772
+ ...(spoke?.signIn === "hub" ? { signIn: "hub" } : {}),
737
773
  patternVersion: markers["CREATE_AILK_VERSION"] ?? "",
738
774
  siteTemplatesDir: SITE_TEMPLATES_DIR,
739
775
  templatesDir: TEMPLATES_DIR,
@@ -744,14 +780,33 @@ export async function scaffold(opts) {
744
780
  // #7536 D3 — the registry follows `apps/web/package.json`, which a module
745
781
  // bundle's `patches` (and the template layer) just finished editing.
746
782
  writePaidSections(result.targetDir);
783
+ // #7874 D9 — a spoke's rows again, now the module bundles and the template
784
+ // layer are in the scan; it appends only what those layers added. A
785
+ // commerce spoke also declares what the checkout package reads (#7838 D9).
786
+ if (spoke !== undefined) {
787
+ appendEnvRows(result.targetDir, {
788
+ topology: "spoke",
789
+ ...spokeEnvRowOptions(spokeCommerce),
790
+ });
791
+ }
747
792
  // #6107 D2 — after every layer landed, so nothing later overwrites it: a
748
793
  // served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
749
794
  // A spoke has no sign-in (#7426 D4), so it gets its hub's origin instead
750
795
  // (D2) — never the spoke key, which the hub issues.
796
+ // #7894 D11 — a spoke signing in through its hub also gets the hub's web
797
+ // origin and its own session secret; never the client secret.
751
798
  if (spoke !== undefined)
752
799
  writeHubOriginEnv(result.targetDir, spoke.hubOrigin);
753
800
  else
754
801
  writeAuthSecretFile(result.targetDir);
802
+ if (spoke?.hubSignInOrigin !== undefined) {
803
+ writeHubSignInEnv(result.targetDir, spoke.hubSignInOrigin);
804
+ }
805
+ // #7876 D9 — a spoke carrying the flow stepper says what its `flows/` is
806
+ // and how its rollout seeds it. A hub, and a spoke without flows, get none.
807
+ if (spoke !== undefined && modules.includes("signal-collectors")) {
808
+ writeSpokeFlowsReadme(result.targetDir);
809
+ }
755
810
  // #6683 D9 — persisted only after every layer landed, and only when the
756
811
  // caller passed `license`: a scaffold without it writes nothing new. A
757
812
  // spoke has no apps/api, so it keeps the key in ailk.key alone (#7426 D6).
@@ -765,6 +820,9 @@ export async function scaffold(opts) {
765
820
  ...result,
766
821
  ...(dropped.length > 0 ? { droppedModules: dropped } : {}),
767
822
  ...(spoke !== undefined ? { hubOrigin: spoke.hubOrigin } : {}),
823
+ ...(spoke?.hubSignInOrigin !== undefined
824
+ ? { hubSignInOrigin: spoke.hubSignInOrigin }
825
+ : {}),
768
826
  };
769
827
  if (template !== undefined && resolvedDesign !== undefined) {
770
828
  return {
@@ -783,6 +841,7 @@ function resolveSpoke(opts, template) {
783
841
  const spokeOnly = [
784
842
  opts.hubUrl !== undefined ? "hubUrl" : undefined,
785
843
  opts.mcp !== undefined ? "mcp" : undefined,
844
+ opts.hubSignInUrl !== undefined ? "hubSignInUrl" : undefined,
786
845
  ].filter((f) => f !== undefined);
787
846
  if (spokeOnly.length > 0) {
788
847
  throw new TemplateError(`${spokeOnly.join(" and ")} only apply to a spoke — pass topology: "spoke"`);
@@ -793,8 +852,12 @@ function resolveSpoke(opts, template) {
793
852
  throw new TemplateError("a spoke needs a starter — pass --shape <shape> or --template <name>: the site manifest is where the topology is recorded");
794
853
  }
795
854
  let hubOrigin;
855
+ let hubSignInOrigin;
796
856
  try {
797
857
  hubOrigin = normalizeHubUrl(opts.hubUrl ?? process.env["AILK_API_URL"]);
858
+ if (opts.hubSignInUrl !== undefined) {
859
+ hubSignInOrigin = normalizeHubSignInUrl(opts.hubSignInUrl);
860
+ }
798
861
  }
799
862
  catch (err) {
800
863
  if (err instanceof TopologyError)
@@ -809,7 +872,13 @@ function resolveSpoke(opts, template) {
809
872
  : refusal);
810
873
  }
811
874
  }
812
- return { mcp: opts.mcp !== false, hubOrigin };
875
+ return {
876
+ mcp: opts.mcp !== false,
877
+ hubOrigin,
878
+ ...(hubSignInOrigin !== undefined
879
+ ? { signIn: "hub", hubSignInOrigin }
880
+ : {}),
881
+ };
813
882
  }
814
883
  /**
815
884
  * The per-row install of the two SHARED seed layers (#6646 D1, disposition
@@ -0,0 +1,65 @@
1
+ /** The environment variable the spoke key is read from — and nowhere else. */
2
+ export declare const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
3
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
4
+ export declare class SeedFlowsRefusedError extends Error {
5
+ readonly exitCode = 2;
6
+ constructor(message: string);
7
+ }
8
+ export interface SeedFlowsOptions {
9
+ /** The spoke site. */
10
+ targetDir: string;
11
+ /** `--hub-url`. */
12
+ hubUrl?: string | undefined;
13
+ /** `--dry-run` — read and check the files, send nothing, read no key. */
14
+ dryRun?: boolean;
15
+ /** Injectable for tests; defaults to `globalThis.fetch`. */
16
+ fetch?: typeof globalThis.fetch;
17
+ /** Injectable for tests; defaults to `process.env`. */
18
+ env?: NodeJS.ProcessEnv;
19
+ /** Each report line as it happens; defaults to nothing. */
20
+ onLine?: (line: string) => void;
21
+ }
22
+ /** Where the hub origin came from. */
23
+ export type HubSource = "--hub-url" | "AILK_API_URL" | "apps/web/.env.local";
24
+ export type SeedFlowOutcome = {
25
+ slug: string;
26
+ ok: true;
27
+ result: string;
28
+ status: string;
29
+ } | {
30
+ slug: string;
31
+ ok: false;
32
+ failure: string;
33
+ } | {
34
+ slug: string;
35
+ ok: "dry-run";
36
+ };
37
+ export interface SeedFlowsResult {
38
+ /** The hub origin, when one resolved (a dry run may have none). */
39
+ hub?: {
40
+ origin: string;
41
+ source: HubSource;
42
+ };
43
+ /** Set when there were no declared flows to send. */
44
+ empty?: string;
45
+ flows: SeedFlowOutcome[];
46
+ /** 0 every flow answered 200 (or a dry run); 1 any failed. */
47
+ exitCode: 0 | 1;
48
+ }
49
+ /** The whole report, one line per row — what `seedFlows` printed as it went. */
50
+ export declare function formatSeedFlowsReport(result: SeedFlowsResult): string;
51
+ export declare function seedFlows(opts: SeedFlowsOptions): Promise<SeedFlowsResult>;
52
+ export declare const SPOKE_FLOWS_README = "# flows/\n\nEach file here is one **declared flow**: `flows/<slug>.json` holds one flow\nconfig, exactly as the hub stores it. This repo owns these configs \u2014 the hub\nruns them for this site.\n\n## What a spoke may declare\n\nA spoke key may seed only what this site's own flow pages show:\n`slug`, `defaultLocale`, `steps`, `thankYou` and `flags`. Anything the\nhub acts on outside these pages \u2014 emails, deliverables, pricing, registers,\nlistings \u2014 is refused (`field_not_seedable`). A flow that needs one of those\nis authored in the hub's Admin instead.\n\nThe file name must be the config's `slug` plus `.json`.\n\n## Seeding them into the hub\n\nRun this as part of each rollout, from the site's root, with the spoke key\nyour hub issued set as `AILK_SPOKE_KEY` in the environment (a shell or CI\nsecret \u2014 never a file in this repo):\n\n npx create-ailk seed-flows .\n\nThe hub origin comes from `--hub-url`, else `AILK_API_URL`, else the\n`AILK_API_URL=` line of `apps/web/.env.local`. The key is read from the\nenvironment only. Each flow answers `created`, `updated` or `unchanged`,\nso running it again is safe. `--dry-run` checks the files and sends nothing.\n\nThe hub's operator must first turn seeding on (`AILK_SPOKE_FLOW_SEED=true`\non the hub). Until it is on and this command has run, links to these flows\n(`/flows/<slug>`) reach no flow.\n\n## Who owns what\n\nThe next seed that differs overwrites the hub's copy of a flow's config, so\nedit flows here, not in the Admin. A flow's status (live or draft) is the\nhub's: switching a seeded flow to draft in the Admin keeps it draft through\nevery later seed. Removing a file here does not delete the hub's flow. If\nthe Admin adds a block only it can author (an email, a deliverable, an\noffer), the hub refuses the next seed of that flow (`slug_owned`) rather\nthan delete the block.\n";
53
+ /**
54
+ * Write `SPOKE_FLOWS_README` to `<targetDir>/flows/README.md` unless that
55
+ * file exists. Returns whether it wrote.
56
+ */
57
+ export declare function writeSpokeFlowsReadme(targetDir: string): boolean;
58
+ export interface SeedFlowsArgs {
59
+ target: string | undefined;
60
+ hubUrl: string | undefined;
61
+ dryRun: boolean;
62
+ help: boolean;
63
+ }
64
+ export declare const SEED_FLOWS_HELP = "create-ailk seed-flows \u2014 send a spoke's declared flows to its hub.\n\nUsage:\n AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]\n\nArguments:\n <dir> The spoke site. Defaults to the current dir.\n\nOptions:\n --hub-url <origin> The hub's origin. Else AILK_API_URL, else the\n AILK_API_URL= line of apps/web/.env.local. https:\n only (http: on localhost or 127.0.0.1).\n --dry-run Check every flows/*.json and print what would be sent.\n Sends nothing and reads no key.\n -h, --help Show this help.\n\nEach flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with\nthe spoke key, which is read from AILK_SPOKE_KEY only. The hub must have\nAILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any\nfailed, 2 on a refusal (nothing is sent).\n";
65
+ export declare function parseSeedFlowsArgs(argv: string[]): SeedFlowsArgs;
@@ -0,0 +1,350 @@
1
+ /**
2
+ * `create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]` — send a
3
+ * spoke's declared flows to its hub (#7876).
4
+ *
5
+ * A spoke's flows live in its own repo as `flows/<slug>.json`, each one flow
6
+ * config exactly as the hub stores it (D6). This command is the rollout step
7
+ * that writes them into the spoke's hub workspace: one
8
+ * `PUT <hub>/v1/flows/<slug>/definition` per file, carrying the spoke key, and
9
+ * the hub answers `created`, `updated` or `unchanged` (D4) — so a second run
10
+ * writes nothing.
11
+ *
12
+ * In order (D7): the site must be a spoke; every `flows/*.json` must parse
13
+ * and carry a `slug` equal to its file name; `--dry-run` stops there; the hub
14
+ * origin comes from `--hub-url`, else the `AILK_API_URL` environment
15
+ * variable, else the `AILK_API_URL=` line of `apps/web/.env.local`, and must
16
+ * be `https:` (or `http:` on a loopback host); the key comes from the
17
+ * `AILK_SPOKE_KEY` environment variable and nowhere else. Every refusal is
18
+ * thrown before the first request. The hub origin and its source are printed
19
+ * before any request, so where the key goes is visible before it is sent.
20
+ *
21
+ * The key is sent only as `x-ailk-spoke-key`, never followed across a
22
+ * redirect (`redirect: "error"`), and never printed. Of a hub's answer only a
23
+ * status and a `[a-z0-9_]` error code are printed — no other hub-supplied
24
+ * text reaches the terminal (SEC-7876-S1).
25
+ *
26
+ * Pure module: no `import.meta.url`, so jest can import it directly. `fetch`
27
+ * and `env` are injectable. Spec: docs/specs/7876-flows-as-declared-data-spec.md.
28
+ */
29
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
30
+ import { join, resolve } from "node:path";
31
+ import { parseArgs } from "node:util";
32
+ import { ParseError } from "./parse-args.js";
33
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
34
+ /** The environment variable the spoke key is read from — and nowhere else. */
35
+ export const SPOKE_KEY_ENV_VAR = "AILK_SPOKE_KEY";
36
+ /** The header the key travels in (`packages/api-client`'s spoke header). */
37
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
38
+ /** `flowSlugSchema`'s rule (`@working-theory/validation`), restated: no runtime dep. */
39
+ const FLOW_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
40
+ const FLOW_SLUG_MAX = 59;
41
+ /** The only hub-supplied text printed: a code of this shape, else `unknown`. */
42
+ const SAFE_CODE = /^[a-z0-9_]{1,64}$/;
43
+ const REQUEST_TIMEOUT_MS = 15_000;
44
+ /** A refusal the CLI exits 2 on, thrown before any request is sent. */
45
+ export class SeedFlowsRefusedError extends Error {
46
+ exitCode = 2;
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = "SeedFlowsRefusedError";
50
+ }
51
+ }
52
+ function readDeclaredFlows(flowsDir) {
53
+ if (!existsSync(flowsDir))
54
+ return [];
55
+ const files = readdirSync(flowsDir)
56
+ .filter((name) => name.endsWith(".json"))
57
+ .sort();
58
+ return files.map((file) => {
59
+ let config;
60
+ try {
61
+ config = JSON.parse(readFileSync(join(flowsDir, file), "utf8"));
62
+ }
63
+ catch {
64
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} is not JSON. Nothing was sent.`);
65
+ }
66
+ if (config === null ||
67
+ typeof config !== "object" ||
68
+ Array.isArray(config)) {
69
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} is not a flow config object. Nothing was sent.`);
70
+ }
71
+ const slug = config["slug"];
72
+ const expected = file.slice(0, -".json".length);
73
+ if (typeof slug !== "string" ||
74
+ !FLOW_SLUG.test(slug) ||
75
+ slug.length > FLOW_SLUG_MAX) {
76
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} has no valid "slug" (lowercase letters, digits and single hyphens, at most ${FLOW_SLUG_MAX} characters). Nothing was sent.`);
77
+ }
78
+ if (slug !== expected) {
79
+ throw new SeedFlowsRefusedError(`seed-flows: flows/${file} declares slug "${slug}"; a declared flow's file is named <slug>.json. Nothing was sent.`);
80
+ }
81
+ return { slug, file, config: config };
82
+ });
83
+ }
84
+ // ── The hub origin ──────────────────────────────────────────────────────────
85
+ /** The `AILK_API_URL=` value in `apps/web/.env.local`, if one is assigned. */
86
+ function readEnvLocalHubUrl(targetDir) {
87
+ const path = join(targetDir, "apps", "web", ".env.local");
88
+ if (!existsSync(path))
89
+ return undefined;
90
+ const line = readFileSync(path, "utf8")
91
+ .split(/\r?\n/)
92
+ .map((l) => /^\s*(?:export\s+)?AILK_API_URL\s*=\s*(.*)$/.exec(l))
93
+ .filter((m) => m !== null)
94
+ .pop(); // dotenv: the last assignment wins
95
+ const value = line?.[1]?.trim().replace(/^(["'])(.*)\1$/, "$2");
96
+ return value !== undefined && value !== "" ? value : undefined;
97
+ }
98
+ const HUB_SOURCES_HINT = `The hub origin comes from --hub-url, else the ${HUB_ORIGIN_ENV_VAR} environment variable, else the ${HUB_ORIGIN_ENV_VAR}= line of apps/web/.env.local.`;
99
+ function resolveHub(targetDir, hubUrl, env) {
100
+ const fromEnv = env[HUB_ORIGIN_ENV_VAR];
101
+ const candidates = [
102
+ ["--hub-url", hubUrl],
103
+ [HUB_ORIGIN_ENV_VAR, fromEnv],
104
+ ["apps/web/.env.local", readEnvLocalHubUrl(targetDir)],
105
+ ];
106
+ const picked = candidates.find(([, value]) => value !== undefined && value.trim() !== "");
107
+ if (picked === undefined) {
108
+ throw new SeedFlowsRefusedError(`seed-flows: no hub origin was given. ${HUB_SOURCES_HINT} Nothing was sent.`);
109
+ }
110
+ const [source, raw] = picked;
111
+ try {
112
+ return { origin: normalizeHubUrl(raw), source };
113
+ }
114
+ catch (err) {
115
+ if (!(err instanceof TopologyError))
116
+ throw err;
117
+ throw new SeedFlowsRefusedError(`seed-flows: ${err.message} (from ${source}). ${HUB_SOURCES_HINT} Nothing was sent.`);
118
+ }
119
+ }
120
+ // ── Sending ─────────────────────────────────────────────────────────────────
121
+ function safeCode(value) {
122
+ return typeof value === "string" && SAFE_CODE.test(value) ? value : "unknown";
123
+ }
124
+ async function sendFlow(fetchImpl, origin, key, flow) {
125
+ let response;
126
+ try {
127
+ response = await fetchImpl(`${origin}/v1/flows/${flow.slug}/definition`, {
128
+ method: "PUT",
129
+ headers: {
130
+ "content-type": "application/json",
131
+ [SPOKE_KEY_HEADER]: key,
132
+ },
133
+ body: JSON.stringify(flow.config),
134
+ // A redirect would re-send the key and the body to another URL.
135
+ redirect: "error",
136
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
137
+ });
138
+ }
139
+ catch (err) {
140
+ const name = err instanceof Error ? err.name.toLowerCase() : undefined;
141
+ return { slug: flow.slug, ok: false, failure: `network ${safeCode(name)}` };
142
+ }
143
+ let body;
144
+ try {
145
+ body = await response.json();
146
+ }
147
+ catch {
148
+ body = undefined;
149
+ }
150
+ const envelope = (body ?? {});
151
+ if (response.status === 200) {
152
+ const result = envelope.data?.result;
153
+ if (result === "created" ||
154
+ result === "updated" ||
155
+ result === "unchanged") {
156
+ return {
157
+ slug: flow.slug,
158
+ ok: true,
159
+ result,
160
+ status: safeCode(envelope.data?.status),
161
+ };
162
+ }
163
+ return { slug: flow.slug, ok: false, failure: "200 unknown" };
164
+ }
165
+ return {
166
+ slug: flow.slug,
167
+ ok: false,
168
+ failure: `${response.status} ${safeCode(envelope.error?.code)}`,
169
+ };
170
+ }
171
+ // ── The report ──────────────────────────────────────────────────────────────
172
+ function hubLine(hub) {
173
+ return hub === undefined
174
+ ? "hub: none resolved"
175
+ : `hub: ${hub.origin} (from ${hub.source})`;
176
+ }
177
+ function flowLine(outcome) {
178
+ if (outcome.ok === "dry-run")
179
+ return `${outcome.slug}: would send`;
180
+ if (outcome.ok === false)
181
+ return `${outcome.slug}: failed — ${outcome.failure}`;
182
+ const draft = outcome.status === "draft" ? " (status draft — not shown to visitors)" : "";
183
+ return `${outcome.slug}: ${outcome.result}${draft}`;
184
+ }
185
+ /** The whole report, one line per row — what `seedFlows` printed as it went. */
186
+ export function formatSeedFlowsReport(result) {
187
+ if (result.empty !== undefined)
188
+ return `${result.empty}\n`;
189
+ return `${[hubLine(result.hub), ...result.flows.map(flowLine)].join("\n")}\n`;
190
+ }
191
+ // ── The command ─────────────────────────────────────────────────────────────
192
+ export async function seedFlows(opts) {
193
+ const targetDir = resolve(opts.targetDir);
194
+ const env = opts.env ?? process.env;
195
+ const onLine = opts.onLine ?? (() => undefined);
196
+ let topology;
197
+ try {
198
+ topology = readSiteTopology(targetDir);
199
+ }
200
+ catch (err) {
201
+ if (!(err instanceof TopologyError))
202
+ throw err;
203
+ throw new SeedFlowsRefusedError(`seed-flows: ${err.message}`);
204
+ }
205
+ if (topology !== "spoke") {
206
+ throw new SeedFlowsRefusedError(`seed-flows: ${opts.targetDir} is a hub; a hub's flows are authored in its Admin or seeded with seed-presets.`);
207
+ }
208
+ const flows = readDeclaredFlows(join(targetDir, "flows"));
209
+ if (flows.length === 0) {
210
+ const empty = `no declared flows in ${opts.targetDir}/flows`;
211
+ onLine(empty);
212
+ return { empty, flows: [], exitCode: 0 };
213
+ }
214
+ if (opts.dryRun === true) {
215
+ let hub;
216
+ try {
217
+ hub = resolveHub(targetDir, opts.hubUrl, env);
218
+ }
219
+ catch (err) {
220
+ if (!(err instanceof SeedFlowsRefusedError))
221
+ throw err;
222
+ hub = undefined;
223
+ }
224
+ const outcomes = flows.map((flow) => ({
225
+ slug: flow.slug,
226
+ ok: "dry-run",
227
+ }));
228
+ onLine(hubLine(hub));
229
+ outcomes.forEach((outcome) => onLine(flowLine(outcome)));
230
+ return { ...(hub !== undefined && { hub }), flows: outcomes, exitCode: 0 };
231
+ }
232
+ const hub = resolveHub(targetDir, opts.hubUrl, env);
233
+ const key = env[SPOKE_KEY_ENV_VAR]?.trim() ?? "";
234
+ if (key === "") {
235
+ throw new SeedFlowsRefusedError(`seed-flows: set ${SPOKE_KEY_ENV_VAR} to the spoke key your hub issued. It is read from the environment only — never a flag or a file. Nothing was sent.`);
236
+ }
237
+ onLine(hubLine(hub));
238
+ const fetchImpl = opts.fetch ?? globalThis.fetch;
239
+ const outcomes = [];
240
+ for (const flow of flows) {
241
+ const outcome = await sendFlow(fetchImpl, hub.origin, key, flow);
242
+ onLine(flowLine(outcome));
243
+ outcomes.push(outcome);
244
+ }
245
+ return {
246
+ hub,
247
+ flows: outcomes,
248
+ exitCode: outcomes.every((o) => o.ok === true) ? 0 : 1,
249
+ };
250
+ }
251
+ // ── The spoke's flows/README.md (D9) ────────────────────────────────────────
252
+ export const SPOKE_FLOWS_README = `# flows/
253
+
254
+ Each file here is one **declared flow**: \`flows/<slug>.json\` holds one flow
255
+ config, exactly as the hub stores it. This repo owns these configs — the hub
256
+ runs them for this site.
257
+
258
+ ## What a spoke may declare
259
+
260
+ A spoke key may seed only what this site's own flow pages show:
261
+ \`slug\`, \`defaultLocale\`, \`steps\`, \`thankYou\` and \`flags\`. Anything the
262
+ hub acts on outside these pages — emails, deliverables, pricing, registers,
263
+ listings — is refused (\`field_not_seedable\`). A flow that needs one of those
264
+ is authored in the hub's Admin instead.
265
+
266
+ The file name must be the config's \`slug\` plus \`.json\`.
267
+
268
+ ## Seeding them into the hub
269
+
270
+ Run this as part of each rollout, from the site's root, with the spoke key
271
+ your hub issued set as \`AILK_SPOKE_KEY\` in the environment (a shell or CI
272
+ secret — never a file in this repo):
273
+
274
+ npx create-ailk seed-flows .
275
+
276
+ The hub origin comes from \`--hub-url\`, else \`AILK_API_URL\`, else the
277
+ \`AILK_API_URL=\` line of \`apps/web/.env.local\`. The key is read from the
278
+ environment only. Each flow answers \`created\`, \`updated\` or \`unchanged\`,
279
+ so running it again is safe. \`--dry-run\` checks the files and sends nothing.
280
+
281
+ The hub's operator must first turn seeding on (\`AILK_SPOKE_FLOW_SEED=true\`
282
+ on the hub). Until it is on and this command has run, links to these flows
283
+ (\`/flows/<slug>\`) reach no flow.
284
+
285
+ ## Who owns what
286
+
287
+ The next seed that differs overwrites the hub's copy of a flow's config, so
288
+ edit flows here, not in the Admin. A flow's status (live or draft) is the
289
+ hub's: switching a seeded flow to draft in the Admin keeps it draft through
290
+ every later seed. Removing a file here does not delete the hub's flow. If
291
+ the Admin adds a block only it can author (an email, a deliverable, an
292
+ offer), the hub refuses the next seed of that flow (\`slug_owned\`) rather
293
+ than delete the block.
294
+ `;
295
+ /**
296
+ * Write `SPOKE_FLOWS_README` to `<targetDir>/flows/README.md` unless that
297
+ * file exists. Returns whether it wrote.
298
+ */
299
+ export function writeSpokeFlowsReadme(targetDir) {
300
+ const dir = join(targetDir, "flows");
301
+ const path = join(dir, "README.md");
302
+ if (existsSync(path))
303
+ return false;
304
+ mkdirSync(dir, { recursive: true });
305
+ writeFileSync(path, SPOKE_FLOWS_README);
306
+ return true;
307
+ }
308
+ export const SEED_FLOWS_HELP = `create-ailk seed-flows — send a spoke's declared flows to its hub.
309
+
310
+ Usage:
311
+ AILK_SPOKE_KEY=<key> create-ailk seed-flows [<dir>] [--hub-url <origin>] [--dry-run]
312
+
313
+ Arguments:
314
+ <dir> The spoke site. Defaults to the current dir.
315
+
316
+ Options:
317
+ --hub-url <origin> The hub's origin. Else AILK_API_URL, else the
318
+ AILK_API_URL= line of apps/web/.env.local. https:
319
+ only (http: on localhost or 127.0.0.1).
320
+ --dry-run Check every flows/*.json and print what would be sent.
321
+ Sends nothing and reads no key.
322
+ -h, --help Show this help.
323
+
324
+ Each flows/<slug>.json is sent to PUT <hub>/v1/flows/<slug>/definition with
325
+ the spoke key, which is read from AILK_SPOKE_KEY only. The hub must have
326
+ AILK_SPOKE_FLOW_SEED=true. Exit 0 when every flow was accepted, 1 when any
327
+ failed, 2 on a refusal (nothing is sent).
328
+ `;
329
+ export function parseSeedFlowsArgs(argv) {
330
+ const parsed = parseArgs({
331
+ args: argv,
332
+ strict: true,
333
+ allowPositionals: true,
334
+ options: {
335
+ "hub-url": { type: "string" },
336
+ "dry-run": { type: "boolean", default: false },
337
+ help: { type: "boolean", default: false, short: "h" },
338
+ },
339
+ });
340
+ const [target, ...rest] = parsed.positionals;
341
+ if (rest.length > 0) {
342
+ throw new ParseError(`seed-flows takes at most one target directory, got ${parsed.positionals.length} arguments`);
343
+ }
344
+ return {
345
+ target,
346
+ hubUrl: parsed.values["hub-url"],
347
+ dryRun: parsed.values["dry-run"] === true,
348
+ help: parsed.values.help === true,
349
+ };
350
+ }