create-ailk 0.29.1 → 0.31.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.
Files changed (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. package/versions.json +48 -48
@@ -3,15 +3,16 @@
3
3
  * [--workspace <id>]` — import an existing site's MDX pages into its
4
4
  * workspace (#10207).
5
5
  *
6
- * The same import as `seed-workspace` — `importMdx` over `<dir>/content` with
6
+ * The same import as `seed-workspace` — `importMdx` over `<dir>/content`, and
7
+ * the docs-blog tree `<dir>/apps/web/content` when it exists (#10701), with
7
8
  * the site's own installed adapter — for the site `seed-workspace` refuses: one
8
9
  * with no `.ailk-site-manifest.json`, or a spoke, whose workspace lives on its
9
10
  * hub (D1). No manifest and no topology is read; the operator names the API
10
11
  * origin and the workspace, which is all the topology would decide.
11
12
  *
12
13
  * A dry run is the default (D3): the importer runs over `readOnlyFetch`, which
13
- * passes each `GET` through and answers a `POST` or `PATCH` locally without
14
- * sending it, so the plan — create · update · unchanged · skipped · failed — is
14
+ * passes each `GET` through and answers a `POST`, `PATCH` or `PUT` locally
15
+ * without sending it, so the plan — create · update · unchanged · skipped · failed — is
15
16
  * decided by the importer itself against the live workspace, and no write can
16
17
  * leave the process. `--apply` writes; a second `--apply` reports every page
17
18
  * `unchanged`.
@@ -27,7 +28,8 @@
27
28
  */
28
29
  import { join, resolve } from "node:path";
29
30
  import { parseArgs } from "node:util";
30
- import { formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
31
+ import { languageLines, languageOffLine, withLanguage, } from "./language-import.js";
32
+ import { formatImportMdxReport, importMdx, isDirectory, loadSiteMdxAdapter, outcomePath, } from "./lib/import-mdx.js";
31
33
  import { ParseError } from "./parse-args.js";
32
34
  import { AGENT_KEY_ENV_VAR, WORKSPACE_ID_ENV_VAR } from "./seed-workspace.js";
33
35
  import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, } from "./topology.js";
@@ -53,17 +55,20 @@ function given(value) {
53
55
  /**
54
56
  * A fetch that sends `GET`s through `inner` and never sends a write: a `POST`
55
57
  * is answered `201` and a `PATCH` `200` locally, with the one member of a
56
- * write's answer `importMdx` reads (`data.locale`). Any other method throws.
58
+ * write's answer `importMdx` reads (`data.locale`), and the structure write's
59
+ * `PUT` (#10534) `200` as a layout unchanged (`data.written: false`). Any
60
+ * other method throws.
57
61
  */
58
62
  export function readOnlyFetch(inner, locale) {
59
63
  return (async (input, init) => {
60
64
  const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
61
65
  if (method === "GET")
62
66
  return inner(input, init);
63
- if (method !== "POST" && method !== "PATCH") {
67
+ if (method !== "POST" && method !== "PATCH" && method !== "PUT") {
64
68
  throw new Error(`a dry run sends no ${method} request`);
65
69
  }
66
- return new Response(JSON.stringify({ ok: true, data: { locale } }), {
70
+ const data = method === "PUT" ? { locale, written: false } : { locale };
71
+ return new Response(JSON.stringify({ ok: true, data }), {
67
72
  status: method === "POST" ? 201 : 200,
68
73
  headers: { "content-type": "application/json" },
69
74
  });
@@ -81,8 +86,8 @@ const PLAN_VERB = {
81
86
  function planLine(outcome) {
82
87
  const verb = PLAN_VERB[outcome.result];
83
88
  return "reason" in outcome
84
- ? `${outcome.path}: ${verb} — ${outcome.reason}`
85
- : `${outcome.path}: ${verb}`;
89
+ ? `${outcomePath(outcome)}: ${verb} — ${outcome.reason}`
90
+ : `${outcomePath(outcome)}: ${verb}`;
86
91
  }
87
92
  // ── The command (D1–D4) ─────────────────────────────────────────────────────
88
93
  export async function migrateContent(opts) {
@@ -119,14 +124,29 @@ export async function migrateContent(opts) {
119
124
  : `Planning ${opts.targetDir} against ${at} Nothing is written without --apply.`);
120
125
  const adapters = opts.adapters ?? (await loadSiteMdxAdapter(targetDir));
121
126
  const fetch = opts.fetch ?? globalThis.fetch;
127
+ const transport = apply ? fetch : readOnlyFetch(fetch, LOCALE);
128
+ // #10735 D1 — the Language pass wraps the importer's read.
129
+ const pass = withLanguage(adapters, {
130
+ apiOrigin: origin,
131
+ workspaceId,
132
+ apiKey,
133
+ locale: LOCALE,
134
+ fetch: transport,
135
+ apply,
136
+ ...(opts.sleep !== undefined && { sleep: opts.sleep }),
137
+ onLine,
138
+ });
139
+ // #10701 D7 — the docs-blog tree, when the site has one.
140
+ const editorialRoot = join(targetDir, "apps", "web", "content");
122
141
  const result = await importMdx({
123
142
  contentRoot: join(targetDir, "content"),
124
- adapters,
143
+ ...(isDirectory(editorialRoot) && { editorialRoot }),
144
+ adapters: pass.adapters,
125
145
  apiOrigin: origin,
126
146
  workspaceId,
127
147
  apiKey,
128
148
  defaultLocale: LOCALE,
129
- fetch: apply ? fetch : readOnlyFetch(fetch, LOCALE),
149
+ fetch: transport,
130
150
  ...(opts.sleep !== undefined && { sleep: opts.sleep }),
131
151
  // A dry run's importer lines say `created`/`updated`, which nothing was.
132
152
  ...(apply && { onLine }),
@@ -148,7 +168,17 @@ export async function migrateContent(opts) {
148
168
  onLine(`${failed} page(s) would fail. Fix them before --apply.`);
149
169
  }
150
170
  }
151
- return { result, exitCode: failed > 0 ? 1 : 0 };
171
+ // #10735 D7 — the Language lines come last.
172
+ const { result: language, off } = pass.report();
173
+ if (off !== undefined)
174
+ onLine(languageOffLine(off));
175
+ if (language !== undefined)
176
+ languageLines(language, apply).forEach(onLine);
177
+ return {
178
+ result,
179
+ exitCode: failed > 0 ? 1 : 0,
180
+ ...(language !== undefined && { language }),
181
+ };
152
182
  }
153
183
  export const MIGRATE_CONTENT_HELP = `create-ailk migrate-content — import an existing site's pages into its workspace.
154
184
 
@@ -174,11 +204,27 @@ content/<Type>/<slug>.mdx) is written to the workspace as a draft through
174
204
  the API's content routes, with the agent key, which is read from
