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
package/dist/parse-args.js
CHANGED
|
@@ -59,6 +59,14 @@ const SHAPE_DESCRIPTIONS = {
|
|
|
59
59
|
"person-service": "a person selling their own service",
|
|
60
60
|
};
|
|
61
61
|
const SHAPE_LIST = RECOGNIZED_SHAPES.join(", ");
|
|
62
|
+
/**
|
|
63
|
+
* The `--topology` vocabulary (#7426 D1) — orthogonal to `--shape`: any shape
|
|
64
|
+
* can be scaffolded as either. Declared here rather than imported from
|
|
65
|
+
* `topology.ts` for the reason `RECOGNIZED_MODULES` is: that module reads
|
|
66
|
+
* `MODULE_SLICES`, and importing it here closes a runtime cycle through
|
|
67
|
+
* `lib/module-tier.ts`. `spoke-topology.test.ts` pins the two lists equal.
|
|
68
|
+
*/
|
|
69
|
+
export const RECOGNIZED_TOPOLOGIES = ["hub", "spoke"];
|
|
62
70
|
export class ParseError extends Error {
|
|
63
71
|
constructor(message) {
|
|
64
72
|
super(message);
|
|
@@ -158,6 +166,24 @@ Options:
|
|
|
158
166
|
--license <key> Your licence key: fetch paid modules with it and
|
|
159
167
|
write it into the scaffold (apps/api/.env and a
|
|
160
168
|
gitignored ailk.key, both owner-only).
|
|
169
|
+
--topology <hub|spoke> hub (the default) scaffolds the whole site: web,
|
|
170
|
+
API, MCP and database. spoke scaffolds a web-only
|
|
171
|
+
site that sends its forms, flows and listings to a
|
|
172
|
+
hub — no apps/api, no database, no sign-in, and
|
|
173
|
+
apps/mcp kept as a stateless front. Composes with
|
|
174
|
+
any --shape or --template. A spoke carries
|
|
175
|
+
marketing and docs-blog.
|
|
176
|
+
--hub-url <origin> A spoke's hub, e.g. https://hub.example.com (https:,
|
|
177
|
+
or http: on localhost). Defaults to AILK_API_URL;
|
|
178
|
+
a spoke with neither is refused. Written to
|
|
179
|
+
apps/web/.env.local — the spoke key never is.
|
|
180
|
+
--without-mcp A spoke without apps/mcp.
|
|
181
|
+
--hub-sign-in <origin> A spoke whose /account signs visitors in through
|
|
182
|
+
its hub's own sign-in page (the hub's web origin,
|
|
183
|
+
https: or http: on localhost). No database: the
|
|
184
|
+
session is a sealed cookie. Writes the origin and
|
|
185
|
+
a session secret to apps/web/.env.local — never the
|
|
186
|
+
client secret, which the hub issues.
|
|
161
187
|
--install After scaffolding, run \`pnpm install\` in the
|
|
162
188
|
target directory. Off by default.
|
|
163
189
|
--help, -h Print this message.
|
|
@@ -194,6 +220,10 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
194
220
|
shape: { type: "string" },
|
|
195
221
|
design: { type: "string" },
|
|
196
222
|
license: { type: "string" },
|
|
223
|
+
topology: { type: "string" },
|
|
224
|
+
"hub-url": { type: "string" },
|
|
225
|
+
"without-mcp": { type: "boolean", default: false },
|
|
226
|
+
"hub-sign-in": { type: "string" },
|
|
197
227
|
surfaces: { type: "string" },
|
|
198
228
|
"sync-architecture": { type: "string" },
|
|
199
229
|
"sync-catalog": { type: "boolean", default: false },
|
|
@@ -224,6 +254,11 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
224
254
|
const syncClaudeMd = parsed.values["sync-claude-md"] === true;
|
|
225
255
|
const withCatalog = parsed.values["with-catalog"] === true;
|
|
226
256
|
const license = parseLicense(parsed.values.license);
|
|
257
|
+
const rawTopology = parsed.values.topology;
|
|
258
|
+
const topology = parseTopology(rawTopology);
|
|
259
|
+
const hubUrl = parsed.values["hub-url"];
|
|
260
|
+
const withoutMcp = parsed.values["without-mcp"] === true;
|
|
261
|
+
const hubSignIn = parsed.values["hub-sign-in"];
|
|
227
262
|
// The sync modes each exit after rewriting their one file, so asking for
|
|
228
263
|
// more than one in one invocation has no coherent meaning.
|
|
229
264
|
const syncFlags = [
|
|
@@ -234,6 +269,22 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
234
269
|
if (syncFlags.length > 1) {
|
|
235
270
|
throw new ParseError(`${syncFlags.join(" and ")} cannot be combined; run them one at a time`);
|
|
236
271
|
}
|
|
272
|
+
// #7426 D1 — a sync reads the topology from the site's manifest; naming one
|
|
273
|
+
// here has no coherent meaning.
|
|
274
|
+
if (rawTopology !== undefined && syncFlags.length > 0) {
|
|
275
|
+
throw new ParseError(`--topology cannot be combined with ${syncFlags[0]}: a sync reads the site's topology from its .ailk-site-manifest.json`);
|
|
276
|
+
}
|
|
277
|
+
// #7426 D2/D3 — the spoke-only flags.
|
|
278
|
+
if (topology !== "spoke") {
|
|
279
|
+
const spokeOnly = [
|
|
280
|
+
hubUrl !== undefined ? "--hub-url" : undefined,
|
|
281
|
+
withoutMcp ? "--without-mcp" : undefined,
|
|
282
|
+
hubSignIn !== undefined ? "--hub-sign-in" : undefined,
|
|
283
|
+
].filter((f) => f !== undefined);
|
|
284
|
+
if (spokeOnly.length > 0) {
|
|
285
|
+
throw new ParseError(`${spokeOnly.join(" and ")} only apply${spokeOnly.length === 1 ? "s" : ""} to a spoke. Pass --topology spoke, or drop ${spokeOnly.length === 1 ? "it" : "them"}.`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
237
288
|
// Validate the sync-architecture value now, through the SAME parseModules
|
|
238
289
|
// path `--modules` uses, so an unknown module throws ParseError at parse time
|
|
239
290
|
// (cli.ts re-parses the raw value to the module list in sync mode).
|
|
@@ -255,6 +306,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
255
306
|
syncClaudeMd: false,
|
|
256
307
|
withCatalog: false,
|
|
257
308
|
syncArchitecture: undefined,
|
|
309
|
+
topology: "hub",
|
|
258
310
|
};
|
|
259
311
|
}
|
|
260
312
|
// Sync modes: the target dir is optional (positional if given, else cwd).
|
|
@@ -275,6 +327,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
275
327
|
syncClaudeMd,
|
|
276
328
|
withCatalog,
|
|
277
329
|
syncArchitecture,
|
|
330
|
+
topology: "hub",
|
|
278
331
|
};
|
|
279
332
|
}
|
|
280
333
|
if (parsed.positionals.length === 0) {
|
|
@@ -292,6 +345,13 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
292
345
|
: "--design";
|
|
293
346
|
throw new ParseError(`--template cannot be combined with ${flags}: a template already fixes its shape and design. Pass --template alone, or --shape with an optional --design.`);
|
|
294
347
|
}
|
|
348
|
+
// #7426 D2 — a spoke needs its hub's origin. Presence only, here: the value
|
|
349
|
+
// is validated and normalized by `scaffold()` (`topology.ts`
|
|
350
|
+
// `normalizeHubUrl`), still before the target directory exists.
|
|
351
|
+
if (topology === "spoke" &&
|
|
352
|
+
(hubUrl ?? process.env["AILK_API_URL"] ?? "").trim() === "") {
|
|
353
|
+
throw new ParseError("a spoke needs its hub's origin, and none was given. Pass --hub-url <origin>, or set AILK_API_URL (e.g. https://hub.example.com).");
|
|
354
|
+
}
|
|
295
355
|
// R1 — no silent default: with neither a template nor a shape, ask on a
|
|
296
356
|
// terminal and refuse without one.
|
|
297
357
|
const needsShapePrompt = template === undefined && shape === undefined;
|
|
@@ -314,8 +374,26 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
314
374
|
withCatalog,
|
|
315
375
|
syncArchitecture: undefined,
|
|
316
376
|
...(license !== undefined ? { license } : {}),
|
|
377
|
+
topology,
|
|
378
|
+
...(topology === "spoke"
|
|
379
|
+
? {
|
|
380
|
+
...(hubUrl !== undefined ? { hubUrl } : {}),
|
|
381
|
+
mcp: !withoutMcp,
|
|
382
|
+
...(hubSignIn !== undefined ? { hubSignIn } : {}),
|
|
383
|
+
}
|
|
384
|
+
: {}),
|
|
317
385
|
};
|
|
318
386
|
}
|
|
387
|
+
/** Validate `--topology` (#7426 D1): `hub` when absent, trimmed and lowercased. */
|
|
388
|
+
function parseTopology(raw) {
|
|
389
|
+
if (raw === undefined)
|
|
390
|
+
return "hub";
|
|
391
|
+
const name = raw.trim().toLowerCase();
|
|
392
|
+
if (!RECOGNIZED_TOPOLOGIES.includes(name)) {
|
|
393
|
+
throw new ParseError(`unknown topology "${raw}". Recognized: ${RECOGNIZED_TOPOLOGIES.join(", ")}`);
|
|
394
|
+
}
|
|
395
|
+
return name;
|
|
396
|
+
}
|
|
319
397
|
/**
|
|
320
398
|
* Validate `--shape` (#6754 D2 step 2): trimmed and lowercased. A reserved
|
|
321
399
|
* shape is refusal R2; any other unrecognized value names the recognized set.
|
|
@@ -467,6 +545,17 @@ export function toScaffoldOptions(opts) {
|
|
|
467
545
|
...(opts.shape !== undefined ? { shape: opts.shape } : {}),
|
|
468
546
|
...(opts.design !== undefined ? { design: opts.design } : {}),
|
|
469
547
|
...(opts.license !== undefined ? { license: opts.license } : {}),
|
|
548
|
+
// #7426 — a hub run hands `scaffold()` the object it did before.
|
|
549
|
+
...(opts.topology === "spoke"
|
|
550
|
+
? {
|
|
551
|
+
topology: "spoke",
|
|
552
|
+
...(opts.hubUrl !== undefined ? { hubUrl: opts.hubUrl } : {}),
|
|
553
|
+
mcp: opts.mcp !== false,
|
|
554
|
+
...(opts.hubSignIn !== undefined
|
|
555
|
+
? { hubSignInUrl: opts.hubSignIn }
|
|
556
|
+
: {}),
|
|
557
|
+
}
|
|
558
|
+
: {}),
|
|
470
559
|
};
|
|
471
560
|
}
|
|
472
561
|
/**
|
package/dist/programmatic.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { type ModuleName } from "./parse-args.js";
|
|
|
22
22
|
import { type TemplateLayout } from "./site-chrome.js";
|
|
23
23
|
import { type SyncRoutesResult } from "./sync-routes.js";
|
|
24
24
|
import { type StarterShape } from "./template-manifest.js";
|
|
25
|
+
import { type Topology } from "./topology.js";
|
|
25
26
|
export interface ScaffoldOptions {
|
|
26
27
|
/** Absolute or relative path to the target directory. Must not exist or be empty. */
|
|
27
28
|
targetDir: string;
|
|
@@ -136,6 +137,24 @@ export interface ScaffoldOptions {
|
|
|
136
137
|
* code registry that has since been deleted (#6638's per-name table).
|
|
137
138
|
*/
|
|
138
139
|
designLayout?: TemplateLayout;
|
|
140
|
+
/**
|
|
141
|
+
* The scaffold's topology (`--topology`, #7426 D1). `"hub"` — the default
|
|
142
|
+
* — is the whole site. `"spoke"` is the same scaffold with nodes removed
|
|
143
|
+
* (`topology.ts`): no `apps/api`, no `database/`, no sign-in, `apps/mcp`
|
|
144
|
+
* a stateless front unless `mcp: false`. A spoke needs a `template` or a
|
|
145
|
+
* `shape` (the manifest records the topology) and a hub origin.
|
|
146
|
+
*/
|
|
147
|
+
topology?: Topology;
|
|
148
|
+
/** A spoke's hub origin (`--hub-url`), else `AILK_API_URL` (#7426 D2). */
|
|
149
|
+
hubUrl?: string;
|
|
150
|
+
/** Spoke only: `false` drops `apps/mcp` (`--without-mcp`, #7426 D3). Default true. */
|
|
151
|
+
mcp?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Spoke only: the hub's web origin this spoke signs visitors in at
|
|
154
|
+
* (`--hub-sign-in`, #7894 D1). Set, the spoke is scaffolded in the hub
|
|
155
|
+
* sign-in mode (`SpokeShapeOptions.signIn: "hub"`).
|
|
156
|
+
*/
|
|
157
|
+
hubSignInUrl?: string;
|
|
139
158
|
}
|
|
140
159
|
export interface ScaffoldResult {
|
|
141
160
|
/** Resolved absolute path to the scaffolded directory. */
|
|
@@ -161,6 +180,15 @@ export interface ScaffoldResult {
|
|
|
161
180
|
* a keyed run with no `--modules` carries the key's derived level here.
|
|
162
181
|
*/
|
|
163
182
|
modules: ModuleName[];
|
|
183
|
+
/**
|
|
184
|
+
* A spoke only (#7426 D7): modules the key's default set carried that a
|
|
185
|
+
* spoke does not admit, dropped from this scaffold. Absent when none were.
|
|
186
|
+
*/
|
|
187
|
+
droppedModules?: ModuleName[];
|
|
188
|
+
/** A spoke only (#7426 D2): the normalized hub origin written to apps/web/.env.local. */
|
|
189
|
+
hubOrigin?: string;
|
|
190
|
+
/** A hub sign-in spoke only (#7894 D11): the hub web origin written to apps/web/.env.local. */
|
|
191
|
+
hubSignInOrigin?: string;
|
|
164
192
|
}
|
|
165
193
|
/**
|
|
166
194
|
* Scaffold an AI Launch Kit starter project into targetDir (synchronous).
|
package/dist/programmatic.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
19
19
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { AddModuleRefusedError, applyModuleToScaffold, } from "./add-module.js";
|
|
21
|
+
import { AddModuleRefusedError, addModuleSiteTopology, applyModuleToScaffold, } from "./add-module.js";
|
|
22
22
|
import { planDesignMd } from "./apply-design-md.js";
|
|
23
23
|
import { renderClaudeMd, syncClaudeMd, } from "./claude-md.js";
|
|
24
24
|
import { CATALOG_TARGET_PATH, writeComponentCatalog, } from "./component-catalog.js";
|
|
@@ -29,21 +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";
|
|
51
|
+
import { SPOKE_ONLY_FILES, TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubSignInUrl, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeDestination, spokeModuleRefusal, spokeModules, writeHubOriginEnv, writeHubSignInEnv, } from "./topology.js";
|
|
47
52
|
import { validateTarget } from "./validate.js";
|
|
48
53
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
49
54
|
const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
|
|
@@ -151,14 +156,31 @@ export function scaffoldSync(opts) {
|
|
|
151
156
|
// The declared route surface (#4154). Resolved BEFORE the copy: it decides
|
|
152
157
|
// both which route directories are skipped and what architecture.yaml records.
|
|
153
158
|
const enabledSurfaces = resolveSurfaces(opts.modules ?? ["marketing"], opts.surfaces ?? []);
|
|
159
|
+
// #7426 D4 — a spoke is this same copy with the spoke's exclusions added:
|
|
160
|
+
// one registry over the one template tree, never a second tree.
|
|
161
|
+
const spoke = opts.topology === "spoke"
|
|
162
|
+
? {
|
|
163
|
+
mcp: opts.mcp !== false,
|
|
164
|
+
...(opts.hubSignInUrl !== undefined ? { signIn: "hub" } : {}),
|
|
165
|
+
}
|
|
166
|
+
: undefined;
|
|
154
167
|
const versions = readVersionMap();
|
|
155
168
|
copyTemplate({
|
|
156
169
|
templatesDir: TEMPLATES_DIR,
|
|
157
170
|
targetDir: targetAbs,
|
|
158
171
|
versions,
|
|
159
|
-
excludePaths:
|
|
172
|
+
excludePaths: [
|
|
173
|
+
...excludedRoutePaths(enabledSurfaces),
|
|
174
|
+
// #7874 D11 — the declared-env check is a spoke's alone.
|
|
175
|
+
...(spoke !== undefined
|
|
176
|
+
? spokeExclusions(TEMPLATES_DIR, spoke)
|
|
177
|
+
: SPOKE_ONLY_FILES),
|
|
178
|
+
],
|
|
160
179
|
markers: buildScaffoldMarkers(projectName),
|
|
161
180
|
});
|
|
181
|
+
// #7426 D5/D6 — the root files a spoke rewrites, and the spoke seams.
|
|
182
|
+
if (spoke !== undefined)
|
|
183
|
+
applySpokeTransforms(targetAbs, spoke);
|
|
162
184
|
// Stamp the copied CLAUDE.md's template regions with their hashes (#6904
|
|
163
185
|
// D2). No later layer writes a root CLAUDE.md, so this is the final file.
|
|
164
186
|
writeFileSync(join(targetAbs, "CLAUDE.md"), renderClaudeMd(readFileSync(join(TEMPLATES_DIR, "CLAUDE.md"), "utf8"), projectName));
|
|
@@ -167,7 +189,7 @@ export function scaffoldSync(opts) {
|
|
|
167
189
|
// closes the dangling `architecture_rules: architecture.yaml` reference in
|
|
168
190
|
// the scaffolded project.yaml. Written AFTER the template copy, at the target
|
|
169
191
|
// root — a deterministic function of the selected module set.
|
|
170
|
-
writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces));
|
|
192
|
+
writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
|
|
171
193
|
// Regenerate the site nav from the same surface set (#4154). The nav is a
|
|
172
194
|
// static list the layout renders into the header AND the footer, so leaving
|
|
173
195
|
// the template's copy in place would link /docs and /blog sitewide in a
|
|
@@ -187,6 +209,11 @@ export function scaffoldSync(opts) {
|
|
|
187
209
|
// scaffold with `withCatalog` unset touches exactly the files it did before.
|
|
188
210
|
if (opts.withCatalog === true)
|
|
189
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
|
+
}
|
|
190
217
|
const filesCreated = countFiles(targetAbs);
|
|
191
218
|
return {
|
|
192
219
|
targetDir: targetAbs,
|
|
@@ -252,6 +279,9 @@ export async function scaffold(opts) {
|
|
|
252
279
|
? resolveShapeTemplate(opts.shape)
|
|
253
280
|
: undefined;
|
|
254
281
|
assertReleased("modules", [...(opts.modules ?? []), ...(template?.requires ?? [])]);
|
|
282
|
+
// #7426 D1-D3/D7 — a spoke's refusals, before anything is fetched or
|
|
283
|
+
// written: the target directory never exists for a refused spoke.
|
|
284
|
+
const spoke = resolveSpoke(opts, template);
|
|
255
285
|
// #6813 D8 step 3 — the design this scaffold renders, resolved from its
|
|
256
286
|
// package ONCE, before any fetch or write: the caller's override, else the
|
|
257
287
|
// template's own. Unset for a scaffold naming neither. Replaces the
|
|
@@ -308,6 +338,8 @@ export async function scaffold(opts) {
|
|
|
308
338
|
// list.
|
|
309
339
|
let modules;
|
|
310
340
|
let bundles;
|
|
341
|
+
// #7426 D7 — the key's default modules a spoke does not admit.
|
|
342
|
+
let dropped = [];
|
|
311
343
|
// The run's derived level (#6759), for `assertDesignAllowed` below.
|
|
312
344
|
// Undefined on the alias branch and on a `--modules`-keep run (D2/D4 in
|
|
313
345
|
// module-license-gate.ts — the level plays no part in composing either),
|
|
@@ -360,6 +392,19 @@ export async function scaffold(opts) {
|
|
|
360
392
|
});
|
|
361
393
|
bundles = chosen.bundles;
|
|
362
394
|
level = chosen.level;
|
|
395
|
+
// #7426 D7 — a key-derived default set keeps only what a spoke admits;
|
|
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))) {
|
|
402
|
+
const admitted = new Set(spokeModules());
|
|
403
|
+
dropped = chosen.modules.filter((m) => !admitted.has(m));
|
|
404
|
+
chosen.modules = chosen.modules.filter((m) => admitted.has(m));
|
|
405
|
+
for (const name of dropped)
|
|
406
|
+
bundles.delete(name);
|
|
407
|
+
}
|
|
363
408
|
// #7010 — a `--modules`-keep run composes no level (NFR-2), so a chosen
|
|
364
409
|
// non-Free design derives one from the key here, as the addition branch
|
|
365
410
|
// does; a plain keep run makes no extra network call.
|
|
@@ -453,6 +498,7 @@ export async function scaffold(opts) {
|
|
|
453
498
|
const result = scaffoldSync({
|
|
454
499
|
...opts,
|
|
455
500
|
modules,
|
|
501
|
+
...(spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}),
|
|
456
502
|
...(surfaces !== undefined ? { surfaces } : {}),
|
|
457
503
|
// #5612 D1/D2 — the code registry's own `layout` block, known
|
|
458
504
|
// synchronously (unlike a bundle's `ailk-template.json`, only readable
|
|
@@ -476,7 +522,8 @@ export async function scaffold(opts) {
|
|
|
476
522
|
// it a `{{MARKER}}` authored in a base-layer or site-template file would
|
|
477
523
|
// survive into the scaffold verbatim — the state before this card, and the
|
|
478
524
|
// reason a template file could not carry the version stamp.
|
|
479
|
-
const
|
|
525
|
+
const projectName = opts.projectName ?? basename(result.targetDir);
|
|
526
|
+
const markers = buildScaffoldMarkers(projectName);
|
|
480
527
|
// #5618 D4 — the file-grain ownership registry for THIS scaffold: which
|
|
481
528
|
// layer wrote which destination. Call-scoped (a fresh Map per scaffold(),
|
|
482
529
|
// never module-level), threaded through every layer install below in the
|
|
@@ -486,6 +533,16 @@ export async function scaffold(opts) {
|
|
|
486
533
|
// anything. Starts empty at the base-layer boundary: the `templates/`
|
|
487
534
|
// snapshot (layer 0) is deliberately outside it.
|
|
488
535
|
const ownership = new Map();
|
|
536
|
+
// #7426 D4 — every layer below lands through the spoke filter: a module
|
|
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.
|
|
539
|
+
const exclusions = spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : [];
|
|
540
|
+
const spokeSkip = spoke !== undefined
|
|
541
|
+
? {
|
|
542
|
+
skip: (destination) => isSpokeExcluded(destination, { ...spoke, exclusions }),
|
|
543
|
+
remapDestination: spokeDestination,
|
|
544
|
+
}
|
|
545
|
+
: {};
|
|
489
546
|
// The BASE LAYER lands first (#5618 D1) — the seed files every template
|
|
490
547
|
// reaches by `extends` (today the three module-less base pages), read off
|
|
491
548
|
// the `site-templates/_base/` tree this package ships, through the same
|
|
@@ -505,6 +562,7 @@ export async function scaffold(opts) {
|
|
|
505
562
|
versions,
|
|
506
563
|
markers,
|
|
507
564
|
ownership,
|
|
565
|
+
...spokeSkip,
|
|
508
566
|
});
|
|
509
567
|
}
|
|
510
568
|
for (const [moduleName, bytes] of bundles) {
|
|
@@ -515,6 +573,7 @@ export async function scaffold(opts) {
|
|
|
515
573
|
versions,
|
|
516
574
|
markers,
|
|
517
575
|
ownership,
|
|
576
|
+
...spokeSkip,
|
|
518
577
|
});
|
|
519
578
|
}
|
|
520
579
|
// The template lands LAST — after `scaffoldSync` and after every module
|
|
@@ -550,6 +609,7 @@ export async function scaffold(opts) {
|
|
|
550
609
|
versions,
|
|
551
610
|
markers,
|
|
552
611
|
ownership,
|
|
612
|
+
...spokeSkip,
|
|
553
613
|
})
|
|
554
614
|
: (() => {
|
|
555
615
|
const sourceRoot = join(SITE_TEMPLATES_DIR, templateTreeDir(template.name));
|
|
@@ -565,6 +625,7 @@ export async function scaffold(opts) {
|
|
|
565
625
|
versions,
|
|
566
626
|
markers,
|
|
567
627
|
ownership,
|
|
628
|
+
...spokeSkip,
|
|
568
629
|
});
|
|
569
630
|
})();
|
|
570
631
|
installedLayout = installed.layout;
|
|
@@ -682,6 +743,17 @@ export async function scaffold(opts) {
|
|
|
682
743
|
writeFileSync(rosterPath, nextManifest);
|
|
683
744
|
}
|
|
684
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
|
+
}
|
|
685
757
|
// #6944 — the manifest records what this scaffold IS, once every layer has
|
|
686
758
|
// landed: the effective module set, a row for every route on disk, and the
|
|
687
759
|
// level only when the key path above derived one — `resolveModuleSet`, or
|
|
@@ -696,6 +768,9 @@ export async function scaffold(opts) {
|
|
|
696
768
|
roster,
|
|
697
769
|
modules: result.modules,
|
|
698
770
|
level,
|
|
771
|
+
topology: spoke !== undefined ? "spoke" : "hub",
|
|
772
|
+
...(spoke?.signIn === "hub" ? { signIn: "hub" } : {}),
|
|
773
|
+
patternVersion: markers["CREATE_AILK_VERSION"] ?? "",
|
|
699
774
|
siteTemplatesDir: SITE_TEMPLATES_DIR,
|
|
700
775
|
templatesDir: TEMPLATES_DIR,
|
|
701
776
|
markers,
|
|
@@ -705,22 +780,105 @@ export async function scaffold(opts) {
|
|
|
705
780
|
// #7536 D3 — the registry follows `apps/web/package.json`, which a module
|
|
706
781
|
// bundle's `patches` (and the template layer) just finished editing.
|
|
707
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
|
+
}
|
|
708
792
|
// #6107 D2 — after every layer landed, so nothing later overwrites it: a
|
|
709
793
|
// served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
|
|
710
|
-
|
|
794
|
+
// A spoke has no sign-in (#7426 D4), so it gets its hub's origin instead
|
|
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.
|
|
798
|
+
if (spoke !== undefined)
|
|
799
|
+
writeHubOriginEnv(result.targetDir, spoke.hubOrigin);
|
|
800
|
+
else
|
|
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
|
+
}
|
|
711
810
|
// #6683 D9 — persisted only after every layer landed, and only when the
|
|
712
|
-
// caller passed `license`: a scaffold without it writes nothing new.
|
|
811
|
+
// caller passed `license`: a scaffold without it writes nothing new. A
|
|
812
|
+
// spoke has no apps/api, so it keeps the key in ailk.key alone (#7426 D6).
|
|
713
813
|
if (opts.license !== undefined) {
|
|
714
|
-
writeLicenseKeyFiles(result.targetDir, opts.license
|
|
814
|
+
writeLicenseKeyFiles(result.targetDir, opts.license, {
|
|
815
|
+
apiEnv: spoke === undefined,
|
|
816
|
+
});
|
|
715
817
|
}
|
|
716
818
|
// #6754 D4/D5 — the resolved starter, so the CLI names what it built.
|
|
819
|
+
const withDropped = {
|
|
820
|
+
...result,
|
|
821
|
+
...(dropped.length > 0 ? { droppedModules: dropped } : {}),
|
|
822
|
+
...(spoke !== undefined ? { hubOrigin: spoke.hubOrigin } : {}),
|
|
823
|
+
...(spoke?.hubSignInOrigin !== undefined
|
|
824
|
+
? { hubSignInOrigin: spoke.hubSignInOrigin }
|
|
825
|
+
: {}),
|
|
826
|
+
};
|
|
717
827
|
if (template !== undefined && resolvedDesign !== undefined) {
|
|
718
828
|
return {
|
|
719
|
-
...
|
|
829
|
+
...withDropped,
|
|
720
830
|
starter: { template: template.name, shape: template.shape, design: resolvedDesign.recorded },
|
|
721
831
|
};
|
|
722
832
|
}
|
|
723
|
-
return
|
|
833
|
+
return withDropped;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* A spoke's resolved inputs (#7426 D1-D3/D7), or undefined for a hub. Every
|
|
837
|
+
* refusal is a `TemplateError` thrown before anything is fetched or written.
|
|
838
|
+
*/
|
|
839
|
+
function resolveSpoke(opts, template) {
|
|
840
|
+
if (opts.topology !== "spoke") {
|
|
841
|
+
const spokeOnly = [
|
|
842
|
+
opts.hubUrl !== undefined ? "hubUrl" : undefined,
|
|
843
|
+
opts.mcp !== undefined ? "mcp" : undefined,
|
|
844
|
+
opts.hubSignInUrl !== undefined ? "hubSignInUrl" : undefined,
|
|
845
|
+
].filter((f) => f !== undefined);
|
|
846
|
+
if (spokeOnly.length > 0) {
|
|
847
|
+
throw new TemplateError(`${spokeOnly.join(" and ")} only apply to a spoke — pass topology: "spoke"`);
|
|
848
|
+
}
|
|
849
|
+
return undefined;
|
|
850
|
+
}
|
|
851
|
+
if (template === undefined) {
|
|
852
|
+
throw new TemplateError("a spoke needs a starter — pass --shape <shape> or --template <name>: the site manifest is where the topology is recorded");
|
|
853
|
+
}
|
|
854
|
+
let hubOrigin;
|
|
855
|
+
let hubSignInOrigin;
|
|
856
|
+
try {
|
|
857
|
+
hubOrigin = normalizeHubUrl(opts.hubUrl ?? process.env["AILK_API_URL"]);
|
|
858
|
+
if (opts.hubSignInUrl !== undefined) {
|
|
859
|
+
hubSignInOrigin = normalizeHubSignInUrl(opts.hubSignInUrl);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
if (err instanceof TopologyError)
|
|
864
|
+
throw new TemplateError(err.message);
|
|
865
|
+
throw err;
|
|
866
|
+
}
|
|
867
|
+
for (const name of normalizeModules([...(opts.modules ?? []), ...template.requires])) {
|
|
868
|
+
const refusal = spokeModuleRefusal(name);
|
|
869
|
+
if (refusal !== undefined) {
|
|
870
|
+
throw new TemplateError(template.requires.includes(name) && !(opts.modules ?? []).includes(name)
|
|
871
|
+
? `template "${template.name}" requires ${refusal}`
|
|
872
|
+
: refusal);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
mcp: opts.mcp !== false,
|
|
877
|
+
hubOrigin,
|
|
878
|
+
...(hubSignInOrigin !== undefined
|
|
879
|
+
? { signIn: "hub", hubSignInOrigin }
|
|
880
|
+
: {}),
|
|
881
|
+
};
|
|
724
882
|
}
|
|
725
883
|
/**
|
|
726
884
|
* The per-row install of the two SHARED seed layers (#6646 D1, disposition
|
|
@@ -842,7 +1000,12 @@ export function syncArchitectureSync(opts) {
|
|
|
842
1000
|
throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
|
|
843
1001
|
}
|
|
844
1002
|
const enabledSurfaces = resolveSurfaces(opts.modules, opts.surfaces ?? []);
|
|
845
|
-
|
|
1003
|
+
// #7426 D13 — a spoke stays a spoke: its topology is read from its
|
|
1004
|
+
// manifest, and `mcp` from whether it still carries apps/mcp.
|
|
1005
|
+
const topology = readSiteTopology(targetAbs);
|
|
1006
|
+
writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces, topology === "spoke"
|
|
1007
|
+
? { topology, mcp: existsSync(join(targetAbs, "apps", "mcp")) }
|
|
1008
|
+
: {}));
|
|
846
1009
|
return {
|
|
847
1010
|
targetDir: targetAbs,
|
|
848
1011
|
surfaces: enabledSurfaces,
|
|
@@ -960,6 +1123,13 @@ export async function addModule(opts) {
|
|
|
960
1123
|
if (!existsSync(join(targetAbs, "architecture.yaml"))) {
|
|
961
1124
|
throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
|
|
962
1125
|
}
|
|
1126
|
+
// #7426 D13 — a spoke refuses a module it does not admit before any key
|
|
1127
|
+
// read or network call (`applyModuleToScaffold` refuses it again).
|
|
1128
|
+
if (addModuleSiteTopology(targetAbs) === "spoke") {
|
|
1129
|
+
const refusal = spokeModuleRefusal(opts.moduleName);
|
|
1130
|
+
if (refusal !== undefined)
|
|
1131
|
+
throw new AddModuleRefusedError(refusal);
|
|
1132
|
+
}
|
|
963
1133
|
const { paid } = classifyModules([opts.moduleName]);
|
|
964
1134
|
if (paid.length === 0) {
|
|
965
1135
|
throw new AddModuleRefusedError(`"${opts.moduleName}" ships with every scaffold — there is no module bundle to add`);
|
|
@@ -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;
|