create-ailk 0.14.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +80 -3
- package/dist/add-route.js +26 -2
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +103 -3
- package/dist/derive-architecture.d.ts +37 -2
- package/dist/derive-architecture.js +92 -7
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- 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 +20 -0
- package/dist/lib/extract-module-bundle.js +53 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +82 -0
- package/dist/parse-args.d.ts +27 -0
- package/dist/parse-args.js +89 -0
- package/dist/programmatic.d.ts +28 -0
- package/dist/programmatic.js +181 -11
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +55 -5
- package/dist/site-manifest.js +121 -12
- 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 +29 -5
- package/dist/topology.d.ts +246 -0
- package/dist/topology.js +942 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +21 -4
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- 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 +99 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- 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__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- 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/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +41 -3
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +129 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- 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/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
- 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 +492 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +278 -30
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/CLAUDE.md +1 -0
- 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]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +83 -18
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- 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 +104 -1
- package/templates/apps/web/app/openapi.json/route.ts +72 -7
- 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/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- 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 +317 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -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/closing-footer-path.ts +50 -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 +209 -3
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +33 -0
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +51 -0
- package/templates/database/__tests__/agents.db.test.ts +85 -0
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -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 +24 -0
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +111 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -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 -34
|
@@ -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
|
+
}
|
package/dist/site-chrome.d.ts
CHANGED
|
@@ -76,6 +76,8 @@ export interface TemplateLayout {
|
|
|
76
76
|
colorMode?: SiteChromeColorMode;
|
|
77
77
|
/** #7742 D1 — `minimal`: the minimal closing slide, consent bar and themed 404. */
|
|
78
78
|
chromeStyle?: SiteChromeChromeStyle;
|
|
79
|
+
/** #7797 D1 — route paths whose page ends on the closing footer (wordmark, contact lines, legal line). */
|
|
80
|
+
closingFooterPaths?: readonly string[];
|
|
79
81
|
}
|
|
80
82
|
/** `TemplateLayout` with every field resolved — what `site-chrome.ts` exports. */
|
|
81
83
|
export interface ResolvedSiteChrome {
|
|
@@ -93,6 +95,8 @@ export interface ResolvedSiteChrome {
|
|
|
93
95
|
colorMode?: "light";
|
|
94
96
|
/** Present only when it resolves to "minimal" — never "default" (#7742 D1). */
|
|
95
97
|
chromeStyle?: "minimal";
|
|
98
|
+
/** Present only when a non-empty list resolves — never `[]` (#7797 D1). */
|
|
99
|
+
closingFooterPaths?: readonly string[];
|
|
96
100
|
}
|
|
97
101
|
/** Defaults (#5612 D1; banner off since #6469). */
|
|
98
102
|
export declare const DEFAULT_SITE_CHROME: ResolvedSiteChrome;
|
package/dist/site-chrome.js
CHANGED
|
@@ -66,6 +66,14 @@ const HOME_VALUES = ["default", "deck"];
|
|
|
66
66
|
const INNER_PAGES_VALUES = ["default", "bare"];
|
|
67
67
|
const COLOR_MODE_VALUES = ["user", "light"];
|
|
68
68
|
const CHROME_STYLE_VALUES = ["default", "minimal"];
|
|
69
|
+
/**
|
|
70
|
+
* A `closingFooterPaths` member (#7797): a route path as the site writes it —
|
|
71
|
+
* a leading "/" and no query, fragment, whitespace or control character. The
|
|
72
|
+
* layout normalizes the visitor's pathname (locale prefix, trailing slash)
|
|
73
|
+
* before comparing, so "/about" and "/about/" name the same page.
|
|
74
|
+
*/
|
|
75
|
+
// eslint-disable-next-line no-control-regex -- the class deliberately refuses control characters
|
|
76
|
+
const CLOSING_FOOTER_PATH = /^\/[^\s?#\u0000-\u001f]*$/;
|
|
69
77
|
/**
|
|
70
78
|
* Validate an UNTRUSTED `layout` value (a bundle's `ailk-template.json`
|
|
71
79
|
* `layout` field — arbitrary JSON, never compile-time checked the way the
|
|
@@ -144,6 +152,16 @@ export function validateTemplateLayout(value, subject) {
|
|
|
144
152
|
}
|
|
145
153
|
result.chromeStyle = raw.chromeStyle;
|
|
146
154
|
}
|
|
155
|
+
if ("closingFooterPaths" in raw && raw.closingFooterPaths !== undefined) {
|
|
156
|
+
const paths = raw.closingFooterPaths;
|
|
157
|
+
const badIndex = Array.isArray(paths)
|
|
158
|
+
? paths.findIndex((p) => typeof p !== "string" || !CLOSING_FOOTER_PATH.test(p))
|
|
159
|
+
: -1;
|
|
160
|
+
if (!Array.isArray(paths) || paths.length === 0 || badIndex !== -1) {
|
|
161
|
+
throw new TemplateLayoutError(`${subject}: layout.closingFooterPaths must be a non-empty list of route paths ("/about") with no query or fragment — got ${JSON.stringify(badIndex !== -1 ? paths[badIndex] : paths)}`);
|
|
162
|
+
}
|
|
163
|
+
result.closingFooterPaths = paths;
|
|
164
|
+
}
|
|
147
165
|
return result;
|
|
148
166
|
}
|
|
149
167
|
/**
|
|
@@ -195,6 +213,12 @@ export function resolveSiteChrome(layout, designLayout) {
|
|
|
195
213
|
if ((layout?.chromeStyle ?? fromDesign.chromeStyle) === "minimal") {
|
|
196
214
|
resolved.chromeStyle = "minimal";
|
|
197
215
|
}
|
|
216
|
+
// #7797 D1 — the list resolves as a whole (an explicit layout wins over the
|
|
217
|
+
// design's), and only a non-empty list is ever a key.
|
|
218
|
+
const closingFooterPaths = layout?.closingFooterPaths ?? fromDesign.closingFooterPaths;
|
|
219
|
+
if (closingFooterPaths !== undefined && closingFooterPaths.length > 0) {
|
|
220
|
+
resolved.closingFooterPaths = closingFooterPaths;
|
|
221
|
+
}
|
|
198
222
|
return resolved;
|
|
199
223
|
}
|
|
200
224
|
/**
|
|
@@ -225,6 +249,10 @@ export function renderSiteChrome(resolved) {
|
|
|
225
249
|
// only for the opt-in values.
|
|
226
250
|
const colorModeValue = resolved.colorMode === "light" ? '\n colorMode: "light",' : "";
|
|
227
251
|
const chromeStyleValue = resolved.chromeStyle === "minimal" ? '\n chromeStyle: "minimal",' : "";
|
|
252
|
+
// #7797 D1 — the same form: member and bullet always, the value line only for a non-empty list.
|
|
253
|
+
const closingFooterPathsValue = resolved.closingFooterPaths === undefined
|
|
254
|
+
? ""
|
|
255
|
+
: `\n closingFooterPaths: [${resolved.closingFooterPaths.map((p) => JSON.stringify(p)).join(", ")}],`;
|
|
228
256
|
return `// site-chrome.ts — resolved template layout knobs.
|
|
229
257
|
//
|
|
230
258
|
// GENERATED by create-ailk from the declared template's \`layout\` block
|
|
@@ -247,7 +275,9 @@ export function renderSiteChrome(resolved) {
|
|
|
247
275
|
// switch; dark appears only as a deck ground.
|
|
248
276
|
// - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
|
|
249
277
|
// contact lines and one legal line; the consent bar is a hairline bar on
|
|
250
|
-
// the page ground; the 404 renders in the site's theme
|
|
278
|
+
// the page ground; the 404 renders in the site's theme.
|
|
279
|
+
// - closingFooterPaths → each listed inner page ends on the closing footer
|
|
280
|
+
// (the wordmark, contact lines and legal line) instead of its usual footer.${groundsComment}
|
|
251
281
|
|
|
252
282
|
export interface SiteChrome {
|
|
253
283
|
nav: "full" | "mark-only";
|
|
@@ -257,14 +287,15 @@ export interface SiteChrome {
|
|
|
257
287
|
innerPages?: "bare";
|
|
258
288
|
deckPresentation?: true;
|
|
259
289
|
colorMode?: "light";
|
|
260
|
-
chromeStyle?: "minimal"
|
|
290
|
+
chromeStyle?: "minimal";
|
|
291
|
+
closingFooterPaths?: readonly string[];${groundsMember}
|
|
261
292
|
}
|
|
262
293
|
|
|
263
294
|
export const siteChrome: SiteChrome = {
|
|
264
295
|
nav: ${JSON.stringify(resolved.nav)},
|
|
265
296
|
footer: ${JSON.stringify(resolved.footer)},
|
|
266
297
|
announcementBanner: ${JSON.stringify(resolved.announcementBanner)},
|
|
267
|
-
home: ${JSON.stringify(resolved.home)},${groundsValue}${innerPagesValue}${presentationValue}${colorModeValue}${chromeStyleValue}
|
|
298
|
+
home: ${JSON.stringify(resolved.home)},${groundsValue}${innerPagesValue}${presentationValue}${colorModeValue}${chromeStyleValue}${closingFooterPathsValue}
|
|
268
299
|
};
|
|
269
300
|
`;
|
|
270
301
|
}
|
package/dist/site-manifest.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Level } from "./lib/module-tier.js";
|
|
|
2
2
|
import type { ModuleName } from "./parse-args.js";
|
|
3
3
|
import { type SiteRoster } from "./site-roster.js";
|
|
4
4
|
import type { MarkerMap } from "./substitute.js";
|
|
5
|
+
import type { Topology } from "./topology.js";
|
|
5
6
|
export interface PlanSiteManifestOptions {
|
|
6
7
|
targetDir: string;
|
|
7
8
|
/** The installed manifest, read after every layer landed. */
|
|
@@ -10,16 +11,65 @@ export interface PlanSiteManifestOptions {
|
|
|
10
11
|
modules: readonly ModuleName[];
|
|
11
12
|
/** The level the key path derived, or undefined when it derived none. */
|
|
12
13
|
level: Level | undefined;
|
|
14
|
+
/** The scaffold's topology (#7426 D10). */
|
|
15
|
+
topology: Topology;
|
|
16
|
+
/**
|
|
17
|
+
* `"hub"` on a spoke that signs in through its hub (#7894 D1); absent
|
|
18
|
+
* otherwise, so every other manifest is byte-identical to before.
|
|
19
|
+
*/
|
|
20
|
+
signIn?: "hub";
|
|
21
|
+
/** The create-ailk version that wrote the scaffold (`CREATE_AILK_VERSION`). */
|
|
22
|
+
patternVersion: string;
|
|
13
23
|
siteTemplatesDir: string;
|
|
14
24
|
templatesDir: string;
|
|
15
25
|
markers: MarkerMap;
|
|
16
26
|
}
|
|
17
27
|
/**
|
|
18
|
-
* The manifest's next text: `modules` (and `level`, when derived)
|
|
19
|
-
* `requires`, and a row for every
|
|
28
|
+
* The manifest's next text: `modules` (and `level`, when derived), then
|
|
29
|
+
* `topology` and `patternVersion`, after `requires`, and a row for every
|
|
30
|
+
* route present on disk.
|
|
20
31
|
*
|
|
21
|
-
* @throws AddModuleRefusedError when the file already records `modules
|
|
22
|
-
* `level`, or its layout is not the
|
|
23
|
-
* at the top level) — guessing would
|
|
32
|
+
* @throws AddModuleRefusedError when the file already records `modules`,
|
|
33
|
+
* `level`, `topology` or `patternVersion`, or its layout is not the
|
|
34
|
+
* generator's (`rows` last, `requires` at the top level) — guessing would
|
|
35
|
+
* silently mis-record the site.
|
|
24
36
|
*/
|
|
25
37
|
export declare function planSiteManifest(opts: PlanSiteManifestOptions): string;
|
|
38
|
+
/** The two fields `create-ailk adopt` records (#7875). */
|
|
39
|
+
export interface TopologyRecord {
|
|
40
|
+
topology: Topology;
|
|
41
|
+
patternVersion: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `text` with `topology` and `patternVersion` inserted directly after the
|
|
45
|
+
* last of `requires`, `modules`, `level` the file records — the position
|
|
46
|
+
* `planSiteManifest` gives them (#7875 D3). Every other byte is the input's.
|
|
47
|
+
*
|
|
48
|
+
* @throws AddModuleRefusedError when the file already records either field,
|
|
49
|
+
* records none of the anchor keys, records one more than once at the top
|
|
50
|
+
* level, or the result does not read back the two values exactly.
|
|
51
|
+
*/
|
|
52
|
+
export declare function planTopologyRecord(text: string, record: TopologyRecord, label: string): string;
|
|
53
|
+
export interface PlanAdoptedSiteManifestOptions extends TopologyRecord {
|
|
54
|
+
targetDir: string;
|
|
55
|
+
/** The template the site was built from — a `SHAPE_STARTERS` or `resolveTemplate` name. */
|
|
56
|
+
template: string;
|
|
57
|
+
/** The site's module set. */
|
|
58
|
+
modules: readonly ModuleName[];
|
|
59
|
+
siteTemplatesDir: string;
|
|
60
|
+
templatesDir: string;
|
|
61
|
+
markers: MarkerMap;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The manifest for a site that has none (#7875 D4): a seed naming the
|
|
65
|
+
* template, its `requires` and no rows, parsed the way `readSiteRoster`
|
|
66
|
+
* reads a manifest, then `planSiteManifest` over it — so module rows, sample
|
|
67
|
+
* rows and a code-route row per page route on disk arrive exactly as a
|
|
68
|
+
* scaffold's would. No `level` (it comes only from a licence key), and none
|
|
69
|
+
* of the template's `shape`, `design`, `categories`, `layout` or `theme`:
|
|
70
|
+
* they would claim a look the site may not wear.
|
|
71
|
+
*
|
|
72
|
+
* @throws AddModuleRefusedError when the template is undeclared, or as
|
|
73
|
+
* `planSiteManifest` does.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planAdoptedSiteManifest(opts: PlanAdoptedSiteManifestOptions): string;
|