175
205
  AILK_AGENT_KEY only and needs the content-edit grant. Nothing is published,
176
206
  so the live site does not change. The site needs no .ailk-site-manifest.json
177
- and may be a spoke; its dependencies must be installed.
207
+ and may be a spoke; its dependencies must be installed. A page whose
208
+ frontmatter carries sections is converted: its words are written to the
209
+ page's Markdown and its sections to its layout.
210
+
211
+ Each FAQ item and glossary-index term becomes one Language item of its kind,
212
+ set Live when the run creates it, and its section pins the items in the
213
+ order the file lists them. A question on several pages is one item, with
214
+ the FAQ page's words (the glossary page's, for a term). Lines prefixed
215
+ language: report each item, and each page the database read will not show
216
+ as its file does. An API with no Language collection leaves them inline.
217
+
218
+ When the site has apps/web/content, each blog post and docs page in it is
219
+ written too: apps/web/content/en/blog/<slug>.mdx (or <slug>/index.mdx) as a
220
+ BlogPosting, the same under docs as a DocsDetail, and each directory's own
221
+ index.mdx as its BlogList or DocsList index. Their lines are prefixed
222
+ editorial: — each outcome's root is content or editorial.
178
223
 
179
224
  A dry run prints create, update, unchanged, skipped or failed per page. It
180
225
  cannot see a write the API would refuse at --apply (a conflict or a server
181
- error), and a page whose content fails its page type is planned and saved
226
+ error), nor a layout change on a page whose words did not change, and a
227
+ page whose content fails its page type is planned and saved
182
228
  with errors the editor shows, not failed. --apply prints created, updated,
183
229
  unchanged, skipped or failed per page, so a re-run is safe. Exit 0 when no
184
230
  page failed, 1 when any did, 2 on a refusal (nothing is sent).
