create-ailk 0.14.0 → 0.15.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/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +23 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- 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/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +110 -9
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/sync-routes.js +11 -2
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/apps/api/CLAUDE.md +4 -2
- 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/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -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/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 +275 -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-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- 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/CLAUDE.md +1 -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 +71 -19
- 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/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- 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 +23 -0
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -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/package.json +1 -1
- package/versions.json +34 -33
|
@@ -49,6 +49,8 @@ export interface ExtractModuleBundleOptions {
|
|
|
49
49
|
existingFilePolicy?: ExistingFilePolicy;
|
|
50
50
|
/** A caller check on the manifest's `patches` (#6653 D3). See `InstallBundleTreeOptions`. */
|
|
51
51
|
checkPatches?: (patches: readonly ModulePatch[]) => void;
|
|
52
|
+
/** Destinations this scaffold does not carry (#7426 D4). See `InstallBundleTreeOptions`. */
|
|
53
|
+
skip?: (destination: string) => boolean;
|
|
52
54
|
}
|
|
53
55
|
/**
|
|
54
56
|
* Extract a fetched module bundle into `targetDir`, merging its files onto
|
|
@@ -131,6 +133,15 @@ export interface InstallBundleTreeOptions {
|
|
|
131
133
|
* bundle's patches to the plus-config allowlist.
|
|
132
134
|
*/
|
|
133
135
|
checkPatches?: (patches: readonly ModulePatch[]) => void;
|
|
136
|
+
/**
|
|
137
|
+
* Scaffold-relative destinations this scaffold does not carry (#7426 D4) —
|
|
138
|
+
* a spoke passes `topology.ts` `isSpokeExcluded`. A skipped destination is
|
|
139
|
+
* never written: a plain file or an `installMap` destination it names is
|
|
140
|
+
* dropped, and so is a `patches` entry whose target it names. It is NOT an
|
|
141
|
+
* ownership claim, and a skipped patch target is not a missing one. Every
|
|
142
|
+
* entry is still validated as before. Absent ⟹ nothing is skipped.
|
|
143
|
+
*/
|
|
144
|
+
skip?: (destination: string) => boolean;
|
|
134
145
|
}
|
|
135
146
|
/** See `InstallBundleTreeOptions.existingFilePolicy`. */
|
|
136
147
|
export type ExistingFilePolicy = "overwrite" | "skip-existing";
|
|
@@ -385,6 +385,24 @@ function validateLayerOwnership(destinations, ownership, subject, isTemplate, pa
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
|
+
/** Every regular file under `srcRoot`, as a `/`-separated relative path. */
|
|
389
|
+
function stagedFiles(srcRoot) {
|
|
390
|
+
const out = [];
|
|
391
|
+
const stack = [srcRoot];
|
|
392
|
+
while (stack.length > 0) {
|
|
393
|
+
const dir = stack.pop();
|
|
394
|
+
if (dir === undefined)
|
|
395
|
+
break;
|
|
396
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
397
|
+
const srcPath = join(dir, entry.name);
|
|
398
|
+
if (entry.isDirectory())
|
|
399
|
+
stack.push(srcPath);
|
|
400
|
+
else if (entry.isFile())
|
|
401
|
+
out.push(relative(srcRoot, srcPath).split(sep).join("/"));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
388
406
|
/**
|
|
389
407
|
* Every scaffold-relative destination this tree will write: each unmapped
|
|
390
408
|
* staged file at its own path (exactly the set `copyTree` lands, same walk,
|
|
@@ -457,6 +475,7 @@ export async function extractModuleBundle(options) {
|
|
|
457
475
|
...(options.checkPatches !== undefined
|
|
458
476
|
? { checkPatches: options.checkPatches }
|
|
459
477
|
: {}),
|
|
478
|
+
...(options.skip !== undefined ? { skip: options.skip } : {}),
|
|
460
479
|
});
|
|
461
480
|
}
|
|
462
481
|
finally {
|
|
@@ -484,13 +503,18 @@ export function installBundleTree(options) {
|
|
|
484
503
|
const manifest = isTemplate
|
|
485
504
|
? readAilkTemplateManifest(sourceRoot, name, templateRequires, templateExtends)
|
|
486
505
|
: readAilkModuleManifest(sourceRoot);
|
|
487
|
-
const
|
|
488
|
-
validateInstallMap(
|
|
506
|
+
const declaredInstallMap = manifest?.installMap ?? {};
|
|
507
|
+
validateInstallMap(declaredInstallMap, sourceRoot, targetAbs, subject, isTemplate);
|
|
508
|
+
// #7426 D4 — a destination the scaffold does not carry is dropped here,
|
|
509
|
+
// after validation, so a spoke never receives it and never records it.
|
|
510
|
+
const skip = options.skip;
|
|
511
|
+
const isSkipped = (destination) => skip !== undefined && skip(normalize(destination).split(sep).join("/"));
|
|
512
|
+
const installMap = Object.fromEntries(Object.entries(declaredInstallMap).filter(([, destination]) => !isSkipped(destination)));
|
|
489
513
|
// patches validation (spec 5418 § 3 D6) runs at the same point, before
|
|
490
514
|
// any copy — a bad target/kind/op rejects the whole tree with nothing
|
|
491
515
|
// written to targetDir, same posture as installMap and the tar-entry
|
|
492
516
|
// validation above.
|
|
493
|
-
const
|
|
517
|
+
const declaredPatches = manifest?.patches ?? [];
|
|
494
518
|
// #5514 (Medium) — content, never code (spec 5462 D9/D16): every `patches`
|
|
495
519
|
// kind either edits a code file directly (`next-config`, `jest-config`)
|
|
496
520
|
// or a manifest a template has no legitimate reason to touch
|
|
@@ -498,10 +522,13 @@ export function installBundleTree(options) {
|
|
|
498
522
|
// content template) — no shipped template uses it, so the simplest
|
|
499
523
|
// fail-closed reading refuses it outright rather than enumerating an
|
|
500
524
|
// allowed subset that has no current user.
|
|
501
|
-
if (isTemplate &&
|
|
525
|
+
if (isTemplate && declaredPatches.length > 0) {
|
|
502
526
|
throw new Error(`${subject}: a template must not carry patches — content, never code (spec 5462 D9)`);
|
|
503
527
|
}
|
|
504
|
-
|
|
528
|
+
// Every declared patch is validated, skipped or not (#7426 D4), exactly as
|
|
529
|
+
// the installMap is above; only then are the skipped ones dropped.
|
|
530
|
+
validatePatches(declaredPatches, targetAbs, subject);
|
|
531
|
+
const patches = declaredPatches.filter((patch) => !isSkipped(patch.target));
|
|
505
532
|
options.checkPatches?.(patches);
|
|
506
533
|
// The exclusion set copyTree/the content-only guard both use — built from
|
|
507
534
|
// NORMALIZED installMap keys (#5514) so a "./x" key excludes the same
|
|
@@ -512,7 +539,10 @@ export function installBundleTree(options) {
|
|
|
512
539
|
const excluded = new Set([
|
|
513
540
|
AILK_TEMPLATE_JSON,
|
|
514
541
|
AILK_MODULE_JSON,
|
|
515
|
-
...Object.keys(
|
|
542
|
+
...Object.keys(declaredInstallMap).map((key) => normalize(key).split(sep).join("/")),
|
|
543
|
+
// A plain file lands at its own staged path, so that path IS its
|
|
544
|
+
// destination: a skipped one is excluded from the copy like a mapped source.
|
|
545
|
+
...(skip !== undefined ? stagedFiles(sourceRoot).filter(isSkipped) : []),
|
|
516
546
|
]);
|
|
517
547
|
if (isTemplate) {
|
|
518
548
|
// #6658 D7 — a template's roster of record lands as JSON, through its
|
|
@@ -20,5 +20,10 @@ export declare function appendLine(text: string, line: string): string;
|
|
|
20
20
|
* `apps/api/.env` line (0600, other lines kept), and a `.gitignore` entry for
|
|
21
21
|
* `ailk.key` when no existing line covers it. Idempotent — a second call with
|
|
22
22
|
* the same key changes no byte.
|
|
23
|
+
*
|
|
24
|
+
* `apiEnv: false` skips the `apps/api/.env` line — a spoke (#7426 D6) has no
|
|
25
|
+
* `apps/api`, so it keeps the key in `ailk.key` alone.
|
|
23
26
|
*/
|
|
24
|
-
export declare function writeLicenseKeyFiles(dir: string, key: string
|
|
27
|
+
export declare function writeLicenseKeyFiles(dir: string, key: string, opts?: {
|
|
28
|
+
apiEnv?: boolean;
|
|
29
|
+
}): void;
|
|
@@ -85,13 +85,18 @@ function upsertEnvLine(existing, key) {
|
|
|
85
85
|
* `apps/api/.env` line (0600, other lines kept), and a `.gitignore` entry for
|
|
86
86
|
* `ailk.key` when no existing line covers it. Idempotent — a second call with
|
|
87
87
|
* the same key changes no byte.
|
|
88
|
+
*
|
|
89
|
+
* `apiEnv: false` skips the `apps/api/.env` line — a spoke (#7426 D6) has no
|
|
90
|
+
* `apps/api`, so it keeps the key in `ailk.key` alone.
|
|
88
91
|
*/
|
|
89
|
-
export function writeLicenseKeyFiles(dir, key) {
|
|
92
|
+
export function writeLicenseKeyFiles(dir, key, opts = {}) {
|
|
90
93
|
assertLicenseKeyShape(key);
|
|
91
94
|
writeOwnerOnly(join(dir, LICENSE_KEY_FILE), `${key}\n`);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
+
if (opts.apiEnv !== false) {
|
|
96
|
+
const envPath = join(dir, LICENSE_ENV_FILE);
|
|
97
|
+
const env = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
98
|
+
writeOwnerOnly(envPath, upsertEnvLine(env, key));
|
|
99
|
+
}
|
|
95
100
|
const ignorePath = join(dir, ".gitignore");
|
|
96
101
|
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
97
102
|
const covered = ignore
|
|
@@ -83,6 +83,15 @@ export interface PageRosterRow {
|
|
|
83
83
|
* scaffold carries (#6940).
|
|
84
84
|
*/
|
|
85
85
|
export interface ModuleSlice {
|
|
86
|
+
/**
|
|
87
|
+
* Admits the module on a spoke (`create-ailk --topology spoke`, #7426 D7).
|
|
88
|
+
* `true` → a spoke may carry it, and its bundle installs through the spoke
|
|
89
|
+
* filter (`topology.ts` `isSpokeExcluded`), so any `apps/api` or
|
|
90
|
+
* `database` part it carries is dropped. Unset → refused on a spoke.
|
|
91
|
+
* `topology.ts` `spokeModules()` reads it; a sibling issue admits its
|
|
92
|
+
* module by setting it here, never in a second list.
|
|
93
|
+
*/
|
|
94
|
+
spoke?: true;
|
|
86
95
|
/**
|
|
87
96
|
* The paid UI package base-tree code loads through
|
|
88
97
|
* `apps/web/lib/paid-sections.generated.ts` (#7536). Present → one row in the
|
|
@@ -206,6 +206,7 @@ function payloadPaths(kind, name, destRoot) {
|
|
|
206
206
|
export const MODULE_SLICES = {
|
|
207
207
|
// ── marketing — thin presentation module (the default) ───────────────────
|
|
208
208
|
marketing: {
|
|
209
|
+
spoke: true,
|
|
209
210
|
webImports: [
|
|
210
211
|
WEB_AUTH_ADAPTER_IMPORT,
|
|
211
212
|
"packages/content-adapters",
|
|
@@ -287,6 +288,7 @@ export const MODULE_SLICES = {
|
|
|
287
288
|
},
|
|
288
289
|
// ── docs-blog — Pro module: both fumadocs surfaces (D-46) ────────────────
|
|
289
290
|
"docs-blog": {
|
|
291
|
+
spoke: true,
|
|
290
292
|
webImports: [WEB_AUTH_ADAPTER_IMPORT, "packages/fumadocs-schema"],
|
|
291
293
|
packages: {
|
|
292
294
|
"fumadocs-schema": {
|
|
@@ -1388,6 +1390,10 @@ export const MODULE_SLICES = {
|
|
|
1388
1390
|
"apps/api/src/bin/deliverable-upload.ts",
|
|
1389
1391
|
"apps/api/src/bin/session-retention-sweep.ts",
|
|
1390
1392
|
"apps/api/src/bin/seed-presets.ts",
|
|
1393
|
+
// #7844 D7 — the operator flow-copy CLI and its service: flows code by
|
|
1394
|
+
// subject, and a Free scaffold carries no flows.
|
|
1395
|
+
"apps/api/src/bin/flows-copy.ts",
|
|
1396
|
+
"apps/api/src/services/flow-copy.ts",
|
|
1391
1397
|
"apps/api/src/bin/listing-csv-sweep.ts",
|
|
1392
1398
|
"apps/api/src/bin/followup-sweep.ts",
|
|
1393
1399
|
"apps/api/src/bin/seed-waitlist-experiments.ts",
|
package/dist/parse-args.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ export declare const RESERVED_SHAPES: readonly ["local-business-product"];
|
|
|
26
26
|
/** The `--design` vocabulary (#6754 D1) — `STARTER_DESIGNS`, pinned by test. */
|
|
27
27
|
export declare const RECOGNIZED_DESIGNS: readonly ["standard", "minimalism"];
|
|
28
28
|
export type DesignName = (typeof RECOGNIZED_DESIGNS)[number];
|
|
29
|
+
/**
|
|
30
|
+
* The `--topology` vocabulary (#7426 D1) — orthogonal to `--shape`: any shape
|
|
31
|
+
* can be scaffolded as either. Declared here rather than imported from
|
|
32
|
+
* `topology.ts` for the reason `RECOGNIZED_MODULES` is: that module reads
|
|
33
|
+
* `MODULE_SLICES`, and importing it here closes a runtime cycle through
|
|
34
|
+
* `lib/module-tier.ts`. `spoke-topology.test.ts` pins the two lists equal.
|
|
35
|
+
*/
|
|
36
|
+
export declare const RECOGNIZED_TOPOLOGIES: readonly ["hub", "spoke"];
|
|
37
|
+
export type TopologyName = (typeof RECOGNIZED_TOPOLOGIES)[number];
|
|
29
38
|
export interface CliOptions {
|
|
30
39
|
/** Positional target directory (the dir to scaffold into). Optional only
|
|
31
40
|
* when `help` or `version` is set. */
|
|
@@ -94,6 +103,19 @@ export interface CliOptions {
|
|
|
94
103
|
* flag is.
|
|
95
104
|
*/
|
|
96
105
|
license?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The scaffold's topology (`--topology`, #7426 D1). `hub` — the default,
|
|
108
|
+
* and the only value before #7426 — unless `--topology spoke`.
|
|
109
|
+
*/
|
|
110
|
+
topology: TopologyName;
|
|
111
|
+
/**
|
|
112
|
+
* The raw `--hub-url` value (#7426 D2), present only on a spoke run that
|
|
113
|
+
* passed it. `scaffold()` validates and normalizes it, falling back to
|
|
114
|
+
* `AILK_API_URL`.
|
|
115
|
+
*/
|
|
116
|
+
hubUrl?: string;
|
|
117
|
+
/** Spoke only: `false` with `--without-mcp` (#7426 D3), else `true`. */
|
|
118
|
+
mcp?: boolean;
|
|
97
119
|
}
|
|
98
120
|
export declare class ParseError extends Error {
|
|
99
121
|
constructor(message: string);
|
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,18 @@ 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.
|
|
161
181
|
--install After scaffolding, run \`pnpm install\` in the
|
|
162
182
|
target directory. Off by default.
|
|
163
183
|
--help, -h Print this message.
|
|
@@ -194,6 +214,9 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
194
214
|
shape: { type: "string" },
|
|
195
215
|
design: { type: "string" },
|
|
196
216
|
license: { type: "string" },
|
|
217
|
+
topology: { type: "string" },
|
|
218
|
+
"hub-url": { type: "string" },
|
|
219
|
+
"without-mcp": { type: "boolean", default: false },
|
|
197
220
|
surfaces: { type: "string" },
|
|
198
221
|
"sync-architecture": { type: "string" },
|
|
199
222
|
"sync-catalog": { type: "boolean", default: false },
|
|
@@ -224,6 +247,10 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
224
247
|
const syncClaudeMd = parsed.values["sync-claude-md"] === true;
|
|
225
248
|
const withCatalog = parsed.values["with-catalog"] === true;
|
|
226
249
|
const license = parseLicense(parsed.values.license);
|
|
250
|
+
const rawTopology = parsed.values.topology;
|
|
251
|
+
const topology = parseTopology(rawTopology);
|
|
252
|
+
const hubUrl = parsed.values["hub-url"];
|
|
253
|
+
const withoutMcp = parsed.values["without-mcp"] === true;
|
|
227
254
|
// The sync modes each exit after rewriting their one file, so asking for
|
|
228
255
|
// more than one in one invocation has no coherent meaning.
|
|
229
256
|
const syncFlags = [
|
|
@@ -234,6 +261,21 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
234
261
|
if (syncFlags.length > 1) {
|
|
235
262
|
throw new ParseError(`${syncFlags.join(" and ")} cannot be combined; run them one at a time`);
|
|
236
263
|
}
|
|
264
|
+
// #7426 D1 — a sync reads the topology from the site's manifest; naming one
|
|
265
|
+
// here has no coherent meaning.
|
|
266
|
+
if (rawTopology !== undefined && syncFlags.length > 0) {
|
|
267
|
+
throw new ParseError(`--topology cannot be combined with ${syncFlags[0]}: a sync reads the site's topology from its .ailk-site-manifest.json`);
|
|
268
|
+
}
|
|
269
|
+
// #7426 D2/D3 — the spoke-only flags.
|
|
270
|
+
if (topology !== "spoke") {
|
|
271
|
+
const spokeOnly = [
|
|
272
|
+
hubUrl !== undefined ? "--hub-url" : undefined,
|
|
273
|
+
withoutMcp ? "--without-mcp" : undefined,
|
|
274
|
+
].filter((f) => f !== undefined);
|
|
275
|
+
if (spokeOnly.length > 0) {
|
|
276
|
+
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"}.`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
237
279
|
// Validate the sync-architecture value now, through the SAME parseModules
|
|
238
280
|
// path `--modules` uses, so an unknown module throws ParseError at parse time
|
|
239
281
|
// (cli.ts re-parses the raw value to the module list in sync mode).
|
|
@@ -255,6 +297,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
255
297
|
syncClaudeMd: false,
|
|
256
298
|
withCatalog: false,
|
|
257
299
|
syncArchitecture: undefined,
|
|
300
|
+
topology: "hub",
|
|
258
301
|
};
|
|
259
302
|
}
|
|
260
303
|
// Sync modes: the target dir is optional (positional if given, else cwd).
|
|
@@ -275,6 +318,7 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
275
318
|
syncClaudeMd,
|
|
276
319
|
withCatalog,
|
|
277
320
|
syncArchitecture,
|
|
321
|
+
topology: "hub",
|
|
278
322
|
};
|
|
279
323
|
}
|
|
280
324
|
if (parsed.positionals.length === 0) {
|
|
@@ -292,6 +336,13 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
292
336
|
: "--design";
|
|
293
337
|
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
338
|
}
|
|
339
|
+
// #7426 D2 — a spoke needs its hub's origin. Presence only, here: the value
|
|
340
|
+
// is validated and normalized by `scaffold()` (`topology.ts`
|
|
341
|
+
// `normalizeHubUrl`), still before the target directory exists.
|
|
342
|
+
if (topology === "spoke" &&
|
|
343
|
+
(hubUrl ?? process.env["AILK_API_URL"] ?? "").trim() === "") {
|
|
344
|
+
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).");
|
|
345
|
+
}
|
|
295
346
|
// R1 — no silent default: with neither a template nor a shape, ask on a
|
|
296
347
|
// terminal and refuse without one.
|
|
297
348
|
const needsShapePrompt = template === undefined && shape === undefined;
|
|
@@ -314,8 +365,22 @@ export function parseCliArgs(argv, env = { interactive: false }) {
|
|
|
314
365
|
withCatalog,
|
|
315
366
|
syncArchitecture: undefined,
|
|
316
367
|
...(license !== undefined ? { license } : {}),
|
|
368
|
+
topology,
|
|
369
|
+
...(topology === "spoke"
|
|
370
|
+
? { ...(hubUrl !== undefined ? { hubUrl } : {}), mcp: !withoutMcp }
|
|
371
|
+
: {}),
|
|
317
372
|
};
|
|
318
373
|
}
|
|
374
|
+
/** Validate `--topology` (#7426 D1): `hub` when absent, trimmed and lowercased. */
|
|
375
|
+
function parseTopology(raw) {
|
|
376
|
+
if (raw === undefined)
|
|
377
|
+
return "hub";
|
|
378
|
+
const name = raw.trim().toLowerCase();
|
|
379
|
+
if (!RECOGNIZED_TOPOLOGIES.includes(name)) {
|
|
380
|
+
throw new ParseError(`unknown topology "${raw}". Recognized: ${RECOGNIZED_TOPOLOGIES.join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
return name;
|
|
383
|
+
}
|
|
319
384
|
/**
|
|
320
385
|
* Validate `--shape` (#6754 D2 step 2): trimmed and lowercased. A reserved
|
|
321
386
|
* shape is refusal R2; any other unrecognized value names the recognized set.
|
|
@@ -467,6 +532,14 @@ export function toScaffoldOptions(opts) {
|
|
|
467
532
|
...(opts.shape !== undefined ? { shape: opts.shape } : {}),
|
|
468
533
|
...(opts.design !== undefined ? { design: opts.design } : {}),
|
|
469
534
|
...(opts.license !== undefined ? { license: opts.license } : {}),
|
|
535
|
+
// #7426 — a hub run hands `scaffold()` the object it did before.
|
|
536
|
+
...(opts.topology === "spoke"
|
|
537
|
+
? {
|
|
538
|
+
topology: "spoke",
|
|
539
|
+
...(opts.hubUrl !== undefined ? { hubUrl: opts.hubUrl } : {}),
|
|
540
|
+
mcp: opts.mcp !== false,
|
|
541
|
+
}
|
|
542
|
+
: {}),
|
|
470
543
|
};
|
|
471
544
|
}
|
|
472
545
|
/**
|
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,18 @@ 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;
|
|
139
152
|
}
|
|
140
153
|
export interface ScaffoldResult {
|
|
141
154
|
/** Resolved absolute path to the scaffolded directory. */
|
|
@@ -161,6 +174,13 @@ export interface ScaffoldResult {
|
|
|
161
174
|
* a keyed run with no `--modules` carries the key's derived level here.
|
|
162
175
|
*/
|
|
163
176
|
modules: ModuleName[];
|
|
177
|
+
/**
|
|
178
|
+
* A spoke only (#7426 D7): modules the key's default set carried that a
|
|
179
|
+
* spoke does not admit, dropped from this scaffold. Absent when none were.
|
|
180
|
+
*/
|
|
181
|
+
droppedModules?: ModuleName[];
|
|
182
|
+
/** A spoke only (#7426 D2): the normalized hub origin written to apps/web/.env.local. */
|
|
183
|
+
hubOrigin?: string;
|
|
164
184
|
}
|
|
165
185
|
/**
|
|
166
186
|
* 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";
|
|
@@ -44,6 +44,7 @@ import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoste
|
|
|
44
44
|
import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
|
|
45
45
|
import { syncRoutes } from "./sync-routes.js";
|
|
46
46
|
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";
|
|
47
48
|
import { validateTarget } from "./validate.js";
|
|
48
49
|
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
49
50
|
const TEMPLATES_DIR = resolve(PKG_ROOT, "templates");
|
|
@@ -151,14 +152,23 @@ export function scaffoldSync(opts) {
|
|
|
151
152
|
// The declared route surface (#4154). Resolved BEFORE the copy: it decides
|
|
152
153
|
// both which route directories are skipped and what architecture.yaml records.
|
|
153
154
|
const enabledSurfaces = resolveSurfaces(opts.modules ?? ["marketing"], opts.surfaces ?? []);
|
|
155
|
+
// #7426 D4 — a spoke is this same copy with the spoke's exclusions added:
|
|
156
|
+
// one registry over the one template tree, never a second tree.
|
|
157
|
+
const spoke = opts.topology === "spoke" ? { mcp: opts.mcp !== false } : undefined;
|
|
154
158
|
const versions = readVersionMap();
|
|
155
159
|
copyTemplate({
|
|
156
160
|
templatesDir: TEMPLATES_DIR,
|
|
157
161
|
targetDir: targetAbs,
|
|
158
162
|
versions,
|
|
159
|
-
excludePaths:
|
|
163
|
+
excludePaths: [
|
|
164
|
+
...excludedRoutePaths(enabledSurfaces),
|
|
165
|
+
...(spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : []),
|
|
166
|
+
],
|
|
160
167
|
markers: buildScaffoldMarkers(projectName),
|
|
161
168
|
});
|
|
169
|
+
// #7426 D5/D6 — the root files a spoke rewrites, and the spoke seams.
|
|
170
|
+
if (spoke !== undefined)
|
|
171
|
+
applySpokeTransforms(targetAbs, spoke);
|
|
162
172
|
// Stamp the copied CLAUDE.md's template regions with their hashes (#6904
|
|
163
173
|
// D2). No later layer writes a root CLAUDE.md, so this is the final file.
|
|
164
174
|
writeFileSync(join(targetAbs, "CLAUDE.md"), renderClaudeMd(readFileSync(join(TEMPLATES_DIR, "CLAUDE.md"), "utf8"), projectName));
|
|
@@ -167,7 +177,7 @@ export function scaffoldSync(opts) {
|
|
|
167
177
|
// closes the dangling `architecture_rules: architecture.yaml` reference in
|
|
168
178
|
// the scaffolded project.yaml. Written AFTER the template copy, at the target
|
|
169
179
|
// root — a deterministic function of the selected module set.
|
|
170
|
-
writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces));
|
|
180
|
+
writeFileSync(join(targetAbs, "architecture.yaml"), deriveArchitecture(opts.modules ?? ["marketing"], enabledSurfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
|
|
171
181
|
// Regenerate the site nav from the same surface set (#4154). The nav is a
|
|
172
182
|
// static list the layout renders into the header AND the footer, so leaving
|
|
173
183
|
// the template's copy in place would link /docs and /blog sitewide in a
|
|
@@ -252,6 +262,9 @@ export async function scaffold(opts) {
|
|
|
252
262
|
? resolveShapeTemplate(opts.shape)
|
|
253
263
|
: undefined;
|
|
254
264
|
assertReleased("modules", [...(opts.modules ?? []), ...(template?.requires ?? [])]);
|
|
265
|
+
// #7426 D1-D3/D7 — a spoke's refusals, before anything is fetched or
|
|
266
|
+
// written: the target directory never exists for a refused spoke.
|
|
267
|
+
const spoke = resolveSpoke(opts, template);
|
|
255
268
|
// #6813 D8 step 3 — the design this scaffold renders, resolved from its
|
|
256
269
|
// package ONCE, before any fetch or write: the caller's override, else the
|
|
257
270
|
// template's own. Unset for a scaffold naming neither. Replaces the
|
|
@@ -308,6 +321,8 @@ export async function scaffold(opts) {
|
|
|
308
321
|
// list.
|
|
309
322
|
let modules;
|
|
310
323
|
let bundles;
|
|
324
|
+
// #7426 D7 — the key's default modules a spoke does not admit.
|
|
325
|
+
let dropped = [];
|
|
311
326
|
// The run's derived level (#6759), for `assertDesignAllowed` below.
|
|
312
327
|
// Undefined on the alias branch and on a `--modules`-keep run (D2/D4 in
|
|
313
328
|
// module-license-gate.ts — the level plays no part in composing either),
|
|
@@ -360,6 +375,15 @@ export async function scaffold(opts) {
|
|
|
360
375
|
});
|
|
361
376
|
bundles = chosen.bundles;
|
|
362
377
|
level = chosen.level;
|
|
378
|
+
// #7426 D7 — a key-derived default set keeps only what a spoke admits;
|
|
379
|
+
// the run names what it dropped (`droppedModules`).
|
|
380
|
+
if (spoke !== undefined && opts.modules === undefined) {
|
|
381
|
+
const admitted = new Set(spokeModules());
|
|
382
|
+
dropped = chosen.modules.filter((m) => !admitted.has(m));
|
|
383
|
+
chosen.modules = chosen.modules.filter((m) => admitted.has(m));
|
|
384
|
+
for (const name of dropped)
|
|
385
|
+
bundles.delete(name);
|
|
386
|
+
}
|
|
363
387
|
// #7010 — a `--modules`-keep run composes no level (NFR-2), so a chosen
|
|
364
388
|
// non-Free design derives one from the key here, as the addition branch
|
|
365
389
|
// does; a plain keep run makes no extra network call.
|
|
@@ -453,6 +477,7 @@ export async function scaffold(opts) {
|
|
|
453
477
|
const result = scaffoldSync({
|
|
454
478
|
...opts,
|
|
455
479
|
modules,
|
|
480
|
+
...(spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}),
|
|
456
481
|
...(surfaces !== undefined ? { surfaces } : {}),
|
|
457
482
|
// #5612 D1/D2 — the code registry's own `layout` block, known
|
|
458
483
|
// synchronously (unlike a bundle's `ailk-template.json`, only readable
|
|
@@ -486,6 +511,14 @@ export async function scaffold(opts) {
|
|
|
486
511
|
// anything. Starts empty at the base-layer boundary: the `templates/`
|
|
487
512
|
// snapshot (layer 0) is deliberately outside it.
|
|
488
513
|
const ownership = new Map();
|
|
514
|
+
// #7426 D4 — every layer below lands through the spoke filter: a module
|
|
515
|
+
// bundle's or the template's destination a spoke does not carry is dropped.
|
|
516
|
+
const exclusions = spoke !== undefined ? spokeExclusions(TEMPLATES_DIR, spoke) : [];
|
|
517
|
+
const spokeSkip = spoke !== undefined
|
|
518
|
+
? {
|
|
519
|
+
skip: (destination) => isSpokeExcluded(destination, { mcp: spoke.mcp, exclusions }),
|
|
520
|
+
}
|
|
521
|
+
: {};
|
|
489
522
|
// The BASE LAYER lands first (#5618 D1) — the seed files every template
|
|
490
523
|
// reaches by `extends` (today the three module-less base pages), read off
|
|
491
524
|
// the `site-templates/_base/` tree this package ships, through the same
|
|
@@ -505,6 +538,7 @@ export async function scaffold(opts) {
|
|
|
505
538
|
versions,
|
|
506
539
|
markers,
|
|
507
540
|
ownership,
|
|
541
|
+
...spokeSkip,
|
|
508
542
|
});
|
|
509
543
|
}
|
|
510
544
|
for (const [moduleName, bytes] of bundles) {
|
|
@@ -515,6 +549,7 @@ export async function scaffold(opts) {
|
|
|
515
549
|
versions,
|
|
516
550
|
markers,
|
|
517
551
|
ownership,
|
|
552
|
+
...spokeSkip,
|
|
518
553
|
});
|
|
519
554
|
}
|
|
520
555
|
// The template lands LAST — after `scaffoldSync` and after every module
|
|
@@ -550,6 +585,7 @@ export async function scaffold(opts) {
|
|
|
550
585
|
versions,
|
|
551
586
|
markers,
|
|
552
587
|
ownership,
|
|
588
|
+
...spokeSkip,
|
|
553
589
|
})
|
|
554
590
|
: (() => {
|
|
555
591
|
const sourceRoot = join(SITE_TEMPLATES_DIR, templateTreeDir(template.name));
|
|
@@ -565,6 +601,7 @@ export async function scaffold(opts) {
|
|
|
565
601
|
versions,
|
|
566
602
|
markers,
|
|
567
603
|
ownership,
|
|
604
|
+
...spokeSkip,
|
|
568
605
|
});
|
|
569
606
|
})();
|
|
570
607
|
installedLayout = installed.layout;
|
|
@@ -696,6 +733,8 @@ export async function scaffold(opts) {
|
|
|
696
733
|
roster,
|
|
697
734
|
modules: result.modules,
|
|
698
735
|
level,
|
|
736
|
+
topology: spoke !== undefined ? "spoke" : "hub",
|
|
737
|
+
patternVersion: markers["CREATE_AILK_VERSION"] ?? "",
|
|
699
738
|
siteTemplatesDir: SITE_TEMPLATES_DIR,
|
|
700
739
|
templatesDir: TEMPLATES_DIR,
|
|
701
740
|
markers,
|
|
@@ -707,20 +746,70 @@ export async function scaffold(opts) {
|
|
|
707
746
|
writePaidSections(result.targetDir);
|
|
708
747
|
// #6107 D2 — after every layer landed, so nothing later overwrites it: a
|
|
709
748
|
// served scaffold needs BETTER_AUTH_SECRET (#6044), and this is its value.
|
|
710
|
-
|
|
749
|
+
// A spoke has no sign-in (#7426 D4), so it gets its hub's origin instead
|
|
750
|
+
// (D2) — never the spoke key, which the hub issues.
|
|
751
|
+
if (spoke !== undefined)
|
|
752
|
+
writeHubOriginEnv(result.targetDir, spoke.hubOrigin);
|
|
753
|
+
else
|
|
754
|
+
writeAuthSecretFile(result.targetDir);
|
|
711
755
|
// #6683 D9 — persisted only after every layer landed, and only when the
|
|
712
|
-
// caller passed `license`: a scaffold without it writes nothing new.
|
|
756
|
+
// caller passed `license`: a scaffold without it writes nothing new. A
|
|
757
|
+
// spoke has no apps/api, so it keeps the key in ailk.key alone (#7426 D6).
|
|
713
758
|
if (opts.license !== undefined) {
|
|
714
|
-
writeLicenseKeyFiles(result.targetDir, opts.license
|
|
759
|
+
writeLicenseKeyFiles(result.targetDir, opts.license, {
|
|
760
|
+
apiEnv: spoke === undefined,
|
|
761
|
+
});
|
|
715
762
|
}
|
|
716
763
|
// #6754 D4/D5 — the resolved starter, so the CLI names what it built.
|
|
764
|
+
const withDropped = {
|
|
765
|
+
...result,
|
|
766
|
+
...(dropped.length > 0 ? { droppedModules: dropped } : {}),
|
|
767
|
+
...(spoke !== undefined ? { hubOrigin: spoke.hubOrigin } : {}),
|
|
768
|
+
};
|
|
717
769
|
if (template !== undefined && resolvedDesign !== undefined) {
|
|
718
770
|
return {
|
|
719
|
-
...
|
|
771
|
+
...withDropped,
|
|
720
772
|
starter: { template: template.name, shape: template.shape, design: resolvedDesign.recorded },
|
|
721
773
|
};
|
|
722
774
|
}
|
|
723
|
-
return
|
|
775
|
+
return withDropped;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* A spoke's resolved inputs (#7426 D1-D3/D7), or undefined for a hub. Every
|
|
779
|
+
* refusal is a `TemplateError` thrown before anything is fetched or written.
|
|
780
|
+
*/
|
|
781
|
+
function resolveSpoke(opts, template) {
|
|
782
|
+
if (opts.topology !== "spoke") {
|
|
783
|
+
const spokeOnly = [
|
|
784
|
+
opts.hubUrl !== undefined ? "hubUrl" : undefined,
|
|
785
|
+
opts.mcp !== undefined ? "mcp" : undefined,
|
|
786
|
+
].filter((f) => f !== undefined);
|
|
787
|
+
if (spokeOnly.length > 0) {
|
|
788
|
+
throw new TemplateError(`${spokeOnly.join(" and ")} only apply to a spoke — pass topology: "spoke"`);
|
|
789
|
+
}
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
if (template === undefined) {
|
|
793
|
+
throw new TemplateError("a spoke needs a starter — pass --shape <shape> or --template <name>: the site manifest is where the topology is recorded");
|
|
794
|
+
}
|
|
795
|
+
let hubOrigin;
|
|
796
|
+
try {
|
|
797
|
+
hubOrigin = normalizeHubUrl(opts.hubUrl ?? process.env["AILK_API_URL"]);
|
|
798
|
+
}
|
|
799
|
+
catch (err) {
|
|
800
|
+
if (err instanceof TopologyError)
|
|
801
|
+
throw new TemplateError(err.message);
|
|
802
|
+
throw err;
|
|
803
|
+
}
|
|
804
|
+
for (const name of normalizeModules([...(opts.modules ?? []), ...template.requires])) {
|
|
805
|
+
const refusal = spokeModuleRefusal(name);
|
|
806
|
+
if (refusal !== undefined) {
|
|
807
|
+
throw new TemplateError(template.requires.includes(name) && !(opts.modules ?? []).includes(name)
|
|
808
|
+
? `template "${template.name}" requires ${refusal}`
|
|
809
|
+
: refusal);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return { mcp: opts.mcp !== false, hubOrigin };
|
|
724
813
|
}
|
|
725
814
|
/**
|
|
726
815
|
* The per-row install of the two SHARED seed layers (#6646 D1, disposition
|
|
@@ -842,7 +931,12 @@ export function syncArchitectureSync(opts) {
|
|
|
842
931
|
throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
|
|
843
932
|
}
|
|
844
933
|
const enabledSurfaces = resolveSurfaces(opts.modules, opts.surfaces ?? []);
|
|
845
|
-
|
|
934
|
+
// #7426 D13 — a spoke stays a spoke: its topology is read from its
|
|
935
|
+
// manifest, and `mcp` from whether it still carries apps/mcp.
|
|
936
|
+
const topology = readSiteTopology(targetAbs);
|
|
937
|
+
writeFileSync(archPath, deriveArchitecture(opts.modules, enabledSurfaces, topology === "spoke"
|
|
938
|
+
? { topology, mcp: existsSync(join(targetAbs, "apps", "mcp")) }
|
|
939
|
+
: {}));
|
|
846
940
|
return {
|
|
847
941
|
targetDir: targetAbs,
|
|
848
942
|
surfaces: enabledSurfaces,
|
|
@@ -960,6 +1054,13 @@ export async function addModule(opts) {
|
|
|
960
1054
|
if (!existsSync(join(targetAbs, "architecture.yaml"))) {
|
|
961
1055
|
throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
|
|
962
1056
|
}
|
|
1057
|
+
// #7426 D13 — a spoke refuses a module it does not admit before any key
|
|
1058
|
+
// read or network call (`applyModuleToScaffold` refuses it again).
|
|
1059
|
+
if (addModuleSiteTopology(targetAbs) === "spoke") {
|
|
1060
|
+
const refusal = spokeModuleRefusal(opts.moduleName);
|
|
1061
|
+
if (refusal !== undefined)
|
|
1062
|
+
throw new AddModuleRefusedError(refusal);
|
|
1063
|
+
}
|
|
963
1064
|
const { paid } = classifyModules([opts.moduleName]);
|
|
964
1065
|
if (paid.length === 0) {
|
|
965
1066
|
throw new AddModuleRefusedError(`"${opts.moduleName}" ships with every scaffold — there is no module bundle to add`);
|