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/topology.js
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The spoke topology (#7426) — `create-ailk --topology spoke`.
|
|
3
|
+
*
|
|
4
|
+
* A spoke is the existing scaffold with nodes REMOVED, never a second
|
|
5
|
+
* template tree: no `apps/api`, no `database/`, no sign-in, and `apps/mcp`
|
|
6
|
+
* kept as a stateless front unless `--without-mcp`. Its forms, flows and
|
|
7
|
+
* listings reach a hub at `AILK_API_URL` from server code. Everything that
|
|
8
|
+
* decides what a spoke is lives here, in one registry, so the spoke tree
|
|
9
|
+
* cannot drift from the hub tree it is cut from:
|
|
10
|
+
*
|
|
11
|
+
* - `SPOKE_REMOVED_ROOTS` / `SPOKE_REMOVAL_SEEDS` and `spokeExclusions` —
|
|
12
|
+
* the paths a spoke does not carry: the removed roots, the sign-in and
|
|
13
|
+
* database seeds, and every file that imports a seed (D4);
|
|
14
|
+
* - `isSpokeExcluded` — the ONE predicate every writer (the base copy,
|
|
15
|
+
* each module bundle, the template layer, the later commands) asks;
|
|
16
|
+
* - the two generated seam files (D5) — the only place hub and spoke code
|
|
17
|
+
* differ, rendered here for either topology;
|
|
18
|
+
* - the root-file transforms (D6), the hard floors (D9), the module
|
|
19
|
+
* admission (D7), the manifest reader (D10) and the hub origin (D2);
|
|
20
|
+
* - the sign-in-through-the-hub mode (`signIn: "hub"`, #7894): its seeds,
|
|
21
|
+
* its renders of the web and `(authed)` gate seams, its env and floors.
|
|
22
|
+
*
|
|
23
|
+
* Specs: docs/specs/7426-spoke-topology-option-spec.md,
|
|
24
|
+
* docs/specs/7894-the-hub-signs-in-its-spec.md (D1, D8, D9, D11).
|
|
25
|
+
*
|
|
26
|
+
* Pure module: no `import.meta.url`, so jest imports it directly. The
|
|
27
|
+
* package's own `templates/` dir is supplied by the caller.
|
|
28
|
+
*/
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { ENV_LOCAL_IGNORE_LINES } from "./lib/auth-secret-file.js";
|
|
33
|
+
import { listFilesUnder, relativeImportsOf, resolveRelativeImport, } from "./lib/import-graph.js";
|
|
34
|
+
import { appendLine, writeOwnerOnly } from "./lib/license-key-files.js";
|
|
35
|
+
import { MODULE_SLICES } from "./module-architecture.js";
|
|
36
|
+
import { RECOGNIZED_MODULES } from "./parse-args.js";
|
|
37
|
+
/** The two topologies. `hub` is the default and the only one before #7426. */
|
|
38
|
+
export const TOPOLOGIES = ["hub", "spoke"];
|
|
39
|
+
/** A refusal or a malformed input this module names. */
|
|
40
|
+
export class TopologyError extends Error {
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "TopologyError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ── D4 — the removal registry ──────────────────────────────────────────────
|
|
47
|
+
/** Roots no spoke carries. `apps/mcp` joins them with `--without-mcp`. */
|
|
48
|
+
export const SPOKE_REMOVED_ROOTS = ["apps/api", "database"];
|
|
49
|
+
/** The root `--without-mcp` removes. */
|
|
50
|
+
export const SPOKE_MCP_ROOT = "apps/mcp";
|
|
51
|
+
/** Commerce's apps/web files that reach the hub's own checkout (spoke-intake Rule 3, #7838). */
|
|
52
|
+
export const SPOKE_HUB_CHECKOUT_SEEDS = [
|
|
53
|
+
"apps/web/app/[locale]/flows/[slug]/checkout.actions.ts",
|
|
54
|
+
"apps/web/app/[locale]/flows/[slug]/results",
|
|
55
|
+
"apps/web/app/[locale]/dev/purchase",
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* The files a spoke does not carry — files and directories, scaffold-relative:
|
|
59
|
+
* the sign-in and database seeds, then the commerce files that call the hub's
|
|
60
|
+
* own checkout (`SPOKE_HUB_CHECKOUT_SEEDS`; a spoke keeps its own, #7838).
|
|
61
|
+
* Every other file that imports one of them goes with it (`spokeExclusions`).
|
|
62
|
+
*/
|
|
63
|
+
export const SPOKE_REMOVAL_SEEDS = [
|
|
64
|
+
"apps/web/auth.ts",
|
|
65
|
+
"apps/web/lib/prisma-for-auth.ts",
|
|
66
|
+
"apps/web/lib/license-key-store.ts",
|
|
67
|
+
"apps/web/app/api/auth",
|
|
68
|
+
"apps/web/app/[locale]/(auth)",
|
|
69
|
+
"apps/web/app/[locale]/(authed)",
|
|
70
|
+
"apps/mcp/src/lib/prisma.ts",
|
|
71
|
+
"apps/mcp/src/lib/public-workspace.ts",
|
|
72
|
+
"apps/mcp/src/lib/license-key-store.ts",
|
|
73
|
+
...SPOKE_HUB_CHECKOUT_SEEDS,
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* Module paths a spoke drops even when it carries the module (#7876 D8,
|
|
77
|
+
* clarified 2026-10-09). `signal-collectors`' report reader links a
|
|
78
|
+
* grant-gated report's bytes on the hub from `NEXT_PUBLIC_AILK_API_URL`,
|
|
79
|
+
* which a spoke refuses (#7874: its hub origin is server-only), and a
|
|
80
|
+
* deliverable is hub-side anyway — so a spoke carries neither the
|
|
81
|
+
* `/reports` routes nor the report seeds that would have no route.
|
|
82
|
+
*/
|
|
83
|
+
export const SPOKE_EXCLUDED_MODULE_PATHS = [
|
|
84
|
+
"apps/web/app/[locale]/reports",
|
|
85
|
+
"content/en/ReportPage",
|
|
86
|
+
"content/en/ReportsList",
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
89
|
+
* The `(authed)` seeds of the sign-in-through-the-hub mode (#7894 D9): the
|
|
90
|
+
* whole `(authed)` group goes in the default mode; in the hub mode its account
|
|
91
|
+
* area, layout and gate seam stay, and only these go — the operator surfaces,
|
|
92
|
+
* the Better Auth gate and the suites that mock `auth.ts` by path.
|
|
93
|
+
*/
|
|
94
|
+
export const HUB_MODE_AUTHED_SEEDS = [
|
|
95
|
+
"apps/web/app/[locale]/(authed)/__tests__",
|
|
96
|
+
"apps/web/app/[locale]/(authed)/account/__tests__",
|
|
97
|
+
"apps/web/app/[locale]/(authed)/admin",
|
|
98
|
+
"apps/web/app/[locale]/(authed)/appointments",
|
|
99
|
+
"apps/web/app/[locale]/(authed)/edit",
|
|
100
|
+
"apps/web/app/[locale]/(authed)/leads",
|
|
101
|
+
"apps/web/app/[locale]/(authed)/waitlist",
|
|
102
|
+
"apps/web/app/[locale]/(authed)/sign-in-gate.tsx",
|
|
103
|
+
];
|
|
104
|
+
/** The default mode's one `(authed)` seed, which the hub mode replaces. */
|
|
105
|
+
const AUTHED_SEED = "apps/web/app/[locale]/(authed)";
|
|
106
|
+
/** The generated seam files (D5; #7874 D12). A seam is rewritten, never removed. */
|
|
107
|
+
export const WEB_SEAM_PATH = "apps/web/lib/topology.generated.ts";
|
|
108
|
+
export const MCP_SEAM_PATH = "apps/mcp/src/lib/topology.generated.ts";
|
|
109
|
+
/** The browser API origin seam (#7874 D12): a spoke's render reads no env. */
|
|
110
|
+
export const API_ORIGIN_SEAM_PATH = "apps/web/lib/api-origin.generated.ts";
|
|
111
|
+
/**
|
|
112
|
+
* The `(authed)` layout's signed-out gate (#7894 D8). Under the `(authed)`
|
|
113
|
+
* seed, so a default-mode spoke drops it with its group and never renders it.
|
|
114
|
+
*/
|
|
115
|
+
export const AUTHED_GATE_SEAM_PATH = "apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx";
|
|
116
|
+
export const SPOKE_SEAM_FILES = [
|
|
117
|
+
WEB_SEAM_PATH,
|
|
118
|
+
MCP_SEAM_PATH,
|
|
119
|
+
API_ORIGIN_SEAM_PATH,
|
|
120
|
+
AUTHED_GATE_SEAM_PATH,
|
|
121
|
+
];
|
|
122
|
+
/**
|
|
123
|
+
* Template files only a spoke carries (#7874 D11): the declared-env check.
|
|
124
|
+
* `scaffoldSync` excludes them from a hub's copy (`copyTemplate`'s
|
|
125
|
+
* `excludePaths` matches an exact file path).
|
|
126
|
+
*/
|
|
127
|
+
export const SPOKE_ONLY_FILES = [
|
|
128
|
+
"tools/declared-env.ts",
|
|
129
|
+
"tools/check-declared-env.ts",
|
|
130
|
+
];
|
|
131
|
+
/**
|
|
132
|
+
* The closure's allow-list: a file `spokeExclusions` removes must sit under
|
|
133
|
+
* one of these prefixes, or under a `__tests__/` directory. A member outside
|
|
134
|
+
* both is a defect — a shared file started importing a sign-in or database
|
|
135
|
+
* file — and `spokeExclusions` throws naming it rather than cut it silently.
|
|
136
|
+
*/
|
|
137
|
+
export const SPOKE_REMOVABLE_PREFIXES = SPOKE_REMOVAL_SEEDS;
|
|
138
|
+
/** The seeds for a sign-in mode, before the mcp filter. */
|
|
139
|
+
function modeSeeds(signIn) {
|
|
140
|
+
return signIn === "hub"
|
|
141
|
+
? SPOKE_REMOVAL_SEEDS.flatMap((seed) => seed === AUTHED_SEED ? [...HUB_MODE_AUTHED_SEEDS] : [seed])
|
|
142
|
+
: [...SPOKE_REMOVAL_SEEDS];
|
|
143
|
+
}
|
|
144
|
+
function toPosix(path) {
|
|
145
|
+
return path.split("\\").join("/").replace(/^\.\//, "");
|
|
146
|
+
}
|
|
147
|
+
/** `path` equals `prefix` or sits below it, at a directory boundary. */
|
|
148
|
+
function underPrefix(path, prefix) {
|
|
149
|
+
const p = prefix.replace(/\/+$/, "");
|
|
150
|
+
return path === p || path.startsWith(`${p}/`);
|
|
151
|
+
}
|
|
152
|
+
/** The removed roots for these options. */
|
|
153
|
+
export function spokeRemovedRoots(opts) {
|
|
154
|
+
return opts.mcp
|
|
155
|
+
? [...SPOKE_REMOVED_ROOTS]
|
|
156
|
+
: [...SPOKE_REMOVED_ROOTS, SPOKE_MCP_ROOT];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The seeds that apply — an mcp seed is moot when `apps/mcp` itself goes; in
|
|
160
|
+
* the hub sign-in mode the `(authed)` seed is replaced by eight (#7894 D9).
|
|
161
|
+
*/
|
|
162
|
+
function spokeSeeds(opts) {
|
|
163
|
+
return modeSeeds(opts.signIn).filter((seed) => opts.mcp || !underPrefix(seed, SPOKE_MCP_ROOT));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether a closure member is one the registry allows a spoke to drop: under
|
|
167
|
+
* a seed of THAT sign-in mode, or a test. In the hub mode a surviving account
|
|
168
|
+
* file that imports `auth.ts` is therefore not removable, and
|
|
169
|
+
* `spokeExclusions` throws naming it (#7894 D9).
|
|
170
|
+
*/
|
|
171
|
+
export function isRemovableClosureMember(path, opts = {}) {
|
|
172
|
+
const p = toPosix(path);
|
|
173
|
+
const prefixes = opts.signIn === "hub" ? modeSeeds("hub") : SPOKE_REMOVABLE_PREFIXES;
|
|
174
|
+
return (p.split("/").includes("__tests__") ||
|
|
175
|
+
prefixes.some((prefix) => underPrefix(p, prefix)));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Every path a spoke does not carry, computed over the built `templates/`
|
|
179
|
+
* tree: the removed roots, the seeds that apply, and every source file under
|
|
180
|
+
* the kept apps that reaches a seed through relative imports, to a fixed
|
|
181
|
+
* point. Sorted; directories and files alike, matched at a directory
|
|
182
|
+
* boundary by `isSpokeExcluded` and `copyTemplate`'s `excludePaths`.
|
|
183
|
+
*
|
|
184
|
+
* The walk is the relative-import resolution `importClosure` uses
|
|
185
|
+
* (`lib/import-graph.ts`), with ONE difference: a seam file is never a
|
|
186
|
+
* candidate, so the walk never crosses it. The hub render of a seam imports
|
|
187
|
+
* a seed by design (D5); its importers keep their place on a spoke because
|
|
188
|
+
* the spoke render of the same file imports none.
|
|
189
|
+
*
|
|
190
|
+
* @throws TopologyError naming every closure member outside
|
|
191
|
+
* `SPOKE_REMOVABLE_PREFIXES` and not a test — a new shared file importing
|
|
192
|
+
* a sign-in or database file, which removing would silently break the
|
|
193
|
+
* spoke (spec § 5.3: escalate, never widen).
|
|
194
|
+
*/
|
|
195
|
+
export function spokeExclusions(templatesDir, opts) {
|
|
196
|
+
const roots = ["apps/web", ...(opts.mcp ? [SPOKE_MCP_ROOT] : [])];
|
|
197
|
+
const seeds = spokeSeeds(opts);
|
|
198
|
+
const seams = new Set(SPOKE_SEAM_FILES);
|
|
199
|
+
const candidates = listFilesUnder(templatesDir, roots);
|
|
200
|
+
for (const seam of seams)
|
|
201
|
+
candidates.delete(seam);
|
|
202
|
+
const closed = new Set();
|
|
203
|
+
for (const file of candidates) {
|
|
204
|
+
if (seeds.some((seed) => underPrefix(file, seed)))
|
|
205
|
+
closed.add(file);
|
|
206
|
+
}
|
|
207
|
+
for (;;) {
|
|
208
|
+
let grew = false;
|
|
209
|
+
for (const file of candidates) {
|
|
210
|
+
if (closed.has(file))
|
|
211
|
+
continue;
|
|
212
|
+
for (const spec of relativeImportsOf(join(templatesDir, file))) {
|
|
213
|
+
const target = resolveRelativeImport(file, spec, candidates);
|
|
214
|
+
if (target !== undefined && closed.has(target)) {
|
|
215
|
+
closed.add(file);
|
|
216
|
+
grew = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (!grew)
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
const outside = [...closed]
|
|
225
|
+
.filter((file) => !isRemovableClosureMember(file, opts))
|
|
226
|
+
.sort();
|
|
227
|
+
if (outside.length > 0) {
|
|
228
|
+
throw new TopologyError(`a spoke cannot drop ${outside.join(", ")}: ${outside.length === 1 ? "it imports" : "each imports"} a sign-in or database file, and ${outside.length === 1 ? "it is" : "they are"} not a sign-in path or a test. Move the import behind the generated seam (${WEB_SEAM_PATH} / ${MCP_SEAM_PATH}) rather than widening SPOKE_REMOVABLE_PREFIXES.`);
|
|
229
|
+
}
|
|
230
|
+
const covered = (file) => seeds.some((seed) => underPrefix(file, seed));
|
|
231
|
+
return [
|
|
232
|
+
...new Set([
|
|
233
|
+
...spokeRemovedRoots(opts),
|
|
234
|
+
...seeds,
|
|
235
|
+
...[...closed].filter((file) => !covered(file)),
|
|
236
|
+
]),
|
|
237
|
+
].sort();
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The one predicate for "a spoke does not carry this path" (the Phase 2
|
|
241
|
+
* generator contract). Scaffold-relative, `/`- or `\`-separated.
|
|
242
|
+
*/
|
|
243
|
+
export function isSpokeExcluded(path, opts) {
|
|
244
|
+
const p = toPosix(path);
|
|
245
|
+
const prefixes = opts.exclusions ?? [
|
|
246
|
+
...spokeRemovedRoots(opts),
|
|
247
|
+
...spokeSeeds(opts),
|
|
248
|
+
];
|
|
249
|
+
return [...prefixes, ...SPOKE_EXCLUDED_MODULE_PATHS].some((prefix) => underPrefix(p, prefix));
|
|
250
|
+
}
|
|
251
|
+
/** A module flow file a hub installs under `database/` (#7876 D10). */
|
|
252
|
+
const DATABASE_FLOW_FILE = /^database\/flows\/([^/]+)\.flow\.json$/;
|
|
253
|
+
/**
|
|
254
|
+
* Where a module bundle's `installMap` destination lands on a spoke (#7876
|
|
255
|
+
* D10): `database/flows/<name>.flow.json` becomes `flows/<name>.json` — a
|
|
256
|
+
* declared flow `create-ailk seed-flows` sends to the hub — and every other
|
|
257
|
+
* destination is returned unchanged. Applied before the spoke filter, so a
|
|
258
|
+
* remapped flow survives it while the rest of `database/` is dropped. A hub
|
|
259
|
+
* install passes no remap.
|
|
260
|
+
*/
|
|
261
|
+
export function spokeDestination(destination) {
|
|
262
|
+
const match = DATABASE_FLOW_FILE.exec(toPosix(destination));
|
|
263
|
+
return match ? `flows/${match[1]}.json` : destination;
|
|
264
|
+
}
|
|
265
|
+
// ── D5 — the generated seam files ───────────────────────────────────────────
|
|
266
|
+
const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a session and registers its stores.
|
|
267
|
+
//
|
|
268
|
+
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
269
|
+
// every scaffold rewrites it. A hub reads the session through Better Auth.
|
|
270
|
+
// A spoke (\`--topology spoke\`) has no database of its own: by default it
|
|
271
|
+
// has no sign-in, so its render reads no session and imports nothing; with
|
|
272
|
+
// \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie.`;
|
|
273
|
+
const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
|
|
274
|
+
//
|
|
275
|
+
// GENERATED by create-ailk for this site's topology (#7426). Do not edit:
|
|
276
|
+
// every scaffold rewrites it. A hub resolves its workspace through the
|
|
277
|
+
// database; a spoke (\`--topology spoke\`) has none, so its content tools
|
|
278
|
+
// read the spoke's own MDX and its writes go to the hub at AILK_API_URL.`;
|
|
279
|
+
const SITE_SESSION_TYPE = `export type SiteSession = {
|
|
280
|
+
user: {
|
|
281
|
+
name?: string | null;
|
|
282
|
+
email: string;
|
|
283
|
+
emailVerified: boolean;
|
|
284
|
+
createdAt?: Date | string | null;
|
|
285
|
+
};
|
|
286
|
+
} | null;`;
|
|
287
|
+
/** The sign-in exports every web seam render carries (#7894 D8). */
|
|
288
|
+
function signInExports(values) {
|
|
289
|
+
const literal = (value) => value === null ? "null" : JSON.stringify(value);
|
|
290
|
+
return `export const SIGN_IN_ENABLED: boolean = ${values.enabled};
|
|
291
|
+
export const SIGN_IN_PROVIDER: "site" | "hub" | "none" = ${JSON.stringify(values.provider)};
|
|
292
|
+
export const SIGN_IN_PATH: string | null = ${literal(values.signInPath)};
|
|
293
|
+
export const SIGN_UP_PATH: string | null = ${literal(values.signUpPath)};
|
|
294
|
+
export const SIGN_OUT_ENDPOINT: string | null = ${literal(values.signOutEndpoint)};`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* `apps/web/lib/topology.generated.ts` for `topology` (D5) and, on a spoke,
|
|
298
|
+
* its sign-in mode (#7894 D8). The hub render loads `auth.ts` only when a
|
|
299
|
+
* session is read, so a module that imports this seam for a constant never
|
|
300
|
+
* loads Better Auth.
|
|
301
|
+
*/
|
|
302
|
+
export function renderWebSeam(topology, signIn = "off") {
|
|
303
|
+
if (topology === "hub") {
|
|
304
|
+
return `${WEB_SEAM_HEADER}
|
|
305
|
+
|
|
306
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
307
|
+
${signInExports({ enabled: true, provider: "site", signInPath: "/sign-in", signUpPath: "/sign-up", signOutEndpoint: null })}
|
|
308
|
+
|
|
309
|
+
${SITE_SESSION_TYPE}
|
|
310
|
+
|
|
311
|
+
export async function readSession(headers: Headers): Promise<SiteSession> {
|
|
312
|
+
const { auth } = await import("../auth");
|
|
313
|
+
return auth.api.getSession({ headers });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function registerSiteStores(): Promise<void> {
|
|
317
|
+
const { registerLicenseKeyStore } = await import("./license-key-store");
|
|
318
|
+
await registerLicenseKeyStore();
|
|
319
|
+
}
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
if (signIn === "hub") {
|
|
323
|
+
return `${WEB_SEAM_HEADER}
|
|
324
|
+
import { readHubSession } from "./hub-session/session";
|
|
325
|
+
|
|
326
|
+
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
327
|
+
${signInExports({ enabled: true, provider: "hub", signInPath: "/account", signUpPath: null, signOutEndpoint: "/api/hub-session/sign-out" })}
|
|
328
|
+
|
|
329
|
+
${SITE_SESSION_TYPE}
|
|
330
|
+
|
|
331
|
+
export function readSession(headers: Headers): Promise<SiteSession> {
|
|
332
|
+
return readHubSession(headers);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function registerSiteStores(): Promise<void> {
|
|
336
|
+
return Promise.resolve();
|
|
337
|
+
}
|
|
338
|
+
`;
|
|
339
|
+
}
|
|
340
|
+
return `${WEB_SEAM_HEADER}
|
|
341
|
+
|
|
342
|
+
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
343
|
+
${signInExports({ enabled: false, provider: "none", signInPath: null, signUpPath: null, signOutEndpoint: null })}
|
|
344
|
+
|
|
345
|
+
${SITE_SESSION_TYPE}
|
|
346
|
+
|
|
347
|
+
export function readSession(headers: Headers): Promise<SiteSession> {
|
|
348
|
+
void headers;
|
|
349
|
+
return Promise.resolve(null);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function registerSiteStores(): Promise<void> {
|
|
353
|
+
return Promise.resolve();
|
|
354
|
+
}
|
|
355
|
+
`;
|
|
356
|
+
}
|
|
357
|
+
const AUTHED_GATE_SEAM_HEADER = `// signed-out-gate.generated.tsx — what the (authed) group shows a signed-out visitor.
|
|
358
|
+
//
|
|
359
|
+
// GENERATED by create-ailk for this site's topology and sign-in mode (#7894).
|
|
360
|
+
// Do not edit: every scaffold rewrites it. A hub renders its own sign-in —
|
|
361
|
+
// the operator gate, or the account area's member gate; a spoke that signs in
|
|
362
|
+
// through its hub (\`--hub-sign-in\`) renders the hub-session gate.`;
|
|
363
|
+
/**
|
|
364
|
+
* The packages the gate seam imports. Interpolated, not written inline, for
|
|
365
|
+
* the reason `CONTENT_ADAPTERS_PACKAGE` below is.
|
|
366
|
+
*/
|
|
367
|
+
const UI_PACKAGE = "@working-theory/ui";
|
|
368
|
+
const WEB_I18N_PACKAGE = "@working-theory/web-i18n";
|
|
369
|
+
const NEXT_INTL_SERVER = "next-intl/server";
|
|
370
|
+
const REACT_PACKAGE = "react";
|
|
371
|
+
const AUTHED_GATE_PROPS = `export interface SignedOutGateProps {
|
|
372
|
+
locale: string;
|
|
373
|
+
/** The incoming request's headers (\`x-pathname\` names the requested page). */
|
|
374
|
+
requestHeaders: Headers;
|
|
375
|
+
}`;
|
|
376
|
+
/**
|
|
377
|
+
* `apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx` (#7894 D8).
|
|
378
|
+
* The hub render is the `(authed)` layout's no-session branch, moved; the
|
|
379
|
+
* `signIn: "hub"` render imports only the hub-session gate. A default-mode
|
|
380
|
+
* spoke carries no `(authed)` group, so it has no render.
|
|
381
|
+
* @throws TopologyError for a spoke in the default mode.
|
|
382
|
+
*/
|
|
383
|
+
export function renderAuthedGateSeam(topology, signIn = "off") {
|
|
384
|
+
if (topology === "hub") {
|
|
385
|
+
return `${AUTHED_GATE_SEAM_HEADER}
|
|
386
|
+
import { localeHref } from "${UI_PACKAGE}";
|
|
387
|
+
import { Button, Heading, Link, Text } from "${UI_PACKAGE}/primitives";
|
|
388
|
+
import type { AuthPanelProvider } from "${UI_PACKAGE}/sections";
|
|
389
|
+
import { readLocales } from "${WEB_I18N_PACKAGE}";
|
|
390
|
+
import { getTranslations } from "${NEXT_INTL_SERVER}";
|
|
391
|
+
import type { ReactElement } from "${REACT_PACKAGE}";
|
|
392
|
+
|
|
393
|
+
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
394
|
+
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
395
|
+
import { isAccountPath } from "../../../lib/auth-routes";
|
|
396
|
+
|
|
397
|
+
import { SignInGate } from "./sign-in-gate";
|
|
398
|
+
|
|
399
|
+
const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
|
|
400
|
+
|
|
401
|
+
${AUTHED_GATE_PROPS}
|
|
402
|
+
|
|
403
|
+
export async function SignedOutGate({
|
|
404
|
+
locale,
|
|
405
|
+
requestHeaders,
|
|
406
|
+
}: SignedOutGateProps): Promise<ReactElement> {
|
|
407
|
+
// No \`x-pathname\`, no locale read: a render outside the middleware keeps
|
|
408
|
+
// the operator gate without touching the locale config.
|
|
409
|
+
const pathname = requestHeaders.get("x-pathname");
|
|
410
|
+
const isAccount =
|
|
411
|
+
pathname !== null && isAccountPath(pathname, readLocales());
|
|
412
|
+
const t = await getTranslations({
|
|
413
|
+
locale,
|
|
414
|
+
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
415
|
+
});
|
|
416
|
+
const active = getActiveProviderIds();
|
|
417
|
+
const emailLink = active.includes("magic-link")
|
|
418
|
+
? { text: await resolveSignInCardText(locale) }
|
|
419
|
+
: undefined;
|
|
420
|
+
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
421
|
+
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
422
|
+
);
|
|
423
|
+
const signInPath = localeHref(locale, "/sign-in");
|
|
424
|
+
|
|
425
|
+
if (isAccount && providers.length === 0 && !emailLink) {
|
|
426
|
+
return (
|
|
427
|
+
<div
|
|
428
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
|
|
429
|
+
data-testid="account-sign-in-gate"
|
|
430
|
+
>
|
|
431
|
+
<Heading level={1} visual={4}>
|
|
432
|
+
{t("emptyHeadline")}
|
|
433
|
+
</Heading>
|
|
434
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
435
|
+
{t("emptyDescription")}
|
|
436
|
+
</Text>
|
|
437
|
+
{/* With no sign-in method on, /sign-in has nothing for a member
|
|
438
|
+
either, so the one action goes back to the site. Outline
|
|
439
|
+
action, 44px on a phone and 36px from md up. */}
|
|
440
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
441
|
+
<Link
|
|
442
|
+
href={localeHref(locale, "/")}
|
|
443
|
+
variant="bare"
|
|
444
|
+
data-testid="account-sign-in-gate-home"
|
|
445
|
+
>
|
|
446
|
+
{t("homeLink")}
|
|
447
|
+
</Link>
|
|
448
|
+
</Button>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<SignInGate
|
|
455
|
+
labels={{
|
|
456
|
+
emptyHeadline: t("emptyHeadline"),
|
|
457
|
+
emptyDescription: t("emptyDescription"),
|
|
458
|
+
signInLinkLabel: t("signInLink"),
|
|
459
|
+
}}
|
|
460
|
+
signInPath={signInPath}
|
|
461
|
+
providers={providers}
|
|
462
|
+
{...(emailLink ? { emailLink } : {})}
|
|
463
|
+
/>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
if (signIn !== "hub") {
|
|
469
|
+
throw new TopologyError(`${AUTHED_GATE_SEAM_PATH}: a spoke without --hub-sign-in carries no (authed) group, so it has no gate to render`);
|
|
470
|
+
}
|
|
471
|
+
return `${AUTHED_GATE_SEAM_HEADER}
|
|
472
|
+
import type { ReactElement } from "${REACT_PACKAGE}";
|
|
473
|
+
|
|
474
|
+
import { HubSignInGate } from "../../../lib/hub-session/sign-in-gate";
|
|
475
|
+
|
|
476
|
+
${AUTHED_GATE_PROPS}
|
|
477
|
+
|
|
478
|
+
export function SignedOutGate({
|
|
479
|
+
locale,
|
|
480
|
+
requestHeaders,
|
|
481
|
+
}: SignedOutGateProps): Promise<ReactElement> {
|
|
482
|
+
return HubSignInGate({ locale, requestHeaders });
|
|
483
|
+
}
|
|
484
|
+
`;
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* The package the spoke mcp seam takes its one type from. Interpolated, not
|
|
488
|
+
* written inline: an inline specifier in this template would read as an
|
|
489
|
+
* import create-ailk itself makes (`tools/lint-architecture.ts`), and
|
|
490
|
+
* create-ailk carries no runtime dependency.
|
|
491
|
+
*/
|
|
492
|
+
const CONTENT_ADAPTERS_PACKAGE = "@working-theory/content-adapters";
|
|
493
|
+
/** `apps/mcp/src/lib/topology.generated.ts` for `topology` (D5). */
|
|
494
|
+
export function renderMcpSeam(topology) {
|
|
495
|
+
if (topology === "hub") {
|
|
496
|
+
return `${MCP_SEAM_HEADER}
|
|
497
|
+
import {
|
|
498
|
+
initMcpPublicWorkspace,
|
|
499
|
+
mcpPublicWorkspace,
|
|
500
|
+
type McpWorkspaceResolution,
|
|
501
|
+
} from "./public-workspace.js";
|
|
502
|
+
|
|
503
|
+
export { registerLicenseKeyStore } from "./license-key-store.js";
|
|
504
|
+
export type { McpWorkspaceResolution };
|
|
505
|
+
|
|
506
|
+
export const TOPOLOGY: "hub" | "spoke" = "hub";
|
|
507
|
+
|
|
508
|
+
export async function initPublicWorkspace(): Promise<void> {
|
|
509
|
+
await initMcpPublicWorkspace();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function publicWorkspace(): McpWorkspaceResolution {
|
|
513
|
+
return mcpPublicWorkspace();
|
|
514
|
+
}
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
return `${MCP_SEAM_HEADER}
|
|
518
|
+
import type { ArtifactPrismaClient } from "${CONTENT_ADAPTERS_PACKAGE}";
|
|
519
|
+
|
|
520
|
+
export type McpWorkspaceResolution =
|
|
521
|
+
| { kind: "not-required" }
|
|
522
|
+
| { kind: "resolved"; workspaceId: string; prisma: ArtifactPrismaClient }
|
|
523
|
+
| { kind: "unresolved"; reason: "ambiguous" };
|
|
524
|
+
|
|
525
|
+
export const TOPOLOGY: "hub" | "spoke" = "spoke";
|
|
526
|
+
|
|
527
|
+
export function initPublicWorkspace(): Promise<void> {
|
|
528
|
+
return Promise.resolve();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function publicWorkspace(): McpWorkspaceResolution {
|
|
532
|
+
return { kind: "not-required" };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function registerLicenseKeyStore(): Promise<void> {
|
|
536
|
+
return Promise.resolve();
|
|
537
|
+
}
|
|
538
|
+
`;
|
|
539
|
+
}
|
|
540
|
+
const API_ORIGIN_SEAM_HEADER = `// api-origin.generated.ts — the browser-facing API origin this site's server reads.
|
|
541
|
+
//
|
|
542
|
+
// GENERATED by create-ailk for this site's topology (#7874). Do not edit:
|
|
543
|
+
// every scaffold rewrites it. A hub reads NEXT_PUBLIC_AILK_API_URL as a
|
|
544
|
+
// fallback API origin; a spoke (\`--topology spoke\`) has no browser-facing
|
|
545
|
+
// API origin — its hub origin is server-only (AILK_API_URL) — so its render
|
|
546
|
+
// reads no environment variable.`;
|
|
547
|
+
/** `apps/web/lib/api-origin.generated.ts` for `topology` (#7874 D12). */
|
|
548
|
+
export function renderApiOriginSeam(topology) {
|
|
549
|
+
if (topology === "hub") {
|
|
550
|
+
return `${API_ORIGIN_SEAM_HEADER}
|
|
551
|
+
|
|
552
|
+
export function browserApiOrigin(): string | undefined {
|
|
553
|
+
return process.env.NEXT_PUBLIC_AILK_API_URL;
|
|
554
|
+
}
|
|
555
|
+
`;
|
|
556
|
+
}
|
|
557
|
+
return `${API_ORIGIN_SEAM_HEADER}
|
|
558
|
+
|
|
559
|
+
export function browserApiOrigin(): string | undefined {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
`;
|
|
563
|
+
}
|
|
564
|
+
// ── D6 — the root-file transforms ───────────────────────────────────────────
|
|
565
|
+
/**
|
|
566
|
+
* `text` with every line `drop` matches removed, then the dangling comma a
|
|
567
|
+
* removed last member leaves fixed. Validated as JSON when `json` is set.
|
|
568
|
+
*/
|
|
569
|
+
function dropLines(text, drop, label, json) {
|
|
570
|
+
const kept = text.split("\n").filter((line) => !drop(line));
|
|
571
|
+
const next = kept.join("\n").replace(/,(\s*\n\s*[}\]])/g, "$1");
|
|
572
|
+
if (json) {
|
|
573
|
+
try {
|
|
574
|
+
JSON.parse(next);
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
throw new TopologyError(`${label}: the spoke transform left it unparseable: ${err.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return next;
|
|
581
|
+
}
|
|
582
|
+
/** `pnpm-workspace.yaml` without its `database` package entry. */
|
|
583
|
+
export function transformWorkspaceYaml(text) {
|
|
584
|
+
const entry = /^\s*-\s*["']?database["']?\s*$/;
|
|
585
|
+
if (!text.split("\n").some((line) => entry.test(line))) {
|
|
586
|
+
throw new TopologyError(`pnpm-workspace.yaml: no "database" package entry to remove`);
|
|
587
|
+
}
|
|
588
|
+
return dropLines(text, (line) => entry.test(line), "pnpm-workspace.yaml", false);
|
|
589
|
+
}
|
|
590
|
+
/** The root `package.json` without its `db:*` scripts. */
|
|
591
|
+
export function transformRootPackageJson(text) {
|
|
592
|
+
const pkg = JSON.parse(text);
|
|
593
|
+
if (pkg.scripts === undefined) {
|
|
594
|
+
throw new TopologyError(`package.json: no "scripts" block to remove the db:* scripts from`);
|
|
595
|
+
}
|
|
596
|
+
const scriptLine = /^\s*"db:[^"]*"\s*:/;
|
|
597
|
+
return dropLines(text, (line) => scriptLine.test(line), "package.json", true);
|
|
598
|
+
}
|
|
599
|
+
/** The spoke's declared-env script (#7874 D11) — named apart from the monorepo's `env:check`. */
|
|
600
|
+
export const ENV_DECLARED_SCRIPT = "env:declared";
|
|
601
|
+
const ENV_DECLARED_COMMAND = "tsx tools/check-declared-env.ts";
|
|
602
|
+
/**
|
|
603
|
+
* The spoke's root `package.json` with the declared-env check (#7874 D11):
|
|
604
|
+
* an `env:declared` script after `claude-md:check`, and `preflight` running
|
|
605
|
+
* it right after `claude-md:check`. Applied after `transformRootPackageJson`.
|
|
606
|
+
* @throws TopologyError when either anchor is missing.
|
|
607
|
+
*/
|
|
608
|
+
export function addEnvCheckScripts(text) {
|
|
609
|
+
const lines = text.split("\n");
|
|
610
|
+
const anchor = lines.findIndex((line) => /^\s*"claude-md:check"\s*:/.test(line));
|
|
611
|
+
if (anchor === -1) {
|
|
612
|
+
throw new TopologyError(`package.json: no "claude-md:check" script to add ${ENV_DECLARED_SCRIPT} after`);
|
|
613
|
+
}
|
|
614
|
+
const preflightAt = lines.findIndex((line) => /^\s*"preflight"\s*:/.test(line));
|
|
615
|
+
const step = "pnpm claude-md:check && ";
|
|
616
|
+
if (preflightAt === -1 || !(lines[preflightAt] ?? "").includes(step)) {
|
|
617
|
+
throw new TopologyError(`package.json: no "preflight" script running "${step.trim()}" to add ${ENV_DECLARED_SCRIPT} to`);
|
|
618
|
+
}
|
|
619
|
+
lines[preflightAt] = (lines[preflightAt] ?? "").replace(step, `${step}pnpm ${ENV_DECLARED_SCRIPT} && `);
|
|
620
|
+
const indent = /^\s*/.exec(lines[anchor] ?? "")?.[0] ?? "";
|
|
621
|
+
lines.splice(anchor + 1, 0, `${indent}${JSON.stringify(ENV_DECLARED_SCRIPT)}: ${JSON.stringify(ENV_DECLARED_COMMAND)},`);
|
|
622
|
+
const next = lines.join("\n");
|
|
623
|
+
try {
|
|
624
|
+
JSON.parse(next);
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
throw new TopologyError(`package.json: adding ${ENV_DECLARED_SCRIPT} left it unparseable: ${err.message}`);
|
|
628
|
+
}
|
|
629
|
+
return next;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* The spoke's `.github/workflows/ci.yml` (#7874 D11): a `pnpm env:declared`
|
|
633
|
+
* step after `pnpm claude-md:check`, and no `pnpm db:generate` step — a
|
|
634
|
+
* spoke has no `database/` and no `db:*` script, so that step would fail
|
|
635
|
+
* first (#7962). The comment block directly above the dropped step goes with
|
|
636
|
+
* it.
|
|
637
|
+
* @throws TopologyError when either anchor is missing.
|
|
638
|
+
*/
|
|
639
|
+
export function transformCiWorkflow(text) {
|
|
640
|
+
const lines = text.split("\n");
|
|
641
|
+
const dbAt = lines.findIndex((line) => /^\s*- run: pnpm db:generate\s*$/.test(line));
|
|
642
|
+
if (dbAt === -1) {
|
|
643
|
+
throw new TopologyError(`.github/workflows/ci.yml: no "- run: pnpm db:generate" step to remove`);
|
|
644
|
+
}
|
|
645
|
+
let start = dbAt;
|
|
646
|
+
while (start > 0 && /^\s*#/.test(lines[start - 1] ?? ""))
|
|
647
|
+
start--;
|
|
648
|
+
// Drop the blank line that separated the removed block from the step above.
|
|
649
|
+
const dropBlank = start > 0 && (lines[start - 1] ?? "").trim() === "" ? 1 : 0;
|
|
650
|
+
lines.splice(start - dropBlank, dbAt - start + 1 + dropBlank);
|
|
651
|
+
const anchor = lines.findIndex((line) => /^\s*- run: pnpm claude-md:check\s*$/.test(line));
|
|
652
|
+
if (anchor === -1) {
|
|
653
|
+
throw new TopologyError(`.github/workflows/ci.yml: no "- run: pnpm claude-md:check" step to add ${ENV_DECLARED_SCRIPT} after`);
|
|
654
|
+
}
|
|
655
|
+
const indent = /^\s*/.exec(lines[anchor] ?? "")?.[0] ?? "";
|
|
656
|
+
lines.splice(anchor + 1, 0, "", `${indent}# A process.env read with no project.yaml env_contract row — the same check \`pnpm preflight\` runs locally.`, `${indent}- run: pnpm ${ENV_DECLARED_SCRIPT}`);
|
|
657
|
+
return lines.join("\n");
|
|
658
|
+
}
|
|
659
|
+
/** The root `tsconfig.json` without a reference under a removed root. */
|
|
660
|
+
export function transformRootTsconfig(text, opts) {
|
|
661
|
+
const roots = spokeRemovedRoots(opts);
|
|
662
|
+
const refLine = /"path"\s*:\s*"([^"]+)"/;
|
|
663
|
+
return dropLines(text, (line) => {
|
|
664
|
+
const m = refLine.exec(line);
|
|
665
|
+
return (m !== null &&
|
|
666
|
+
roots.some((root) => underPrefix(toPosix(m[1] ?? ""), root)));
|
|
667
|
+
}, "tsconfig.json", true);
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* An app's `package.json` without its `@working-theory/database` dependency.
|
|
671
|
+
* @throws TopologyError when `required` and the dependency is absent.
|
|
672
|
+
*/
|
|
673
|
+
export function transformAppPackageJson(text, label, required) {
|
|
674
|
+
const depLine = /^\s*"@working-theory\/database"\s*:/;
|
|
675
|
+
if (required && !text.split("\n").some((line) => depLine.test(line))) {
|
|
676
|
+
throw new TopologyError(`${label}: no "@working-theory/database" dependency to remove`);
|
|
677
|
+
}
|
|
678
|
+
return dropLines(text, (line) => depLine.test(line), label, true);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Apply every D6 root-file transform to the spoke at `targetDir` (after the
|
|
682
|
+
* base copy, before any module or template layer), and write the spoke
|
|
683
|
+
* render of each seam it carries (D5). Also the declared-env check's root
|
|
684
|
+
* script and CI step (#7874 D11), and the browser API origin seam (D12).
|
|
685
|
+
* @throws TopologyError naming the file whose anchor is missing.
|
|
686
|
+
*/
|
|
687
|
+
export function applySpokeTransforms(targetDir, opts) {
|
|
688
|
+
const edit = (rel, transform) => {
|
|
689
|
+
const path = join(targetDir, rel);
|
|
690
|
+
if (!existsSync(path)) {
|
|
691
|
+
throw new TopologyError(`${rel}: missing, so the spoke transform cannot run`);
|
|
692
|
+
}
|
|
693
|
+
const text = readFileSync(path, "utf8");
|
|
694
|
+
const next = transform(text);
|
|
695
|
+
if (next !== text)
|
|
696
|
+
writeFileSync(path, next);
|
|
697
|
+
};
|
|
698
|
+
edit("pnpm-workspace.yaml", transformWorkspaceYaml);
|
|
699
|
+
edit("package.json", (text) => addEnvCheckScripts(transformRootPackageJson(text)));
|
|
700
|
+
edit(".github/workflows/ci.yml", transformCiWorkflow);
|
|
701
|
+
edit("tsconfig.json", (text) => transformRootTsconfig(text, opts));
|
|
702
|
+
edit("apps/web/package.json", (text) => transformAppPackageJson(text, "apps/web/package.json", true));
|
|
703
|
+
if (opts.mcp) {
|
|
704
|
+
edit("apps/mcp/package.json", (text) => transformAppPackageJson(text, "apps/mcp/package.json", false));
|
|
705
|
+
}
|
|
706
|
+
edit("project.yaml", (text) => transformProjectYaml(text, opts));
|
|
707
|
+
writeFileSync(join(targetDir, WEB_SEAM_PATH), renderWebSeam("spoke", opts.signIn));
|
|
708
|
+
// #7894 D8 — the gate seam lives in the `(authed)` group only the hub
|
|
709
|
+
// sign-in mode keeps.
|
|
710
|
+
if (opts.signIn === "hub") {
|
|
711
|
+
writeFileSync(join(targetDir, AUTHED_GATE_SEAM_PATH), renderAuthedGateSeam("spoke", "hub"));
|
|
712
|
+
}
|
|
713
|
+
writeFileSync(join(targetDir, API_ORIGIN_SEAM_PATH), renderApiOriginSeam("spoke"));
|
|
714
|
+
if (opts.mcp) {
|
|
715
|
+
writeFileSync(join(targetDir, MCP_SEAM_PATH), renderMcpSeam("spoke"));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/** The env var a spoke reaches its hub through. */
|
|
719
|
+
export const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
|
|
720
|
+
/** Env files `next start` loads for an app, highest precedence first. */
|
|
721
|
+
const NEXT_ENV_FILES = [
|
|
722
|
+
".env.production.local",
|
|
723
|
+
".env.local",
|
|
724
|
+
".env.production",
|
|
725
|
+
".env",
|
|
726
|
+
];
|
|
727
|
+
/**
|
|
728
|
+
* Write `AILK_API_URL=<origin>` to the spoke's `apps/web/.env.local` (D2) —
|
|
729
|
+
* the `auth-secret-file.ts` never-clobber rule: an assignment already in any
|
|
730
|
+
* env file Next loads for `apps/web` is kept, and `.env.local` stays
|
|
731
|
+
* gitignored. Never writes `AILK_SPOKE_KEY` or `AILK_VISITOR_FORWARD_KEY`:
|
|
732
|
+
* the hub issues those.
|
|
733
|
+
*/
|
|
734
|
+
export function writeHubOriginEnv(targetDir, origin) {
|
|
735
|
+
writeWebEnv(targetDir, HUB_ORIGIN_ENV_VAR, () => origin);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* `<name>=<value()>` into the spoke's `apps/web/.env.local`, unless any env
|
|
739
|
+
* file Next loads for `apps/web` already assigns `name` (`value` is then never
|
|
740
|
+
* called); and `.env.local` gitignored either way.
|
|
741
|
+
*/
|
|
742
|
+
function writeWebEnv(targetDir, name, value) {
|
|
743
|
+
const webDir = join(targetDir, "apps", "web");
|
|
744
|
+
const assigned = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*\\S`, "m");
|
|
745
|
+
const already = NEXT_ENV_FILES.some((file) => {
|
|
746
|
+
const path = join(webDir, file);
|
|
747
|
+
return existsSync(path) && assigned.test(readFileSync(path, "utf8"));
|
|
748
|
+
});
|
|
749
|
+
if (!already) {
|
|
750
|
+
const file = join(webDir, ".env.local");
|
|
751
|
+
const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
752
|
+
writeOwnerOnly(file, appendLine(existing, `${name}=${value()}`));
|
|
753
|
+
}
|
|
754
|
+
const ignorePath = join(targetDir, ".gitignore");
|
|
755
|
+
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
|
|
756
|
+
const covered = ignore
|
|
757
|
+
.split("\n")
|
|
758
|
+
.some((line) => ENV_LOCAL_IGNORE_LINES.has(line.trim()));
|
|
759
|
+
if (!covered)
|
|
760
|
+
writeFileSync(ignorePath, appendLine(ignore, ".env.local"));
|
|
761
|
+
}
|
|
762
|
+
/** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
|
|
763
|
+
export const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
|
|
764
|
+
/** The env var holding a hub-mode spoke's own cookie key material (#7894 D7, D11). */
|
|
765
|
+
export const HUB_SESSION_SECRET_ENV_VAR = "AILK_HUB_SESSION_SECRET";
|
|
766
|
+
/**
|
|
767
|
+
* Write a hub-mode spoke's sign-in env (#7894 D11) to `apps/web/.env.local`,
|
|
768
|
+
* under `writeHubOriginEnv`'s never-clobber rule: the hub's web origin, and a
|
|
769
|
+
* fresh session secret (32 random bytes, 43 base64url characters). Never the
|
|
770
|
+
* client id or client secret — the hub issues those (`spokes sign-in-client`)
|
|
771
|
+
* — and never `BETTER_AUTH_SECRET` or a database URL.
|
|
772
|
+
*/
|
|
773
|
+
export function writeHubSignInEnv(targetDir, origin) {
|
|
774
|
+
writeWebEnv(targetDir, HUB_SIGN_IN_URL_ENV_VAR, () => origin);
|
|
775
|
+
writeWebEnv(targetDir, HUB_SESSION_SECRET_ENV_VAR, () => randomBytes(32).toString("base64url"));
|
|
776
|
+
}
|
|
777
|
+
// ── D9 — the spoke's hard floors ────────────────────────────────────────────
|
|
778
|
+
/** The `hard_floors:` block a spoke's `project.yaml` carries (D9). */
|
|
779
|
+
export function renderSpokeHardFloors(opts) {
|
|
780
|
+
const lines = [
|
|
781
|
+
"hard_floors:",
|
|
782
|
+
" # Surfaces that always require human review on merge, regardless of",
|
|
783
|
+
" # automation level. This site is a SPOKE (create-ailk --topology spoke):",
|
|
784
|
+
" # it has no apps/api, no database and no sign-in of its own. Pricing, the",
|
|
785
|
+
" # Stripe webhook route, the checkout route and its price map, and the",
|
|
786
|
+
" # sign-in paths are floored so their arrival here is a human merge.",
|
|
787
|
+
" - kind: pricing",
|
|
788
|
+
" - kind: stripe_live_mode",
|
|
789
|
+
" - paths:",
|
|
790
|
+
' - "apps/web/app/api/webhooks/stripe/**"',
|
|
791
|
+
" reason: Stripe webhook route — payment state requires human review",
|
|
792
|
+
" - paths:",
|
|
793
|
+
' - "apps/web/app/api/checkout/**"',
|
|
794
|
+
' - "apps/web/lib/checkout-config.ts"',
|
|
795
|
+
" reason: Spoke checkout — the buy route and the price map require human review",
|
|
796
|
+
" - paths:",
|
|
797
|
+
' - "apps/web/auth.ts"',
|
|
798
|
+
' - "apps/web/app/api/auth/**"',
|
|
799
|
+
' - "apps/web/app/[locale]/(auth)/**"',
|
|
800
|
+
' - "apps/web/app/[locale]/(authed)/**"',
|
|
801
|
+
' - "apps/web/lib/hub-session/**"',
|
|
802
|
+
' - "apps/web/app/api/hub-session/**"',
|
|
803
|
+
opts.signIn === "hub"
|
|
804
|
+
? " reason: sign-in paths — this spoke signs in through its hub; every change requires human review"
|
|
805
|
+
: " reason: sign-in paths — absent on a spoke; their return requires human review",
|
|
806
|
+
];
|
|
807
|
+
if (opts.mcp) {
|
|
808
|
+
lines.push(" - paths:", ' - "apps/mcp/src/tools/**"', " reason: MCP tool surface — signatures require human review");
|
|
809
|
+
}
|
|
810
|
+
return `${lines.join("\n")}\n`;
|
|
811
|
+
}
|
|
812
|
+
/** `project.yaml` with its `hard_floors:` block replaced by the spoke's (D9). */
|
|
813
|
+
export function transformProjectYaml(text, opts) {
|
|
814
|
+
const lines = text.split("\n");
|
|
815
|
+
const start = lines.findIndex((line) => line === "hard_floors:");
|
|
816
|
+
if (start === -1 || lines.indexOf("hard_floors:", start + 1) !== -1) {
|
|
817
|
+
throw new TopologyError(`project.yaml: no single top-level "hard_floors:" block to replace`);
|
|
818
|
+
}
|
|
819
|
+
let end = start + 1;
|
|
820
|
+
while (end < lines.length && !/^\S/.test(lines[end] ?? ""))
|
|
821
|
+
end++;
|
|
822
|
+
// Keep the blank line(s) that separate the block from the next key.
|
|
823
|
+
while (end > start + 1 && (lines[end - 1] ?? "").trim() === "")
|
|
824
|
+
end--;
|
|
825
|
+
const block = renderSpokeHardFloors(opts).replace(/\n$/, "").split("\n");
|
|
826
|
+
return [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n");
|
|
827
|
+
}
|
|
828
|
+
// ── D7 — the module admission ───────────────────────────────────────────────
|
|
829
|
+
/** Why a module is not admitted on a spoke, and where its admission lives. */
|
|
830
|
+
export const SPOKE_REFUSAL_REASONS = {
|
|
831
|
+
"multi-tenant": "it needs a database or the Admin app",
|
|
832
|
+
workspace: "it needs a database or the Admin app",
|
|
833
|
+
};
|
|
834
|
+
/** The modules a spoke may carry — every slice declaring `spoke: true`. */
|
|
835
|
+
export function spokeModules() {
|
|
836
|
+
return RECOGNIZED_MODULES.filter((name) => MODULE_SLICES[name].spoke === true);
|
|
837
|
+
}
|
|
838
|
+
/** The refusal for a module a spoke cannot carry, or undefined when admitted. */
|
|
839
|
+
export function spokeModuleRefusal(name) {
|
|
840
|
+
if (MODULE_SLICES[name].spoke === true)
|
|
841
|
+
return undefined;
|
|
842
|
+
const reason = SPOKE_REFUSAL_REASONS[name] ?? "it is not admitted on a spoke";
|
|
843
|
+
return `module "${name}" cannot be added to a spoke: ${reason}. A spoke carries ${spokeModules().join(", ")}.`;
|
|
844
|
+
}
|
|
845
|
+
// ── D10 — reading a site's topology ─────────────────────────────────────────
|
|
846
|
+
/** The manifest a templated scaffold writes (`site-roster.ts` `SITE_ROSTER_PATH`). */
|
|
847
|
+
const SITE_MANIFEST_PATH = ".ailk-site-manifest.json";
|
|
848
|
+
/**
|
|
849
|
+
* The topology `<targetDir>` records. A missing manifest, or one without a
|
|
850
|
+
* `topology` key, reads as `"hub"` — every site scaffolded before #7426.
|
|
851
|
+
* @throws TopologyError on an unreadable manifest or an unknown value.
|
|
852
|
+
*/
|
|
853
|
+
export function readSiteTopology(targetDir) {
|
|
854
|
+
const path = join(targetDir, SITE_MANIFEST_PATH);
|
|
855
|
+
if (!existsSync(path))
|
|
856
|
+
return "hub";
|
|
857
|
+
let doc;
|
|
858
|
+
try {
|
|
859
|
+
doc = JSON.parse(readFileSync(path, "utf8"));
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
throw new TopologyError(`${SITE_MANIFEST_PATH} is not JSON, so this site's topology is unknown: ${err.message}`);
|
|
863
|
+
}
|
|
864
|
+
const value = doc["topology"];
|
|
865
|
+
if (value === undefined)
|
|
866
|
+
return "hub";
|
|
867
|
+
if (typeof value === "string" &&
|
|
868
|
+
TOPOLOGIES.includes(value)) {
|
|
869
|
+
return value;
|
|
870
|
+
}
|
|
871
|
+
throw new TopologyError(`${SITE_MANIFEST_PATH} records an unknown topology ${JSON.stringify(value)} (expected ${TOPOLOGIES.join(" or ")})`);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* The spoke options `<targetDir>` records (#7894 D1), for the later commands
|
|
875
|
+
* (`sync-routes`, `add-module`): `undefined` on a hub; on a spoke, `mcp` from
|
|
876
|
+
* whether it still carries `apps/mcp` and `signIn` from the manifest's
|
|
877
|
+
* `signIn` key — absent reads as `"off"`, every spoke before #7894.
|
|
878
|
+
* @throws TopologyError on an unreadable manifest or an unknown value.
|
|
879
|
+
*/
|
|
880
|
+
export function readSpokeShape(targetDir) {
|
|
881
|
+
if (readSiteTopology(targetDir) !== "spoke")
|
|
882
|
+
return undefined;
|
|
883
|
+
const doc = JSON.parse(readFileSync(join(targetDir, SITE_MANIFEST_PATH), "utf8"));
|
|
884
|
+
const value = doc["signIn"];
|
|
885
|
+
if (value !== undefined && value !== "hub") {
|
|
886
|
+
throw new TopologyError(`${SITE_MANIFEST_PATH} records an unknown sign-in mode ${JSON.stringify(value)} (expected "hub", or no key)`);
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
mcp: existsSync(join(targetDir, SPOKE_MCP_ROOT)),
|
|
890
|
+
signIn: value === "hub" ? "hub" : "off",
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
// ── D2 — the hub origin ─────────────────────────────────────────────────────
|
|
894
|
+
/**
|
|
895
|
+
* Hosts a spoke may reach over plain `http:` — the set
|
|
896
|
+
* `packages/api-client/src/http.ts` sends the spoke key under. Named here
|
|
897
|
+
* rather than imported: create-ailk carries no runtime dependency.
|
|
898
|
+
*/
|
|
899
|
+
export const LOOPBACK_HOSTS = ["localhost", "127.0.0.1"];
|
|
900
|
+
/**
|
|
901
|
+
* The hub origin for a spoke: `--hub-url`, else `AILK_API_URL`. An `https:`
|
|
902
|
+
* URL, or `http:` on a loopback host, with no path but `/`, no query and no
|
|
903
|
+
* fragment — normalized to its origin with no trailing slash.
|
|
904
|
+
* @throws TopologyError naming both inputs when neither is set or the value
|
|
905
|
+
* is not such an origin. Never echoes a query or fragment.
|
|
906
|
+
*/
|
|
907
|
+
export function normalizeHubUrl(raw) {
|
|
908
|
+
const inputs = "Pass --hub-url <origin>, or set AILK_API_URL";
|
|
909
|
+
if (raw === undefined || raw.trim() === "") {
|
|
910
|
+
throw new TopologyError(`a spoke needs its hub's origin, and none was given. ${inputs} (e.g. https://hub.example.com).`);
|
|
911
|
+
}
|
|
912
|
+
return normalizeOrigin(raw, "the hub origin", inputs);
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* The hub's WEB origin a hub-mode spoke signs in at (`--hub-sign-in`, #7894
|
|
916
|
+
* D1) — `normalizeHubUrl`'s rules, each refusal naming the flag.
|
|
917
|
+
* @throws TopologyError when `raw` is not such an origin.
|
|
918
|
+
*/
|
|
919
|
+
export function normalizeHubSignInUrl(raw) {
|
|
920
|
+
return normalizeOrigin(raw, "the --hub-sign-in origin", "Pass --hub-sign-in <origin>");
|
|
921
|
+
}
|
|
922
|
+
/** `raw` normalized to an https (or loopback http) origin; refusals name `what` and `inputs`. */
|
|
923
|
+
function normalizeOrigin(raw, what, inputs) {
|
|
924
|
+
let url;
|
|
925
|
+
try {
|
|
926
|
+
url = new URL(raw.trim());
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
throw new TopologyError(`${what} is not a URL. ${inputs} to an origin such as https://hub.example.com.`);
|
|
930
|
+
}
|
|
931
|
+
const loopback = LOOPBACK_HOSTS.includes(url.hostname);
|
|
932
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
933
|
+
throw new TopologyError(`${what} must be https: (http: only on ${LOOPBACK_HOSTS.join(" or ")}), got ${url.protocol}//${url.host}. ${inputs}.`);
|
|
934
|
+
}
|
|
935
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
|
|
936
|
+
throw new TopologyError(`${what} must be an origin only — no path, query or fragment. ${inputs} to ${url.origin}.`);
|
|
937
|
+
}
|
|
938
|
+
if (url.username !== "" || url.password !== "") {
|
|
939
|
+
throw new TopologyError(`${what} must not carry credentials. ${inputs} to ${url.protocol}//${url.host}.`);
|
|
940
|
+
}
|
|
941
|
+
return url.origin;
|
|
942
|
+
}
|