create-ailk 0.15.0 → 0.16.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 +42 -0
- package/dist/spoke-checkout.js +359 -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 +449 -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 +15 -0
- 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 +27 -0
- 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
package/dist/programmatic.js
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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 {
|
|
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
|
+
}
|