create-ailk 0.10.0 → 0.11.1

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.
Files changed (103) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.js +5 -1
  3. package/dist/content-payloads.d.ts +37 -0
  4. package/dist/content-payloads.js +64 -0
  5. package/dist/lib/fetch-module.d.ts +3 -3
  6. package/dist/lib/fetch-module.js +11 -5
  7. package/dist/lib/fetch-template.d.ts +1 -1
  8. package/dist/lib/fetch-template.js +7 -4
  9. package/dist/lib/scaffold-render-sweep.d.ts +16 -0
  10. package/dist/lib/scaffold-render-sweep.js +52 -3
  11. package/dist/module-architecture.d.ts +9 -0
  12. package/dist/module-architecture.js +43 -12
  13. package/dist/parse-args.d.ts +2 -0
  14. package/dist/parse-args.js +7 -0
  15. package/dist/surfaces.js +2 -3
  16. package/package.json +3 -3
  17. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +4 -3
  18. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +8 -8
  19. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +8 -8
  20. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +8 -8
  21. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +8 -8
  22. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +8 -8
  23. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +8 -8
  24. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +8 -8
  25. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +8 -8
  26. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +8 -8
  27. package/templates/.claude/settings.json +12 -0
  28. package/templates/.env.example +1 -5
  29. package/templates/CLAUDE.md +38 -22
  30. package/templates/README.md +12 -12
  31. package/templates/apps/api/jest.config.cjs +1 -0
  32. package/templates/apps/api/package.json +2 -1
  33. package/templates/apps/mcp/CLAUDE.md +23 -10
  34. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +160 -17
  35. package/templates/apps/mcp/__tests__/index.test.ts +118 -1
  36. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +425 -5
  37. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +67 -3
  38. package/templates/apps/mcp/__tests__/server.test.ts +162 -1
  39. package/templates/apps/mcp/package.json +1 -1
  40. package/templates/apps/mcp/src/index.ts +35 -3
  41. package/templates/apps/mcp/src/lib/commerce-claim.ts +193 -24
  42. package/templates/apps/mcp/src/server.ts +77 -5
  43. package/templates/apps/mcp/src/tools/purchase.ts +1 -1
  44. package/templates/apps/web/CLAUDE.md +1 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +15 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +10 -3
  47. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +0 -10
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +0 -10
  49. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +1 -14
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +0 -10
  51. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +0 -10
  52. package/templates/apps/web/app/[locale]/for/[slug]/page.tsx +0 -10
  53. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +1 -14
  54. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +0 -10
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +0 -10
  56. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +0 -10
  57. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +0 -10
  58. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +1 -15
  59. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +0 -10
  60. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +0 -10
  61. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +0 -10
  62. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +1 -14
  63. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +4 -16
  64. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +3 -15
  65. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +0 -10
  66. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +1 -14
  67. package/templates/apps/web/src/lib/commerce-claim.ts +15 -6
  68. package/templates/content/_site.mdx +23 -4
  69. package/templates/database/CHANGELOG.md +7 -0
  70. package/templates/database/package.json +1 -1
  71. package/versions.json +34 -34
  72. package/templates/.claude/skills/README.md +0 -37
  73. package/templates/.claude/skills/add-auth/SKILL.md +0 -313
  74. package/templates/.claude/skills/add-auth/references/env-vars.md +0 -106
  75. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +0 -180
  76. package/templates/.claude/skills/add-page/SKILL.md +0 -260
  77. package/templates/.claude/skills/add-page/references/page-types.md +0 -123
  78. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +0 -87
  79. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +0 -99
  80. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +0 -18
  81. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +0 -43
  82. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +0 -52
  83. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +0 -38
  84. package/templates/.claude/skills/check-site/SKILL.md +0 -245
  85. package/templates/.claude/skills/check-site/references/common-errors.md +0 -116
  86. package/templates/.claude/skills/check-site/references/findings-glossary.md +0 -140
  87. package/templates/.claude/skills/check-site/references/severity-levels.md +0 -114
  88. package/templates/.claude/skills/fill-page/SKILL.md +0 -199
  89. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +0 -80
  90. package/templates/.claude/skills/fill-page/references/section-sources.md +0 -41
  91. package/templates/.claude/skills/setup-database/SKILL.md +0 -156
  92. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +0 -154
  93. package/templates/.claude/skills/setup-deploy/SKILL.md +0 -312
  94. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +0 -204
  95. package/templates/.claude/skills/setup-deploy/references/vercel-json-template.md +0 -98
  96. package/templates/.claude/skills/update-page/SKILL.md +0 -117
  97. package/templates/.claude/skills/update-page/scripts/update-page.ts +0 -640
  98. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +0 -31
  99. package/templates/.claude/skills/upgrade-plan/SKILL.md +0 -121
  100. package/templates/.claude/skills/upgrade-plan/references/options.md +0 -24
  101. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +0 -527
  102. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +0 -75
  103. package/templates/apps/mcp/src/lib/require-paid-tier.ts +0 -44