@@ -1810,7 +1810,11 @@ export function coOwnedModuleDestinations() {
1810
1810
  // `review-surface-registration.test.ts` and
1811
1811
  // `workspace-module-registration.test.ts` (each a matrix over module
1812
1812
  // sets), and mcp's `parity.test.ts` (counts the Server Actions and
1813
- // tool handlers of the FULL tree, which a stripped scaffold lacks).
1813
+ // tool handlers of the FULL tree, which a stripped scaffold lacks),
1814
+ // and mcp's `workspace-oauth.test.ts` (#10482 — its AC-8 cases call
1815
+ // `get_agent_context` on `/mcp/workspace`, a tool only the
1816
+ // `@working-theory/mcp-workspace` pack ships, and the `workspace`,
1817
+ // `multi-tenant` and `signal-collectors` slices each carry that pack).
1814
1818
  // The exclusion is per file, not a `testPathIgnorePatterns` entry in the
1815
1819
  // scaffold's jest config: that would hide these suites from a keyed
1816
1820
  // scaffold too, and from the suites a slice owns.
@@ -1853,6 +1857,7 @@ export const EXCLUDE_TEMPLATE_DIRS = [
1853
1857
  "apps/api/src/__tests__/hub-install.test.ts",
1854
1858
  "apps/mcp/__tests__/workspace-surface.test.ts",
1855
1859
  "apps/mcp/__tests__/parity.test.ts",
1860
+ "apps/mcp/__tests__/workspace-oauth.test.ts",
1856
1861
  "apps/web/app/[locale]/pricing/__tests__/monorepo-only.test.tsx",
1857
1862
  "apps/web/app/[locale]/pricing/__tests__/__snapshots__/monorepo-only.test.tsx.snap",
1858
1863
  "apps/web/lib/__tests__/hub-children.test.tsx",
@@ -0,0 +1,28 @@
1
+ import { type LicenseSetRunner } from "./license-set.js";
2
+ /** The production variables each deployed app needs (#10392 D9). */
3
+ export declare function provisionEnv(opts: {
4
+ webOrigin: string;
5
+ mcpOrigin: string;
6
+ }): {
7
+ web: {
8
+ MCP_PUBLIC_HTTP_URL: string;
9
+ };
10
+ mcp: {
11
+ MCP_PUBLIC_HTTP_URL: string;
12
+ BETTER_AUTH_URL: string;
13
+ };
14
+ };
15
+ export interface ProvisionEnvIo {
16
+ /** Resolves a relative or absent `[dir]`. */
17
+ cwd: string;
18
+ stdout: (text: string) => void;
19
+ stderr: (text: string) => void;
20
+ /** Defaults to `license set`'s runner: `vercel`, never through a shell. */
21
+ runner?: LicenseSetRunner;
22
+ }
23
+ export declare const PROVISION_ENV_HELP = "create-ailk provision-env \u2014 set a deployed site's MCP discovery and OAuth variables.\n\nUsage:\n pnpm create ailk@latest provision-env --web-origin <origin> --mcp-origin <origin> [<target-dir>]\n\nArguments:\n --web-origin <origin> The site's public origin (apps/web), e.g. https://example.com\n --mcp-origin <origin> The MCP server's public origin (apps/mcp), e.g. https://mcp.example.com\n <target-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n\nSets MCP_PUBLIC_HTTP_URL on apps/web and apps/mcp, and BETTER_AUTH_URL on\napps/mcp, in each app's linked Vercel production environment. The variables\npersist across deploys. An app with no Vercel link gets the values printed to\nset on your host yourself. Re-run it whenever an origin changes.\n";
24
+ /**
25
+ * Run `provision-env` with `argv` = everything after the `provision-env` word.
26
+ * Returns the process exit code; never throws for an expected failure.
27
+ */
28
+ export declare function runProvisionEnv(argv: readonly string[], io: ProvisionEnvIo): number;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `create-ailk provision-env --web-origin <o> --mcp-origin <o> [dir]` — set the
3
+ * production variables a deployed site needs for MCP discovery and OAuth
4
+ * (#10392 D9).
5
+ *
6
+ * `/.well-known/mcp.json` advertises the MCP HTTP endpoint only when
7
+ * `MCP_PUBLIC_HTTP_URL` is set (#7396), and names the OAuth metadata only when
8
+ * `BETTER_AUTH_URL` is set beside it (D8); apps/mcp turns its OAuth branch on
9
+ * only with both (#10391). This step derives every value from the two origins
10
+ * and puts them on each Vercel-linked app's production environment, so a
11
+ * deployment advertises its MCP endpoint without hand configuration:
12
+ *
13
+ * apps/web MCP_PUBLIC_HTTP_URL = <mcp-origin>/mcp
14
+ * apps/mcp MCP_PUBLIC_HTTP_URL = <mcp-origin>/mcp
15
+ * BETTER_AUTH_URL = <web-origin>
16
+ *
17
+ * 1. The target (default: cwd) must contain `architecture.yaml` — the
18
+ * `license set` precondition — else exit 1 with nothing run.
19
+ * 2. For each of `<dir>/apps/web` and `<dir>/apps/mcp` holding
20
+ * `.vercel/project.json`: print its `projectId`, then per variable
21
+ * `vercel env rm <NAME> production --yes` (failure tolerated: it may not
22
+ * exist yet) and `vercel env add <NAME> production`, the value on STDIN.
23
+ * No redeploy: the variables persist, and the next deploy reads them.
24
+ * 3. An app with no link → its `NAME=value` lines are printed, to set by
25
+ * hand. `vercel` missing → exit 1 with the manual commands. A failed
26
+ * `env add` → exit 1 naming the variable now unset, as `license set` does.
27
+ *
28
+ * A value is never an argv element of a spawned process. Local `.env.local`
29
+ * files are deliberately not given an MCP URL: locally apps/mcp defaults to
30
+ * stdio, so a loopback URL would advertise an endpoint nothing serves.
31
+ *
32
+ * Pure module: output and the process runner are injected, so jest drives it.
33
+ */
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import { join, resolve } from "node:path";
36
+ import { defaultRunner } from "./license-set.js";
37
+ /** apps/mcp's default `MCP_HTTP_PATH`. */
38
+ const MCP_PATH = "/mcp";
39
+ const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
40
+ /** The production variables each deployed app needs (#10392 D9). */
41
+ export function provisionEnv(opts) {
42
+ const webOrigin = assertOrigin(opts.webOrigin, "--web-origin");
43
+ const mcpOrigin = assertOrigin(opts.mcpOrigin, "--mcp-origin");
44
+ const mcpUrl = `${mcpOrigin}${MCP_PATH}`;
45
+ return {
46
+ web: { MCP_PUBLIC_HTTP_URL: mcpUrl },
47
+ mcp: { MCP_PUBLIC_HTTP_URL: mcpUrl, BETTER_AUTH_URL: webOrigin },
48
+ };
49
+ }
50
+ /**
51
+ * The origin `value` names: an absolute `https:` URL, or `http:` on a loopback
52
+ * host, with no path, query or fragment. Throws naming the flag otherwise.
53
+ */
54
+ function assertOrigin(value, flag) {
55
+ let url;
56
+ try {
57
+ url = new URL(value);
58
+ }
59
+ catch {
60
+ throw new ProvisionEnvError(`${flag} must be an absolute URL, e.g. https://example.com`);
61
+ }
62
+ const secure = url.protocol === "https:" ||
63
+ (url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname));
64
+ if (!secure) {
65
+ throw new ProvisionEnvError(`${flag} must be https (http is allowed only on localhost)`);
66
+ }
67
+ // A host name only: letters, digits, dots and hyphens (or a bracketed IPv6
68
+ // loopback). The URL parser keeps shell characters in a host, and the value
69
+ // is printed in a copyable manual command.
70
+ if (!/^(?:[A-Za-z0-9.-]+|\[::1\])$/.test(url.hostname)) {
71
+ throw new ProvisionEnvError(`${flag} must name a plain host (letters, digits, dots, hyphens)`);
72
+ }
73
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "") {
74
+ throw new ProvisionEnvError(`${flag} must be an origin only — no path, query or fragment`);
75
+ }
76
+ return url.origin;
77
+ }
78
+ class ProvisionEnvError extends Error {
79
+ }
80
+ export const PROVISION_ENV_HELP = `create-ailk provision-env — set a deployed site's MCP discovery and OAuth variables.
81
+
82
+ Usage:
83
+ pnpm create ailk@latest provision-env --web-origin <origin> --mcp-origin <origin> [<target-dir>]
84
+
85
+ Arguments:
86
+ --web-origin <origin> The site's public origin (apps/web), e.g. https://example.com
87
+ --mcp-origin <origin> The MCP server's public origin (apps/mcp), e.g. https://mcp.example.com
88
+ <target-dir> An already-scaffolded repo (contains
89
+ architecture.yaml). Defaults to the current dir.
90
+
91
+ Sets MCP_PUBLIC_HTTP_URL on apps/web and apps/mcp, and BETTER_AUTH_URL on
92
+ apps/mcp, in each app's linked Vercel production environment. The variables
93
+ persist across deploys. An app with no Vercel link gets the values printed to
94
+ set on your host yourself. Re-run it whenever an origin changes.
95
+ `;
96
+ const APPS = [
97
+ { dir: join("apps", "web"), key: "web" },
98
+ { dir: join("apps", "mcp"), key: "mcp" },
99
+ ];
100
+ function parseProvisionEnvArgs(argv) {
101
+ const parsed = {
102
+ help: false,
103
+ webOrigin: undefined,
104
+ mcpOrigin: undefined,
105
+ target: undefined,
106
+ };
107
+ for (let i = 0; i < argv.length; i += 1) {
108
+ const arg = argv[i];
109
+ if (arg === "--help" || arg === "-h")
110
+ parsed.help = true;
111
+ else if (arg === "--web-origin")
112
+ parsed.webOrigin = argv[++i];
113
+ else if (arg === "--mcp-origin")
114
+ parsed.mcpOrigin = argv[++i];
115
+ else if (arg.startsWith("-"))
116
+ throw new ProvisionEnvError(`unknown option ${arg}`);
117
+ else if (parsed.target === undefined)
118
+ parsed.target = arg;
119
+ else
120
+ throw new ProvisionEnvError("expected at most one target directory");
121
+ }
122
+ return parsed;
123
+ }
124
+ function readProjectId(dir) {
125
+ try {
126
+ const raw = JSON.parse(readFileSync(join(dir, ".vercel", "project.json"), "utf8"));
127
+ if (typeof raw.projectId === "string" && raw.projectId.length > 0)
128
+ return raw.projectId;
129
+ }
130
+ catch {
131
+ // fall through
132
+ }
133
+ throw new ProvisionEnvError(`${join(dir, ".vercel", "project.json")} has no readable projectId; run \`vercel link\` there, then re-run.`);
134
+ }
135
+ const envRm = (name) => ["env", "rm", name, "production", "--yes"];
136
+ const envAdd = (name) => ["env", "add", name, "production"];
137
+ /** The commands that set `vars` by hand, from `dir`. */
138
+ function manualCommands(dir, vars) {
139
+ return [
140
+ ` cd '${dir}'`,
141
+ ...Object.entries(vars).map(([name, value]) => ` printf '%s' '${value}' | vercel ${envAdd(name).join(" ")}`),
142
+ ].join("\n");
143
+ }
144
+ /**
145
+ * Run `provision-env` with `argv` = everything after the `provision-env` word.
146
+ * Returns the process exit code; never throws for an expected failure.
147
+ */
148
+ export function runProvisionEnv(argv, io) {
149
+ const runner = io.runner ?? defaultRunner;
150
+ const fail = (message) => {
151
+ io.stderr(`[create-ailk] provision-env: ${message}\n`);
152
+ return 1;
153
+ };
154
+ let args;
155
+ let values;
156
+ try {
157
+ args = parseProvisionEnvArgs(argv);
158
+ if (args.help) {
159
+ io.stdout(PROVISION_ENV_HELP);
160
+ return 0;
161
+ }
162
+ if (args.webOrigin === undefined || args.mcpOrigin === undefined) {
163
+ throw new ProvisionEnvError(`--web-origin and --mcp-origin are both required.\n\n${PROVISION_ENV_HELP}`);
164
+ }
165
+ values = provisionEnv({ webOrigin: args.webOrigin, mcpOrigin: args.mcpOrigin });
166
+ }
167
+ catch (err) {
168
+ if (err instanceof ProvisionEnvError)
169
+ return fail(err.message);
170
+ throw err;
171
+ }
172
+ const target = resolve(io.cwd, args.target ?? ".");
173
+ if (!existsSync(join(target, "architecture.yaml"))) {
174
+ return fail(`not a scaffolded repo: no architecture.yaml at ${target}. Run it from your site's root, or pass the site's directory.`);
175
+ }
176
+ for (const app of APPS) {
177
+ const dir = join(target, app.dir);
178
+ const vars = values[app.key];
179
+ if (!existsSync(join(dir, ".vercel", "project.json"))) {
180
+ io.stdout(`[create-ailk] ${app.dir} has no Vercel link (.vercel/project.json); set these on its host yourself:\n${Object.entries(vars)
181
+ .map(([name, value]) => ` ${name}=${value}`)
182
+ .join("\n")}\n`);
183
+ continue;
184
+ }
185
+ let projectId;
186
+ try {
187
+ projectId = readProjectId(dir);
188
+ }
189
+ catch (err) {
190
+ return fail(err.message);
191
+ }
192
+ io.stdout(`[create-ailk] Setting ${Object.keys(vars).join(", ")} (production) on Vercel project ${projectId}, linked at ${dir}\n`);
193
+ for (const [name, value] of Object.entries(vars)) {
194
+ const rm = runner(envRm(name), { cwd: dir });
195
+ if (rm.error?.code === "ENOENT") {
196
+ return fail(`the vercel CLI was not found; nothing was pushed. Install it (npm i -g vercel), or set them by hand:\n${manualCommands(dir, vars)}`);
197
+ }
198
+ const removed = rm.error === undefined && rm.status === 0;
199
+ const add = runner(envAdd(name), { cwd: dir, input: value });
200
+ if (add.error !== undefined || add.status !== 0) {
201
+ const state = removed
202
+ ? `The production variable ${name} is now UNSET there (the old value was removed).`
203
+ : `The production variable ${name} was not changed.`;
204
+ return fail(`\`vercel ${envAdd(name).join(" ")}\` failed on project ${projectId}. ${state} Finish with:\n${manualCommands(dir, { [name]: value })}`);
205
+ }
206
+ }
207
+ }
208
+ io.stdout("[create-ailk] MCP discovery and OAuth variables set; the next deploy reads them.\n");
209
+ return 0;
210
+ }
@@ -4,6 +4,8 @@ import { type Topology } from "./topology.js";
4
4
  export declare const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
5
5
  /** The environment variable the workspace id is read from when no flag names one. */
6
6
  export declare const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
7
+ /** The environment variable each app chooses its content source by (#10659). */
8
+ export declare const CONTENT_ADAPTER_ENV_VAR = "CONTENT_ADAPTER";
7
9
  /** A refusal the CLI exits 2 on, thrown before any request is sent. */
8
10
  export declare class SeedWorkspaceRefusedError extends Error {
9
11
  readonly exitCode = 2;
@@ -67,5 +69,5 @@ export interface SeedWorkspaceArgs {
67
69
  workspaceId: string | undefined;
68
70
  help: boolean;
69
71
  }
70
- export declare const SEED_WORKSPACE_HELP = "create-ailk seed-workspace \u2014 import a starter's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --api-url <origin> The site's API origin. Else AILK_API_URL. https:\n only (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page is written to the workspace as a\ndraft through the API's content routes, with the agent key, which is read\nfrom AILK_AGENT_KEY only and needs the content-edit grant. Each page prints\ncreated, updated, unchanged, skipped or failed, so a re-run is safe. The\nsite's dependencies must be installed. Exit 0 when every page was seeded, 1\nwhen any failed, 2 on a refusal (nothing is sent).\n";
72
+ export declare const SEED_WORKSPACE_HELP = "create-ailk seed-workspace \u2014 import a starter's pages into its workspace.\n\nUsage:\n AILK_AGENT_KEY=<key> create-ailk seed-workspace [<dir>] [--api-url <origin>] [--workspace <wksp_id>]\n\nArguments:\n <dir> The site. Defaults to the current dir.\n\nOptions:\n --api-url <origin> The site's API origin. Else AILK_API_URL. https:\n only (http: on localhost or 127.0.0.1).\n --workspace <wksp_id> The workspace the pages are written to. Else\n AILK_WORKSPACE_ID.\n -h, --help Show this help.\n\nEach content/en/<Type>/<slug>.mdx page is written to the workspace, and set\nLive when it has no Live version, through the API's content routes, with the\nagent key, which is read from AILK_AGENT_KEY only and needs the content-edit\nand content-publish grants. Each page prints created, updated, unchanged,\nskipped or failed, so a re-run is safe. When every page is Live, the site\nswitches apps/web and apps/api to CONTENT_ADAPTER=database, keeping a value\neither already sets. The site's dependencies must be installed. Exit 0 when\nevery page was seeded, 1 when any failed, 2 on a refusal (nothing is sent).\n";
71
73
  export declare function parseSeedWorkspaceArgs(argv: string[]): SeedWorkspaceArgs;
@@ -6,7 +6,10 @@
6
6
  * A fresh scaffold's `content/` tree is exactly its roster (spec §1), so the
7
7
  * seed is `importMdx` over `<dir>/content` with the site's own installed
8
8
  * adapter (D1): one `text/page` Artifact per page, and a re-run writes
9
- * nothing and reports every page `unchanged`.
9
+ * nothing and reports every page `unchanged`. Each page with no Live version
10
+ * is set Live, and when every page is Live both apps that choose a content
11
+ * source are switched to `CONTENT_ADAPTER=database`, each keeping a value it
12
+ * already sets (#10659 D3); anything less leaves the site on `content/`.
10
13
  *
11
14
  * The API origin comes from `--api-url`, else `AILK_API_URL`; the workspace
12
15
  * id from `--workspace`, else `AILK_WORKSPACE_ID`; the agent key from
@@ -18,12 +21,14 @@
18
21
  *
19
22
  * The scaffold-time step (D3, D6) runs only on a hub with an Admin
20
23
  * (`workspace` or `multi-tenant`), after `--install`, with all three
21
- * variables set; otherwise it prints why and the command that seeds later,
22
- * or nothing on a site with no Admin. It never fails the scaffold.
24
+ * variables set; otherwise it prints why and the command that seeds and
25
+ * switches later, or nothing on a site with no Admin. It never fails the
26
+ * scaffold.
23
27
  *
24
28
  * Pure module: no `import.meta.url`, so jest can import it directly. `fetch`,
25
29
  * `sleep`, `env` and the adapter are injectable. Spec:
26
- * docs/specs/10204-seed-the-starter-s-roster-spec.md.
30
+ * docs/specs/10204-seed-the-starter-s-roster-spec.md; the publish and the
31
+ * switch docs/specs/10659-a-licensed-hub-scaffold-starts-spec.md.
27
32
  */
28
33
  import { join, resolve } from "node:path";
29
34
  import { parseArgs } from "node:util";
@@ -31,11 +36,13 @@ import { AddModuleRefusedError } from "./add-module-core.js";
31
36
  import { ImportMdxRefusedError, formatImportMdxReport, importMdx, loadSiteMdxAdapter, } from "./lib/import-mdx.js";
32
37
  import { ParseError } from "./parse-args.js";
33
38
  import { readSiteRoster } from "./site-roster.js";
34
- import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, } from "./topology.js";
39
+ import { HUB_ORIGIN_ENV_VAR, TopologyError, normalizeHubUrl, readSiteTopology, writeApiEnv, writeWebEnv, } from "./topology.js";
35
40
  /** The environment variable the agent key is read from — and nowhere else. */
36
41
  export const AGENT_KEY_ENV_VAR = "AILK_AGENT_KEY";
37
42
  /** The environment variable the workspace id is read from when no flag names one. */
38
43
  export const WORKSPACE_ID_ENV_VAR = "AILK_WORKSPACE_ID";
44
+ /** The environment variable each app chooses its content source by (#10659). */
45
+ export const CONTENT_ADAPTER_ENV_VAR = "CONTENT_ADAPTER";
39
46
  /** A refusal the CLI exits 2 on, thrown before any request is sent. */
40
47
  export class SeedWorkspaceRefusedError extends Error {
41
48
  exitCode = 2;
@@ -83,7 +90,7 @@ export async function seedWorkspace(opts) {
83
90
  }
84
91
  const apiKey = given(env[AGENT_KEY_ENV_VAR]);
85
92
  if (apiKey === undefined) {
86
- refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit grant. It is read from the environment only — never a flag or a file.`);
93
+ refuse(`set ${AGENT_KEY_ENV_VAR} to an agent key with the content-edit and content-publish grants. It is read from the environment only — never a flag or a file.`);
87
94
  }
88
95
  const workspaceId = given(opts.workspaceId) ?? given(env[WORKSPACE_ID_ENV_VAR]);
89
96
  if (workspaceId === undefined) {
@@ -115,12 +122,30 @@ export async function seedWorkspace(opts) {
115
122
  ...(opts.fetch !== undefined && { fetch: opts.fetch }),
116
123
  ...(opts.sleep !== undefined && { sleep: opts.sleep }),
117
124
  onLine,
125
+ publish: true,
118
126
  });
119
127
  // The report's count line alone: no page rows.
120
128
  onLine(formatImportMdxReport({ pages: [], counts: result.counts }).trimEnd());
121
- const { failed } = result.counts;
122
- if (failed > 0) {
123
- onLine(`${failed} page(s) were not seeded. Fix them and re-run: create-ailk seed-workspace ${opts.targetDir}`);
129
+ const { created, updated, unchanged, failed } = result.counts;
130
+ const live = result.live ?? 0;
131
+ onLine(`Live: ${live} page(s), ${result.published ?? 0} published now.`);
132
+ // #10659 D3 — both apps switch only when every page is in and Live.
133
+ if (failed === 0 && live >= 1 && live === created + updated + unchanged) {
134
+ for (const [app, file, write] of [
135
+ ["apps/web", "apps/web/.env.local", writeWebEnv],
136
+ ["apps/api", "apps/api/.env", writeApiEnv],
137
+ ]) {
138
+ onLine(write(targetDir, CONTENT_ADAPTER_ENV_VAR, () => "database")
139
+ ? `Content source: the database for ${app} (${CONTENT_ADAPTER_ENV_VAR}=database in ${file}).`
140
+ : `Content source: ${app} already sets ${CONTENT_ADAPTER_ENV_VAR}, which was kept.`);
141
+ }
142
+ onLine(" The API serves these pages from the database it was seeded through (DATABASE_URL), for the workspace its site resolves.");
143
+ }
144
+ else {
145
+ if (failed > 0) {
146
+ onLine(`${failed} page(s) were not seeded. Fix them and re-run: create-ailk seed-workspace ${opts.targetDir}`);
147
+ }
148
+ onLine("Content source: content/ (unchanged) until every page is seeded and Live.");
124
149
  }
125
150
  return { result, exitCode: failed > 0 ? 1 : 0 };
126
151
  }
@@ -166,9 +191,10 @@ export async function runScaffoldSeed(opts) {
166
191
  const plan = scaffoldSeedPlan(opts);
167
192
  if (plan.kind === "not-applicable")
168
193
  return;
194
+ const later = ` To seed it and switch to the database later: ${AGENT_KEY_ENV_VAR}=<key> create-ailk seed-workspace ${opts.targetDir} --api-url <origin> --workspace <wksp_id>`;
169
195
  if (plan.kind === "skip") {
170
196
  opts.onLine(plan.reason);
171
- opts.onLine(` To seed it later: ${AGENT_KEY_ENV_VAR}=<key> create-ailk seed-workspace ${opts.targetDir} --api-url <origin> --workspace <wksp_id>`);
197
+ opts.onLine(later);
172
198
  return;
173
199
  }
174
200
  opts.onLine("Seeding the workspace:");
@@ -188,6 +214,7 @@ export async function runScaffoldSeed(opts) {
188
214
  throw err;
189
215
  }
190
216
  opts.onLine(err.message);
217
+ opts.onLine(later);
191
218
  }
192
219
  }
193
220
  export const SEED_WORKSPACE_HELP = `create-ailk seed-workspace — import a starter's pages into its workspace.
@@ -205,12 +232,14 @@ Options:
205
232
  AILK_WORKSPACE_ID.
206
233
  -h, --help Show this help.
207
234
 
208
- Each content/en/<Type>/<slug>.mdx page is written to the workspace as a
209
- draft through the API's content routes, with the agent key, which is read
210
- from AILK_AGENT_KEY only and needs the content-edit grant. Each page prints
211
- created, updated, unchanged, skipped or failed, so a re-run is safe. The
212
- site's dependencies must be installed. Exit 0 when every page was seeded, 1
213
- when any failed, 2 on a refusal (nothing is sent).
235
+ Each content/en/<Type>/<slug>.mdx page is written to the workspace, and set
236
+ Live when it has no Live version, through the API's content routes, with the
237
+ agent key, which is read from AILK_AGENT_KEY only and needs the content-edit
238
+ and content-publish grants. Each page prints created, updated, unchanged,
239
+ skipped or failed, so a re-run is safe. When every page is Live, the site
240
+ switches apps/web and apps/api to CONTENT_ADAPTER=database, keeping a value
241
+ either already sets. The site's dependencies must be installed. Exit 0 when
242
+ every page was seeded, 1 when any failed, 2 on a refusal (nothing is sent).
214
243
  `;
215
244
  export function parseSeedWorkspaceArgs(argv) {
216
245
  let parsed;
package/dist/surfaces.js CHANGED
@@ -818,6 +818,8 @@ export const ROUTE_SINCE = {
818
818
  "(auth)/forgot-password": "0.24.0",
819
819
  "(auth)/reset-password": "0.24.0",
820
820
  "(auth)/verify-email": "0.24.0",
821
+ // #10357 D13 — the MCP consent step.
822
+ "(auth)/consent": "0.53.0",
821
823
  // #5469 D2 — the generic `[slug]` content route. A genuinely new route
822
824
  // directory, so it attaches at today's fixed-group version, the same rule
823
825
  // #5479, #5371 and #5048 applied.
@@ -1210,6 +1212,7 @@ export const ROUTE_MESSAGE_NAMESPACES = {
1210
1212
  "(auth)/forgot-password": ["auth"],
1211
1213
  "(auth)/reset-password": ["auth"],
1212
1214
  "(auth)/verify-email": ["auth"],
1215
+ "(auth)/consent": ["mcpConsent"],
1213
1216
  "(authed)/account": ["account"],
1214
1217
  "(authed)/account/inbox/[kind]/[id]": ["account"],
1215
1218
  "(authed)/account/settings": ["account"],
@@ -215,6 +215,18 @@ export declare const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
215
215
  * the hub issues those.
216
216
  */
217
217
  export declare function writeHubOriginEnv(targetDir: string, origin: string): void;
218
+ /**
219
+ * `<name>=<value()>` into `apps/web/.env.local`, unless any env file Next
220
+ * loads for `apps/web` already assigns `name` (`value` is then never called);
221
+ * and `.env.local` gitignored either way. `true` when the line was written.
222
+ */
223
+ export declare function writeWebEnv(targetDir: string, name: string, value: () => string): boolean;
224
+ /**
225
+ * `<name>=<value()>` into `apps/api/.env` — the one file the API's
226
+ * `dotenv.config()` reads — under `writeWebEnv`'s never-clobber rule, and
227
+ * `.env` gitignored either way (#10659 D6). `true` when the line was written.
228
+ */
229
+ export declare function writeApiEnv(targetDir: string, name: string, value: () => string): boolean;
218
230
  /** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
219
231
  export declare const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
220
232
  /** The env var holding a hub-mode spoke's own cookie key material (#7894 D7, D11). */