@@ -1,121 +0,0 @@
1
- ---
2
- name: upgrade-plan
3
- description: Explain the AI Launch Kit options — Free, Pro, Pro Plus, Agency and the commerce add-on's three rungs (Commerce, SaaS, SaaS with Usage) — side by side, with the prices the public pricing page publishes right now, read this site's own modules and shape, and recommend exactly one option with a reason. Use when the user asks "what does Pro get me?", "should I upgrade?", "compare plans", "which plan do I need?", "how much is commerce?", or types /upgrade-plan. The skill reads and explains only — it never buys, never installs, and never states a price from memory.
4
- ---
5
-
6
- # upgrade-plan
7
-
8
- Answer "what does a paid level get me, and which one should I buy?" from two
9
- live sources: the public pricing page (for prices) and this repository (for
10
- what the site actually uses). A bundled helper does the reading and the
11
- choosing; this skill runs it and explains the result in plain words.
12
-
13
- ## When to use
14
-
15
- - "what does Pro get me?" / "what's in Pro Plus?"
16
- - "should I upgrade?" / "which plan do I need?"
17
- - "compare the plans" / "how does Agency work?"
18
- - "can I sell things on my site?" / "how much is the commerce add-on?"
19
- - `/upgrade-plan`
20
-
21
- Skip this skill when the user wants to actually buy or install something —
22
- this skill explains and recommends; the purchase happens on the pricing
23
- page, and installing a module is a separate step (see "What happens next").
24
-
25
- ## The mechanic
26
-
27
- ### Step 1 — Map the user's stated need (optional)
28
-
29
- If the user said what they want to do, map it to one `--need` value:
30
-
31
- | The user says… | `--need` |
32
- | ------------------------------------------------- | -------------- |
33
- | "I build sites for clients" | `agency` |
34
- | "I want to sell / take payments" | `sell` |
35
- | "my product bills its own customers on plans" | `saas` |
36
- | "I bill my customers by usage / credits" | `usage` |
37
- | "I need docs or a blog" | `docs` |
38
- | "I need booking / scheduling" | `scheduling` |
39
- | "I need organizations / teams / many workspaces" | `multi-tenant` |
40
-
41
- If the need maps to none of these, omit `--need`, and tell the user which
42
- need you could not map — the recommendation then comes from the site alone.
43
-
44
- ### Step 2 — Run the helper
45
-
46
- From the project root:
47
-
48
- ```bash
49
- pnpm exec tsx .claude/skills/upgrade-plan/scripts/upgrade-plan.ts [--need <value>]
50
- ```
51
-
52
- It prints one JSON object:
53
-
54
- - `pricing.status` — `ok` (the page published prices), `unpublished` (the
55
- page loaded but carries no machine-readable offers) or `unreachable`
56
- (network error, timeout or error status). `pricing.url` is the link to give
57
- the user.
58
- - `options[]` — the seven options, always all seven, each with `adds`,
59
- `requires`, and `prices[]` (empty, with `status: "not published on the
60
- pricing page"`, when the page does not price it).
61
- - `alsoPublished[]` — other offers on the pricing page that are none of the
62
- seven (for example a one-off service), with their published price.
63
- - `site` — this site's `modules`, `template`, `shape`, `design`, whether it
64
- already prices something (`sellsSomething`), whether commerce is installed,
65
- and whether `AILK_LICENSE_KEY` is set in this shell (`licenseKeySet` —
66
- presence only, never the value).
67
- - `recommendation` — one `option`, its `reason`, and its `link`.
68
-
69
- Exit status 0 in all three pricing states. Exit 2 is a usage error — re-run
70
- without the argument it names.
71
-
72
- ### Step 3 — Explain it
73
-
74
- Answer in this order, in plain words:
75
-
76
- 1. **Your site.** State the shape and the modules from `site`, the template
77
- and design when recorded, whether it already prices anything, and whether
78
- a licence key is set in this shell. A `null` field is "not recorded —
79
- scaffolded without `--template`"; never guess a shape. If the key is not
80
- set, say "not set in this shell" (a key kept only in `.env` reads that way).
81
- 2. **The options, side by side.** A table of all seven options: name, what it
82
- adds, what it requires, and its price exactly as `prices[]` gives it
83
- (figure, currency and unit text). An option with no price reads
84
- "not published on the pricing page — see https://ailaunchkit.ai/pricing".
85
- Then list `alsoPublished` under "Also on the pricing page".
86
- 3. **The recommendation.** Exactly one option — `recommendation.option` —
87
- with its reason, naming the module or need that decided it. Do not offer a
88
- second recommendation; you may say what would change it (for example "if
89
- you add multi-tenant later, that moves you to Pro Plus").
90
- 4. **What happens next.** Link `recommendation.link`. After purchase the
91
- licence key arrives by email. To install a paid module into this site
92
- in place, run `AILK_LICENSE_KEY=<key> pnpm create ailk@latest
93
- add-module <module>` in the site root, once per module; it leaves
94
- every file the site already has as-is except `package.json`
95
- dependency/script keys, `next.config.*` and `jest.config.*`, which it
96
- patches. `pnpm create ailk@latest license set -` (key on stdin) keeps
97
- the key with the site (and, on a site linked to Vercel, sets it there
98
- and redeploys). A different design is
99
- `pnpm create ailk@latest apply-design <name|path>`.
100
-
101
- ## Hard rules
102
-
103
- - **Never state a price that is not in the helper's output for this run.**
104
- Not from memory, not from an earlier run, not from another page, not as an
105
- estimate or a range. A price appears in your answer only if it appears in
106
- this run's `options[].prices` or `alsoPublished`.
107
- - **Unreachable means no prices.** When `pricing.status` is `unreachable`,
108
- say the pricing page could not be reached, give no price for any option,
109
- and link https://ailaunchkit.ai/pricing. Still show the site and the
110
- recommendation — neither depends on a price.
111
- - **Unpublished means no prices.** When `pricing.status` is `unpublished`,
112
- say the pricing page does not publish machine-readable prices yet and link
113
- https://ailaunchkit.ai/pricing.
114
- - **Read-only.** Write no file and install nothing. The helper is the only
115
- command you run; `add-module`, `license set` and `apply-design` are named
116
- for the owner to run, never run by you.
117
- - **Never print the licence key.** Report only whether it is set.
118
-
119
- ## References
120
-
121
- - `references/options.md` — the seven options in plain words.
@@ -1,24 +0,0 @@
1
- # The options
2
-
3
- What each option adds and what it requires, in plain words. Names and
4
- structure only — prices come from https://ailaunchkit.ai/pricing at run time,
5
- never from this file.
6
-
7
- Source: the AI Launch Kit product vocabulary (Commerce; Packaging and
8
- delivery). Levels are Free, Pro and Pro Plus; Agency is an agreement, not a
9
- level; Commerce, SaaS and SaaS with Usage are the three rungs of the one
10
- commerce add-on, bought on top of a level.
11
-
12
- | Option | What it adds | Requires |
13
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
14
- | **Free** | The open-source base: the website, its API and MCP server, and the marketing module. Every site is agent-discoverable at this level. | Nothing — no licence key |
15
- | **Pro** | The paid modules for one site — docs and blog, scheduling, signal collectors, the Admin app — kept current. | A licence key for the site |
16
- | **Pro Plus** | Everything in Pro, plus the multi-tenant module: organizations, workspaces and members. | A licence key for the site |
17
- | **Agency** | A reseller agreement for building sites for clients. Not a bigger level. | Each client site is licensed Pro or Pro Plus |
18
- | **Commerce** | Checkout, payment links, hosted invoices, subscriptions and the customer portal, through your own Stripe account. | Any paid level (Pro or Pro Plus) |
19
- | **SaaS** | Commerce plus plans, trials, coupons and entitlements mapped to feature flags — for a product that bills its tenants. | Pro Plus |
20
- | **SaaS with Usage** | SaaS plus usage metering, prepaid credits, overage and spend limits. | Pro Plus |
21
-
22
- Payment always happens on a Stripe-hosted page; the site never renders a card
23
- form. Paid bookings (deposits, no-show holds) belong to the scheduling module,
24
- not to Commerce.
@@ -1,527 +0,0 @@
1
- /**
2
- * upgrade-plan — the helper behind the `/upgrade-plan` skill.
3
- *
4
- * Reads the paid options' prices from ONE public surface — the schema.org
5
- * `Offer` JSON-LD on https://ailaunchkit.ai/pricing — at run time, reads this
6
- * site's own modules / template / shape / design from files the scaffold
7
- * already carries, and recommends exactly one option from a price-free rule
8
- * table (levels per the AI Launch Kit product vocabulary). It holds no price of
9
- * its own, caches nothing, and writes nothing.
10
- *
11
- * Run from the project root:
12
- *
13
- * pnpm exec tsx .claude/skills/upgrade-plan/scripts/upgrade-plan.ts [--need X] [--url U | --html-file F] [--root R]
14
- *
15
- * Prints one JSON object. Exit 0 whether the page was priced, unpriced or
16
- * unreachable (each is a reported state); exit 2 on a usage error.
17
- */
18
- import { existsSync, readFileSync, readdirSync } from "node:fs";
19
- import { join } from "node:path";
20
-
21
- export const PRICING_URL = "https://ailaunchkit.ai/pricing";
22
- const DEFAULT_TIMEOUT_MS = 10_000;
23
-
24
- export type OptionId = "free" | "pro" | "pro-plus" | "agency" | "commerce" | "saas" | "saas-usage";
25
-
26
- export interface OptionInfo {
27
- id: OptionId;
28
- name: string;
29
- adds: string;
30
- requires: string;
31
- /** Normalized names (see `normalizeName`) an `Offer` may carry for this option. */
32
- aliases: string[];
33
- }
34
-
35
- /** The seven options, always listed, in this order. Names and structure only — no figure. */
36
- export const OPTIONS: readonly OptionInfo[] = [
37
- {
38
- id: "free",
39
- name: "Free",
40
- adds: "The open-source base: the website, its API and MCP server, and the marketing module.",
41
- requires: "Nothing — no licence key.",
42
- aliases: ["free", "oss"],
43
- },
44
- {
45
- id: "pro",
46
- name: "Pro",
47
- adds: "The paid modules for one site — docs and blog, scheduling, signal collectors and the Admin app — kept current.",
48
- requires: "A licence key for the site.",
49
- aliases: ["pro"],
50
- },
51
- {
52
- id: "pro-plus",
53
- name: "Pro Plus",
54
- adds: "Everything in Pro, plus the multi-tenant module (organizations, workspaces, members).",
55
- requires: "A licence key for the site.",
56
- aliases: ["pro plus"],
57
- },
58
- {
59
- id: "agency",
60
- name: "Agency",
61
- adds: "A reseller agreement for building sites for clients — not a bigger level.",
62
- requires: "Each client site is then licensed at Pro or Pro Plus.",
63
- aliases: ["agency"],
64
- },
65
- {
66
- id: "commerce",
67
- name: "Commerce",
68
- adds: "The commerce add-on: checkout, payment links, hosted invoices, subscriptions and the customer portal, through your own Stripe account.",
69
- requires: "Any paid level (Pro or Pro Plus).",
70
- aliases: ["commerce", "commerce add-on"],
71
- },
72
- {
73
- id: "saas",
74
- name: "SaaS",
75
- adds: "Commerce plus plans, trials, coupons and entitlements mapped to feature flags — for a product that bills its tenants.",
76
- requires: "Pro Plus.",
77
- aliases: ["saas"],
78
- },
79
- {
80
- id: "saas-usage",
81
- name: "SaaS with Usage",
82
- adds: "SaaS plus usage metering, prepaid credits, overage and spend limits.",
83
- requires: "Pro Plus.",
84
- aliases: ["saas with usage", "saas-usage"],
85
- },
86
- ];
87
-
88
- export const NEEDS = ["agency", "sell", "saas", "usage", "docs", "scheduling", "multi-tenant"] as const;
89
- export type Need = (typeof NEEDS)[number];
90
-
91
- export interface Offer {
92
- name: string;
93
- price?: string;
94
- priceCurrency?: string;
95
- unitText?: string;
96
- url?: string;
97
- }
98
-
99
- export interface Price {
100
- price: string;
101
- priceCurrency?: string;
102
- unitText?: string;
103
- url?: string;
104
- }
105
-
106
- export interface Site {
107
- modules: string[];
108
- template: string | null;
109
- shape: string | null;
110
- design: string | null;
111
- sellsSomething: boolean;
112
- commerceInstalled: boolean;
113
- licenseKeySet: boolean;
114
- }
115
-
116
- export interface Recommendation {
117
- rule: "R1" | "R2" | "R3" | "R4" | "R5" | "R6" | "R7";
118
- option: string;
119
- parts: OptionId[];
120
- reason: string;
121
- }
122
-
123
- export interface PlanOption {
124
- id: OptionId;
125
- name: string;
126
- adds: string;
127
- requires: string;
128
- prices: Price[];
129
- status: "published" | "not published on the pricing page";
130
- }
131
-
132
- export interface Plan {
133
- pricing: {
134
- status: "ok" | "unreachable" | "unpublished";
135
- /** The link shown to the human — always the public pricing page. */
136
- url: string;
137
- /** The URL actually fetched (differs only under `--url`). */
138
- fetchedUrl: string;
139
- reason?: string;
140
- };
141
- options: PlanOption[];
142
- alsoPublished: Offer[];
143
- site: Site;
144
- recommendation: Recommendation & { link: string };
145
- }
146
-
147
- // ── Offers ────────────────────────────────────────────────────────────────
148
-
149
- const LD_JSON_BLOCK = /<script[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
150
-
151
- function str(v: unknown): string | undefined {
152
- if (typeof v === "string" && v.length > 0) return v;
153
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
154
- return undefined;
155
- }
156
-
157
- function isOffer(node: Record<string, unknown>): boolean {
158
- const t = node["@type"];
159
- return t === "Offer" || (Array.isArray(t) && t.includes("Offer"));
160
- }
161
-
162
- function toOffer(node: Record<string, unknown>, parentName: string | undefined): Offer | null {
163
- const name = str(node.name) ?? parentName;
164
- if (name === undefined) return null;
165
- const specs = Array.isArray(node.priceSpecification) ? node.priceSpecification : [node.priceSpecification];
166
- const spec = specs.find((s): s is Record<string, unknown> => typeof s === "object" && s !== null);
167
- const offer: Offer = { name };
168
- const price = str(node.price) ?? str(spec?.price);
169
- const priceCurrency = str(node.priceCurrency) ?? str(spec?.priceCurrency);
170
- const unitText = str(spec?.unitText);
171
- const url = str(node.url);
172
- if (price !== undefined) offer.price = price;
173
- if (priceCurrency !== undefined) offer.priceCurrency = priceCurrency;
174
- if (unitText !== undefined) offer.unitText = unitText;
175
- if (url !== undefined) offer.url = url;
176
- return offer;
177
- }
178
-
179
- function collectOffers(node: unknown, parentName: string | undefined, out: Offer[]): void {
180
- if (Array.isArray(node)) {
181
- for (const item of node) collectOffers(item, parentName, out);
182
- return;
183
- }
184
- if (typeof node !== "object" || node === null) return;
185
- const obj = node as Record<string, unknown>;
186
- if (isOffer(obj)) {
187
- const offer = toOffer(obj, parentName);
188
- if (offer !== null) out.push(offer);
189
- // An offer may carry others (an `addOn`, an `itemOffered` chain) — walk them too.
190
- for (const value of Object.values(obj)) collectOffers(value, offer?.name ?? parentName, out);
191
- return;
192
- }
193
- const ownName = str(obj.name) ?? parentName;
194
- for (const value of Object.values(obj)) collectOffers(value, ownName, out);
195
- }
196
-
197
- /** Every `Offer` node in the page's `application/ld+json` blocks, in document order. */
198
- export function parseOffers(html: string): Offer[] {
199
- const out: Offer[] = [];
200
- for (const match of html.matchAll(LD_JSON_BLOCK)) {
201
- let data: unknown;
202
- try {
203
- data = JSON.parse(match[1] ?? "");
204
- } catch {
205
- continue; // a block that fails to parse is skipped, not fatal
206
- }
207
- collectOffers(data, undefined, out);
208
- }
209
- return out;
210
- }
211
-
212
- /** Lower-case, drop a parenthetical and a product prefix, collapse whitespace. */
213
- export function normalizeName(name: string): string {
214
- return name
215
- .toLowerCase()
216
- .replace(/\([^)]*\)/g, " ")
217
- .replace(/\s+/g, " ")
218
- .trim()
219
- .replace(/^(ailk|ai launch kit) /, "");
220
- }
221
-
222
- function matchOption(offer: Offer): OptionInfo | undefined {
223
- const n = normalizeName(offer.name);
224
- return OPTIONS.find((o) => o.aliases.includes(n));
225
- }
226
-
227
- // ── The site ──────────────────────────────────────────────────────────────
228
-
229
- function readText(path: string): string | null {
230
- try {
231
- return readFileSync(path, "utf8");
232
- } catch {
233
- return null;
234
- }
235
- }
236
-
237
- function readJson(path: string): Record<string, unknown> | null {
238
- const text = readText(path);
239
- if (text === null) return null;
240
- try {
241
- const data: unknown = JSON.parse(text);
242
- return typeof data === "object" && data !== null ? (data as Record<string, unknown>) : null;
243
- } catch {
244
- return null;
245
- }
246
- }
247
-
248
- function listMdx(dir: string): string[] {
249
- if (!existsSync(dir)) return [];
250
- const out: string[] = [];
251
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
252
- const full = join(dir, entry.name);
253
- if (entry.isDirectory()) out.push(...listMdx(full));
254
- else if (entry.isFile() && entry.name.endsWith(".mdx")) out.push(full);
255
- }
256
- return out;
257
- }
258
-
259
- /** True when the file's frontmatter carries a TOP-LEVEL `price:` that is a real figure. */
260
- function hasTopLevelPrice(mdx: string): boolean {
261
- const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdx);
262
- if (fm === null) return false;
263
- const line = /^price:[ \t]*(.*)$/m.exec(fm[1] ?? "");
264
- if (line === null) return false;
265
- const value = (line[1] ?? "").trim().replace(/^["']|["']$/g, "");
266
- return !value.includes("{") && /^\d+(\.\d+)?$/.test(value);
267
- }
268
-
269
- /** What this scaffold records about itself. Each field from its own source; nothing inferred. */
270
- export function readSite(root: string, env: Record<string, string | undefined> = process.env): Site {
271
- const arch = readText(join(root, "architecture.yaml"));
272
- const modulesLine = arch === null ? null : /^# Modules:[ \t]*(.*)$/m.exec(arch);
273
- const modules =
274
- modulesLine === null
275
- ? []
276
- : (modulesLine[1] ?? "")
277
- .split(",")
278
- .map((m) => m.trim())
279
- .filter((m) => m.length > 0);
280
-
281
- let template: string | null = null;
282
- const provenance = readJson(join(root, ".ailk-template.json"));
283
- if (provenance !== null) {
284
- template = str(provenance.template) ?? null;
285
- } else {
286
- const design = readText(join(root, "DESIGN.md"));
287
- const source = design === null ? null : /Source template:[ \t]*(\S+)/.exec(design);
288
- const value = source?.[1];
289
- if (value !== undefined && !value.startsWith("_base")) template = value;
290
- }
291
-
292
- // The scaffold's roster of record (#6658 D7); a scaffold from before it
293
- // carries the same JSON at its root under the template's own file name.
294
- const manifest =
295
- readJson(join(root, ".ailk-site-manifest.json")) ?? readJson(join(root, "TEMPLATE-MANIFEST.json"));
296
- const shape = str(manifest?.shape) ?? null;
297
- const designName = str(manifest?.design) ?? null;
298
-
299
- const sellsSomething = listMdx(join(root, "content")).some((f) => hasTopLevelPrice(readText(f) ?? ""));
300
- const commerceInstalled = existsSync(join(root, ".claude", "skills", "scaffold-commerce"));
301
- // Presence only: the value is reduced to a boolean here and never kept.
302
- const key = env.AILK_LICENSE_KEY;
303
- const licenseKeySet = typeof key === "string" && key.length > 0;
304
-
305
- return { modules, template, shape, design: designName, sellsSomething, commerceInstalled, licenseKeySet };
306
- }
307
-
308
- // ── The recommendation ────────────────────────────────────────────────────
309
-
310
- const PRO_MODULES = ["docs-blog", "scheduling", "signal-collectors", "workspace"];
311
-
312
- /** One recommendation from a price-free rule table; the first matching rule wins. */
313
- export function recommend(site: Site, need?: Need): Recommendation {
314
- const has = (m: string): boolean => site.modules.includes(m);
315
- const multiTenant = has("multi-tenant") || need === "multi-tenant";
316
-
317
- if (need === "agency") {
318
- return {
319
- rule: "R1",
320
- option: "Agency",
321
- parts: ["agency"],
322
- reason: "You build sites for clients; each client site is then Pro or Pro Plus.",
323
- };
324
- }
325
- if (need === "usage") {
326
- return {
327
- rule: "R2",
328
- option: "Pro Plus + SaaS with Usage",
329
- parts: ["pro-plus", "saas-usage"],
330
- reason: "You want to meter your own product's usage; SaaS with Usage requires Pro Plus.",
331
- };
332
- }
333
- if (need === "saas") {
334
- return {
335
- rule: "R3",
336
- option: "Pro Plus + SaaS",
337
- parts: ["pro-plus", "saas"],
338
- reason: "You want to bill your own tenants on plans; SaaS requires Pro Plus.",
339
- };
340
- }
341
- if (multiTenant && !sells(site, need)) {
342
- return {
343
- rule: "R4",
344
- option: "Pro Plus",
345
- parts: ["pro-plus"],
346
- reason: has("multi-tenant")
347
- ? "This site uses the multi-tenant module, which needs Pro Plus."
348
- : "You need multi-tenant (organizations and workspaces), which needs Pro Plus.",
349
- };
350
- }
351
- if (sells(site, need)) {
352
- const why = has("commerce")
353
- ? "This site uses the commerce module"
354
- : site.commerceInstalled
355
- ? "This site has the commerce skill installed"
356
- : site.sellsSomething
357
- ? "This site already has priced pages but no commerce module"
358
- : "You want to take payments on this site";
359
- return multiTenant
360
- ? {
361
- rule: "R5",
362
- option: "Pro Plus + Commerce",
363
- parts: ["pro-plus", "commerce"],
364
- reason: `${why}; the Commerce add-on sits on a paid level, and multi-tenant needs Pro Plus.`,
365
- }
366
- : {
367
- rule: "R5",
368
- option: "Pro + Commerce",
369
- parts: ["pro", "commerce"],
370
- reason: `${why}; the Commerce add-on sits on a paid level, and Pro is the first one.`,
371
- };
372
- }
373
- const proModules = site.modules.filter((m) => PRO_MODULES.includes(m));
374
- if (proModules.length > 0 || need === "docs" || need === "scheduling") {
375
- const named = proModules.length > 0 ? proModules.join(", ") : need === "docs" ? "docs-blog" : "scheduling";
376
- return {
377
- rule: "R6",
378
- option: "Pro",
379
- parts: ["pro"],
380
- reason: `${proModules.length > 0 ? "This site uses" : "You want"} ${named}, which Pro covers.`,
381
- };
382
- }
383
- return {
384
- rule: "R7",
385
- option: "Free",
386
- parts: ["free"],
387
- reason:
388
- "This site is marketing only with nothing priced, so what it uses today is covered by Free; Pro adds a kept-current foundation and the paid modules.",
389
- };
390
- }
391
-
392
- function sells(site: Site, need?: Need): boolean {
393
- return site.modules.includes("commerce") || site.commerceInstalled || need === "sell" || site.sellsSomething;
394
- }
395
-
396
- // ── The plan ──────────────────────────────────────────────────────────────
397
-
398
- async function fetchPage(
399
- url: string,
400
- fetchImpl: typeof fetch,
401
- timeoutMs: number,
402
- ): Promise<{ html: string } | { error: string }> {
403
- const controller = new AbortController();
404
- let timer: ReturnType<typeof setTimeout> | undefined;
405
- const timeout = new Promise<{ error: string }>((resolve) => {
406
- timer = setTimeout(() => {
407
- controller.abort();
408
- resolve({ error: `timed out after ${timeoutMs} ms` });
409
- }, timeoutMs);
410
- });
411
- const attempt = (async (): Promise<{ html: string } | { error: string }> => {
412
- try {
413
- const res = await fetchImpl(url, { signal: controller.signal, redirect: "follow" });
414
- if (!res.ok) return { error: `HTTP status ${res.status}` };
415
- return { html: await res.text() };
416
- } catch (err) {
417
- return { error: err instanceof Error ? err.message : String(err) };
418
- }
419
- })();
420
- try {
421
- return await Promise.race([attempt, timeout]);
422
- } finally {
423
- clearTimeout(timer);
424
- }
425
- }
426
-
427
- export async function buildPlan(opts: {
428
- root: string;
429
- need?: Need;
430
- url?: string;
431
- html?: string;
432
- fetch?: typeof fetch;
433
- timeoutMs?: number;
434
- }): Promise<Plan> {
435
- const fetchedUrl = opts.url ?? PRICING_URL;
436
- let html: string | undefined = opts.html;
437
- let reason: string | undefined;
438
- if (html === undefined) {
439
- const page = await fetchPage(fetchedUrl, opts.fetch ?? fetch, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
440
- if ("html" in page) html = page.html;
441
- else reason = page.error;
442
- }
443
-
444
- const offers = html === undefined ? [] : parseOffers(html);
445
- const prices = new Map<OptionId, Price[]>(OPTIONS.map((o) => [o.id, []]));
446
- const alsoPublished: Offer[] = [];
447
- for (const offer of offers) {
448
- const option = matchOption(offer);
449
- if (option === undefined || offer.price === undefined) {
450
- if (option === undefined) alsoPublished.push(offer);
451
- continue;
452
- }
453
- const { name: _name, ...price } = offer;
454
- prices.get(option.id)?.push({ ...price, price: offer.price });
455
- }
456
-
457
- const options: PlanOption[] = OPTIONS.map((o) => {
458
- const p = prices.get(o.id) ?? [];
459
- return {
460
- id: o.id,
461
- name: o.name,
462
- adds: o.adds,
463
- requires: o.requires,
464
- prices: p,
465
- status: p.length > 0 ? "published" : "not published on the pricing page",
466
- };
467
- });
468
-
469
- const site = readSite(opts.root);
470
- const rec = recommend(site, opts.need);
471
- const link = prices.get(rec.parts[0] ?? "free")?.find((p) => p.url !== undefined)?.url ?? PRICING_URL;
472
-
473
- const priced = options.some((o) => o.prices.length > 0) || alsoPublished.some((o) => o.price !== undefined);
474
- const status = html === undefined ? "unreachable" : priced ? "ok" : "unpublished";
475
- return {
476
- pricing: { status, url: PRICING_URL, fetchedUrl, ...(reason !== undefined ? { reason } : {}) },
477
- options,
478
- alsoPublished,
479
- site,
480
- recommendation: { ...rec, link },
481
- };
482
- }
483
-
484
- // ── CLI ───────────────────────────────────────────────────────────────────
485
-
486
- const USAGE =
487
- "usage: upgrade-plan.ts [--need agency|sell|saas|usage|docs|scheduling|multi-tenant] [--url U | --html-file F] [--root R]";
488
-
489
- function usageError(message: string): never {
490
- process.stderr.write(`${message}\n${USAGE}\n`);
491
- process.exit(2);
492
- }
493
-
494
- async function main(argv: string[]): Promise<void> {
495
- const opts: { root: string; need?: Need; url?: string; html?: string } = { root: process.cwd() };
496
- for (let i = 0; i < argv.length; i++) {
497
- const flag = argv[i];
498
- const value = argv[i + 1];
499
- if (flag !== "--need" && flag !== "--url" && flag !== "--html-file" && flag !== "--root") {
500
- usageError(`unknown argument: ${flag}`);
501
- }
502
- if (value === undefined || value.startsWith("--")) usageError(`${flag} needs a value`);
503
- i++;
504
- if (flag === "--need") {
505
- if (!(NEEDS as readonly string[]).includes(value)) usageError(`unknown need: ${value}`);
506
- opts.need = value as Need;
507
- } else if (flag === "--url") {
508
- opts.url = value;
509
- } else if (flag === "--html-file") {
510
- const text = readText(value);
511
- if (text === null) usageError(`cannot read ${value}`);
512
- opts.html = text;
513
- } else {
514
- opts.root = value;
515
- }
516
- }
517
- if (opts.url !== undefined && opts.html !== undefined) usageError("--url and --html-file are exclusive");
518
- const plan = await buildPlan(opts);
519
- process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
520
- }
521
-
522
- if (/upgrade-plan\.ts$/.test(process.argv[1] ?? "")) {
523
- main(process.argv.slice(2)).catch((err: unknown) => {
524
- process.stderr.write(`upgrade-plan: ${err instanceof Error ? err.message : String(err)}\n`);
525
- process.exit(1);
526
- });
527
- }